Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.
// ==UserScript==
// @name GitHub Watch Later
// @namespace https://github.com/hanmi255/github-WatchLater
// @version 0.1.1
// @description Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.
// @author hanmi255
// @match https://github.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @license MIT
// @icon https://github.githubassets.com/pinned-octocat.svg
// @homepageURL https://github.com/hanmi255/github-WatchLater
// @supportURL https://github.com/hanmi255/github-WatchLater/issues
// ==/UserScript==
(function () {
"use strict";
// ── Storage ──────────────────────────────────────────────────────────────
const STORAGE_KEY = "gwl_data";
function loadData() {
const raw = GM_getValue(STORAGE_KEY, null);
if (raw) return JSON.parse(raw);
return { version: 1, repos: [], settings: { sortBy: "addedAt_desc" } };
}
function saveData(data) {
GM_setValue(STORAGE_KEY, JSON.stringify(data));
}
function getRepos() {
return loadData().repos;
}
function isAdded(id) {
return getRepos().some((r) => r.id === id);
}
function addRepo(repo) {
const data = loadData();
if (!data.repos.some((r) => r.id === repo.id)) {
data.repos.unshift(repo);
saveData(data);
}
}
function removeRepo(id) {
const data = loadData();
data.repos = data.repos.filter((r) => r.id !== id);
saveData(data);
}
// ── Page detection ────────────────────────────────────────────────────────
const RESERVED = new Set([
"features",
"topics",
"explore",
"marketplace",
"settings",
"notifications",
"pulls",
"issues",
"login",
"join",
"organizations",
"orgs",
"sponsors",
"about",
"pricing",
"contact",
"security",
"enterprise",
"readme",
"new",
"codespaces",
"gist",
"discussions",
]);
function getRepoInfo() {
const parts = location.pathname.split("/").filter(Boolean);
if (parts.length < 2) return null;
if (RESERVED.has(parts[0])) return null;
const owner = parts[0];
const name = parts[1];
if (
[
"repositories",
"projects",
"packages",
"stars",
"followers",
"following",
].includes(name)
)
return null;
const descEl = document.querySelector('meta[name="description"]');
const description = descEl
? descEl.content.replace(/\s*\d+[\s\S]*$/, "").trim()
: "";
return {
id: `${owner}/${name}`,
owner,
name,
fullName: `${owner}/${name}`,
url: `https://github.com/${owner}/${name}`,
description,
addedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
note: "",
status: "unread",
};
}
// ── Styles ────────────────────────────────────────────────────────────────
GM_addStyle(`
.gwl-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
color: var(--fgColor-default, #24292f);
background-color: var(--bgColor-default, #f6f8fa);
border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
}
.gwl-btn:hover { background-color: var(--bgColor-muted, #eaeef2); }
.gwl-btn.gwl-added {
color: var(--fgColor-success, #1a7f37);
border-color: var(--borderColor-success, rgba(31,136,61,0.4));
background-color: var(--bgColor-success-subtle, #dafbe1);
}
.gwl-btn.gwl-added:hover { background-color: #c6efce; }
.gwl-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 99998; display: flex; align-items: flex-start; justify-content: flex-end;
}
.gwl-panel {
width: 420px; max-width: 100vw; height: 100vh;
background: var(--bgColor-default, #ffffff);
border-left: 1px solid var(--borderColor-default, #d0d7de);
display: flex; flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px; color: var(--fgColor-default, #24292f);
overflow: hidden; z-index: 99999;
}
.gwl-panel-header {
padding: 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
}
.gwl-panel-header h2 { margin: 0; font-size: 16px; font-weight: 600; }
.gwl-close-btn {
background: none; border: none; cursor: pointer; padding: 4px;
color: var(--fgColor-muted, #57606a); font-size: 18px; line-height: 1;
}
.gwl-close-btn:hover { color: var(--fgColor-default, #24292f); }
.gwl-controls {
padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);
display: flex; flex-direction: column; gap: 8px; flex-shrink: 0;
}
.gwl-search {
width: 100%; padding: 5px 10px; font-size: 14px;
border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
box-sizing: border-box;
}
.gwl-search:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.3); }
.gwl-row { display: flex; gap: 8px; }
.gwl-select {
flex: 1; padding: 5px 8px; font-size: 12px;
border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;
background: var(--bgColor-default, #fff); color: var(--fgColor-default, #24292f);
}
.gwl-list { flex: 1; overflow-y: auto; padding: 8px 0; }
.gwl-item {
padding: 12px 16px;
border-bottom: 1px solid var(--borderColor-muted, #eaeef2);
}
.gwl-item:last-child { border-bottom: none; }
.gwl-item-title {
font-weight: 600; font-size: 14px; color: #0969da;
text-decoration: none; display: block; margin-bottom: 4px;
}
.gwl-item-title:hover { text-decoration: underline; }
.gwl-item-meta { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 4px; }
.gwl-item-desc {
font-size: 12px; color: var(--fgColor-muted, #57606a); margin-bottom: 8px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.gwl-item-actions { display: flex; gap: 6px; }
.gwl-action-btn {
padding: 2px 10px; font-size: 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--borderColor-default, #d0d7de);
background: var(--bgColor-default, #f6f8fa); color: var(--fgColor-default, #24292f);
text-decoration: none; display: inline-flex; align-items: center;
}
.gwl-action-btn:hover { background: var(--bgColor-muted, #eaeef2); }
.gwl-action-btn.danger { color: #cf222e; border-color: rgba(207,34,46,0.4); }
.gwl-action-btn.danger:hover { background: #ffebe9; }
.gwl-empty {
padding: 40px 16px; text-align: center;
color: var(--fgColor-muted, #57606a); font-size: 14px;
}
.gwl-empty p { margin: 8px 0; }
.gwl-footer {
padding: 12px 16px; border-top: 1px solid var(--borderColor-default, #d0d7de);
display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; align-items: center;
}
.gwl-count { font-size: 12px; color: var(--fgColor-muted, #57606a); margin-left: auto; }
.gwl-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--bgColor-emphasis, #24292f); color: #fff;
padding: 8px 16px; border-radius: 6px; font-size: 13px; z-index: 100000;
animation: gwl-fadein 0.2s ease;
}
@keyframes gwl-fadein {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.gwl-fab {
position: fixed; bottom: 24px; right: 24px; z-index: 99997;
padding: 5px 12px; font-size: 12px; font-weight: 500; line-height: 20px;
color: var(--fgColor-default, #24292f);
background-color: var(--bgColor-default, #f6f8fa);
border: 1px solid var(--borderColor-default, rgba(31,35,40,0.15));
border-radius: 6px; cursor: pointer; white-space: nowrap; user-select: none;
}
.gwl-fab:hover { background-color: var(--bgColor-muted, #eaeef2); }
`);
// ── Toast ─────────────────────────────────────────────────────────────────
function showToast(msg) {
const el = document.createElement("div");
el.className = "gwl-toast";
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2000);
}
// ── Later Button ──────────────────────────────────────────────────────────
function updateBtn(btn, added) {
btn.className = "gwl-btn" + (added ? " gwl-added" : "");
btn.textContent = added ? "Later ✓" : "Later";
btn.title = added ? "Remove from Watch Later" : "Add to Watch Later";
}
function injectLaterBtn() {
const repoInfo = getRepoInfo();
if (!repoInfo) return;
const existing = document.getElementById("gwl-later-btn");
if (existing) {
updateBtn(existing, isAdded(repoInfo.id));
return;
}
const targets = [
() =>
document.querySelector("#repository-details-container .d-flex.gap-2"),
() => document.querySelector(".pagehead-actions"),
() => {
const star =
document.querySelector('[data-hydro-click*="star"]') ||
document.querySelector('button[aria-label*="Star"]') ||
document.querySelector(".starring-container");
return star ? star.closest("li, div.d-flex, div.BtnGroup") : null;
},
];
let container = null;
for (const fn of targets) {
try {
container = fn();
} catch (_) {}
if (container) break;
}
if (!container) return;
const btn = document.createElement("button");
btn.id = "gwl-later-btn";
updateBtn(btn, isAdded(repoInfo.id));
btn.addEventListener("click", () => {
const info = getRepoInfo() || repoInfo;
if (isAdded(info.id)) {
removeRepo(info.id);
updateBtn(btn, false);
showToast("Removed from Later");
} else {
addRepo(info);
updateBtn(btn, true);
showToast("Added to Later");
}
});
if (container.tagName === "UL") {
const li = document.createElement("li");
li.appendChild(btn);
container.appendChild(li);
} else {
container.appendChild(btn);
}
}
// ── Panel ─────────────────────────────────────────────────────────────────
let panelOpen = false;
function openPanel() {
if (panelOpen) return;
panelOpen = true;
const overlay = document.createElement("div");
overlay.className = "gwl-overlay";
const panel = document.createElement("div");
panel.className = "gwl-panel";
// Header
const header = document.createElement("div");
header.className = "gwl-panel-header";
const h2 = document.createElement("h2");
h2.textContent = "GitHub Watch Later";
const headerRight = document.createElement("div");
headerRight.style.cssText = "display:flex;gap:4px;align-items:center";
const refreshBtn = document.createElement("button");
refreshBtn.className = "gwl-close-btn";
refreshBtn.title = "Refresh";
refreshBtn.innerHTML = "↻";
refreshBtn.addEventListener("click", render);
const closeBtn = document.createElement("button");
closeBtn.className = "gwl-close-btn";
closeBtn.textContent = "×";
closeBtn.addEventListener("click", close);
headerRight.appendChild(refreshBtn);
headerRight.appendChild(closeBtn);
header.appendChild(h2);
header.appendChild(headerRight);
// Controls
const controls = document.createElement("div");
controls.className = "gwl-controls";
const search = document.createElement("input");
search.className = "gwl-search";
search.type = "text";
search.placeholder = "Search by repo or owner...";
const sortSel = document.createElement("select");
sortSel.className = "gwl-select";
[
["addedAt_desc", "Newest first"],
["addedAt_asc", "Oldest first"],
["name_asc", "Name A→Z"],
["name_desc", "Name Z→A"],
].forEach(([v, t]) => {
const o = document.createElement("option");
o.value = v;
o.textContent = t;
sortSel.appendChild(o);
});
sortSel.value = loadData().settings.sortBy;
controls.appendChild(search);
controls.appendChild(sortSel);
// List
const list = document.createElement("div");
list.className = "gwl-list";
// Footer
const footer = document.createElement("div");
footer.className = "gwl-footer";
const exportBtn = document.createElement("button");
exportBtn.className = "gwl-action-btn";
exportBtn.textContent = "Export";
exportBtn.addEventListener("click", exportData);
const importBtn = document.createElement("button");
importBtn.className = "gwl-action-btn";
importBtn.textContent = "Import";
const importInput = document.createElement("input");
importInput.type = "file";
importInput.accept = ".json";
importInput.style.display = "none";
importInput.addEventListener("change", (e) => {
importData(e);
render();
});
importBtn.addEventListener("click", () => importInput.click());
const clearBtn = document.createElement("button");
clearBtn.className = "gwl-action-btn danger";
clearBtn.textContent = "Clear All";
clearBtn.addEventListener("click", () => {
if (
!confirm("Are you sure you want to clear all Watch Later repositories?")
)
return;
const data = loadData();
data.repos = [];
saveData(data);
render();
showToast("Cleared all repositories");
});
const countEl = document.createElement("span");
countEl.className = "gwl-count";
footer.appendChild(exportBtn);
footer.appendChild(importBtn);
footer.appendChild(importInput);
footer.appendChild(clearBtn);
footer.appendChild(countEl);
panel.appendChild(header);
panel.appendChild(controls);
panel.appendChild(list);
panel.appendChild(footer);
overlay.appendChild(panel);
document.body.appendChild(overlay);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close();
});
const onKey = (e) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
function render() {
const keyword = search.value.trim().toLowerCase();
const sortBy = sortSel.value;
let repos = getRepos();
if (keyword)
repos = repos.filter(
(r) =>
r.fullName.toLowerCase().includes(keyword) ||
r.owner.toLowerCase().includes(keyword) ||
r.name.toLowerCase().includes(keyword) ||
(r.description || "").toLowerCase().includes(keyword),
);
repos = [...repos].sort((a, b) => {
switch (sortBy) {
case "addedAt_asc":
return new Date(a.addedAt) - new Date(b.addedAt);
case "name_asc":
return a.fullName.localeCompare(b.fullName);
case "name_desc":
return b.fullName.localeCompare(a.fullName);
default:
return new Date(b.addedAt) - new Date(a.addedAt);
}
});
countEl.textContent = `${repos.length} repo${repos.length !== 1 ? "s" : ""}`;
list.innerHTML = "";
if (repos.length === 0) {
const empty = document.createElement("div");
empty.className = "gwl-empty";
empty.innerHTML =
getRepos().length === 0
? '<p>No repositories saved yet.</p><p>Click "Later" on any GitHub repository to save it here.</p>'
: "<p>No matching repositories found.</p>";
list.appendChild(empty);
return;
}
repos.forEach((repo) => {
const item = document.createElement("div");
item.className = "gwl-item";
const title = document.createElement("a");
title.className = "gwl-item-title";
title.href = repo.url;
title.target = "_blank";
title.rel = "noopener noreferrer";
title.textContent = repo.fullName;
const meta = document.createElement("div");
meta.className = "gwl-item-meta";
meta.textContent = `added ${new Date(repo.addedAt).toLocaleDateString()}`;
const actions = document.createElement("div");
actions.className = "gwl-item-actions";
const openBtn = document.createElement("a");
openBtn.className = "gwl-action-btn";
openBtn.href = repo.url;
openBtn.target = "_blank";
openBtn.rel = "noopener noreferrer";
openBtn.textContent = "Open";
const removeBtn = document.createElement("button");
removeBtn.className = "gwl-action-btn danger";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
removeRepo(repo.id);
render();
const btn = document.getElementById("gwl-later-btn");
const info = getRepoInfo();
if (btn && info && info.id === repo.id) updateBtn(btn, false);
showToast("Removed from Later");
});
actions.appendChild(openBtn);
actions.appendChild(removeBtn);
item.appendChild(title);
item.appendChild(meta);
if (repo.description) {
const desc = document.createElement("div");
desc.className = "gwl-item-desc";
desc.textContent = repo.description;
item.appendChild(desc);
}
item.appendChild(actions);
list.appendChild(item);
});
}
sortSel.addEventListener("change", () => {
const data = loadData();
data.settings.sortBy = sortSel.value;
saveData(data);
render();
});
let searchTimer;
search.addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(render, 200);
});
render();
function close() {
document.removeEventListener("keydown", onKey);
overlay.remove();
panelOpen = false;
}
}
// ── Import / Export ───────────────────────────────────────────────────────
function exportData() {
const blob = new Blob([JSON.stringify(loadData(), null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `github-watch-later-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
function importData(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
if (!Array.isArray(imported.repos)) throw new Error();
const data = loadData();
const ids = new Set(data.repos.map((r) => r.id));
let added = 0;
imported.repos.forEach((r) => {
if (!ids.has(r.id)) {
data.repos.push(r);
added++;
}
});
saveData(data);
showToast(`Imported ${added} new repo${added !== 1 ? "s" : ""}`);
} catch (_) {
showToast("Import failed: invalid file");
}
};
reader.readAsText(file);
e.target.value = "";
}
// ── Keyboard shortcut + event bridge ─────────────────────────────────────
document.addEventListener('gwl:open-panel', openPanel);
let bgnLoaded = false;
document.addEventListener('bgn:loaded', () => {
bgnLoaded = true;
const fab = document.getElementById('gwl-fab');
if (fab) fab.remove();
});
function injectFab() {
if (bgnLoaded) return;
if (document.getElementById('gwl-fab')) return;
const btn = document.createElement('button');
btn.id = 'gwl-fab';
btn.className = 'gwl-fab';
btn.title = 'Later (Alt+L)';
btn.textContent = 'Later';
btn.addEventListener('click', openPanel);
document.body.appendChild(btn);
}
document.addEventListener('keydown', e => {
if (e.altKey && e.key === 'l') openPanel();
});
// ── MutationObserver + URL change detection ───────────────────────────────
let lastUrl = location.href;
function onPageChange() {
injectFab();
injectLaterBtn();
}
const observer = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(onPageChange, 300);
} else {
if (!document.getElementById("gwl-later-btn")) injectLaterBtn();
if (!bgnLoaded && !document.getElementById("gwl-fab")) injectFab();
}
});
observer.observe(document.body, { childList: true, subtree: true });
onPageChange();
})();