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
160
161
162
163
164
165
166
|
import './quick-repo-deletion.css';
import delay from 'delay';
import React from 'dom-chef';
import select from 'select-dom';
import {TrashIcon} from '@primer/octicons-react';
import elementReady from 'element-ready';
import {assertError} from 'ts-extras';
import * as pageDetect from 'github-url-detection';
import delegate, {DelegateEvent} from 'delegate-it';
import features from '../feature-manager';
import * as api from '../github-helpers/api';
import {getForkedRepo, getRepo} from '../github-helpers';
import pluralize from '../helpers/pluralize';
import addNotice from '../github-widgets/notice-bar';
import looseParseInt from '../helpers/loose-parse-int';
import parseBackticks from '../github-helpers/parse-backticks';
import attachElement from '../helpers/attach-element';
function handleToggle(event: DelegateEvent<Event, HTMLDetailsElement>): void {
const hasContent = select.exists([
'[data-hotkey="g i"] .Counter:not([hidden])', // Open issues
'[data-hotkey="g p"] .Counter:not([hidden])', // Open PRs
'.rgh-open-prs-of-forks', // PRs opened in the source repo
]);
if (hasContent && !confirm('This repo has open issues/PRs, are you sure you want to delete everything?')) {
// Close the <details> element again
event.delegateTarget.open = false;
return;
}
if (!pageDetect.isForkedRepo() && !confirm('⚠️ This action cannot be undone. This will permanently delete the repository, wiki, issues, comments, packages, secrets, workflow runs, and remove all collaborator associations.')) {
event.delegateTarget.open = false;
return;
}
// Without the timeout, the same toggle event will also trigger the AbortController
setTimeout(start, 1, event.delegateTarget);
}
async function verifyScopesWhileWaiting(abortController: AbortController): Promise<void> {
try {
await api.expectTokenScope('delete_repo');
} catch (error) {
assertError(error);
abortController.abort();
await addNotice([
'Could not delete the repository. ',
parseBackticks(error.message),
], {
type: 'error',
action: (
<a className="btn btn-sm primary flash-action" href="https://github.com/settings/tokens">
Update token…
</a>
),
});
}
}
async function buttonTimeout(buttonContainer: HTMLDetailsElement): Promise<boolean> {
const abortController = new AbortController();
// Add a global click listener to avoid potential future issues with z-index
document.addEventListener('click', event => {
event.preventDefault();
abortController.abort();
buttonContainer.open = false;
}, {once: true});
void verifyScopesWhileWaiting(abortController);
let secondsLeft = 5;
const button = select('.btn', buttonContainer)!;
try {
do {
button.style.transform = `scale(${1.2 - ((secondsLeft - 5) / 3)})`; // Dividend is zoom speed
button.textContent = `Deleting repo in ${pluralize(secondsLeft, '$$ second')}. Cancel?`;
await delay(1000, {signal: abortController.signal}); // eslint-disable-line no-await-in-loop
} while (--secondsLeft);
} catch {
button.textContent = 'Delete fork';
button.style.transform = '';
}
return !abortController.signal.aborted;
}
async function start(buttonContainer: HTMLDetailsElement): Promise<void> {
if (!await buttonTimeout(buttonContainer)) {
return;
}
select('.btn', buttonContainer)!.textContent = 'Deleting repo…';
const {nameWithOwner, owner} = getRepo()!;
try {
await api.v3('/repos/' + nameWithOwner, {
method: 'DELETE',
json: false,
});
} catch (error) {
assertError(error);
buttonContainer.closest('li')!.remove(); // Remove button
await addNotice([
'Could not delete the repository. ',
(error as api.RefinedGitHubAPIError).response?.message ?? error.message,
], {
type: 'error',
});
throw error;
}
const forkSource = '/' + getForkedRepo()!;
const restoreURL = pageDetect.isOrganizationRepo()
? `/organizations/${owner}/settings/deleted_repositories`
: '/settings/deleted_repositories';
const otherForksURL = `/${owner}?tab=repositories&type=fork`;
await addNotice(
<><TrashIcon/> <span>Repository <strong>{nameWithOwner}</strong> deleted. <a href={restoreURL}>Restore it</a>, <a href={forkSource}>visit the source repo</a>, or see <a href={otherForksURL}>your other forks.</a></span></>,
{action: false},
);
select('.application-main')!.remove();
if (document.hidden) {
// Try closing the tab if in the background. Could fail, so we still update the UI above
void browser.runtime.sendMessage({closeTab: true});
}
}
async function init(signal: AbortSignal): Promise<void | false> {
if (
// Only if the user can delete the repository
// TODO: Replace with https://github.com/refined-github/github-url-detection/issues/85
!await elementReady('nav [data-content="Settings"]')
// Only if the repository hasn't been starred
|| looseParseInt(select('.starring-container .Counter')) > 0
) {
return false;
}
await api.expectToken();
// (Ab)use the details element as state and an accessible "click-anywhere-to-cancel" utility
attachElement('.pagehead-actions', {
prepend: () => (
<li>
<details className="details-reset details-overlay select-menu rgh-quick-repo-deletion">
<summary aria-haspopup="menu" role="button">
{/* This extra element is needed to keep the button above the <summary>’s lightbox */}
<span className="btn btn-sm btn-danger">Delete fork</span>
</summary>
</details>
</li>
),
});
delegate('.rgh-quick-repo-deletion[open]', 'toggle', handleToggle, {capture: true, signal});
}
void features.add(import.meta.url, {
include: [
pageDetect.isForkedRepo,
],
init,
});
|