Full-featured Similar Repos panel for GitHub. Tabs, filters, sort, bookmarks, dismissals, settings, keyboard shortcut and API token support.
// ==UserScript==
// @name GitHub Similar Repos
// @namespace https://github.com/
// @version 3.1.0
// @description Full-featured Similar Repos panel for GitHub. Tabs, filters, sort, bookmarks, dismissals, settings, keyboard shortcut and API token support.
// @author a9s2w5
// @license GNU GPLv3
// @match https://github.com/*/*
// @exclude https://github.com/*/*/**
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @connect libhunt.com
// @connect api.github.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ════════════════════════════════════════════════════════════
// STORAGE KEYS
// ════════════════════════════════════════════════════════════
const KEY = {
prefix: 'gsr3_',
cache: (o, r) => `gsr3_cache_${o}_${r}`,
settings: 'gsr3_settings',
dismissed: 'gsr3_dismissed',
saved: 'gsr3_saved',
visited: 'gsr3_visited',
recent: 'gsr3_recent',
ghStarred: 'gsr3_ghstarred',
};
// ════════════════════════════════════════════════════════════
// DEFAULTS
// ════════════════════════════════════════════════════════════
const DEFAULTS = {
token: '',
fontSize: 'default', // 'small' | 'default' | 'large'
fontFace: 'system', // 'system' | 'mono' | 'serif'
shortcutOn: true,
shortcutKey: 'alt+s',
panelOpen: true,
enabled: true, // global on/off toggle
sortBy: 'relevance', // 'relevance' | 'stars' | 'activity'
langFilter: '',
};
const FONT_FACES = {
system: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`,
mono: `"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace`,
serif: `Georgia, "Times New Roman", serif`,
};
const FONT_SIZES = { small: '11px', default: '13px', large: '15px' };
const CACHE_TTL = 6 * 60 * 60 * 1000;
const PANEL_ID = 'gsr-panel';
const PANEL_MAX_H= '400px';
// ════════════════════════════════════════════════════════════
// STATE
// ════════════════════════════════════════════════════════════
let settings = {};
let dismissed = {}; // slug → true
let saved = {}; // slug → { slug, href, desc, stars, pushedAt, language }
let visited = {}; // slug → timestamp
let recent = {}; // slug → { slug, href, desc, stars, pushedAt, language, visitedAt }
let ghStarred = {}; // slug → true (locally tracked GitHub stars)
let activeTab = 'similar'; // 'similar' | 'saved' | 'recent' | 'dismissed'
let settingsOpen = false;
let currentResult = null;
let currentOwner = '';
let currentRepo = '';
function loadState() {
settings = Object.assign({}, DEFAULTS, safeGet(KEY.settings, {}));
dismissed = safeGet(KEY.dismissed, {});
saved = safeGet(KEY.saved, {});
visited = safeGet(KEY.visited, {});
recent = safeGet(KEY.recent, {});
ghStarred = safeGet(KEY.ghStarred, {});
}
function saveSettings() { GM_setValue(KEY.settings, JSON.stringify(settings)); }
function saveDismissed() { GM_setValue(KEY.dismissed, JSON.stringify(dismissed)); }
function saveSaved() { GM_setValue(KEY.saved, JSON.stringify(saved)); }
function saveVisited() { GM_setValue(KEY.visited, JSON.stringify(visited)); }
function saveRecent() { GM_setValue(KEY.recent, JSON.stringify(recent)); }
function saveGhStarred() { GM_setValue(KEY.ghStarred, JSON.stringify(ghStarred)); }
function safeGet(key, fallback) {
try { const v = GM_getValue(key, null); return v ? JSON.parse(v) : fallback; }
catch { return fallback; }
}
// ════════════════════════════════════════════════════════════
// HELPERS
// ════════════════════════════════════════════════════════════
function getRepoParts() {
const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/?$/);
return m ? { owner: m[1], repo: m[2] } : null;
}
function timeAgo(iso) {
if (!iso) return null;
const d = (Date.now() - new Date(iso)) / 86400000;
if (d < 1/24) return `${Math.floor(d*24*60)}m ago`;
if (d < 1) return `${Math.floor(d*24)}h ago`;
if (d < 7) return `${Math.floor(d)}d ago`;
if (d < 30) return `${Math.floor(d/7)}w ago`;
if (d < 365) return `${Math.floor(d/30)}mo ago`;
return `${Math.floor(d/365)}y ago`;
}
function freshnessColor(iso) {
if (!iso) return '#9B9A97';
const d = (Date.now() - new Date(iso)) / 86400000;
if (d < 7) return '#4CAF50';
if (d < 30) return '#8BC34A';
if (d < 90) return '#FF9800';
if (d < 365) return '#F44336';
return '#9B9A97';
}
function formatStars(n) {
if (n == null) return '';
if (n >= 1000000) return `${(n/1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n/1000).toFixed(1)}k`;
return `${n}`;
}
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function apiHeaders() {
const h = { Accept: 'application/vnd.github.v3+json' };
if (settings.token) h['Authorization'] = `token ${settings.token}`;
return h;
}
function gmFetch(url, opts = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout: 10000, ...opts,
onload: r => resolve(r),
onerror: e => reject(e),
ontimeout: () => reject(new Error('timeout')),
});
});
}
// Toggle a GitHub star via the API. Requires a token with public_repo scope.
// Returns { ok: bool, nowStarred: bool }
function toggleGhStar(slug) {
const isStarred = !!ghStarred[slug];
const method = isStarred ? 'DELETE' : 'PUT';
return new Promise(resolve => {
if (!settings.token) { resolve({ ok: false, nowStarred: isStarred }); return; }
GM_xmlhttpRequest({
method,
url: `https://api.github.com/user/starred/${slug}`,
headers: {
Authorization: `token ${settings.token}`,
Accept: 'application/vnd.github.v3+json',
'Content-Length': '0',
},
timeout: 8000,
onload(r) {
// 204 = success for both PUT and DELETE
if (r.status === 204) {
if (isStarred) delete ghStarred[slug];
else ghStarred[slug] = true;
saveGhStarred();
resolve({ ok: true, nowStarred: !isStarred });
} else {
resolve({ ok: false, nowStarred: isStarred });
}
},
onerror: () => resolve({ ok: false, nowStarred: isStarred }),
ontimeout: () => resolve({ ok: false, nowStarred: isStarred }),
});
});
}
// ════════════════════════════════════════════════════════════
// DATA FETCHING
// ════════════════════════════════════════════════════════════
async function fetchLibHunt(owner, repo) {
const url = `https://www.libhunt.com/r/${repo}`;
let resp;
try { resp = await gmFetch(url); } catch { return null; }
if (resp.status !== 200) return null;
const doc = new DOMParser().parseFromString(resp.responseText, 'text/html');
const seen = new Set();
const slugs = [];
for (const a of doc.querySelectorAll('a[href]')) {
if (a.closest('nav, header, footer')) continue;
const href = a.getAttribute('href') || '';
const m = href.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/);
if (!m) continue;
const slug = m[1];
if (slug.toLowerCase() === `${owner}/${repo}`.toLowerCase()) continue;
if (/^(github|topics|collections|trending)\//i.test(slug)) continue;
if (seen.has(slug)) continue;
seen.add(slug);
slugs.push(slug);
}
return slugs.length ? { source: 'LibHunt', libhuntUrl: url, slugs } : null;
}
async function fetchGitHubTopics(owner, repo) {
let r1;
try {
r1 = await gmFetch(`https://api.github.com/repos/${owner}/${repo}/topics`, {
headers: Object.assign({ Accept: 'application/vnd.github.mercy-preview+json' }, settings.token ? { Authorization: `token ${settings.token}` } : {}),
});
} catch { return null; }
let topics;
try { ({ names: topics } = JSON.parse(r1.responseText)); } catch { return null; }
if (!topics?.length) return null;
const topic = [...topics].sort((a, b) => b.length - a.length)[0];
let r2;
try {
r2 = await gmFetch(
`https://api.github.com/search/repositories?q=topic:${encodeURIComponent(topic)}&sort=stars&per_page=30`,
{ headers: apiHeaders() }
);
} catch { return null; }
let raw;
try { ({ items: raw } = JSON.parse(r2.responseText)); } catch { return null; }
const items = (raw || [])
.filter(r => r.full_name.toLowerCase() !== `${owner}/${repo}`.toLowerCase())
.map(r => ({
slug: r.full_name, stars: r.stargazers_count, pushedAt: r.pushed_at,
desc: (r.description || '').slice(0, 140), href: r.html_url, language: r.language,
archived: r.archived,
}));
return items.length ? { source: `topic: ${topic}`, libhuntUrl: null, items } : null;
}
async function enrichSlugs(slugs) {
const CONCURRENCY = 4;
const results = [];
let rateLimited = false;
for (let i = 0; i < slugs.length; i += CONCURRENCY) {
const batch = slugs.slice(i, i + CONCURRENCY);
const settled = await Promise.allSettled(batch.map(slug =>
gmFetch(`https://api.github.com/repos/${slug}`, { headers: apiHeaders() }).then(r => {
const d = JSON.parse(r.responseText);
if (d.message?.includes('rate limit')) { rateLimited = true; return null; }
if (d.message) return null;
return {
slug: d.full_name, stars: d.stargazers_count, pushedAt: d.pushed_at,
desc: (d.description || '').slice(0, 140), href: d.html_url,
language: d.language, archived: d.archived,
};
})
));
settled.forEach(s => { if (s.status === 'fulfilled' && s.value) results.push(s.value); });
}
return { items: results, rateLimited };
}
// ════════════════════════════════════════════════════════════
// SORTED / FILTERED VIEW
// ════════════════════════════════════════════════════════════
function applyFiltersAndSort(items) {
let out = items.filter(it => !dismissed[it.slug]);
if (settings.langFilter) {
out = out.filter(it => (it.language || '').toLowerCase() === settings.langFilter.toLowerCase());
}
if (settings.sortBy === 'stars') {
out = out.slice().sort((a, b) => (b.stars || 0) - (a.stars || 0));
} else if (settings.sortBy === 'activity') {
out = out.slice().sort((a, b) => {
const ta = a.pushedAt ? new Date(a.pushedAt).getTime() : 0;
const tb = b.pushedAt ? new Date(b.pushedAt).getTime() : 0;
return tb - ta;
});
}
return out;
}
function uniqueLangs(items) {
const counts = {};
items.forEach(it => { if (it.language) counts[it.language] = (counts[it.language]||0)+1; });
return Object.entries(counts).sort((a,b) => b[1]-a[1]).map(e => e[0]);
}
// ════════════════════════════════════════════════════════════
// CSS
// ════════════════════════════════════════════════════════════
function injectStyles() {
const existing = document.getElementById('gsr-styles');
if (existing) existing.remove();
const ff = FONT_FACES[settings.fontFace] || FONT_FACES.system;
const fz = FONT_SIZES[settings.fontSize] || FONT_SIZES.default;
const fzSm = settings.fontSize === 'large' ? '13px' : settings.fontSize === 'small' ? '10px' : '11.5px';
const fzXs = settings.fontSize === 'large' ? '12px' : settings.fontSize === 'small' ? '9.5px' : '11px';
const s = document.createElement('style');
s.id = 'gsr-styles';
s.textContent = `
/* ── Root ── */
#gsr-panel {
font-family: ${ff};
font-size: ${fz};
line-height: 1.5;
margin-bottom: 16px;
}
/* ── Header bar ── */
#gsr-panel .gsr-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 10px;
border-bottom: 1px solid var(--color-border-muted,#d0d7de);
user-select: none;
gap: 6px;
}
#gsr-panel .gsr-title-row {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
flex: 1;
min-width: 0;
}
/* Notion-style pill wrapping icon + title */
#gsr-panel .gsr-title-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px 3px 6px;
border-radius: 6px;
background: var(--color-accent-subtle, rgba(9,105,218,0.1));
border: 1px solid var(--color-accent-emphasis, rgba(9,105,218,0.2));
min-width: 0;
overflow: hidden;
}
#gsr-panel .gsr-title-icon {
width: 13px; height: 13px;
flex-shrink: 0;
color: var(--color-accent-fg, #0969da);
}
#gsr-panel .gsr-title-text {
font-size: ${fz};
font-weight: 700;
color: var(--color-accent-fg, #0969da);
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#gsr-panel .gsr-chevron {
font-size: 9px;
color: var(--color-fg-subtle,#848d97);
transition: transform 0.18s ease;
flex-shrink: 0;
}
#gsr-panel .gsr-header-actions {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
#gsr-panel .gsr-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
border-radius: 5px;
border: none;
background: transparent;
cursor: pointer;
color: var(--color-fg-subtle,#848d97);
transition: background 0.12s, color 0.12s;
padding: 0;
font-size: 13px;
line-height: 1;
}
#gsr-panel .gsr-icon-btn:hover {
background: var(--color-neutral-subtle,rgba(175,184,193,0.15));
color: var(--color-fg-default,#1f2328);
}
#gsr-panel .gsr-icon-btn.active {
background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
color: var(--color-fg-default,#1f2328);
}
/* ── Enabled/disabled pill toggle ── */
#gsr-panel .gsr-enable-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 5px;
border-radius: 20px;
border: 1px solid;
cursor: pointer;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
transition: background 0.15s, border-color 0.15s, color 0.15s;
user-select: none;
white-space: nowrap;
font-family: inherit;
line-height: 1;
}
#gsr-panel .gsr-enable-pill.on {
background: rgba(74,185,88,0.12);
border-color: rgba(74,185,88,0.35);
color: #2da44e;
}
#gsr-panel .gsr-enable-pill.off {
background: rgba(207,34,46,0.08);
border-color: rgba(207,34,46,0.25);
color: #cf222e;
}
#gsr-panel .gsr-enable-pill:hover.on {
background: rgba(74,185,88,0.2);
border-color: rgba(74,185,88,0.5);
}
#gsr-panel .gsr-enable-pill:hover.off {
background: rgba(207,34,46,0.14);
border-color: rgba(207,34,46,0.4);
}
#gsr-panel .gsr-enable-dot {
width: 6px; height: 6px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.15s;
}
#gsr-panel .gsr-enable-pill.on .gsr-enable-dot { background: #2da44e; }
#gsr-panel .gsr-enable-pill.off .gsr-enable-dot { background: #cf222e; }
/* Disabled state: body is dimmed / locked */
#gsr-panel.gsr-disabled .gsr-body {
opacity: 0.45;
pointer-events: none;
user-select: none;
}
/* ── Body ── */
#gsr-panel .gsr-body { padding-top: 10px; }
/* ── Tabs ── */
#gsr-panel .gsr-tabs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
margin-bottom: 10px;
background: var(--color-neutral-subtle,rgba(175,184,193,0.1));
border-radius: 8px;
padding: 3px;
}
#gsr-panel .gsr-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 2px;
border-radius: 6px;
font-size: ${fzXs};
font-weight: 500;
cursor: pointer;
color: var(--color-fg-muted,#656d76);
transition: background 0.14s, color 0.14s;
user-select: none;
min-width: 0;
overflow: hidden;
}
#gsr-panel .gsr-tab-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: center;
line-height: 1.2;
}
#gsr-panel .gsr-tab.active {
background: var(--color-canvas-default,#fff);
color: var(--color-fg-default,#1f2328);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
#gsr-panel .gsr-tab-badge {
display: inline-block;
background: var(--color-neutral-subtle,rgba(175,184,193,0.3));
border-radius: 20px;
padding: 0 5px;
font-size: ${fzXs};
font-weight: 700;
min-width: 18px;
text-align: center;
line-height: 1.6;
letter-spacing: 0;
}
#gsr-panel .gsr-tab.active .gsr-tab-badge {
background: var(--color-accent-subtle,rgba(9,105,218,0.12));
color: var(--color-accent-fg,#0969da);
}
/* ── Controls bar (sort + lang filter) ── */
#gsr-panel .gsr-controls {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
}
#gsr-panel .gsr-select {
flex: 1;
min-width: 0;
font-family: inherit;
font-size: ${fzXs};
padding: 3px 7px;
border-radius: 6px;
border: 1px solid var(--color-border-default,#d0d7de);
background: var(--color-canvas-default,#fff);
color: var(--color-fg-default,#1f2328);
cursor: pointer;
outline: none;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23848d97'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 7px center;
padding-right: 22px;
}
#gsr-panel .gsr-select:focus {
border-color: var(--color-accent-fg,#0969da);
box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
}
#gsr-panel .gsr-lang-chips {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 8px;
}
#gsr-panel .gsr-chip {
font-size: ${fzXs};
padding: 2px 8px;
border-radius: 20px;
border: 1px solid var(--color-border-default,#d0d7de);
background: transparent;
color: var(--color-fg-muted,#656d76);
cursor: pointer;
transition: all 0.12s;
font-family: inherit;
white-space: nowrap;
}
#gsr-panel .gsr-chip:hover {
border-color: var(--color-accent-fg,#0969da);
color: var(--color-accent-fg,#0969da);
}
#gsr-panel .gsr-chip.active {
background: var(--color-accent-subtle,rgba(9,105,218,0.1));
border-color: var(--color-accent-fg,#0969da);
color: var(--color-accent-fg,#0969da);
font-weight: 600;
}
/* ── Scrollable list ── */
#gsr-panel .gsr-scroll {
overflow-y: auto;
max-height: ${PANEL_MAX_H};
scrollbar-width: thin;
scrollbar-color: var(--color-border-default,#d0d7de) transparent;
margin-right: -4px;
padding-right: 2px;
}
#gsr-panel .gsr-scroll::-webkit-scrollbar { width: 3px; }
#gsr-panel .gsr-scroll::-webkit-scrollbar-thumb { background: var(--color-border-default,#d0d7de); border-radius: 4px; }
#gsr-panel .gsr-scroll::-webkit-scrollbar-track { background: transparent; }
/* ── Repo card ── */
#gsr-panel .gsr-item {
display: block;
padding: 8px 9px;
border-radius: 8px;
margin-bottom: 2px;
text-decoration: none;
color: inherit;
transition: background 0.1s;
position: relative;
}
#gsr-panel .gsr-item:hover { background: var(--color-neutral-subtle,rgba(175,184,193,0.12)); }
#gsr-panel .gsr-item.visited .gsr-repo-name { opacity: 0.55; }
#gsr-panel .gsr-item.archived { opacity: 0.6; }
#gsr-panel .gsr-item-top {
display: flex;
align-items: center;
gap: 5px;
}
#gsr-panel .gsr-repo-name {
font-size: ${fz};
font-weight: 500;
color: var(--color-accent-fg,#0969da);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
#gsr-panel .gsr-badges {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
#gsr-panel .gsr-stars {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: ${fzXs};
color: var(--color-fg-muted,#656d76);
font-variant-numeric: tabular-nums;
}
#gsr-panel .gsr-activity {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: ${fzXs};
font-weight: 500;
padding: 1px 5px;
border-radius: 20px;
border: 1px solid;
white-space: nowrap;
line-height: 1.6;
}
#gsr-panel .gsr-dot {
width: 5px; height: 5px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
#gsr-panel .gsr-archived-badge {
font-size: ${fzXs};
padding: 1px 5px;
border-radius: 4px;
background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
color: var(--color-fg-subtle,#848d97);
border: 1px solid var(--color-border-muted,#d0d7de);
white-space: nowrap;
}
#gsr-panel .gsr-desc {
font-size: ${fzSm};
color: var(--color-fg-muted,#656d76);
margin-top: 3px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
#gsr-panel .gsr-item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 3px;
}
#gsr-panel .gsr-lang {
font-size: ${fzXs};
color: var(--color-fg-subtle,#848d97);
}
#gsr-panel .gsr-lang-chip {
display: inline-block;
font-size: ${fzXs};
padding: 1px 7px;
border-radius: 20px;
border: 1px solid var(--color-border-default,#d0d7de);
color: var(--color-fg-muted,#656d76);
background: transparent;
line-height: 1.6;
}
/* Action buttons that appear on hover */
#gsr-panel .gsr-item-actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: none;
align-items: center;
gap: 3px;
background: var(--color-canvas-default,#fff);
border-radius: 6px;
padding: 2px;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
z-index: 1;
}
#gsr-panel .gsr-item:hover .gsr-item-actions { display: flex; }
#gsr-panel .gsr-action-btn {
width: 22px; height: 22px;
border-radius: 4px;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--color-fg-muted,#656d76);
transition: background 0.1s, color 0.1s;
padding: 0;
line-height: 1;
}
#gsr-panel .gsr-action-btn:hover {
background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
color: var(--color-fg-default,#1f2328);
}
#gsr-panel .gsr-action-btn.is-saved { color: var(--color-accent-fg,#0969da); }
#gsr-panel .gsr-action-btn.gh-star-btn { color: var(--color-fg-muted,#656d76); }
#gsr-panel .gsr-action-btn.gh-star-btn.starred { color: #e3b341; }
#gsr-panel .gsr-action-btn.gh-star-btn:hover { color: #e3b341; }
#gsr-panel .gsr-action-btn.gh-star-btn.no-token { opacity: 0.4; cursor: default; }
#gsr-panel .gsr-action-btn.gh-star-btn.no-token:hover { color: var(--color-fg-muted,#656d76); background: transparent; }
/* ── Footer ── */
#gsr-panel .gsr-footer {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border-muted,#d0d7de);
font-size: ${fzXs};
color: var(--color-fg-subtle,#848d97);
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
#gsr-panel .gsr-footer a {
color: var(--color-fg-subtle,#848d97);
text-decoration: none;
}
#gsr-panel .gsr-footer a:hover { text-decoration: underline; }
#gsr-panel .gsr-footer-actions {
display: flex;
gap: 8px;
align-items: center;
}
#gsr-panel .gsr-footer-btn {
background: none; border: none; padding: 0;
font-size: ${fzXs};
color: var(--color-fg-subtle,#848d97);
cursor: pointer;
font-family: inherit;
text-decoration: none;
}
#gsr-panel .gsr-footer-btn:hover { color: var(--color-fg-default,#1f2328); text-decoration: underline; }
/* ── Rate limit banner ── */
#gsr-panel .gsr-ratelimit {
font-size: ${fzXs};
color: #9a6700;
background: #fff8c5;
border: 1px solid #e3b341;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 8px;
line-height: 1.4;
}
#gsr-panel .gsr-ratelimit a { color: #9a6700; }
/* ── Loading / empty state ── */
#gsr-panel .gsr-loading {
font-size: ${fzSm};
color: var(--color-fg-muted,#656d76);
padding: 6px 2px 10px;
display: flex;
align-items: center;
gap: 8px;
}
#gsr-panel .gsr-spinner {
width: 12px; height: 12px;
border: 2px solid var(--color-border-default,#d0d7de);
border-top-color: var(--color-accent-fg,#0969da);
border-radius: 50%;
animation: gsr-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes gsr-spin { to { transform: rotate(360deg); } }
/* ── Settings panel ── */
#gsr-settings {
position: fixed;
z-index: 99999;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 340px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 48px);
overflow-y: auto;
background: var(--color-canvas-default,#fff);
border: 1px solid var(--color-border-default,#d0d7de);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.08);
padding: 20px;
font-family: ${ff};
font-size: 13px;
color: var(--color-fg-default,#1f2328);
}
#gsr-settings-backdrop {
position: fixed;
inset: 0;
z-index: 99998;
background: rgba(0,0,0,0.3);
backdrop-filter: blur(2px);
}
#gsr-settings .gsr-s-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
#gsr-settings .gsr-s-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--color-fg-default,#1f2328);
}
#gsr-settings .gsr-s-close {
width: 28px; height: 28px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
color: var(--color-fg-subtle,#848d97);
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.1s;
}
#gsr-settings .gsr-s-close:hover {
background: var(--color-neutral-subtle,rgba(175,184,193,0.2));
color: var(--color-fg-default,#1f2328);
}
#gsr-settings .gsr-s-section {
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--color-border-muted,#d0d7de);
}
#gsr-settings .gsr-s-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
#gsr-settings .gsr-s-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-fg-subtle,#848d97);
margin-bottom: 10px;
}
#gsr-settings .gsr-s-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
#gsr-settings .gsr-s-row:last-child { margin-bottom: 0; }
#gsr-settings .gsr-s-row-label {
font-size: 13px;
color: var(--color-fg-default,#1f2328);
flex: 1;
}
#gsr-settings .gsr-s-row-sub {
font-size: 11px;
color: var(--color-fg-subtle,#848d97);
margin-top: 1px;
}
#gsr-settings .gsr-s-select {
font-family: inherit;
font-size: 12px;
padding: 4px 28px 4px 8px;
border-radius: 6px;
border: 1px solid var(--color-border-default,#d0d7de);
background: var(--color-canvas-default,#fff);
color: var(--color-fg-default,#1f2328);
cursor: pointer;
outline: none;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23848d97'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 7px center;
min-width: 110px;
}
/* Toggle switch */
#gsr-settings .gsr-toggle-wrap {
display: flex;
align-items: center;
gap: 8px;
}
#gsr-settings .gsr-toggle {
position: relative;
width: 34px; height: 18px;
flex-shrink: 0;
cursor: pointer;
}
#gsr-settings .gsr-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
#gsr-settings .gsr-toggle-track {
position: absolute;
inset: 0;
border-radius: 18px;
background: var(--color-neutral-muted,rgba(175,184,193,0.4));
transition: background 0.18s;
}
#gsr-settings .gsr-toggle input:checked + .gsr-toggle-track {
background: var(--color-accent-fg,#0969da);
}
#gsr-settings .gsr-toggle-thumb {
position: absolute;
top: 2px; left: 2px;
width: 14px; height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 0.18s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
pointer-events: none;
}
#gsr-settings .gsr-toggle input:checked ~ .gsr-toggle-thumb { transform: translateX(16px); }
/* Shortcut key input */
#gsr-settings .gsr-key-input {
font-family: ${FONT_FACES.mono};
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--color-border-default,#d0d7de);
background: var(--color-canvas-default,#fff);
color: var(--color-fg-default,#1f2328);
width: 90px;
outline: none;
}
#gsr-settings .gsr-key-input:focus {
border-color: var(--color-accent-fg,#0969da);
box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
}
/* Token input */
#gsr-settings .gsr-token-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
#gsr-settings .gsr-token-input {
font-family: ${FONT_FACES.mono};
font-size: 12px;
padding: 7px 10px;
border-radius: 7px;
border: 1px solid var(--color-border-default,#d0d7de);
background: var(--color-canvas-default,#fff);
color: var(--color-fg-default,#1f2328);
width: 100%;
box-sizing: border-box;
outline: none;
}
#gsr-settings .gsr-token-input:focus {
border-color: var(--color-accent-fg,#0969da);
box-shadow: 0 0 0 2px rgba(9,105,218,0.12);
}
#gsr-settings .gsr-token-hint {
font-size: 11px;
color: var(--color-fg-subtle,#848d97);
line-height: 1.4;
}
#gsr-settings .gsr-token-hint a { color: var(--color-accent-fg,#0969da); text-decoration: none; }
#gsr-settings .gsr-token-hint a:hover { text-decoration: underline; }
/* Slider */
#gsr-settings .gsr-slider-row {
display: flex;
align-items: center;
gap: 10px;
}
#gsr-settings .gsr-slider-label {
font-size: 11px;
color: var(--color-fg-muted,#656d76);
white-space: nowrap;
}
#gsr-settings input[type=range].gsr-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
border-radius: 3px;
background: var(--color-border-default,#d0d7de);
outline: none;
cursor: pointer;
}
#gsr-settings input[type=range].gsr-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--color-accent-fg,#0969da);
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Action buttons in settings */
#gsr-settings .gsr-s-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 7px;
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--color-border-default,#d0d7de);
background: var(--color-canvas-subtle,#f6f8fa);
color: var(--color-fg-default,#1f2328);
transition: background 0.1s;
}
#gsr-settings .gsr-s-btn:hover { background: var(--color-neutral-subtle,rgba(175,184,193,0.2)); }
#gsr-settings .gsr-s-btn.danger {
color: #cf222e;
border-color: rgba(207,34,46,0.3);
}
#gsr-settings .gsr-s-btn.danger:hover { background: rgba(207,34,46,0.06); }
#gsr-settings .gsr-s-btn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
`;
document.head.appendChild(s);
}
// ════════════════════════════════════════════════════════════
// SETTINGS MODAL
// ════════════════════════════════════════════════════════════
function openSettings() {
if (document.getElementById('gsr-settings')) return;
const backdrop = document.createElement('div');
backdrop.id = 'gsr-settings-backdrop';
backdrop.onclick = closeSettings;
document.body.appendChild(backdrop);
const modal = document.createElement('div');
modal.id = 'gsr-settings';
const fontSizeVal = { small: 0, default: 1, large: 2 }[settings.fontSize] ?? 1;
modal.innerHTML = `
<div class="gsr-s-header">
<span class="gsr-s-title">⚙ Settings</span>
<button class="gsr-s-close" id="gsr-s-close">✕</button>
</div>
<!-- Typography -->
<div class="gsr-s-section">
<div class="gsr-s-label">Typography</div>
<div class="gsr-s-row">
<div>
<div class="gsr-s-row-label">Text size</div>
</div>
<div class="gsr-slider-row" style="width:160px">
<span class="gsr-slider-label">A</span>
<input type="range" class="gsr-slider" id="gsr-s-fontsize" min="0" max="2" step="1" value="${fontSizeVal}">
<span class="gsr-slider-label" style="font-size:15px">A</span>
</div>
</div>
<div class="gsr-s-row">
<div class="gsr-s-row-label">Font style</div>
<select class="gsr-s-select" id="gsr-s-fontface">
<option value="system" ${settings.fontFace==='system'?'selected':''}>System UI</option>
<option value="mono" ${settings.fontFace==='mono' ?'selected':''}>Monospace</option>
<option value="serif" ${settings.fontFace==='serif' ?'selected':''}>Serif</option>
</select>
</div>
</div>
<!-- Keyboard shortcut -->
<div class="gsr-s-section">
<div class="gsr-s-label">Keyboard Shortcut</div>
<div class="gsr-s-row">
<div>
<div class="gsr-s-row-label">Enable shortcut</div>
<div class="gsr-s-row-sub">Toggle panel open/closed</div>
</div>
<label class="gsr-toggle">
<input type="checkbox" id="gsr-s-shortcuton" ${settings.shortcutOn?'checked':''}>
<span class="gsr-toggle-track"></span>
<span class="gsr-toggle-thumb"></span>
</label>
</div>
<div class="gsr-s-row">
<div class="gsr-s-row-label">Shortcut key</div>
<input class="gsr-key-input" id="gsr-s-shortcutkey"
placeholder="e.g. alt+s"
value="${esc(settings.shortcutKey)}"
title="Format: modifier+key, e.g. alt+s, ctrl+shift+r">
</div>
</div>
<!-- GitHub Token -->
<div class="gsr-s-section">
<div class="gsr-s-label">GitHub API Token</div>
<div class="gsr-token-wrap">
<input class="gsr-token-input" type="password" id="gsr-s-token"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
value="${esc(settings.token)}"
autocomplete="off" spellcheck="false">
<div class="gsr-token-hint">
Increases the API rate limit from 60 to 5,000 req/hr, and enables starring repos directly from the panel.<br>
<a href="https://github.com/settings/tokens/new?description=GitHub+Similar+Repos&scopes=public_repo" target="_blank" rel="noopener">
Generate a token ↗
</a>
— enable the <code>public_repo</code> scope to allow starring.
</div>
</div>
</div>
<!-- Cache & Data -->
<div class="gsr-s-section">
<div class="gsr-s-label">Cache & Data</div>
<div class="gsr-s-btn-row">
<button class="gsr-s-btn" id="gsr-s-clearcache">🗑 Clear results cache</button>
<button class="gsr-s-btn danger" id="gsr-s-clearall">⚠ Reset everything</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Wire up events
modal.querySelector('#gsr-s-close').onclick = closeSettings;
// Font size slider
const sizeLabels = ['small','default','large'];
modal.querySelector('#gsr-s-fontsize').oninput = e => {
settings.fontSize = sizeLabels[+e.target.value];
saveSettings(); injectStyles();
};
// Font face
modal.querySelector('#gsr-s-fontface').onchange = e => {
settings.fontFace = e.target.value;
saveSettings(); injectStyles();
};
// Shortcut toggle
modal.querySelector('#gsr-s-shortcuton').onchange = e => {
settings.shortcutOn = e.target.checked;
saveSettings(); rebindShortcut();
};
// Shortcut key
modal.querySelector('#gsr-s-shortcutkey').onchange = e => {
settings.shortcutKey = e.target.value.trim().toLowerCase();
saveSettings(); rebindShortcut();
};
// Token
modal.querySelector('#gsr-s-token').onchange = e => {
settings.token = e.target.value.trim();
saveSettings();
};
// Clear cache
modal.querySelector('#gsr-s-clearcache').onclick = () => {
const all = GM_listValues();
all.filter(k => k.startsWith('gsr3_cache_')).forEach(k => GM_deleteValue(k));
closeSettings();
// Reload current panel
const p = document.getElementById(PANEL_ID);
if (p) p.remove();
document.getElementById('gsr-styles')?.remove();
run();
};
// Reset everything
modal.querySelector('#gsr-s-clearall').onclick = () => {
if (!confirm('Reset all Similar Repos data including settings, bookmarks, and dismissals?')) return;
GM_listValues().filter(k => k.startsWith('gsr3_')).forEach(k => GM_deleteValue(k));
loadState();
recent = {};
ghStarred = {};
closeSettings();
const p = document.getElementById(PANEL_ID);
if (p) p.remove();
document.getElementById('gsr-styles')?.remove();
run();
};
}
function closeSettings() {
document.getElementById('gsr-settings')?.remove();
document.getElementById('gsr-settings-backdrop')?.remove();
}
// ════════════════════════════════════════════════════════════
// KEYBOARD SHORTCUT
// ════════════════════════════════════════════════════════════
let _shortcutHandler = null;
function parseShortcut(str) {
const parts = str.toLowerCase().split('+').map(s => s.trim());
const key = parts.pop();
return {
alt: parts.includes('alt'),
ctrl: parts.includes('ctrl') || parts.includes('control'),
shift: parts.includes('shift'),
meta: parts.includes('meta') || parts.includes('cmd'),
key,
};
}
function rebindShortcut() {
if (_shortcutHandler) document.removeEventListener('keydown', _shortcutHandler);
if (!settings.shortcutOn || !settings.shortcutKey) return;
const sc = parseShortcut(settings.shortcutKey);
_shortcutHandler = e => {
if (e.key.toLowerCase() !== sc.key) return;
if (e.altKey !== sc.alt) return;
if (e.ctrlKey !== sc.ctrl) return;
if (e.shiftKey !== sc.shift) return;
if (e.metaKey !== sc.meta) return;
e.preventDefault();
togglePanel();
};
document.addEventListener('keydown', _shortcutHandler);
}
function togglePanel() {
const body = document.getElementById('gsr-body');
const chev = document.getElementById('gsr-chevron');
if (!body) return;
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
if (chev) chev.style.transform = isHidden ? '' : 'rotate(-90deg)';
settings.panelOpen = isHidden; // we just opened it if it was hidden
saveSettings();
}
// ════════════════════════════════════════════════════════════
// PANEL SCAFFOLD
// ════════════════════════════════════════════════════════════
function buildPanel() {
if (document.getElementById(PANEL_ID)) return document.getElementById(PANEL_ID);
injectStyles();
const sidebar =
document.querySelector('[data-testid="sidebar"]') ||
document.querySelector('.Layout-sidebar') ||
document.querySelector('.repository-sidebar') ||
document.querySelector('aside') ||
document.querySelector('.BorderGrid');
if (!sidebar) return null;
const wrap = document.createElement('div');
wrap.id = PANEL_ID;
wrap.innerHTML = `
<div class="gsr-header">
<div class="gsr-title-row" id="gsr-toggle">
<div class="gsr-title-pill">
<!-- Similar: branching/network icon -->
<svg class="gsr-title-icon" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h4.5a.75.75 0 0 1 .75.75v.879a2.25 2.25 0 1 0 1.5 0V9.25a2.25 2.25 0 0 0-2.25-2.25h-4.5a.75.75 0 0 1-.75-.75v-.878Zm5.5 1.628a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm-6.75 4a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Z"/>
</svg>
<span class="gsr-title-text">Similar Repos</span>
</div>
<span class="gsr-chevron" id="gsr-chevron">▾</span>
</div>
<div class="gsr-header-actions">
<!-- Enabled/disabled pill toggle -->
<button class="gsr-enable-pill ${settings.enabled ? 'on' : 'off'}" id="gsr-enable-pill" title="${settings.enabled ? 'Click to disable Similar Repos' : 'Click to enable Similar Repos'}">
<span class="gsr-enable-dot"></span>
<span id="gsr-enable-label">${settings.enabled ? 'On' : 'Off'}</span>
</button>
<button class="gsr-icon-btn" id="gsr-open-all-btn" title="Open visible repos in new tabs (up to 10)">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h6A1.5 1.5 0 0 1 10 2.5v1a.75.75 0 0 1-1.5 0v-1h-6v6h1a.75.75 0 0 1 0 1.5h-1A1.5 1.5 0 0 1 1 8.5v-6Z"/>
<path d="M6.5 6a1.5 1.5 0 0 1 1.5-1.5h6A1.5 1.5 0 0 1 15.5 6v6a1.5 1.5 0 0 1-1.5 1.5H8A1.5 1.5 0 0 1 6.5 12V6Zm1.5 0v6h6V6H8Z"/>
</svg>
</button>
<!-- Copy markdown: clipboard icon -->
<button class="gsr-icon-btn" id="gsr-export-btn" title="Copy list as Markdown">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.75 1a.75.75 0 0 0-.75.75v1.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75V1.75a.75.75 0 0 0-.75-.75h-4.5Zm-.25 2.75v-.5h5v.5h-5Zm-2.5-.5h1v.5A1.75 1.75 0 0 0 5.75 5.5h4.5A1.75 1.75 0 0 0 12 3.75v-.5h1A1.5 1.5 0 0 1 14.5 4.75v8.5A1.5 1.5 0 0 1 13 14.75H3A1.5 1.5 0 0 1 1.5 13.25v-8.5A1.5 1.5 0 0 1 3 3.25Zm1 8.5a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5H4Zm0-2.5a.75.75 0 0 0 0 1.5h6a.75.75 0 0 0 0-1.5H4Z"/>
</svg>
</button>
<!-- Settings: gear icon -->
<button class="gsr-icon-btn" id="gsr-settings-btn" title="Settings">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 0 1 1.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.178.502.274.45.263.98.314 1.465.15l1.094-.364c.105-.035.192.016.229.051.88.88 1.52 1.965 1.832 3.166.028.111-.013.207-.103.255l-.985.54c-.453.247-.71.707-.704 1.188.002.172.002.343 0 .515-.006.48.25.941.703 1.188l.985.54c.09.048.131.144.103.255a8.593 8.593 0 0 1-1.832 3.166c-.037.035-.124.086-.229.051l-1.094-.364c-.485-.164-1.015-.113-1.465.15a6.3 6.3 0 0 1-.502.274c-.447.222-.85.629-.997 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 0 1-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a6.3 6.3 0 0 1-.502-.274c-.45-.263-.98-.314-1.465-.15l-1.094.364c-.105.035-.192-.016-.229-.051a8.593 8.593 0 0 1-1.832-3.166c-.028-.111.013-.207.103-.255l.985-.54c.453-.247.71-.708.703-1.188a7.022 7.022 0 0 1 0-.515c.007-.48-.25-.941-.703-1.188l-.985-.54c-.09-.048-.131-.144-.103-.255a8.593 8.593 0 0 1 1.832-3.166c.037-.035.124-.086.229-.051l1.094.364c.485.164 1.015.113 1.465-.15.161-.096.328-.188.502-.274.447-.222.85-.629.997-1.189l.289-1.105c.029-.11.101-.143.137-.146ZM8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6Z" clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>
<div class="gsr-body" id="gsr-body">
<div class="gsr-tabs" id="gsr-tabs">
<div class="gsr-tab active" data-tab="similar">
<span class="gsr-tab-label">Similar</span>
<span class="gsr-tab-badge" id="gsr-tab-similar-count">…</span>
</div>
<div class="gsr-tab" data-tab="saved">
<span class="gsr-tab-label">Saved</span>
<span class="gsr-tab-badge" id="gsr-tab-saved-count">0</span>
</div>
<div class="gsr-tab" data-tab="recent">
<span class="gsr-tab-label">Recent</span>
<span class="gsr-tab-badge" id="gsr-tab-recent-count">0</span>
</div>
<div class="gsr-tab" data-tab="dismissed">
<span class="gsr-tab-label">Hidden</span>
<span class="gsr-tab-badge" id="gsr-tab-dismissed-count">0</span>
</div>
</div>
<div id="gsr-tab-content"></div>
</div>
`;
// Collapse/expand — persist state
wrap.querySelector('#gsr-toggle').onclick = () => {
const body = wrap.querySelector('#gsr-body');
const chev = wrap.querySelector('#gsr-chevron');
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
chev.style.transform = isHidden ? '' : 'rotate(-90deg)';
settings.panelOpen = isHidden; // isHidden was true → we're opening it now
saveSettings();
};
// Apply saved open/closed state immediately
if (!settings.panelOpen) {
wrap.querySelector('#gsr-body').style.display = 'none';
wrap.querySelector('#gsr-chevron').style.transform = 'rotate(-90deg)';
}
// Apply disabled visual state
if (!settings.enabled) wrap.classList.add('gsr-disabled');
// Enable/disable pill
wrap.querySelector('#gsr-enable-pill').onclick = e => {
e.stopPropagation();
settings.enabled = !settings.enabled;
saveSettings();
const pill = wrap.querySelector('#gsr-enable-pill');
const label = wrap.querySelector('#gsr-enable-label');
const body = wrap.querySelector('#gsr-body');
pill.classList.toggle('on', settings.enabled);
pill.classList.toggle('off', !settings.enabled);
label.textContent = settings.enabled ? 'On' : 'Off';
pill.title = settings.enabled ? 'Click to disable Similar Repos' : 'Click to enable Similar Repos';
wrap.classList.toggle('gsr-disabled', !settings.enabled);
if (settings.enabled) {
// Re-open body if it was open before, then kick off a fetch for current repo
if (settings.panelOpen) body.style.display = '';
if (!currentResult) {
renderTabContent(wrap); // show spinner
fetchAndRender(wrap, currentOwner, currentRepo);
} else {
renderTabContent(wrap);
}
}
};
// Tab switching
wrap.querySelector('#gsr-tabs').addEventListener('click', e => {
const tab = e.target.closest('[data-tab]');
if (!tab) return;
activeTab = tab.dataset.tab;
wrap.querySelectorAll('.gsr-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === activeTab));
renderTabContent(wrap);
});
// Settings button
wrap.querySelector('#gsr-settings-btn').onclick = e => {
e.stopPropagation();
openSettings();
};
// ── Shared helper: get currently visible items for active tab ──
function getTabItems() {
if (activeTab === 'similar' && currentResult?.items) {
return applyFiltersAndSort(currentResult.items);
} else if (activeTab === 'saved') {
return Object.values(saved);
} else if (activeTab === 'recent') {
return getRecentItems();
} else if (activeTab === 'dismissed') {
return Object.keys(dismissed).map(slug => {
const fromResult = currentResult?.items?.find(i => i.slug === slug);
const fromRecent = recent[slug];
return fromResult || fromRecent || { slug, href: `https://github.com/${slug}`, stars: null, pushedAt: null, desc: '', language: null, archived: false };
});
}
return [];
}
// Open all in tabs — GM_openInTab bypasses popup blockers entirely,
// it's a privileged userscript API that doesn't need a user gesture chain.
wrap.querySelector('#gsr-open-all-btn').onclick = e => {
e.stopPropagation();
const visible = getTabItems().slice(0, 10);
if (!visible.length) return;
const now = Date.now();
visible.forEach(it => {
GM_openInTab(it.href, { active: false, insert: true });
visited[it.slug] = now;
const source = currentResult?.items?.find(r => r.slug === it.slug) || it;
recent[it.slug] = { ...source, visitedAt: now };
});
saveVisited();
saveRecent();
updateTabCounts(wrap);
};
// Export as markdown — uses active tab items
wrap.querySelector('#gsr-export-btn').onclick = e => {
e.stopPropagation();
const btn = wrap.querySelector('#gsr-export-btn');
const items = getTabItems();
if (!items.length) return;
const lines = items.map(it =>
`- [${it.slug}](${it.href})${it.stars ? ` — ★${formatStars(it.stars)}` : ''}${it.desc ? ` — ${it.desc}` : ''}`
);
navigator.clipboard.writeText(lines.join('\n')).then(() => {
btn.title = 'Copied!';
setTimeout(() => { btn.title = 'Copy list as Markdown'; }, 2000);
});
};
sidebar.insertBefore(wrap, sidebar.firstChild);
return wrap;
}
// ════════════════════════════════════════════════════════════
// TAB RENDERING
// ════════════════════════════════════════════════════════════
function updateTabCounts(panel) {
const similarCount = currentResult?.items?.filter(it => !dismissed[it.slug]).length ?? '…';
panel.querySelector('#gsr-tab-similar-count').textContent = similarCount;
panel.querySelector('#gsr-tab-saved-count').textContent = Object.keys(saved).length;
panel.querySelector('#gsr-tab-recent-count').textContent = getRecentItems().length;
panel.querySelector('#gsr-tab-dismissed-count').textContent = Object.keys(dismissed).length;
}
// Returns recent items visited within the last 7 days, sorted newest first
function getRecentItems() {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
return Object.values(recent)
.filter(r => r.visitedAt > cutoff)
.sort((a, b) => b.visitedAt - a.visitedAt);
}
function renderTabContent(panel) {
updateTabCounts(panel);
const container = panel.querySelector('#gsr-tab-content');
container.innerHTML = '';
if (activeTab === 'similar') {
renderSimilarTab(container, panel);
} else if (activeTab === 'saved') {
renderSavedTab(container);
} else if (activeTab === 'recent') {
renderRecentTab(container);
} else if (activeTab === 'dismissed') {
renderDismissedTab(container);
}
}
// ── Similar tab ─────────────────────────────────────────────
function renderSimilarTab(container, panel) {
// Show loading
if (!currentResult) {
container.innerHTML = `<div class="gsr-loading"><span class="gsr-spinner"></span>Finding similar repos…</div>`;
return;
}
const items = applyFiltersAndSort(currentResult.items);
const langs = uniqueLangs(currentResult.items);
// Controls
const controlsEl = document.createElement('div');
controlsEl.innerHTML = `
<div class="gsr-controls">
<select class="gsr-select" id="gsr-sort">
<option value="relevance" ${settings.sortBy==='relevance'?'selected':''}>Sort: Relevance</option>
<option value="stars" ${settings.sortBy==='stars' ?'selected':''}>Sort: Stars</option>
<option value="activity" ${settings.sortBy==='activity' ?'selected':''}>Sort: Activity</option>
</select>
</div>
${langs.length > 1 ? `
<div class="gsr-lang-chips" id="gsr-lang-chips">
<button class="gsr-chip ${!settings.langFilter?'active':''}" data-lang="">All</button>
${langs.map(l => `<button class="gsr-chip ${settings.langFilter===l?'active':''}" data-lang="${esc(l)}">${esc(l)}</button>`).join('')}
</div>` : ''}
`;
container.appendChild(controlsEl);
controlsEl.querySelector('#gsr-sort').onchange = e => {
settings.sortBy = e.target.value;
saveSettings();
renderTabContent(panel);
};
controlsEl.querySelector('#gsr-lang-chips')?.addEventListener('click', e => {
const chip = e.target.closest('[data-lang]');
if (!chip) return;
settings.langFilter = chip.dataset.lang;
saveSettings();
renderTabContent(panel);
});
// Rate limit warning
if (currentResult._rateLimited) {
const warn = document.createElement('div');
warn.className = 'gsr-ratelimit';
warn.innerHTML = `⚡ GitHub API rate limit hit — some star counts may be missing. <a href="#" id="gsr-add-token-link">Add a token</a> in settings for 5,000 req/hr.`;
container.appendChild(warn);
warn.querySelector('#gsr-add-token-link').onclick = e => { e.preventDefault(); openSettings(); };
}
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'gsr-loading';
empty.textContent = settings.langFilter
? `No ${settings.langFilter} repos found. Try a different language filter.`
: 'No similar repos found for this project.';
container.appendChild(empty);
return;
}
const scroll = document.createElement('div');
scroll.className = 'gsr-scroll';
scroll.innerHTML = items.map(it => renderCard(it)).join('');
container.appendChild(scroll);
// Wire card buttons
scroll.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
const action = btn.dataset.action;
const slug = btn.dataset.slug;
const item = currentResult.items.find(i => i.slug === slug);
if (action === 'dismiss') {
dismissed[slug] = true;
saveDismissed();
renderTabContent(panel);
} else if (action === 'save') {
if (saved[slug]) {
delete saved[slug];
} else if (item) {
saved[slug] = item;
}
saveSaved();
renderTabContent(panel);
} else if (action === 'gh-star') {
if (!settings.token) return; // no-token state, do nothing
toggleGhStar(slug).then(({ ok, nowStarred }) => {
if (!ok) return;
// Update just this button in place without full re-render
const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
if (starBtn) {
starBtn.classList.toggle('starred', nowStarred);
starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
}
});
}
});
// Track visits — write to both visited (for dimming) and recent (for tab)
scroll.addEventListener('click', e => {
const card = e.target.closest('a.gsr-item');
if (!card) return;
const slug = card.dataset.slug;
if (!slug) return;
const now = Date.now();
visited[slug] = now;
saveVisited();
// Also store in recent with full metadata for the Recent tab
const item = currentResult?.items?.find(i => i.slug === slug);
if (item) {
recent[slug] = { ...item, visitedAt: now };
saveRecent();
}
updateTabCounts(document.getElementById(PANEL_ID));
});
// Footer
const footer = document.createElement('div');
footer.className = 'gsr-footer';
const src = currentResult.source || 'LibHunt';
const lhLink = currentResult.libhuntUrl
? `<a href="${currentResult.libhuntUrl}" target="_blank" rel="noopener noreferrer">LibHunt ↗</a>`
: '';
footer.innerHTML = `
<span>${items.length} result${items.length!==1?'s':''} · ${src}</span>
<div class="gsr-footer-actions">
${lhLink}
<button class="gsr-footer-btn" id="gsr-refresh-btn">↻ Refresh</button>
</div>
`;
container.appendChild(footer);
footer.querySelector('#gsr-refresh-btn').onclick = () => {
GM_deleteValue(KEY.cache(currentOwner, currentRepo));
currentResult = null;
renderTabContent(panel);
fetchAndRender(panel, currentOwner, currentRepo);
};
}
// ── Saved tab ───────────────────────────────────────────────
function renderSavedTab(container) {
const items = Object.values(saved);
if (!items.length) {
container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
<span>No saved repos yet.</span>
<span style="font-size:${FONT_SIZES[settings.fontSize]==='13px'?'11.5px':'10px'};color:var(--color-fg-subtle,#848d97)">Bookmark repos with the ★ button on any result.</span>
</div>`;
return;
}
const scroll = document.createElement('div');
scroll.className = 'gsr-scroll';
scroll.innerHTML = items.map(it => renderCard(it, { inSaved: true })).join('');
container.appendChild(scroll);
scroll.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
const slug = btn.dataset.slug;
if (btn.dataset.action === 'unsave') {
delete saved[slug];
saveSaved();
renderTabContent(document.getElementById(PANEL_ID));
} else if (btn.dataset.action === 'gh-star') {
if (!settings.token) return;
toggleGhStar(slug).then(({ ok, nowStarred }) => {
if (!ok) return;
const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
if (starBtn) {
starBtn.classList.toggle('starred', nowStarred);
starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
}
});
}
});
}
// ── Recent tab (last 7 days of clicked-through repos) ───────
function renderRecentTab(container) {
const items = getRecentItems();
if (!items.length) {
container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
<span>No recently visited repos.</span>
<span style="font-size:11px;color:var(--color-fg-subtle,#848d97)">Repos you click from the Similar tab appear here for 7 days.</span>
</div>`;
return;
}
const scroll = document.createElement('div');
scroll.className = 'gsr-scroll';
scroll.innerHTML = items.map(it => {
const ago = timeAgo(it.visitedAt);
// Use visitedAt for freshness so recent visits are green, older are amber/red
const dotColor = freshnessColor(new Date(it.visitedAt).toISOString());
const borderColor = dotColor + '55';
return `
<div class="gsr-item" style="cursor:default">
<div class="gsr-item-top">
<a href="${esc(it.href)}" class="gsr-repo-name" style="text-decoration:none">${esc(it.slug)}</a>
<span class="gsr-badges">
${it.stars ? `<span class="gsr-stars">★ ${esc(formatStars(it.stars))}</span>` : ''}
${ago ? `<span class="gsr-activity" style="color:${dotColor};border-color:${borderColor};background:${dotColor}18">
<span class="gsr-dot" style="background:${dotColor}"></span>visited ${esc(ago)}
</span>` : ''}
</span>
</div>
${it.desc ? `<span class="gsr-desc">${esc(it.desc)}</span>` : ''}
${it.language ? `<div class="gsr-item-meta"><span class="gsr-lang-chip">${esc(it.language)}</span></div>` : ''}
</div>
`;
}).join('');
container.appendChild(scroll);
// Footer with clear button
const footer = document.createElement('div');
footer.className = 'gsr-footer';
footer.innerHTML = `
<span>${items.length} visited in last 7 days</span>
<button class="gsr-footer-btn" id="gsr-clear-recent">Clear history</button>
`;
container.appendChild(footer);
footer.querySelector('#gsr-clear-recent').onclick = () => {
recent = {};
saveRecent();
renderTabContent(document.getElementById(PANEL_ID));
};
}
// ── Dismissed tab ───────────────────────────────────────────
function renderDismissedTab(container) {
const slugs = Object.keys(dismissed);
if (!slugs.length) {
container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
<span>No hidden repos.</span>
<span style="font-size:${FONT_SIZES[settings.fontSize]==='13px'?'11.5px':'10px'};color:var(--color-fg-subtle,#848d97)">Dismiss repos with the ✕ button on any result.</span>
</div>`;
return;
}
// Build item objects: use stored metadata from currentResult or recent, fall back to slug-only stub
const items = slugs.map(slug => {
const fromResult = currentResult?.items?.find(i => i.slug === slug);
const fromRecent = recent[slug];
return fromResult || fromRecent || { slug, href: `https://github.com/${slug}`, desc: '', stars: null, pushedAt: null, language: null, archived: false };
});
const scroll = document.createElement('div');
scroll.className = 'gsr-scroll';
scroll.innerHTML = items.map(it => renderCard(it, { inDismissed: true })).join('');
container.appendChild(scroll);
scroll.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (btn) {
e.preventDefault(); e.stopPropagation();
const slug = btn.dataset.slug;
if (btn.dataset.action === 'undismiss') {
delete dismissed[slug];
saveDismissed();
renderTabContent(document.getElementById(PANEL_ID));
} else if (btn.dataset.action === 'gh-star') {
if (!settings.token) return;
toggleGhStar(slug).then(({ ok, nowStarred }) => {
if (!ok) return;
const starBtn = scroll.querySelector(`[data-action="gh-star"][data-slug="${slug}"]`);
if (starBtn) {
starBtn.classList.toggle('starred', nowStarred);
starBtn.title = nowStarred ? 'Unstar on GitHub' : 'Star on GitHub';
starBtn.innerHTML = nowStarred ? SVG.starFilled : SVG.starHollow;
}
});
}
return;
}
// Clicking the card body (not a button) opens the repo in a new tab
const card = e.target.closest('.gsr-item[data-href]');
if (card) {
e.preventDefault();
const href = card.dataset.href;
if (href) window.open(href, '_blank', 'noopener,noreferrer');
}
});
const footer = document.createElement('div');
footer.className = 'gsr-footer';
footer.innerHTML = `<span>${items.length} hidden repo${items.length !== 1 ? 's' : ''}</span>`;
container.appendChild(footer);
}
// ── Card renderer ────────────────────────────────────────────
// SVG icon strings
const SVG = {
// Hollow bookmark (unsaved)
bookmarkHollow: `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v11.792l-4.5-2.796L4 14.792 3 14.5V2.5Z"/></svg>`,
// Filled bookmark (saved) - filled with accent blue
bookmarkFilled: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2.5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v11.792l-4.5-2.796L4 14.292V2.5Z"/></svg>`,
// Hollow star (not GH-starred)
starHollow: `<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1.5l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.1l-3.8 2.2.7-4.3-3.1-3 4.3-.6L8 1.5Z"/></svg>`,
// Filled star (GH-starred)
starFilled: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1.5l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.1l-3.8 2.2.7-4.3-3.1-3 4.3-.6L8 1.5Z"/></svg>`,
// Undo / unhide
undismiss: `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 3.5A.5.5 0 0 1 2 3h4a.5.5 0 0 1 0 1H3.707l2.147 2.146a.5.5 0 0 1-.708.708L3 4.707V6a.5.5 0 0 1-1 0V3.5ZM13 8a5 5 0 1 1-10 0 5 5 0 0 1 10 0Zm-4.5-2a.5.5 0 0 0-1 0v2.293l-1.146-1.147a.5.5 0 0 0-.708.708l2 2a.5.5 0 0 0 .708 0l2-2a.5.5 0 0 0-.708-.708L8.5 8.293V6Z"/></svg>`,
};
function renderCard(it, { inSaved = false, inDismissed = false } = {}) {
const { slug, href, stars, pushedAt, desc, language, archived } = it;
const starsStr = formatStars(stars);
const ago = timeAgo(pushedAt);
const dotColor = freshnessColor(pushedAt);
const borderColor = dotColor + '55';
const isVisited = !!visited[slug];
const isSaved = !!saved[slug];
const isGhStarred = !!ghStarred[slug];
const hasToken = !!settings.token;
// GH star button — hollow/gold star; greyed out + no-token class if no API token
const ghStarBtn = `<button
class="gsr-action-btn gh-star-btn${isGhStarred ? ' starred' : ''}${!hasToken ? ' no-token' : ''}"
data-action="gh-star"
data-slug="${esc(slug)}"
title="${!hasToken ? 'Add a GitHub token in Settings to star repos' : isGhStarred ? 'Unstar on GitHub' : 'Star on GitHub'}"
>${isGhStarred ? SVG.starFilled : SVG.starHollow}</button>`;
let actionBtns;
if (inDismissed) {
// Unhide + GH star available on hidden cards too
actionBtns = `
${ghStarBtn}
<button class="gsr-action-btn" data-action="undismiss" data-slug="${esc(slug)}" title="Unhide this repo">${SVG.undismiss}</button>
`;
} else if (inSaved) {
actionBtns = `
${ghStarBtn}
<button class="gsr-action-btn is-saved" data-action="unsave" data-slug="${esc(slug)}" title="Remove from saved">${SVG.bookmarkFilled}</button>
`;
} else {
actionBtns = `
${ghStarBtn}
<button class="gsr-action-btn${isSaved ? ' is-saved' : ''}" data-action="save" data-slug="${esc(slug)}" title="${isSaved ? 'Remove from saved' : 'Save this repo'}">${isSaved ? SVG.bookmarkFilled : SVG.bookmarkHollow}</button>
<button class="gsr-action-btn" data-action="dismiss" data-slug="${esc(slug)}" title="Hide this repo">✕</button>
`;
}
// For dismissed tab, wrap in a div (not <a>) so clicking the card doesn't navigate away
const tag = inDismissed ? 'div' : 'a';
const hrefAttr = inDismissed ? '' : `href="${esc(href)}"`;
const extraStyle = inDismissed ? 'cursor:default;' : '';
return `
<${tag} class="gsr-item${isVisited ? ' visited' : ''}${archived ? ' archived' : ''}"
${hrefAttr} data-slug="${esc(slug)}" data-href="${esc(href)}" style="${extraStyle}" title="${esc(slug)}">
<div class="gsr-item-top">
<span class="gsr-repo-name"${inDismissed ? ` onclick="window.open('${esc(href)}','_blank','noopener,noreferrer');event.stopPropagation()" style="cursor:pointer"` : ''}>${esc(slug)}</span>
<span class="gsr-badges">
${starsStr ? `<span class="gsr-stars">★ ${esc(starsStr)}</span>` : ''}
${archived ? `<span class="gsr-archived-badge">Archived</span>` : ''}
${ago ? `<span class="gsr-activity" style="color:${dotColor};border-color:${borderColor};background:${dotColor}18">
<span class="gsr-dot" style="background:${dotColor}"></span>${esc(ago)}
</span>` : ''}
</span>
</div>
${desc ? `<span class="gsr-desc">${esc(desc)}</span>` : ''}
${language ? `<div class="gsr-item-meta"><span class="gsr-lang-chip">${esc(language)}</span></div>` : ''}
<div class="gsr-item-actions">${actionBtns}</div>
</${tag}>
`;
}
// ════════════════════════════════════════════════════════════
// MAIN FETCH + RENDER FLOW
// ════════════════════════════════════════════════════════════
async function fetchAndRender(panel, owner, repo) {
try {
const libhuntData = await fetchLibHunt(owner, repo);
if (libhuntData) {
const { items, rateLimited } = await enrichSlugs(libhuntData.slugs);
currentResult = {
source: 'LibHunt', libhuntUrl: libhuntData.libhuntUrl,
items, _rateLimited: rateLimited,
};
} else {
const fallback = await fetchGitHubTopics(owner, repo);
currentResult = fallback || { source: 'No results', libhuntUrl: null, items: [], _rateLimited: false };
}
GM_setValue(KEY.cache(owner, repo), JSON.stringify({ ts: Date.now(), data: currentResult }));
renderTabContent(panel);
} catch {
currentResult = { source: 'Error', libhuntUrl: null, items: [], _rateLimited: false };
renderTabContent(panel);
}
}
async function run() {
const parts = getRepoParts();
if (!parts) return;
const skipOwners = ['settings','notifications','organizations','marketplace','explore','login','join'];
if (skipOwners.includes(parts.owner)) return;
currentOwner = parts.owner;
currentRepo = parts.repo;
currentResult = null;
activeTab = 'similar';
const panel = buildPanel();
if (!panel) return;
// If disabled, show a quiet disabled state and stop — don't fetch anything
if (!settings.enabled) {
const container = panel.querySelector('#gsr-tab-content');
if (container) {
container.innerHTML = `<div class="gsr-loading" style="flex-direction:column;align-items:flex-start;gap:4px">
<span style="color:var(--color-fg-muted,#656d76)">Similar Repos is turned off.</span>
<span style="font-size:11px;color:var(--color-fg-subtle,#848d97)">Toggle it on above to find similar repos.</span>
</div>`;
}
updateTabCounts(panel);
return;
}
// Try cache
try {
const raw = GM_getValue(KEY.cache(parts.owner, parts.repo), null);
if (raw) {
const { ts, data } = JSON.parse(raw);
if (Date.now() - ts < CACHE_TTL) {
currentResult = data;
renderTabContent(panel);
return;
}
}
// eslint-disable-next-line no-empty
} catch {}
renderTabContent(panel); // show spinner
fetchAndRender(panel, parts.owner, parts.repo);
}
// ════════════════════════════════════════════════════════════
// INIT + SPA WATCHER
// ════════════════════════════════════════════════════════════
loadState();
rebindShortcut();
let lastPath = location.pathname;
new MutationObserver(() => {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
setTimeout(() => {
document.getElementById(PANEL_ID)?.remove();
document.getElementById('gsr-styles')?.remove();
run();
}, 800);
}
}).observe(document.body, { childList: true, subtree: true });
run();
})();