summaryrefslogtreecommitdiff
path: root/source/features/list-prs-for-file.tsx
blob: d7e407f612323c8c30a34be7e9d2f362a0e741a5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import React from 'dom-chef';
import {CachedFunction} from 'webext-storage-cache';
import {isFirefox} from 'webext-detect-page';
import * as pageDetect from 'github-url-detection';
import {AlertIcon, GitPullRequestIcon} from '@primer/octicons-react';

import features from '../feature-manager.js';
import api from '../github-helpers/api.js';
import getDefaultBranch from '../github-helpers/get-default-branch.js';
import {buildRepoURL, cacheByRepo} from '../github-helpers/index.js';
import GitHubFileURL from '../github-helpers/github-file-url.js';
import observe from '../helpers/selector-observer.js';
import listPrsForFileQuery from './list-prs-for-file.gql';

function getPRUrl(prNumber: number): string {
	// https://caniuse.com/url-scroll-to-text-fragment
	const hash = isFirefox() ? '' : `#:~:text=${new GitHubFileURL(location.href).filePath}`;
	return buildRepoURL('pull', prNumber, 'files') + hash;
}

function getHovercardUrl(prNumber: number): string {
	return buildRepoURL('pull', prNumber, 'hovercard');
}

function getDropdown(prs: number[]): HTMLElement {
	const isEditing = pageDetect.isEditingFile();
	const icon = isEditing
		? <AlertIcon className="v-align-middle color-fg-attention"/>
		: <GitPullRequestIcon className="v-align-middle"/>;
	// Markup copied from https://primer.style/css/components/dropdown
	return (
		<details className="dropdown details-reset details-overlay flex-self-center">
			<summary className="btn btn-sm">
				{icon}
				<span className="v-align-middle"> {prs.length} </span>
				<div className="dropdown-caret"/>
			</summary>

			<details-menu className="dropdown-menu dropdown-menu-sw" style={{width: '13em'}}>
				<div className="dropdown-header">
					File also being edited in
				</div>
				{prs.map(prNumber => (
					<a
						className="dropdown-item"
						href={getPRUrl(prNumber)}
						data-hovercard-url={getHovercardUrl(prNumber)}
					>
						#{prNumber}
					</a>
				))}
			</details-menu>
		</details>
	);
}

/**
@returns prsByFile {"filename1": [10, 3], "filename2": [2]}
*/
const getPrsByFile = new CachedFunction('files-with-prs', {
	async updater(): Promise<Record<string, number[]>> {
		const {repository} = await api.v4(listPrsForFileQuery, {
			variables: {
				defaultBranch: await getDefaultBranch(),
			},
		});

		const files: Record<string, number[]> = {};

		for (const pr of repository.pullRequests.nodes) {
			for (const {path} of pr.files.nodes) {
				files[path] = files[path] ?? [];
				if (files[path].length < 10) {
					files[path].push(pr.number);
				}
			}
		}

		return files;
	},
	maxAge: {hours: 2},
	staleWhileRevalidate: {days: 9},
	cacheKey: cacheByRepo,
});

async function addToSingleFile(moreFileActionsDropdown: HTMLElement): Promise<void> {
	const path = new GitHubFileURL(location.href).filePath;
	const prsByFile = await getPrsByFile.get();
	const prs = prsByFile[path];

	if (prs) {
		const dropdown = getDropdown(prs);
		if (!moreFileActionsDropdown.parentElement!.matches('.gap-2')) {
			dropdown.classList.add('mr-2');
		}

		moreFileActionsDropdown.before(dropdown);
	}
}

async function addToEditingFile(saveButton: HTMLElement): Promise<false | void> {
	const path = new GitHubFileURL(location.href).filePath;
	const prsByFile = await getPrsByFile.get();
	let prs = prsByFile[path];

	if (!prs) {
		return;
	}

	const editingPRNumber = new URLSearchParams(location.search).get('pr')?.split('/').slice(-1);
	if (editingPRNumber) {
		prs = prs.filter(pr => pr !== Number(editingPRNumber));
		if (prs.length === 0) {
			return;
		}
	}

	const dropdown = getDropdown(prs);
	dropdown.classList.add('mr-2');
	saveButton.parentElement!.prepend(dropdown);
}

function initSingleFile(signal: AbortSignal): void {
	observe('[aria-label="More file actions"]', addToSingleFile, {signal});
}

function initEditingFile(signal: AbortSignal): void {
	observe('[data-hotkey="Meta+s,Control+s"]', addToEditingFile, {signal});
}

void features.add(import.meta.url, {
	include: [
		pageDetect.isSingleFile,
	],
	exclude: [
		pageDetect.isRepoFile404,
	],
	init: initSingleFile,
}, {
	include: [
		pageDetect.isEditingFile,
	],
	exclude: [
		pageDetect.isBlank,
	],
	awaitDomReady: true, // End of the page; DOM-based detections
	init: initEditingFile,
});

/*

## Test URLs

- isSingleFile: One PR https://github.com/refined-github/sandbox/blob/6619/6619
- isSingleFile: Multiple PRs https://github.com/refined-github/sandbox/blob/default-a/README.md
- isEditingFile: One PR https://github.com/refined-github/sandbox/edit/6619/6619
- isEditingFile: Multiple PRs https://github.com/refined-github/sandbox/edit/default-a/README.md

*/