GitHub Watch Later

Add a Watch Later list to GitHub repositories. Save repos for later, search and sort by added time.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 = "&#8635;";
    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();
})();