[Feature] Custom domain support (#1)

* made each provider a function so it always gives a new object instead of a reference, added name property, added domains property, removed domain property, added canSelfHost property

* getGitProvider now tests only the host of the url instead of the whole string. Removed switch statement for thesting domains in favor of a for loop which will allow custom domains. added new function addGitProvider which adds a copy of the config for given provider and new custom domain

* added custom providers library

* added loading of custom providers

* change async/await calls with with promise.then

* added detect property to provider.selectors object

* added flag isCustom to all custom provider objects for use in filtering

* added mozilla webextension polyfill so storage works across browsers

* wip: custom domains

* added wildcard for content scripts, so that popup can talk to current page

* added eslint exception for parameter reassignment in custom-providers.js

* added isCustom property to each provider, so it is consistent in all provider objects

* Closes #82

* Closes #76

* Added previous selectors as fallback for older instances

* Added ability to opt in to custom websites instead of the extension running on all of them by default. Only chromium browsers supported. Firefox does not allow requesting permissions from background scripts

---------

Co-authored-by: Michael Goodman <bulgedition@gmail.com>
This commit is contained in:
Philipp Kief 2024-06-24 17:45:35 +02:00
parent 71ff6c0ed3
commit e153f99d10
No known key found for this signature in database
GPG Key ID: CC872C197CBB41C8
24 changed files with 2470 additions and 607 deletions

View File

@ -32,7 +32,7 @@
}
},
{
"files": ["src/providers/*.js"],
"files": ["src/providers/*.js", "src/lib/*.js"],
"rules": {
"no-param-reassign": "off"
}

1760
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,8 @@
"npm": "^8.0.0"
},
"dependencies": {
"selector-observer": "2.1.6"
"selector-observer": "2.1.6",
"webextension-polyfill": "0.11.0"
},
"devDependencies": {
"@octokit/core": "3.5.1",

View File

@ -37,7 +37,12 @@ function src(distPath) {
bundleJS(distPath, path.resolve(srcPath, 'ui', 'popup', 'settings-popup.js'));
const bundleOptionsScript = () =>
bundleJS(distPath, path.resolve(srcPath, 'ui', 'options', 'options.js'));
const bundleAll = bundleMainScript().then(bundlePopupScript).then(bundleOptionsScript);
const bundleBackgroundScript = () =>
bundleJS(distPath, path.resolve(srcPath, 'background', 'background.js'));
const bundleAll = bundleMainScript()
.then(bundlePopupScript)
.then(bundleOptionsScript)
.then(bundleBackgroundScript);
const copyPopup = Promise.all(
['settings-popup.html', 'settings-popup.css', 'settings-popup.github-logo.svg'].map((file) =>

View File

@ -0,0 +1,35 @@
import Browser from 'webextension-polyfill';
Browser.runtime.onMessage.addListener((message) => {
if (message.event === 'request-access') {
const perm = {
permissions: ['activeTab'],
origins: [`*://${message.data.host}/*`],
};
Browser.permissions.request(perm).then((granted) => {
if (!granted) {
return;
}
// run the script now
Browser.scripting.executeScript({
files: ['./main.js'],
target: {
tabId: message.data.tabId,
},
});
// register content script for future
return Browser.scripting.registerContentScripts([
{
id: 'github-material-icons',
js: ['./main.js'],
css: ['./injected-styles.css'],
matches: [`*://${message.data.host}/*`],
runAt: 'document_start',
},
]);
});
}
});

View File

@ -0,0 +1,10 @@
import Browser from 'webextension-polyfill';
export const getCustomProviders = () =>
Browser.storage.sync.get('customProviders').then((data) => data.customProviders || {});
export const addCustomProvider = (name, handler) =>
getCustomProviders().then((customProviders) => {
customProviders[name] = handler;
return Browser.storage.sync.set({ customProviders });
});

View File

@ -1,3 +1,4 @@
import Browser from 'webextension-polyfill';
import iconsList from '../icon-list.json';
import iconMap from '../icon-map.json';
import languageMap from '../language-map.json';
@ -82,7 +83,7 @@ export function replaceElementWithIcon(iconEl, iconName, fileName, iconPack, pro
newSVG.setAttribute('data-material-icons-extension', 'icon');
newSVG.setAttribute('data-material-icons-extension-iconname', iconName);
newSVG.setAttribute('data-material-icons-extension-filename', fileName);
newSVG.src = chrome.runtime.getURL(`${svgFileName}.svg`);
newSVG.src = Browser.runtime.getURL(`${svgFileName}.svg`);
provider.replaceIcon(iconEl, newSVG);
}

View File

@ -1,3 +1,5 @@
import Browser from 'webextension-polyfill';
const hardDefaults = {
iconPack: 'react',
iconSize: 'md',
@ -5,31 +7,28 @@ const hardDefaults = {
};
export const getConfig = (config, domain = window.location.hostname, useDefault = true) =>
new Promise((resolve) => {
chrome.storage.sync.get(
{
// get custom domain config (if not getting default).
[`${domain !== 'default' ? domain : 'SKIP'}:${config}`]: null,
// also get user default as fallback
[`default:${config}`]: hardDefaults[config],
},
Browser.storage.sync
.get({
// get custom domain config (if not getting default).
[`${domain !== 'default' ? domain : 'SKIP'}:${config}`]: null,
// also get user default as fallback
[`default:${config}`]: hardDefaults[config],
})
.then(
({ [`${domain}:${config}`]: value, [`default:${config}`]: fallback }) =>
resolve(value ?? (useDefault ? fallback : null))
value ?? (useDefault ? fallback : null)
);
});
export const setConfig = (config, value, domain = window.location.hostname) =>
chrome.storage.sync.set({
Browser.storage.sync.set({
[`${domain}:${config}`]: value,
});
export const clearConfig = (config, domain = window.location.hostname) =>
new Promise((resolve) => {
chrome.storage.sync.remove(`${domain}:${config}`, resolve);
});
Browser.storage.sync.remove(`${domain}:${config}`);
export const onConfigChange = (config, handler, domain = window.location.hostname) =>
chrome.storage.onChanged.addListener(
Browser.storage.onChanged.addListener(
(changes) =>
changes[`${domain}:${config}`]?.newValue !== undefined &&
handler(changes[`${domain}:${config}`]?.newValue)

View File

@ -1,18 +1,49 @@
import Browser from 'webextension-polyfill';
import { getGitProvider } from './providers';
import { observePage, replaceAllIcons } from './lib/replace-icons';
import { initIconSizes } from './lib/icon-sizes';
import { getConfig, onConfigChange } from './lib/userConfig';
initIconSizes();
const { href } = window.location;
const gitProvider = getGitProvider(href);
function init() {
initIconSizes();
Promise.all([
getConfig('iconPack'),
getConfig('extEnabled'),
getConfig('extEnabled', 'default'),
]).then(([iconPack, extEnabled, globalExtEnabled]) => {
if (!globalExtEnabled || !extEnabled || !gitProvider) return;
observePage(gitProvider, iconPack);
onConfigChange('iconPack', (newIconPack) => replaceAllIcons(gitProvider, newIconPack));
const { href } = window.location;
getGitProvider(href).then((gitProvider) => {
Promise.all([
getConfig('iconPack'),
getConfig('extEnabled'),
getConfig('extEnabled', 'default'),
]).then(([iconPack, extEnabled, globalExtEnabled]) => {
if (!globalExtEnabled || !extEnabled || !gitProvider) return;
observePage(gitProvider, iconPack);
onConfigChange('iconPack', (newIconPack) => replaceAllIcons(gitProvider, newIconPack));
});
});
}
const handlers = {
init,
guessProvider(possibilities) {
for (const [name, selector] of Object.entries(possibilities)) {
if (document.querySelector(selector)) {
return name;
}
}
return null;
},
};
Browser.runtime.onMessage.addListener((message, sender, response) => {
if (!handlers[message.cmd]) {
return response(null);
}
const result = handlers[message.cmd].apply(null, message.args || []);
return response(result);
});
init();

View File

@ -30,5 +30,5 @@
"page": "options.html",
"open_in_tab": true
},
"permissions": ["storage", "activeTab"]
"permissions": ["storage", "activeTab", "scripting"]
}

View File

@ -11,7 +11,8 @@
"*://gitea.com/*",
"*://gitlab.com/*",
"*://gitee.com/*",
"*://sourceforge.net/*"
"*://sourceforge.net/*",
"*://*/*"
]
}
],
@ -24,5 +25,9 @@
"48": "icon-48.png",
"128": "icon-128.png"
}
}
},
"background": {
"service_worker": "./background.js"
},
"optional_host_permissions": ["*://*/*"]
}

View File

@ -15,5 +15,23 @@
"48": "icon-48.png",
"128": "icon-128.png"
}
}
},
"content_scripts": [
{
"matches": [
"*://github.com/*",
"*://bitbucket.org/*",
"*://dev.azure.com/*",
"*://*.visualstudio.com/*",
"*://gitea.com/*",
"*://gitlab.com/*",
"*://gitee.com/*",
"*://sourceforge.net/*",
"*://*/*"
],
"js": ["./main.js"],
"css": ["./injected-styles.css"],
"run_at": "document_start"
}
]
}

