summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--readme.md1
-rw-r--r--source/background.ts2
-rw-r--r--source/features/comments-time-machine-links.tsx11
-rw-r--r--source/features/quick-fork-deletion.css10
-rw-r--r--source/features/quick-fork-deletion.tsx138
-rw-r--r--source/features/show-open-prs-of-forks.tsx5
-rw-r--r--source/github-helpers/api.ts13
-rw-r--r--source/github-widgets/notice-bar.tsx29
-rw-r--r--source/helpers/pluralize.ts11
-rw-r--r--source/refined-github.ts1
10 files changed, 209 insertions, 12 deletions
diff --git a/readme.md b/readme.md
index 08f7c2d0..eccc9e91 100644
--- a/readme.md
+++ b/readme.md
@@ -96,6 +96,7 @@ Thanks for contributing! 🦋🙌
- [](# "sticky-sidebar") [Makes conversation sidebars and repository sidebars sticky, if they fit the viewport.](https://user-images.githubusercontent.com/10238474/62276723-5a2eaa80-b44d-11e9-810b-ff598d1c5c6a.gif)
- [](# "link-to-github-io") [Adds a link to visit the user’s github.io website from its repo.](https://user-images.githubusercontent.com/31387795/94045261-dbcd5e80-fdec-11ea-83fa-30bb673cc26e.jpg)
- [](# "next-scheduled-github-action") [Shows the next scheduled time of relevant GitHub Actions in the workflows sidebar.](https://user-images.githubusercontent.com/46634000/94690232-2476a180-0330-11eb-99d7-e174bb762cea.png)
+- [](# "quick-fork-deletion") [Lets you delete your forks in a click, if they have no stars, issues, or PRs.](https://user-images.githubusercontent.com/1402241/99716945-54a80a00-2a6e-11eb-9107-f3517a6ab1bc.gif)
<!-- Refer to style guide above. Keep this message between sections. -->
diff --git a/source/background.ts b/source/background.ts
index 1e182660..6cca0d7f 100644
--- a/source/background.ts
+++ b/source/background.ts
@@ -12,6 +12,8 @@ browser.runtime.onMessage.addListener((message, {tab}) => {
active: false
});
}
+ } else if (message?.closeTab) {
+ void browser.tabs.remove(tab!.id!);
}
});
diff --git a/source/features/comments-time-machine-links.tsx b/source/features/comments-time-machine-links.tsx
index 3b16758f..c415bc31 100644
--- a/source/features/comments-time-machine-links.tsx
+++ b/source/features/comments-time-machine-links.tsx
@@ -1,5 +1,4 @@
import React from 'dom-chef';
-import XIcon from 'octicon/x.svg';
import select from 'select-dom';
import elementReady from 'element-ready';
import * as pageDetect from 'github-url-detection';
@@ -7,6 +6,7 @@ import * as pageDetect from 'github-url-detection';
import features from '.';
import * as api from '../github-helpers/api';
import GitHubURL from '../github-helpers/github-url';
+import addNotice from '../github-widgets/notice-bar';
import {appendBefore} from '../helpers/dom-utils';
import {buildRepoURL, isPermalink} from '../github-helpers';
@@ -107,13 +107,8 @@ async function showTimemachineBar(): Promise<void | false> {
url.pathname = parsedUrl.pathname;
}
- const closeButton = <button className="flash-close js-flash-close" type="button" aria-label="Dismiss this message"><XIcon/></button>;
- select('#start-of-content')!.after(
- <div className="flash flash-full flash-notice">
- <div className="container-lg px-3">
- {closeButton} You can also <a className="rgh-link-date" href={String(url)}>view this object as it appeared at the time of the comment</a> (<relative-time datetime={date}/>)
- </div>
- </div>
+ addNotice(
+ <>You can also <a className="rgh-link-date" href={String(url)}>view this object as it appeared at the time of the comment</a> (<relative-time datetime={date}/>)</>
);
}
diff --git a/source/features/quick-fork-deletion.css b/source/features/quick-fork-deletion.css
new file mode 100644
index 00000000..28725ec3
--- /dev/null
+++ b/source/features/quick-fork-deletion.css
@@ -0,0 +1,10 @@
+:root .rgh-quick-fork-deletion[open] span {
+ position: relative;
+ z-index: 81;
+ transform-origin: top;
+}
+
+:root .rgh-quick-fork-deletion[open] summary::before {
+ background-color: var(--color-scale-red-6);
+ opacity: 30%;
+}
diff --git a/source/features/quick-fork-deletion.tsx b/source/features/quick-fork-deletion.tsx
new file mode 100644
index 00000000..fef675b6
--- /dev/null
+++ b/source/features/quick-fork-deletion.tsx
@@ -0,0 +1,138 @@
+import './quick-fork-deletion.css';
+import delay from 'delay';
+import React from 'dom-chef';
+import select from 'select-dom';
+import delegate from 'delegate-it';
+import elementReady from 'element-ready';
+import * as pageDetect from 'github-url-detection';
+
+import features from '.';
+import * as api from '../github-helpers/api';
+import {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';
+
+function handleToggle(event: delegate.Event<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 fork has open issues/PRs, are you sure you want to delete everything?')) {
+ // Close the <details> element again
+ event.delegateTarget.open = false;
+ } else {
+ // Without the timeout, the same toggle event will also trigger the AbortController
+ setTimeout(start, 1, event.delegateTarget);
+ }
+}
+
+async function buttonTimeout(buttonContainer: HTMLDetailsElement): Promise<boolean> {
+ // Watch for cancellations
+ const abortController = new AbortController();
+ buttonContainer.addEventListener('toggle', () => {
+ abortController.abort();
+ }, {once: true});
+
+ void api.expectTokenScope('delete_repo').catch((error: Error) => {
+ abortController.abort();
+ buttonContainer.open = false;
+ 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>
+ )
+ });
+ });
+
+ 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 fork 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 fork…';
+
+ try {
+ const {nameWithOwner} = getRepo()!;
+ await api.v3('/repos/' + nameWithOwner, {
+ method: 'DELETE',
+ json: false
+ });
+ addNotice(`Repository ${nameWithOwner} deleted`, {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});
+ }
+ } catch (error: unknown) {
+ buttonContainer.closest('li')!.remove(); // Remove button
+ addNotice([
+ 'Could not delete the repository. ',
+ (error as any).response?.message ?? (error as any).message
+ ], {
+ type: 'error'
+ });
+
+ throw error;
+ }
+}
+
+async function init(): Promise<void | false> {
+ if (
+ // Only if the user can delete the repository
+ !await elementReady('[data-tab-item="settings-tab"]') ||
+
+ // Only if the repository hasn't been starred
+ looseParseInt(select('.starring-container .social-count')!) > 0
+ ) {
+ return false;
+ }
+
+ await api.expectToken();
+
+ // (Ab)use the details element as state and an accessible "click-anywhere-to-cancel" utility
+ select('.pagehead-actions')!.prepend(
+ <li>
+ <details className="details-reset details-overlay select-menu rgh-quick-fork-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(document, '.rgh-quick-fork-deletion[open]', 'toggle', handleToggle, true);
+}
+
+void features.add(__filebasename, {
+ include: [
+ pageDetect.isForkedRepo
+ ],
+ awaitDomReady: false,
+ init
+});
diff --git a/source/features/show-open-prs-of-forks.tsx b/source/features/show-open-prs-of-forks.tsx
index 03ff92d7..9eb51834 100644
--- a/source/features/show-open-prs-of-forks.tsx
+++ b/source/features/show-open-prs-of-forks.tsx
@@ -72,8 +72,9 @@ async function initHeadHint(): Promise<void | false> {
return false;
}
- select<HTMLAnchorElement>(`[data-hovercard-type="repository"][href="/${getForkedRepo()!}"]`)!.after(
- <> with <a href={url}>{getLinkCopy(count)}</a></>
+ select(`[data-hovercard-type="repository"][href="/${getForkedRepo()!}"]`)!.after(
+ // The class is used by `quick-fork-deletion`
+ <> with <a href={url} className="rgh-open-prs-of-forks">{getLinkCopy(count)}</a></>
);
}
diff --git a/source/github-helpers/api.ts b/source/github-helpers/api.ts
index 98b2ddf9..504e4755 100644
--- a/source/github-helpers/api.ts
+++ b/source/github-helpers/api.ts
@@ -51,6 +51,7 @@ interface RestResponse extends AnyObject {
export const escapeKey = (value: string | number): string => '_' + String(value).replace(/[ ./-]/g, '_');
export class RefinedGitHubAPIError extends Error {
+ response: AnyObject = {};
constructor(...messages: string[]) {
super(messages.join('\n'));
}
@@ -66,6 +67,14 @@ export async function expectToken(): Promise<string> {
return personalToken;
}
+export async function expectTokenScope(scope: string): Promise<void> {
+ const {headers} = await v3('/');
+ const tokenScopes = headers.get('X-OAuth-Scopes')!;
+ if (!tokenScopes.split(', ').includes(scope)) {
+ throw new Error(`The token you provided does not have the \`${scope}\` scope. It only includes \`${tokenScopes}\``);
+ }
+}
+
const api3 = pageDetect.isEnterprise() ?
`${location.origin}/api/v3/` :
'https://api.github.com/';
@@ -210,11 +219,13 @@ export async function getError(apiResponse: JsonObject): Promise<RefinedGitHubAP
);
}
- return new RefinedGitHubAPIError(
+ const error = new RefinedGitHubAPIError(
'Unable to fetch.',
personalToken ?
'Ensure that your token has access to this repo.' :
'Maybe adding a token in the options will fix this issue.',
JSON.stringify(apiResponse, null, '\t') // Beautify
);
+ error.response = apiResponse;
+ return error;
}
diff --git a/source/github-widgets/notice-bar.tsx b/source/github-widgets/notice-bar.tsx
new file mode 100644
index 00000000..9fec13d8
--- /dev/null
+++ b/source/github-widgets/notice-bar.tsx
@@ -0,0 +1,29 @@
+import React from 'dom-chef';
+import XIcon from 'octicon/x.svg';
+import select from 'select-dom';
+
+interface Options {
+ action?: Element | false;
+ type?: 'success' | 'notice' | 'warn' | 'error';
+}
+
+/** https://primer.style/css/components/alerts */
+export default function addNotice(
+ message: string | Node | Array<string | Node>,
+ {
+ type = 'notice',
+ action = (
+ <button className="flash-close js-flash-close" type="button" aria-label="Dismiss this message">
+ <XIcon/>
+ </button>
+ )
+ }: Options = {}
+): void {
+ select('#start-of-content')!.after(
+ <div className={`flash flash-full flash-${type}`}>
+ <div className="container-lg px-3 d-flex flex-items-center flex-justify-between">
+ <div>{message}</div> {action}
+ </div>
+ </div>
+ );
+}
diff --git a/source/helpers/pluralize.ts b/source/helpers/pluralize.ts
index e0ed4e33..d90d6ca5 100644
--- a/source/helpers/pluralize.ts
+++ b/source/helpers/pluralize.ts
@@ -1,4 +1,13 @@
-export default function pluralize(count: number, single: string, plural: string, zero?: string): string {
+function regular(single: string): string {
+ return single + 's';
+}
+
+export default function pluralize(
+ count: number,
+ single: string,
+ plural = regular(single),
+ zero?: string
+): string {
if (count === 0 && zero) {
return zero.replace('$$', '0');
}
diff --git a/source/refined-github.ts b/source/refined-github.ts
index f9f53820..c0d8aafb 100644
--- a/source/refined-github.ts
+++ b/source/refined-github.ts
@@ -203,6 +203,7 @@ import './features/unfinished-comments';
import './features/single-diff-column-selection';
import './features/jump-to-change-requested-comment';
import './features/esc-to-cancel';
+import './features/quick-fork-deletion';
// Add global for easier debugging
(window as any).select = select;