diff options
-rw-r--r-- | readme.md | 1 | ||||
-rw-r--r-- | source/background.ts | 2 | ||||
-rw-r--r-- | source/features/comments-time-machine-links.tsx | 11 | ||||
-rw-r--r-- | source/features/quick-fork-deletion.css | 10 | ||||
-rw-r--r-- | source/features/quick-fork-deletion.tsx | 138 | ||||
-rw-r--r-- | source/features/show-open-prs-of-forks.tsx | 5 | ||||
-rw-r--r-- | source/github-helpers/api.ts | 13 | ||||
-rw-r--r-- | source/github-widgets/notice-bar.tsx | 29 | ||||
-rw-r--r-- | source/helpers/pluralize.ts | 11 | ||||
-rw-r--r-- | source/refined-github.ts | 1 |
10 files changed, 209 insertions, 12 deletions
@@ -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; |