View File

@ -1,57 +1,72 @@
/** The name of the class used to hide the pseudo element `:before` on Azure */
const HIDE_PSEUDO_CLASS = 'material-icons-exension-hide-pseudo';
const azureConfig = {
domain: 'dev.azure.com',
selectors: {
row: 'table.bolt-table tbody > a',
filename: 'table.bolt-table tbody > a > td[aria-colindex="1"] span.text-ellipsis',
icon: 'td[aria-colindex="1"] span.icon-margin',
},
getIsLightTheme: () =>
document.defaultView.getComputedStyle(document.body).getPropertyValue('color') ===
'rgba(0, 0, 0, 0.9)', // TODO: There is probably a better way to determine whether Azure is in light mode
getIsDirectory: ({ icon }) => icon.classList.contains('repos-folder-icon'),
getIsSubmodule: () => false, // There appears to be no way to tell if a folder is a submodule
getIsSymlink: ({ icon }) => icon.classList.contains('ms-Icon--PageArrowRight'),
replaceIcon: (svgEl, newSVG) => {
newSVG.style.display = 'inline-flex';
newSVG.style.height = '1rem';
newSVG.style.width = '1rem';
export default function azure() {
return {
name: 'azure',
domains: [
{
host: 'dev.azure.com',
test: /^dev\.azure\.com$/,
},
{
host: 'visualstudio.com',
test: /.*\.visualstudio\.com$/,
},
],
selectors: {
row: 'table.bolt-table tbody tr.bolt-table-row, table.bolt-table tbody > a',
filename:
'td.bolt-table-cell[data-column-index="0"] .bolt-table-link .text-ellipsis, table.bolt-table tbody > a > td[aria-colindex="1"] span.text-ellipsis',
icon: 'td.bolt-table-cell[data-column-index="0"] span.icon-margin, td[aria-colindex="1"] span.icon-margin',
// Element by which to detect if the tested domain is azure.
detect: 'body > input[type=hidden][name=__RequestVerificationToken]',
},
canSelfHost: false,
isCustom: false,
getIsLightTheme: () =>
document.defaultView.getComputedStyle(document.body).getPropertyValue('color') ===
'rgba(0, 0, 0, 0.9)', // TODO: There is probably a better way to determine whether Azure is in light mode
getIsDirectory: ({ icon }) => icon.classList.contains('repos-folder-icon'),
getIsSubmodule: () => false, // There appears to be no way to tell if a folder is a submodule
getIsSymlink: ({ icon }) => icon.classList.contains('ms-Icon--PageArrowRight'),
replaceIcon: (svgEl, newSVG) => {
newSVG.style.display = 'inline-flex';
newSVG.style.height = '1rem';
newSVG.style.width = '1rem';
if (!svgEl.classList.contains(HIDE_PSEUDO_CLASS)) {
svgEl.classList.add(HIDE_PSEUDO_CLASS);
}
// Instead of replacing the child icon, add the new icon as a child,
// otherwise Azure DevOps crashes when you navigate through the repository
if (svgEl.hasChildNodes()) {
svgEl.replaceChild(newSVG, svgEl.firstChild);
} else {
svgEl.appendChild(newSVG);
}
},
onAdd: (row, callback) => {
// Mutation observer is required for azure to work properly because the rows are not removed
// from the page when navigating through the repository. Without this the page will render
// fine initially but any subsequent changes will reult in inaccurate icons.
const mutationCallback = (mutationsList) => {
// Check whether the mutation was made by this extension
// this is determined by whether there is an image node added to the dom
const isExtensionMutation = mutationsList.some((mutation) =>
Array.from(mutation.addedNodes).some((node) => node.nodeName === 'IMG')
);
// If the mutation was not caused by the extension, run the icon replacement
// otherwise there will be an infinite loop
if (!isExtensionMutation) {
callback();
if (!svgEl.classList.contains(HIDE_PSEUDO_CLASS)) {
svgEl.classList.add(HIDE_PSEUDO_CLASS);
}
};
const observer = new MutationObserver(mutationCallback);
observer.observe(row, { attributes: true, childList: true, subtree: true });
},
};
// Instead of replacing the child icon, add the new icon as a child,
// otherwise Azure DevOps crashes when you navigate through the repository
if (svgEl.hasChildNodes()) {
svgEl.replaceChild(newSVG, svgEl.firstChild);
} else {
svgEl.appendChild(newSVG);
}
},
onAdd: (row, callback) => {
// Mutation observer is required for azure to work properly because the rows are not removed
// from the page when navigating through the repository. Without this the page will render
// fine initially but any subsequent changes will reult in inaccurate icons.
const mutationCallback = (mutationsList) => {
// Check whether the mutation was made by this extension
// this is determined by whether there is an image node added to the dom
const isExtensionMutation = mutationsList.some((mutation) =>
Array.from(mutation.addedNodes).some((node) => node.nodeName === 'IMG')
);
export default azureConfig;
// If the mutation was not caused by the extension, run the icon replacement
// otherwise there will be an infinite loop
if (!isExtensionMutation) {
callback();
}
};
const observer = new MutationObserver(mutationCallback);
observer.observe(row, { attributes: true, childList: true, subtree: true });
},
};
}

View File

@ -1,34 +1,44 @@
const bitbucketConfig = {
domain: 'bitbucket.org',
selectors: {
// Don't replace the icon for the parent directory row
row: 'table[data-qa="repository-directory"] td:first-child a:first-child:not([aria-label="Parent directory,"])',
filename: 'span',
icon: 'svg',
},
getIsLightTheme: () => true, // No dark mode available for bitbucket currently
getIsDirectory: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Directory,',
getIsSubmodule: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Submodule,',
getIsSymlink: () => false, // There appears to be no way to determine this for bitbucket
replaceIcon: (svgEl, newSVG) => {
newSVG.style.overflow = 'hidden';
newSVG.style.pointerEvents = 'none';
newSVG.style.maxHeight = '100%';
newSVG.style.maxWidth = '100%';
newSVG.style.verticalAlign = 'bottom';
export default function bitbucket() {
return {
name: 'bitbucket',
domains: [
{
host: 'bitbucket.org',
test: /^bitbucket\.org$/,
},
],
selectors: {
// Don't replace the icon for the parent directory row
row: 'table[data-qa="repository-directory"] td:first-child a:first-child:not([aria-label="Parent directory,"])',
filename: 'span',
icon: 'svg',
// Element by which to detect if the tested domain is bitbucket.
detect: 'body[data-aui-version] > #root',
},
canSelfHost: true,
isCustom: false,
getIsLightTheme: () => true, // No dark mode available for bitbucket currently
getIsDirectory: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Directory,',
getIsSubmodule: ({ icon }) => icon.parentNode?.getAttribute('aria-label') === 'Submodule,',
getIsSymlink: () => false, // There appears to be no way to determine this for bitbucket
replaceIcon: (svgEl, newSVG) => {
newSVG.style.overflow = 'hidden';
newSVG.style.pointerEvents = 'none';
newSVG.style.maxHeight = '100%';
newSVG.style.maxWidth = '100%';
newSVG.style.verticalAlign = 'bottom';
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
export default bitbucketConfig;
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
}

View File

@ -1,27 +1,37 @@
const giteaConfig = {
domain: 'gitea.com',
selectors: {
row: 'tr.ready.entry',
filename: 'td.name.four.wide > span.truncate > a',
icon: 'td.name.four.wide > span.truncate > svg',
},
getIsLightTheme: () => false,
getIsDirectory: ({ icon }) => icon.classList.contains('octicon-file-directory-fill'),
getIsSubmodule: ({ icon }) => icon.classList.contains('octicon-file-submodule'),
getIsSymlink: ({ icon }) => icon.classList.contains('octicon-file-symlink-file'),
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
export default function gitea() {
return {
name: 'gitea',
domains: [
{
host: 'gitea.com',
test: /^gitea\.com$/,
},
],
selectors: {
row: 'tr.ready.entry',
filename: 'td.name.four.wide > span.truncate > a',
icon: 'td.name.four.wide > span.truncate > svg',
// Element by which to detect if the tested domain is gitea.
detect: 'body > .full.height > .page-content[role=main]',
},
canSelfHost: true,
isCustom: false,
getIsLightTheme: () => false,
getIsDirectory: ({ icon }) => icon.classList.contains('octicon-file-directory-fill'),
getIsSubmodule: ({ icon }) => icon.classList.contains('octicon-file-submodule'),
getIsSymlink: ({ icon }) => icon.classList.contains('octicon-file-symlink-file'),
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
export default giteaConfig;
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
}

View File

@ -1,33 +1,43 @@
const giteeConfig = {
domain: 'gitee.com',
selectors: {
// File list row, README header, file view header
row: '#git-project-content .tree-content .row.tree-item, .file_title, .blob-description',
// File name table cell, Submodule name table cell, file view header
filename: '.tree-list-item > a, .tree-item-submodule-name a, span.file_name',
// The iconfont icon not including the delete button icon in the file view header
icon: 'i.iconfont:not(.icon-delete)',
},
getIsLightTheme: () => true, // There appears to be no dark theme available for gitee.
getIsDirectory: ({ icon }) => icon.classList.contains('icon-folders'),
getIsSubmodule: ({ icon }) => icon.classList.contains('icon-submodule'),
getIsSymlink: ({ icon }) => icon.classList.contains('icon-file-shortcut'),
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
export default function gitee() {
return {
name: 'gitee',
domains: [
{
host: 'gitee.com',
test: /^gitee\.com$/,
},
],
selectors: {
// File list row, README header, file view header
row: '#git-project-content .tree-content .row.tree-item, .file_title, .blob-description',
// File name table cell, Submodule name table cell, file view header
filename: '.tree-list-item > a, .tree-item-submodule-name a, span.file_name',
// The iconfont icon not including the delete button icon in the file view header
icon: 'i.iconfont:not(.icon-delete)',
// Element by which to detect if the tested domain is gitee.
detect: null,
},
canSelfHost: false,
isCustom: false,
getIsLightTheme: () => true, // There appears to be no dark theme available for gitee.
getIsDirectory: ({ icon }) => icon.classList.contains('icon-folders'),
getIsSubmodule: ({ icon }) => icon.classList.contains('icon-submodule'),
getIsSymlink: ({ icon }) => icon.classList.contains('icon-file-shortcut'),
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
newSVG.style.height = '28px';
newSVG.style.width = '18px';
newSVG.style.height = '28px';
newSVG.style.width = '18px';
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
export default giteeConfig;
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
}

View File

@ -1,58 +1,69 @@
const githubConfig = {
domain: 'github.com',
selectors: {
row: `.js-navigation-container[role=grid] > .js-navigation-item,
file-tree .ActionList-content,
a.tree-browser-result,
.PRIVATE_TreeView-item-content,
.react-directory-filename-column`,
filename: `div[role="rowheader"] > span,
.ActionList-item-label,
a.tree-browser-result > marked-text,
.PRIVATE_TreeView-item-content > .PRIVATE_TreeView-item-content-text,
.react-directory-filename-column a`,
icon: `.octicon-file,
.octicon-file-directory-fill,
.octicon-file-directory-open-fill,
.octicon-file-submodule,
.react-directory-filename-column > svg`,
},
getIsLightTheme: () => document.querySelector('html').getAttribute('data-color-mode') === 'light',
getIsDirectory: ({ icon }) =>
icon.getAttribute('aria-label') === 'Directory' ||
icon.classList.contains('octicon-file-directory-fill') ||
icon.classList.contains('octicon-file-directory-open-fill') ||
icon.classList.contains('icon-directory'),
getIsSubmodule: ({ icon }) => icon.getAttribute('aria-label') === 'Submodule',
getIsSymlink: ({ icon }) => icon.getAttribute('aria-label') === 'Symlink Directory',
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
export default function github() {
return {
name: 'github',
domains: [
{
host: 'github.com',
test: /^github\.com$/,
},
],
selectors: {
row: `.js-navigation-container[role=grid] > .js-navigation-item,
file-tree .ActionList-content,
a.tree-browser-result,
.PRIVATE_TreeView-item-content,
.react-directory-filename-column`,
filename: `div[role="rowheader"] > span,
.ActionList-item-label,
a.tree-browser-result > marked-text,
.PRIVATE_TreeView-item-content > .PRIVATE_TreeView-item-content-text,
.react-directory-filename-column a`,
icon: `.octicon-file,
.octicon-file-directory-fill,
.octicon-file-directory-open-fill,
.octicon-file-submodule,
.react-directory-filename-column > svg`,
// Element by which to detect if the tested domain is github.
detect: 'body > div[data-turbo-body]',
},
canSelfHost: true,
isCustom: false,
getIsLightTheme: () =>
document.querySelector('html').getAttribute('data-color-mode') === 'light',
getIsDirectory: ({ icon }) =>
icon.getAttribute('aria-label') === 'Directory' ||
icon.classList.contains('octicon-file-directory-fill') ||
icon.classList.contains('octicon-file-directory-open-fill') ||
icon.classList.contains('icon-directory'),
getIsSubmodule: ({ icon }) => icon.getAttribute('aria-label') === 'Submodule',
getIsSymlink: ({ icon }) => icon.getAttribute('aria-label') === 'Symlink Directory',
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
const prevEl = svgEl.previousElementSibling;
if (prevEl?.getAttribute('data-material-icons-extension') === 'icon') {
newSVG.replaceWith(prevEl);
}
// If the icon to replace is an icon from this extension, replace it with the new icon
else if (svgEl.getAttribute('data-material-icons-extension') === 'icon') {
svgEl.replaceWith(newSVG);
}
// If neither of the above, prepend the new icon in front of the original icon.
// If we remove the icon, GitHub code view crashes when you navigate through the
// tree view. Instead, we just hide it via `style` attribute (not CSS class)
// https://github.com/material-extensions/material-icons-browser-addon/pull/66
else {
svgEl.style.display = 'none';
svgEl.before(newSVG);
}
},
onAdd: () => {},
};
export default githubConfig;
const prevEl = svgEl.previousElementSibling;
if (prevEl?.getAttribute('data-material-icons-extension') === 'icon') {
newSVG.replaceWith(prevEl);
}
// If the icon to replace is an icon from this extension, replace it with the new icon
else if (svgEl.getAttribute('data-material-icons-extension') === 'icon') {
svgEl.replaceWith(newSVG);
}
// If neither of the above, prepend the new icon in front of the original icon.
// If we remove the icon, GitHub code view crashes when you navigate through the
// tree view. Instead, we just hide it via `style` attribute (not CSS class)
// https://github.com/Claudiohbsantos/github-material-icons-extension/pull/66
else {
svgEl.style.display = 'none';
svgEl.before(newSVG);
}
},
onAdd: () => {},
};
}

View File

@ -1,34 +1,45 @@
const gitlabConfig = {
domain: 'gitlab.com',
selectors: {
// Row in file list, file view header
row: 'table[data-qa-selector="file_tree_table"] tr, .file-header-content',
// Cell in file list, file view header, readme header
filename:
'td.tree-item-file-name, .file-header-content .file-title-name, .file-header-content .gl-link',
// Any icon not contained in a button
icon: '.tree-item svg, .file-header-content svg:not(.gl-button-icon)',
},
getIsLightTheme: () => !document.querySelector('body').classList.contains('gl-dark'),
getIsDirectory: ({ icon }) => icon.getAttribute('data-testid') === 'folder-icon',
getIsSubmodule: ({ row }) => row.querySelector('a')?.classList.contains('is-submodule') || false,
getIsSymlink: ({ icon }) => icon.getAttribute('data-testid') === 'symlink-icon',
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
export default function gitlab() {
return {
name: 'gitlab',
domains: [
{
host: 'gitlab.com',
test: /^gitlab\.com$/,
},
],
selectors: {
// Row in file list, file view header
row: 'table[data-testid="file-tree-table"].table.tree-table tr.tree-item, table[data-qa-selector="file_tree_table"] tr, .file-header-content',
// Cell in file list, file view header, readme header
filename:
'td.tree-item-file-name .tree-item-link, td.tree-item-file-name, .file-header-content .file-title-name, .file-header-content .gl-link',
// Any icon not contained in a button
icon: 'td.tree-item-file-name .tree-item-link svg, .tree-item svg, .file-header-content svg:not(.gl-button-icon)',
// Element by which to detect if the tested domain is gitlab.
detect: 'body.page-initialized[data-page]',
},
canSelfHost: true,
isCustom: false,
getIsLightTheme: () => !document.querySelector('body').classList.contains('gl-dark'),
getIsDirectory: ({ icon }) => icon.getAttribute('data-testid') === 'folder-icon',
getIsSubmodule: ({ row }) =>
row.querySelector('a')?.classList.contains('is-submodule') || false,
getIsSymlink: ({ icon }) => icon.getAttribute('data-testid') === 'symlink-icon',
replaceIcon: (svgEl, newSVG) => {
svgEl
.getAttributeNames()
.forEach(
(attr) =>
attr !== 'src' &&
!/^data-material-icons-extension/.test(attr) &&
newSVG.setAttribute(attr, svgEl.getAttribute(attr))
);
newSVG.style.height = '16px';
newSVG.style.width = '16px';
newSVG.style.height = '16px';
newSVG.style.width = '16px';
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
export default gitlabConfig;
svgEl.parentNode.replaceChild(newSVG, svgEl);
},
onAdd: () => {},
};
}

View File

@ -1,52 +1,90 @@
import githubConfig from './github';
import bitbucketConfig from './bitbucket';
import azureConfig from './azure';
import giteaConfig from './gitea';
import gitlabConfig from './gitlab';
import giteeConfig from './gitee';
import sourceforgeConfig from './sourceforge';
import github from './github';
import bitbucket from './bitbucket';
import azure from './azure';
import gitea from './gitea';
import gitlab from './gitlab';
import gitee from './gitee';
import sourceforge from './sourceforge';
import { getCustomProviders } from '../lib/custom-providers';
export const providerConfig = {
github: githubConfig,
bitbucket: bitbucketConfig,
azure: azureConfig,
gitea: giteaConfig,
gitlab: gitlabConfig,
gitee: giteeConfig,
sourceforge: sourceforgeConfig,
export const providers = {
azure,
bitbucket,
gitea,
gitee,
github,
gitlab,
sourceforge,
};
export const providerConfig = {};
for (const provider of Object.values(providers)) {
const cfg = provider();
providerConfig[cfg.name] = cfg;
}
function regExpEscape(str) {
return str.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&');
}
/**
* Add custom git provider
*
* @param {string} name
* @param {string|CallableFunction} handler
*/
export const addGitProvider = (name, handler) => {
handler = typeof handler === 'string' ? providers[handler] : handler;
const provider = handler();
provider.isCustom = true;
provider.name = name;
provider.domains = [
{
host: name,
test: new RegExp(`^${regExpEscape(name)}$`),
},
];
providerConfig[name] = provider;
};
export const getGitProviders = () =>
getCustomProviders().then((customProviders) => {
for (const [domain, handler] of Object.entries(customProviders)) {
if (!providerConfig[domain]) {
addGitProvider(domain, handler);
}
}
return providerConfig;
});
/**
* Get all selectors and functions specific to the Git provider
*
* @param {string} href Url of current tab
* @param domain
* @returns {object} All of the values needed for the provider
*/
export const getGitProvider = (href) => {
switch (true) {
case /github\.com.*/.test(href):
return providerConfig.github;
case /bitbucket\.org/.test(href):
return providerConfig.bitbucket;
case /gitea\.com/.test(href):
return providerConfig.gitea;
case /dev\.azure\.com/.test(href):
case /visualstudio\.com/.test(href):
return providerConfig.azure;
case /gitlab\.com/.test(href):
return providerConfig.gitlab;
case /gitee\.com/.test(href):
return providerConfig.gitee;
case /sourceforge\.net/.test(href):
return providerConfig.sourceforge;
default:
return null;
export const getGitProvider = (domain) => {
if (!domain.startsWith('http')) {
domain = new URL(`http://${domain}`).host;
} else {
domain = new URL(domain).host;
}
return getGitProviders().then((p) => {
for (const provider of Object.values(p)) {
for (const d of provider.domains) {
if (d.test.test(domain)) {
return provider;
}
}
}
return null;
});
};

View File

@ -1,58 +1,68 @@
const sourceforgeConfig = {
domain: 'sourceforge.net',
selectors: {
// File list row, README header, file view header
row: 'table#files_list tr, #content_base tr td:first-child',
// File name table cell, file view header
filename: 'th[headers="files_name_h"], td:first-child > a.icon',
// The iconfont icon not including the delete button icon in the file view header
icon: 'th[headers="files_name_h"] > a, a.icon > i.fa',
},
getIsLightTheme: () => true, // There appears to be no dark theme available for sourceforge.
getIsDirectory: ({ row, icon }) => {
if (icon.nodeName === 'I') {
return icon.classList.contains('fa-folder');
}
return row.classList.contains('folder');
},
getIsSubmodule: () => false,
getIsSymlink: ({ icon }) => {
if (icon.nodeName === 'I') {
return icon.classList.contains('fa-star');
}
return false;
},
replaceIcon: (iconOrAnchor, newSVG) => {
newSVG.style.verticalAlign = 'text-bottom';
if (iconOrAnchor.nodeName === 'I') {
newSVG.style.height = '14px';
newSVG.style.width = '14px';
iconOrAnchor.parentNode.replaceChild(newSVG, iconOrAnchor);
}
// For the files list, use the anchor element instead of the icon because in some cases there is no icon
else {
if (iconOrAnchor.querySelector('img[data-material-icons-extension="icon"]')) {
// only replace/prepend the icon once
return;
export default function sourceforge() {
return {
name: 'sourceforge',
domains: [
{
host: 'sourceforge.net',
test: /^sourceforge\.net$/,
},
],
selectors: {
// File list row, README header, file view header
row: 'table#files_list tr, #content_base tr td:first-child',
// File name table cell, file view header
filename: 'th[headers="files_name_h"], td:first-child > a.icon',
// The iconfont icon not including the delete button icon in the file view header
icon: 'th[headers="files_name_h"] > a, a.icon > i.fa',
// Element by which to detect if the tested domain is sourceforge.
detect: null,
},
canSelfHost: false,
isCustom: false,
getIsLightTheme: () => true, // There appears to be no dark theme available for sourceforge.
getIsDirectory: ({ row, icon }) => {
if (icon.nodeName === 'I') {
return icon.classList.contains('fa-folder');
}
newSVG.style.height = '20px';
newSVG.style.width = '20px';
const svgEl = iconOrAnchor.querySelector('svg');
if (svgEl) {
svgEl.parentNode.replaceChild(newSVG, svgEl);
} else {
iconOrAnchor.prepend(newSVG);
return row.classList.contains('folder');
},
getIsSubmodule: () => false,
getIsSymlink: ({ icon }) => {
if (icon.nodeName === 'I') {
return icon.classList.contains('fa-star');
}
}
},
onAdd: () => {},
};
export default sourceforgeConfig;
return false;
},
replaceIcon: (iconOrAnchor, newSVG) => {
newSVG.style.verticalAlign = 'text-bottom';
if (iconOrAnchor.nodeName === 'I') {
newSVG.style.height = '14px';
newSVG.style.width = '14px';
iconOrAnchor.parentNode.replaceChild(newSVG, iconOrAnchor);
}
// For the files list, use the anchor element instead of the icon because in some cases there is no icon
else {
if (iconOrAnchor.querySelector('img[data-material-icons-extension="icon"]')) {
// only replace/prepend the icon once
return;
}
newSVG.style.height = '20px';
newSVG.style.width = '20px';
const svgEl = iconOrAnchor.querySelector('svg');
if (svgEl) {
svgEl.parentNode.replaceChild(newSVG, svgEl);
} else {
iconOrAnchor.prepend(newSVG);
}
}
},
onAdd: () => {},
};
}

View File

@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
import { getConfig, setConfig, clearConfig, onConfigChange } from '../../lib/userConfig';
import { providerConfig } from '../../providers';
import { getGitProviders } from '../../providers';
const resetButton = document.getElementById('reset');
@ -105,8 +105,19 @@ const fillRow = (row, domain) => {
.then(() => row);
};
function getDomains() {
return getGitProviders().then((providers) => [
'default',
...Object.values(providers)
.map((p) => p.domains.map((d) => d.host))
.flat(),
]);
}
const domainsDiv = document.getElementById('domains');
const domains = ['default', ...Object.values(providerConfig).map((p) => p.domain)];
Promise.all(domains.map((d) => fillRow(newDomainRow(), d))).then((rows) =>
rows.forEach((r) => domainsDiv.appendChild(r))
);
getDomains().then((domains) => {
Promise.all(domains.map((d) => fillRow(newDomainRow(), d))).then((rows) =>
rows.forEach((r) => domainsDiv.appendChild(r))
);
});

View File

@ -14,11 +14,11 @@ body {
padding: 1.5rem;
}
#settings {
#not-supported {
display: none;
}
#not-supported {
#domain-settings {
display: none;
}
@ -158,3 +158,35 @@ svg.select-icon {
align-items: center;
justify-content: center;
}
#provider-wrapper {
display: none;
}
#add-provider {
display: none;
}
#request {
display: none;
}
.btn {
color: #5c6068;
padding: 0.3rem 1rem;
outline: transparent solid 2px;
outline-offset: 2px;
transition: 200ms all;
font-size: 1rem;
border-radius: 0.375rem;
border-width: 1px;
border-color: transparent;
background-color: transparent;
margin-bottom: 1rem;
}
.btn:hover {
border-color: red;
color: red;
cursor: pointer;
}

View File

@ -6,10 +6,6 @@
<body>
<div id="content">
<div id="not-supported">
<span id="unsupported-domain"></span> is not supported by this extension
</div>
<div id="settings">
<div id="settings-header">
<img id="logo" src="/icon-128.png" />
@ -21,60 +17,103 @@
<h2 id="domain-name"></h2>
</div>
<div id="request">
We need access to the website to show the icons. If you click the button bellow the
browser will ask you for permision in another popup, this one will close. If you allow the
permission you will have to come back to finish the setup.
<button type="button" id="request-access" class="btn">
<span>Allow access to website</span>
</button>
</div>
<div id="not-supported">
<span id="unsupported-domain"></span> is not supported by this extension
</div>
<div id="default-disabled-note">
All icon replacements are disabled for all domains. To change this setting, go to the
<a id="options-link">options page</a>
</div>
<div id="domain-settings">
<div class="checkbox-wrapper">
<label for="enabled">Enable icons</label>
<input type="checkbox" id="enabled" />
</div>
<label for="icon-size">Icon Size</label>
<div class="select-wrapper">
<select name="icon-size" id="icon-size">
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path>
</svg>
<div id="enable-wrapper">
<div class="checkbox-wrapper">
<label for="enabled">Enable icons</label>
<input type="checkbox" id="enabled" />
</div>
</div>
<label for="icon-pack">Icon Pack</label>
<div class="select-wrapper">
<select name="icon-pack" id="icon-pack">
<option value="angular">angular</option>
<option value="react">react</option>
<option value="vue">vue</option>
<option value="nest">nest</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path>
</svg>
<div id="icon-size-wrapper">
<label for="icon-size">Icon Size</label>
<div class="select-wrapper">
<select name="icon-size" id="icon-size">
<option value="sm">Small</option>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path>
</svg>
</div>
</div>
</div>
<div id="icon-pack-wrapper">
<label for="icon-pack">Icon Pack</label>
<div class="select-wrapper">
<select name="icon-pack" id="icon-pack">
<option value="angular">angular</option>
<option value="react">react</option>
<option value="vue">vue</option>
<option value="nest">nest</option>
</select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path>
</svg>
</div>
</div>
</div>
<div id="provider-wrapper">
<label for="provider">Provider Configuration</label>
<div class="select-wrapper">
<select name="provider" id="provider"></select>
<!-- down arrow -->
<div class="select-icon-wrapper">
<svg
viewBox="0 0 24 24"
role="presentation"
class="select-icon"
focusable="false"
aria-hidden="true"
>
<path fill="currentColor" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path>
</svg>
</div>
</div>
</div>
<button type="button" id="add-provider" class="btn">
<span>Add custom provider</span>
</button>
</div>
<div id="footer">

View File

@ -1,24 +1,19 @@
import Browser from 'webextension-polyfill';
import { addCustomProvider } from '../../lib/custom-providers';
import { getConfig, setConfig } from '../../lib/userConfig';
import { getGitProvider } from '../../providers';
import { addGitProvider, getGitProvider, providerConfig } from '../../providers';
const isPageSupported = (domain) => !!getGitProvider(domain);
const HOST_IS_NEW = 1;
const HOST_NO_MATCH = 2;
function getCurrentTabDomain() {
const queryOptions = { active: true, lastFocusedWindow: true };
return new Promise((resolve) => {
// firefox only supports callback from chrome.tab.query, not Promise return
chrome.tabs.query(queryOptions, ([tab]) => resolve(tab && new URL(tab.url).hostname));
});
const isPageSupported = (domain) => getGitProvider(domain);
function getCurrentTab() {
const queryOptions = { active: true, currentWindow: true };
return Browser.tabs.query(queryOptions).then(([tab]) => tab);
}
getCurrentTabDomain().then((domain) => {
if (!isPageSupported(domain)) return displayPageNotSupported(domain);
registerControls(domain);
displaySettings(domain);
displayAllDisabledNote();
});
function registerControls(domain) {
getConfig('iconSize', domain).then((size) => {
document.getElementById('icon-size').value = size;
@ -40,12 +35,15 @@ function registerControls(domain) {
document
.getElementById('options-btn')
?.addEventListener('click', () => chrome.runtime.openOptionsPage());
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
}
function displaySettings(domain) {
function setDomain(domain) {
document.getElementById('domain-name').innerText = domain;
document.getElementById('settings').style.display = 'block';
}
function displayDomainSettings() {
document.getElementById('domain-settings').style.display = 'block';
}
function displayPageNotSupported(domain) {
@ -53,6 +51,55 @@ function displayPageNotSupported(domain) {
document.getElementById('not-supported').style.display = 'block';
}
function askDomainAccess(tab) {
document.getElementById('request').style.display = 'block';
const clicked = () => {
requestAccess(tab);
// window.close();
};
document.getElementById('request-access').addEventListener('click', clicked);
}
function displayCustomDomain(tab, domain, suggestedProvider) {
document.getElementById('enable-wrapper').style.display = 'none';
document.getElementById('icon-size-wrapper').style.display = 'none';
document.getElementById('icon-pack-wrapper').style.display = 'none';
const btn = document.getElementById('add-provider');
const providerEl = document.getElementById('provider-wrapper');
btn.style.display = 'block';
providerEl.style.display = 'block';
const select = providerEl.querySelector('#provider');
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost) {
const selected = provider.name === suggestedProvider;
const opt = new Option(provider.name, provider.name, selected, selected);
select.append(opt);
}
}
const addProvider = () => {
addCustomProvider(domain, select.value).then(() => {
addGitProvider(domain, select.value);
const cmd = {
cmd: 'init',
};
Browser.tabs.sendMessage(tab.id, cmd);
// reload the popup to show the settings.
window.location.reload();
});
};
btn.addEventListener('click', addProvider);
}
function displayAllDisabledNote() {
getConfig('extEnabled', 'default').then((enabled) => {
if (enabled) return;
@ -60,6 +107,112 @@ function displayAllDisabledNote() {
document.getElementById('domain-settings').style.display = 'none';
document
.getElementById('options-link')
?.addEventListener('click', () => chrome.runtime.openOptionsPage());
?.addEventListener('click', () => Browser.runtime.openOptionsPage());
});
}
function guessProvider(tab) {
const possibilities = {};
for (const provider of Object.values(providerConfig)) {
if (!provider.isCustom && provider.canSelfHost && provider.selectors.detect) {
possibilities[provider.name] = provider.selectors.detect;
}
}
const cmd = {
cmd: 'guessProvider',
args: [possibilities],
};
return Browser.tabs.sendMessage(tab.id, cmd).then((match) => {
if (match === null) {
return HOST_NO_MATCH;
}
return match;
});
}
function checkAccess(tab) {
const { host } = new URL(tab.url);
const perm = {
permissions: ['activeTab'],
origins: [`*://${host}/*`],
};
return Browser.permissions.contains(perm).then((r) => {
if (r) {
return tab;
}
return HOST_IS_NEW;
});
}
function requestAccess(tab) {
const { host } = new URL(tab.url);
return Browser.runtime.sendMessage({
event: 'request-access',
data: {
tabId: tab.id,
url: tab.url,
host,
},
});
}
function doGuessProvider(tab, domain) {
return guessProvider(tab).then((match) => {
if (match !== HOST_NO_MATCH) {
registerControls(domain);
displayDomainSettings();
return displayCustomDomain(tab, domain, match);
}
return displayPageNotSupported(domain);
});
}
function isFirefox() {
return typeof browser !== 'undefined' && typeof chrome !== 'undefined';
}
function init(tab) {
const domain = new URL(tab.url).host;
setDomain(domain);
isPageSupported(domain).then((supported) => {
if (!supported) {
// we are in some internal browser page, not supported.
if (!tab.url.startsWith('http')) {
return displayPageNotSupported(domain);
}
// overwrite for firefox browser, currently does not support
// asking for permissions from background, so it will run
// on all pages.
if (isFirefox()) {
return doGuessProvider(tab, domain);
}
return checkAccess(tab).then((access) => {
if (access === HOST_IS_NEW) {
return askDomainAccess(tab);
}
return doGuessProvider(tab, domain);
});
}
registerControls(domain);
displayDomainSettings();
displayAllDisabledNote();
});
}
getCurrentTab().then(init);