@reviewflow/ui 0.0.0-stage → 6.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +102 -4
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- package/src/slots.tsx +34 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { PullRequestRef } from "@reviewflow/sdk";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
use,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where what is being written on a pull request is kept between two visits.
|
|
13
|
+
* Read synchronously, so an editor opens on its draft rather than flashing
|
|
14
|
+
* empty: an app without synchronous storage passes none, and drafts then live
|
|
15
|
+
* as long as the screen.
|
|
16
|
+
*/
|
|
17
|
+
export interface DraftStore {
|
|
18
|
+
/** how long a draft is kept after its last change, told to the reader */
|
|
19
|
+
retentionDays: number;
|
|
20
|
+
/** undefined when nothing is kept under `key`, or it expired */
|
|
21
|
+
read: (key: string) => unknown;
|
|
22
|
+
write: (key: string, value: unknown) => void;
|
|
23
|
+
remove: (key: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What every editor of the screen holds, blank and unchanged texts included,
|
|
28
|
+
* outside the editors themselves: an editor remounted (its diff annotation
|
|
29
|
+
* moved to another slot) opens on what was typed, and two editors of one key
|
|
30
|
+
* (a thread's reply box in both tabs) show the same text.
|
|
31
|
+
*/
|
|
32
|
+
interface DraftMemory {
|
|
33
|
+
get: (key: string) => string | undefined;
|
|
34
|
+
set: (key: string, text: string) => void;
|
|
35
|
+
delete: (key: string) => void;
|
|
36
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const createDraftMemory = (): DraftMemory => {
|
|
40
|
+
const texts = new Map<string, string>();
|
|
41
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
42
|
+
const notify = (key: string): void => {
|
|
43
|
+
for (const listener of listeners.get(key) ?? []) listener();
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
get: (key) => texts.get(key),
|
|
47
|
+
set: (key, text) => {
|
|
48
|
+
texts.set(key, text);
|
|
49
|
+
notify(key);
|
|
50
|
+
},
|
|
51
|
+
delete: (key) => {
|
|
52
|
+
if (texts.delete(key)) notify(key);
|
|
53
|
+
},
|
|
54
|
+
subscribe: (key, listener) => {
|
|
55
|
+
const ofKey = listeners.get(key) ?? new Set();
|
|
56
|
+
ofKey.add(listener);
|
|
57
|
+
listeners.set(key, ofKey);
|
|
58
|
+
return () => {
|
|
59
|
+
ofKey.delete(listener);
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
interface DraftsContextValue {
|
|
66
|
+
/** the store, its keys scoped to the pull request of the provider */
|
|
67
|
+
store: DraftStore | null;
|
|
68
|
+
memory: DraftMemory;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DraftsContext = createContext<DraftsContextValue | null>(null);
|
|
72
|
+
|
|
73
|
+
interface PullRequestDraftsProviderProps {
|
|
74
|
+
store: DraftStore | undefined;
|
|
75
|
+
pullRequest: PullRequestRef;
|
|
76
|
+
children: ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function PullRequestDraftsProvider({
|
|
80
|
+
store,
|
|
81
|
+
pullRequest,
|
|
82
|
+
children,
|
|
83
|
+
}: PullRequestDraftsProviderProps): ReactNode {
|
|
84
|
+
const scope = `${pullRequest.ownerRepo}#${String(pullRequest.number)}`;
|
|
85
|
+
const value = useMemo<DraftsContextValue>(
|
|
86
|
+
() => ({
|
|
87
|
+
store:
|
|
88
|
+
store === undefined
|
|
89
|
+
? null
|
|
90
|
+
: {
|
|
91
|
+
retentionDays: store.retentionDays,
|
|
92
|
+
read: (key) => store.read(`${scope}:${key}`),
|
|
93
|
+
write: (key, text) => {
|
|
94
|
+
store.write(`${scope}:${key}`, text);
|
|
95
|
+
},
|
|
96
|
+
remove: (key) => {
|
|
97
|
+
store.remove(`${scope}:${key}`);
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
memory: createDraftMemory(),
|
|
101
|
+
}),
|
|
102
|
+
[store, scope],
|
|
103
|
+
);
|
|
104
|
+
return <DraftsContext value={value}>{children}</DraftsContext>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** without a provider (a story), the editor keeps its own memory */
|
|
108
|
+
const useDraftsContext = (): DraftsContextValue => {
|
|
109
|
+
const context = use(DraftsContext);
|
|
110
|
+
// oxlint-disable-next-line react/hook-use-state -- created once, never set
|
|
111
|
+
const [own] = useState((): DraftsContextValue => ({
|
|
112
|
+
store: null,
|
|
113
|
+
memory: createDraftMemory(),
|
|
114
|
+
}));
|
|
115
|
+
return context ?? own;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** null when the app keeps no draft beyond the screen */
|
|
119
|
+
export const usePullRequestDrafts = (): DraftStore | null =>
|
|
120
|
+
useDraftsContext().store;
|
|
121
|
+
|
|
122
|
+
const readKept = (
|
|
123
|
+
{ store, memory }: DraftsContextValue,
|
|
124
|
+
key: string,
|
|
125
|
+
): string | undefined => {
|
|
126
|
+
const held = memory.get(key);
|
|
127
|
+
if (held !== undefined) return held;
|
|
128
|
+
const stored = store?.read(key);
|
|
129
|
+
return typeof stored === "string" ? stored : undefined;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Whether a text draft is kept under `key`, as of the mount. */
|
|
133
|
+
export const useHasDraftText = (key: string): boolean => {
|
|
134
|
+
const context = useDraftsContext();
|
|
135
|
+
// oxlint-disable-next-line react/hook-use-state -- read once, at mount, never set
|
|
136
|
+
const [exists] = useState(() => readKept(context, key) !== undefined);
|
|
137
|
+
return exists;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export interface DraftText {
|
|
141
|
+
text: string;
|
|
142
|
+
setText: (text: string) => void;
|
|
143
|
+
/**
|
|
144
|
+
* Back to `initial`, and forgets the kept draft: on cancel, or on send with
|
|
145
|
+
* the text sent, kept instead when more was typed while it went out.
|
|
146
|
+
*/
|
|
147
|
+
discard: (sent?: string) => void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The text of one editor, kept under `key` while it differs from `initial`
|
|
152
|
+
* and is not blank. `initial` is read at each render: once discarded, the
|
|
153
|
+
* editor shows the latest one.
|
|
154
|
+
*/
|
|
155
|
+
export const useDraftText = (key: string, initial = ""): DraftText => {
|
|
156
|
+
const context = useDraftsContext();
|
|
157
|
+
const { store, memory } = context;
|
|
158
|
+
const kept = useSyncExternalStore(
|
|
159
|
+
(listener) => memory.subscribe(key, listener),
|
|
160
|
+
() => readKept(context, key),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
text: kept ?? initial,
|
|
165
|
+
setText: (next) => {
|
|
166
|
+
memory.set(key, next);
|
|
167
|
+
if (store === null) return;
|
|
168
|
+
if (next === initial || next.trim() === "") store.remove(key);
|
|
169
|
+
else store.write(key, next);
|
|
170
|
+
},
|
|
171
|
+
discard: (sent) => {
|
|
172
|
+
if (sent !== undefined && (readKept(context, key) ?? initial) !== sent) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
memory.delete(key);
|
|
176
|
+
store?.remove(key);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { PullRequestDraftComment } from "@reviewflow/sdk";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { createContext, use, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { Composer } from "../components/pull-request-diff-lines.ts";
|
|
5
|
+
import type { PullRequestShas } from "../dataSource.ts";
|
|
6
|
+
import { usePullRequestDrafts } from "./PullRequestDraftsProvider.tsx";
|
|
7
|
+
|
|
8
|
+
export interface PendingComment extends PullRequestDraftComment {
|
|
9
|
+
/** local only, so a draft can be edited or dropped before the review is sent */
|
|
10
|
+
key: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PullRequestReviewDraft {
|
|
14
|
+
/** the comments that go with the next review, in the order they were written */
|
|
15
|
+
comments: PendingComment[];
|
|
16
|
+
add: (comment: PullRequestDraftComment) => void;
|
|
17
|
+
update: (key: string, body: string) => void;
|
|
18
|
+
remove: (key: string) => void;
|
|
19
|
+
/** after the review is sent */
|
|
20
|
+
clear: () => void;
|
|
21
|
+
/** the comment being written on the pull request's diff */
|
|
22
|
+
composer: Composer | null;
|
|
23
|
+
openComposer: (composer: Composer) => void;
|
|
24
|
+
closeComposer: () => void;
|
|
25
|
+
/** the range the diff shows and a review is sent on, null until known */
|
|
26
|
+
shas: PullRequestShas | null;
|
|
27
|
+
/** the pull request's own range, which `shas` lags once a push landed */
|
|
28
|
+
latest: PullRequestShas | null;
|
|
29
|
+
/** shows `latest`; null while a review is in progress */
|
|
30
|
+
follow: (() => void) | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ReviewDraftContext = createContext<PullRequestReviewDraft | null>(null);
|
|
34
|
+
|
|
35
|
+
interface PullRequestReviewDraftProviderProps {
|
|
36
|
+
/** the pull request's range as last read */
|
|
37
|
+
latest: PullRequestShas | null;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReviewInProgress {
|
|
42
|
+
comments: PendingComment[];
|
|
43
|
+
composer: Composer | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What is kept of a review in progress between two visits. */
|
|
47
|
+
interface StoredReview extends ReviewInProgress {
|
|
48
|
+
pinned: PullRequestShas;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const storedReviewKey = "review";
|
|
52
|
+
|
|
53
|
+
const isStoredReview = (value: unknown): value is StoredReview =>
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
Array.isArray((value as StoredReview).comments) &&
|
|
57
|
+
typeof (value as StoredReview).pinned?.head === "string";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The review being written on one pull request: comments added from the diff,
|
|
61
|
+
* sent together with the verdict from the header. Kept by the app's
|
|
62
|
+
* `DraftStore` when it has one, with the range it was written on, so a
|
|
63
|
+
* reload reopens it as it was; in memory otherwise.
|
|
64
|
+
* Mounted per pull request, so drafts never leak from one to the next.
|
|
65
|
+
*
|
|
66
|
+
* The range is pinned to the first one known and moves only on `follow`: a
|
|
67
|
+
* push swapping the diff under the reader would change the code being read
|
|
68
|
+
* without saying so, move a pending comment's line onto other code, and drop a
|
|
69
|
+
* comment being typed whose line left the diff. `follow` is withheld while a
|
|
70
|
+
* comment is pending or being written, so the comments always go out on the
|
|
71
|
+
* commit they were written on.
|
|
72
|
+
*/
|
|
73
|
+
export function PullRequestReviewDraftProvider({
|
|
74
|
+
latest,
|
|
75
|
+
children,
|
|
76
|
+
}: PullRequestReviewDraftProviderProps): ReactNode {
|
|
77
|
+
const drafts = usePullRequestDrafts();
|
|
78
|
+
// oxlint-disable-next-line react/hook-use-state -- read once, at mount, never set
|
|
79
|
+
const [restored] = useState(() => {
|
|
80
|
+
const stored = drafts?.read(storedReviewKey);
|
|
81
|
+
return isStoredReview(stored) ? stored : null;
|
|
82
|
+
});
|
|
83
|
+
const [review, setReview] = useState<ReviewInProgress>({
|
|
84
|
+
comments: restored?.comments ?? [],
|
|
85
|
+
composer: restored?.composer ?? null,
|
|
86
|
+
});
|
|
87
|
+
// one press can make two changes ("Start a review" adds, then closes the
|
|
88
|
+
// composer): each builds on the one before, not on the last render
|
|
89
|
+
const current = useRef(review);
|
|
90
|
+
const [pinned, setPinned] = useState<PullRequestShas | null>(
|
|
91
|
+
restored?.pinned ?? latest,
|
|
92
|
+
);
|
|
93
|
+
if (pinned === null && latest !== null) setPinned(latest);
|
|
94
|
+
const { comments, composer } = review;
|
|
95
|
+
const inProgress = comments.length > 0 || composer !== null;
|
|
96
|
+
|
|
97
|
+
const draft = useMemo<PullRequestReviewDraft>(() => {
|
|
98
|
+
const change = (
|
|
99
|
+
edit: (previous: ReviewInProgress) => ReviewInProgress,
|
|
100
|
+
): void => {
|
|
101
|
+
const next = edit(current.current);
|
|
102
|
+
current.current = next;
|
|
103
|
+
setReview(next);
|
|
104
|
+
const range = pinned ?? latest;
|
|
105
|
+
if (drafts === null) return;
|
|
106
|
+
if (
|
|
107
|
+
range === null ||
|
|
108
|
+
(next.comments.length === 0 && next.composer === null)
|
|
109
|
+
) {
|
|
110
|
+
drafts.remove(storedReviewKey);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
drafts.write(storedReviewKey, {
|
|
114
|
+
...next,
|
|
115
|
+
pinned: range,
|
|
116
|
+
} satisfies StoredReview);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
comments,
|
|
121
|
+
add: (comment) => {
|
|
122
|
+
change((previous) => ({
|
|
123
|
+
...previous,
|
|
124
|
+
comments: [
|
|
125
|
+
...previous.comments,
|
|
126
|
+
{
|
|
127
|
+
...comment,
|
|
128
|
+
key: `${String(Date.now())}-${String(previous.comments.length)}`,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
}));
|
|
132
|
+
},
|
|
133
|
+
update: (key, body) => {
|
|
134
|
+
change((previous) => ({
|
|
135
|
+
...previous,
|
|
136
|
+
comments: previous.comments.map((comment) =>
|
|
137
|
+
comment.key === key ? { ...comment, body } : comment,
|
|
138
|
+
),
|
|
139
|
+
}));
|
|
140
|
+
},
|
|
141
|
+
remove: (key) => {
|
|
142
|
+
change((previous) => ({
|
|
143
|
+
...previous,
|
|
144
|
+
comments: previous.comments.filter((comment) => comment.key !== key),
|
|
145
|
+
}));
|
|
146
|
+
},
|
|
147
|
+
clear: () => {
|
|
148
|
+
change((previous) => ({ ...previous, comments: [] }));
|
|
149
|
+
},
|
|
150
|
+
composer,
|
|
151
|
+
openComposer: (next) => {
|
|
152
|
+
change((previous) => ({ ...previous, composer: next }));
|
|
153
|
+
},
|
|
154
|
+
closeComposer: () => {
|
|
155
|
+
change((previous) => ({ ...previous, composer: null }));
|
|
156
|
+
},
|
|
157
|
+
shas: pinned ?? latest,
|
|
158
|
+
latest,
|
|
159
|
+
follow: inProgress
|
|
160
|
+
? null
|
|
161
|
+
: () => {
|
|
162
|
+
setPinned(latest);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}, [comments, composer, pinned, latest, inProgress, drafts]);
|
|
166
|
+
|
|
167
|
+
return <ReviewDraftContext value={draft}>{children}</ReviewDraftContext>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const usePullRequestReviewDraft = (): PullRequestReviewDraft => {
|
|
171
|
+
const draft = use(ReviewDraftContext);
|
|
172
|
+
if (draft === null) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"usePullRequestReviewDraft needs a PullRequestReviewDraftProvider",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return draft;
|
|
178
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestDetail,
|
|
3
|
+
PullRequestEdit,
|
|
4
|
+
PullRequestSuggestionRef,
|
|
5
|
+
SuggestionRange,
|
|
6
|
+
} from "@reviewflow/sdk";
|
|
7
|
+
import { View, WarningMessage } from "alouette";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { createContext, use, useMemo, useState } from "react";
|
|
10
|
+
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
11
|
+
|
|
12
|
+
export type BatchedSuggestion = PullRequestSuggestionRef & SuggestionRange;
|
|
13
|
+
|
|
14
|
+
export interface SuggestionCommitMessage {
|
|
15
|
+
headline: string;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PullRequestSuggestionBatch {
|
|
20
|
+
pullRequest: PullRequestDetail;
|
|
21
|
+
batch: BatchedSuggestion[];
|
|
22
|
+
add: (suggestion: BatchedSuggestion) => void;
|
|
23
|
+
remove: (commentId: string) => void;
|
|
24
|
+
/** one commit for `suggestions`, then the batch is emptied */
|
|
25
|
+
commit: (
|
|
26
|
+
suggestions: PullRequestSuggestionRef[],
|
|
27
|
+
message: SuggestionCommitMessage,
|
|
28
|
+
) => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SuggestionBatchContext = createContext<PullRequestSuggestionBatch | null>(
|
|
32
|
+
null,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
interface PullRequestSuggestionBatchProviderProps {
|
|
36
|
+
pullRequest: PullRequestDetail;
|
|
37
|
+
onEdited: (edit: PullRequestEdit) => Promise<void>;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The suggestions gathered to be committed together, as GitHub's "Add
|
|
43
|
+
* suggestion to batch". In memory, per pull request: a reload drops the
|
|
44
|
+
* batch, which commits nothing until pressed.
|
|
45
|
+
*
|
|
46
|
+
* The commit lands even when resolving its threads afterwards fails; that
|
|
47
|
+
* failure is shown here, above the tabs, since the button that committed may
|
|
48
|
+
* be gone with its thread.
|
|
49
|
+
*/
|
|
50
|
+
export function PullRequestSuggestionBatchProvider({
|
|
51
|
+
pullRequest,
|
|
52
|
+
onEdited,
|
|
53
|
+
children,
|
|
54
|
+
}: PullRequestSuggestionBatchProviderProps): ReactNode {
|
|
55
|
+
const [batch, setBatch] = useState<BatchedSuggestion[]>([]);
|
|
56
|
+
const [followUpError, setFollowUpError] = useState<string | null>(null);
|
|
57
|
+
const { edit: writeEdit } = usePullRequestDataSource();
|
|
58
|
+
|
|
59
|
+
const value = useMemo<PullRequestSuggestionBatch>(
|
|
60
|
+
() => ({
|
|
61
|
+
pullRequest,
|
|
62
|
+
batch,
|
|
63
|
+
add: (suggestion) => {
|
|
64
|
+
setBatch((previous) => [...previous, suggestion]);
|
|
65
|
+
},
|
|
66
|
+
remove: (commentId) => {
|
|
67
|
+
setBatch((previous) =>
|
|
68
|
+
previous.filter((suggestion) => suggestion.commentId !== commentId),
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
commit: async (suggestions, { headline, body }) => {
|
|
72
|
+
const edit: PullRequestEdit = {
|
|
73
|
+
kind: "applySuggestions",
|
|
74
|
+
expectedHeadSha: pullRequest.headSha,
|
|
75
|
+
suggestions: suggestions.map(({ threadId, commentId }) => ({
|
|
76
|
+
threadId,
|
|
77
|
+
commentId,
|
|
78
|
+
})),
|
|
79
|
+
headline,
|
|
80
|
+
body,
|
|
81
|
+
};
|
|
82
|
+
const response = await writeEdit(pullRequest, edit);
|
|
83
|
+
setBatch([]);
|
|
84
|
+
setFollowUpError(response.followUpError);
|
|
85
|
+
await onEdited(edit);
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
[pullRequest, batch, onEdited, writeEdit],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<SuggestionBatchContext value={value}>
|
|
93
|
+
<View className="gap-m">
|
|
94
|
+
{followUpError === null ? null : (
|
|
95
|
+
<WarningMessage
|
|
96
|
+
dismissIconAriaLabel="Dismiss"
|
|
97
|
+
onDismiss={() => {
|
|
98
|
+
setFollowUpError(null);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{followUpError}
|
|
102
|
+
</WarningMessage>
|
|
103
|
+
)}
|
|
104
|
+
{children}
|
|
105
|
+
</View>
|
|
106
|
+
</SuggestionBatchContext>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Null outside a pull request screen: its suggestions are not committable there. */
|
|
111
|
+
export const useSuggestionBatch = (): PullRequestSuggestionBatch | null =>
|
|
112
|
+
use(SuggestionBatchContext);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { nameToEmoji } from "gemoji";
|
|
2
|
+
|
|
3
|
+
const shortcode = /:([\w+-]+):/g;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GitHub's `:green_heart:` shortcodes as the emoji GitHub renders them as, with
|
|
7
|
+
* the same table reviewflow reads (`gemoji`). An unknown name stays as typed.
|
|
8
|
+
*/
|
|
9
|
+
export const emojify = (text: string): string =>
|
|
10
|
+
text.replaceAll(
|
|
11
|
+
shortcode,
|
|
12
|
+
(match, name: string) => nameToEmoji[name] ?? match,
|
|
13
|
+
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { DraftStore } from "./PullRequestDraftsProvider.tsx";
|
|
2
|
+
|
|
3
|
+
/** The part of the web `Storage` the store needs, so a test can pass a map. */
|
|
4
|
+
export interface DraftStorage {
|
|
5
|
+
readonly length: number;
|
|
6
|
+
key: (index: number) => string | null;
|
|
7
|
+
getItem: (key: string) => string | null;
|
|
8
|
+
setItem: (key: string, value: string) => void;
|
|
9
|
+
removeItem: (key: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StoredDraft {
|
|
13
|
+
savedAt: number;
|
|
14
|
+
value: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const prefix = "reviewflow:draft:";
|
|
18
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
const parse = (raw: string | null): StoredDraft | null => {
|
|
21
|
+
if (raw === null) return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed: unknown = JSON.parse(raw);
|
|
24
|
+
return typeof parsed === "object" &&
|
|
25
|
+
parsed !== null &&
|
|
26
|
+
typeof (parsed as StoredDraft).savedAt === "number"
|
|
27
|
+
? (parsed as StoredDraft)
|
|
28
|
+
: null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ExpiringDraftStoreOptions {
|
|
35
|
+
storage: DraftStorage;
|
|
36
|
+
retentionDays: number;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A {@link DraftStore} over a synchronous key-value storage, each draft
|
|
42
|
+
* dropped `retentionDays` after its last write. Expired drafts are swept when
|
|
43
|
+
* the store is created, so a pull request never opened again does not keep
|
|
44
|
+
* its drafts forever. Every access is guarded: storage that throws (full,
|
|
45
|
+
* blocked site data) loses the draft, never the editor.
|
|
46
|
+
*/
|
|
47
|
+
export const createExpiringDraftStore = ({
|
|
48
|
+
storage,
|
|
49
|
+
retentionDays,
|
|
50
|
+
now = Date.now,
|
|
51
|
+
}: ExpiringDraftStoreOptions): DraftStore => {
|
|
52
|
+
const isExpired = (draft: StoredDraft): boolean =>
|
|
53
|
+
now() - draft.savedAt > retentionDays * dayMs;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const keys = Array.from({ length: storage.length }, (_, index) =>
|
|
57
|
+
storage.key(index),
|
|
58
|
+
).filter((key) => key?.startsWith(prefix) === true) as string[];
|
|
59
|
+
for (const key of keys) {
|
|
60
|
+
const draft = parse(storage.getItem(key));
|
|
61
|
+
if (draft === null || isExpired(draft)) storage.removeItem(key);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// nothing swept: each read still drops what expired
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const remove = (key: string): void => {
|
|
68
|
+
try {
|
|
69
|
+
storage.removeItem(`${prefix}${key}`);
|
|
70
|
+
} catch {
|
|
71
|
+
// already unreadable
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
retentionDays,
|
|
77
|
+
read: (key) => {
|
|
78
|
+
try {
|
|
79
|
+
const draft = parse(storage.getItem(`${prefix}${key}`));
|
|
80
|
+
if (draft === null) return undefined;
|
|
81
|
+
if (isExpired(draft)) {
|
|
82
|
+
remove(key);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return draft.value;
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
write: (key, value) => {
|
|
91
|
+
try {
|
|
92
|
+
storage.setItem(
|
|
93
|
+
`${prefix}${key}`,
|
|
94
|
+
JSON.stringify({ savedAt: now(), value } satisfies StoredDraft),
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
// the draft lives as long as the screen
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
remove,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IsoDateTime } from "@reviewflow/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static on purpose: the line is read when the board is looked at, and a ticking
|
|
5
|
+
* re-render would cost a timer per button to say the same thing a minute later.
|
|
6
|
+
*/
|
|
7
|
+
export const formatDataAge = (date: IsoDateTime): string => {
|
|
8
|
+
const then = new Date(date);
|
|
9
|
+
return then.toDateString() === new Date().toDateString()
|
|
10
|
+
? then.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
11
|
+
: then.toLocaleString();
|
|
12
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A past date the way the operator says it: "today 14:32", "yesterday 09:10",
|
|
3
|
+
* "last Monday 16:05", then "12 Oct, 14:32" — and the year once it is not this
|
|
4
|
+
* one. The boards read dates to answer "is this from this morning or last
|
|
5
|
+
* week", which a full timestamp makes them work out.
|
|
6
|
+
*
|
|
7
|
+
* Calendar days in the machine's own time zone, not 24-hour spans: 23:50
|
|
8
|
+
* yesterday is "yesterday" at 00:10. The time and the month follow the
|
|
9
|
+
* locale; the words are the app's, which is in English.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** how many days back the weekday alone still names the day without doubt */
|
|
15
|
+
const weekdayReach = 6;
|
|
16
|
+
|
|
17
|
+
const startOfDay = (date: Date): number =>
|
|
18
|
+
new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
19
|
+
|
|
20
|
+
/** Whole calendar days from `date` to `now`; negative for a future date. */
|
|
21
|
+
const calendarDaysAgo = (date: Date, now: Date): number =>
|
|
22
|
+
Math.round((startOfDay(now) - startOfDay(date)) / dayMs);
|
|
23
|
+
|
|
24
|
+
export interface FormatRelativeDateOptions {
|
|
25
|
+
now?: Date;
|
|
26
|
+
/** a BCP 47 tag; the runtime's own when absent */
|
|
27
|
+
locale?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const formatRelativeDate = (
|
|
31
|
+
value: Date | string,
|
|
32
|
+
{ now = new Date(), locale }: FormatRelativeDateOptions = {},
|
|
33
|
+
): string => {
|
|
34
|
+
const date = typeof value === "string" ? new Date(value) : value;
|
|
35
|
+
if (Number.isNaN(date.getTime())) return "unknown date";
|
|
36
|
+
|
|
37
|
+
const time = date.toLocaleTimeString(locale, {
|
|
38
|
+
hour: "2-digit",
|
|
39
|
+
minute: "2-digit",
|
|
40
|
+
});
|
|
41
|
+
const days = calendarDaysAgo(date, now);
|
|
42
|
+
|
|
43
|
+
if (days === 0) return `today ${time}`;
|
|
44
|
+
if (days === 1) return `yesterday ${time}`;
|
|
45
|
+
if (days > 1 && days <= weekdayReach) {
|
|
46
|
+
const weekday = date.toLocaleDateString("en", { weekday: "long" });
|
|
47
|
+
return `last ${weekday} ${time}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const day = date.toLocaleDateString(locale, {
|
|
51
|
+
day: "numeric",
|
|
52
|
+
month: "short",
|
|
53
|
+
...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
|
|
54
|
+
});
|
|
55
|
+
return `${day}, ${time}`;
|
|
56
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PullRequestImageProxies } from "@reviewflow/sdk";
|
|
2
|
+
import { createContext, use } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The camo urls of the pull request's bodies, by the url each body wrote:
|
|
6
|
+
* an image is loaded through GitHub's proxy, so its host never learns who
|
|
7
|
+
* read the pull request nor when.
|
|
8
|
+
*/
|
|
9
|
+
export const GithubImagesContext = createContext<PullRequestImageProxies>({});
|
|
10
|
+
|
|
11
|
+
const githubHost = /(?:^|\.)(?:github\.com|githubusercontent\.com)$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where to load an image from: its camo url, or the image itself when GitHub
|
|
15
|
+
* hosts it (an attachment); null for any other host, whose image is linked
|
|
16
|
+
* rather than loaded.
|
|
17
|
+
*/
|
|
18
|
+
export const imageSourceOf = (
|
|
19
|
+
src: string,
|
|
20
|
+
proxies: PullRequestImageProxies,
|
|
21
|
+
): string | null => {
|
|
22
|
+
const proxied = proxies[src];
|
|
23
|
+
if (proxied !== undefined) return proxied;
|
|
24
|
+
try {
|
|
25
|
+
const { protocol, hostname } = new URL(src);
|
|
26
|
+
return protocol === "https:" && githubHost.test(hostname) ? src : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const useGithubImages = (): PullRequestImageProxies =>
|
|
33
|
+
use(GithubImagesContext);
|