const $status = document.getElementById("status");
const $output = document.getElementById("output");
const $deleteBtn = document.getElementById("delete-btn");
const $undoBtn = document.getElementById("undo-btn");
const $cacheBanner = document.getElementById("cache-banner");
const $filterBar = document.getElementById("filter-bar");
const $modeLive = document.getElementById("mode-live");
const $modeCache = document.getElementById("mode-cache");
const $pauseScan = document.getElementById("pause-scan");
let lastResult = null;
let settings = null;
let activeFilter = "all"; // "all" | "Source" | "Target" | "Catalog"
function syncModeToggle() {
const live = settings && settings.quickMode !== false;
$modeLive.classList.toggle("active", live);
$modeCache.classList.toggle("active", !live);
$modeLive.setAttribute("aria-pressed", live ? "true" : "false");
$modeCache.setAttribute("aria-pressed", !live ? "true" : "false");
}
function syncPauseButton() {
const paused = !!(settings && settings.scanPaused);
$pauseScan.classList.toggle("paused", paused);
$pauseScan.textContent = paused ? "▶" : "⏸";
$pauseScan.title = paused ? "Resume scanning" : "Pause scanning";
$pauseScan.setAttribute("aria-pressed", paused ? "true" : "false");
}
function renderPausedState() {
setStatus("Scanning paused", "err");
$output.innerHTML = "";
const div = document.createElement("div");
div.className = "empty";
div.textContent = "Press ▶ to resume scans.";
$output.appendChild(div);
$deleteBtn.style.display = "none";
$undoBtn.style.display = "none";
}
async function setScanPaused(paused) {
const s = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
settings = Object.assign({}, s, { scanPaused: paused });
await chrome.storage.sync.set({ settings });
chrome.runtime.sendMessage({ type: "settings-changed" });
syncPauseButton();
if (paused) renderPausedState();
else if (manualMode && $searchInput.value.trim()) runManualSearch();
else runCheck(true);
}
async function setSearchMode(mode) {
const quickMode = mode === "live";
if (settings && settings.quickMode === quickMode) return;
const s = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
settings = Object.assign({}, s, { quickMode });
await chrome.storage.sync.set({ settings });
chrome.runtime.sendMessage({ type: "settings-changed" });
syncModeToggle();
if (manualMode && $searchInput.value.trim()) runManualSearch();
else runCheck(true);
}
function setStatus(text, cls = "") {
$status.className = cls;
if (cls === "loading") {
$status.innerHTML = `${text} `;
} else {
$status.textContent = text;
}
}
function showSkeleton(rows = 2) {
$output.innerHTML = "";
for (let i = 0; i < rows; i++) {
const div = document.createElement("div");
div.className = "skeleton";
div.innerHTML = `
`;
$output.appendChild(div);
}
}
function fmtAge(ts) {
if (!ts) return "";
const s = Math.round((Date.now() - ts) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.round(s / 60)}m ago`;
return `${Math.round(s / 3600)}h ago`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function fmtMs(n) {
return Number.isFinite(n) ? `${n}ms` : "?";
}
function renderTimings(r) {
const t = r && r.timings;
if (!t) return;
const div = document.createElement("div");
div.className = "timing-strip";
const engineLabel = Number.isFinite(t.host_cached_ms) ? "HOST" : "RC-JAV";
const engineMs = Number.isFinite(t.host_cached_ms) ? t.host_cached_ms : t.host_rcjav_ms;
const matchMs = t.cache_match_ms ?? t.match_ms;
const metrics = [
["TOTAL", fmtMs(t.total_ms)],
[engineLabel, fmtMs(engineMs)],
["EXTRACT", fmtMs(t.extract_ms)],
["MATCH", fmtMs(matchMs)],
];
div.innerHTML = metrics.map(([label, value]) => `
${escapeHtml(label)}
${escapeHtml(value)}
`).join("");
$output.appendChild(div);
}
function renderSearchMode(_r) {
// Mode chip removed — the LIVE/CACHE toggle in the header already shows the mode.
}
function buildHitCard(h) {
const filename = h.path.split("/").pop();
const idx = h.full_path.lastIndexOf("/");
const dir = idx >= 0 ? h.full_path.slice(0, idx) : h.full_path;
const srcCls = h.source === "Source" ? "src source"
: h.source === "Catalog" ? "src catalog" : "src";
const confidence = h.match_confidence ? ` · ${h.match_confidence}` : "";
const reason = h.match_reason
? `${escapeHtml(h.match_reason)} `
: "";
const div = document.createElement("div");
div.className = "hit";
div.innerHTML = `
${escapeHtml(filename)}
Path: ${escapeHtml(dir)}
${escapeHtml(h.source.toUpperCase())} ${escapeHtml(h.size_human)} ${reason}
`;
return div;
}
function renderFilterBar(structured) {
$filterBar.innerHTML = "";
if (!structured || structured.length < 2) { $filterBar.style.display = "none"; return; }
const sources = [...new Set(structured.map((h) => h.source))];
if (sources.length < 2) { $filterBar.style.display = "none"; return; }
$filterBar.style.display = "";
const filters = ["all", ...sources];
for (const f of filters) {
const chip = document.createElement("span");
chip.className = "filter-chip" + (activeFilter === f ? " active" : "");
chip.textContent = f === "all" ? "All" : f;
chip.addEventListener("click", () => {
activeFilter = f;
renderHits(lastResult && lastResult.structured);
for (const c of $filterBar.children) c.classList.toggle("active", c.textContent === (f === "all" ? "All" : f));
});
$filterBar.appendChild(chip);
}
}
function renderHits(structured) {
// Clear existing hit cards but keep mode/timing lines
for (const el of [...$output.children]) {
if (el.classList.contains("hit") || el.classList.contains("empty")) el.remove();
}
const hits = structured || [];
const filtered = activeFilter === "all" ? hits : hits.filter((h) => h.source === activeFilter);
if (filtered.length) {
const anchor = $output.querySelector(".timing-strip") || $output.querySelector(".timings");
for (const h of filtered) {
const card = buildHitCard(h);
if (anchor) $output.insertBefore(card, anchor);
else $output.appendChild(card);
}
} else {
const div = document.createElement("div");
div.className = "empty";
div.textContent = filtered.length === 0 && activeFilter !== "all"
? `No ${activeFilter} results`
: "no matches";
const anchor = $output.querySelector(".timing-strip") || $output.querySelector(".timings");
if (anchor) $output.insertBefore(div, anchor);
else $output.appendChild(div);
}
}
function isNativeRegistrationIssue(result) {
return result && (result.error_kind === "forbidden" || result.error_kind === "not_found");
}
function renderNativeSetupGuide(result) {
const div = document.createElement("div");
div.className = "setup-guide";
const forbidden = result.error_kind === "forbidden";
div.innerHTML = `
${forbidden ? "Register this extension ID on this PC" : "Native host registration is missing on this PC"}
${forbidden
? "Brave found the host, but this copy of the extension is not allowed to launch it yet."
: "Brave cannot find the rclone-jav native host yet."}
Open Registration Guide
${result.extension_id ? `Copy Extension ID ` : ""}
`;
div.querySelector("[data-open-native-setup]").addEventListener("click", async () => {
await chrome.storage.local.set({
optionsActivePane: "diagnostics",
pendingNativeSetupIssue: {
error: result.error || result.reason || "native host registration failed",
error_kind: result.error_kind,
extension_id: result.extension_id || "",
},
});
chrome.runtime.openOptionsPage();
});
const copy = div.querySelector("[data-copy-extension-id]");
if (copy) {
copy.addEventListener("click", async () => {
await navigator.clipboard.writeText(copy.dataset.copyExtensionId || "");
copy.textContent = "Copied";
setTimeout(() => { copy.textContent = "Copy Extension ID"; }, 1200);
});
}
$output.appendChild(div);
}
function renderResult(r) {
lastResult = r;
activeFilter = "all";
$output.innerHTML = "";
$filterBar.style.display = "none";
if (!r) { setStatus("no response", "err"); return; }
const tag = r.cached ? ` [session ${fmtAge(r.ts)}]` : "";
if (!r.ok) {
setStatus("✗ " + (r.reason || "error") + tag, "err");
$output.innerHTML = "";
if (r.no_match_title || r.no_match_detail) {
const div = document.createElement("div");
div.className = "empty no-match-detail";
div.innerHTML = `${escapeHtml(r.no_match_title || "No result")} ${r.no_match_detail ? `${escapeHtml(r.no_match_detail)}
` : ""}`;
$output.appendChild(div);
}
if (r.stderr || r.error) {
const div = document.createElement("div");
div.className = "err";
div.textContent = r.stderr || r.error;
$output.appendChild(div);
}
if (isNativeRegistrationIssue(r)) renderNativeSetupGuide(r);
$deleteBtn.style.display = "none";
$undoBtn.style.display = "none";
return;
}
const idShown = r.id || (r.structured && r.structured[0] && r.structured[0].jav_id) || "?";
if (r.hits > 0) setStatus(`✓ ${idShown} — ${r.hits} hit(s)${tag}`, "hit");
else setStatus(`✗ ${idShown} — NOT FOUND${tag}`, "miss");
// Render mode + timings first (renderHits inserts before these)
renderSearchMode(r);
renderTimings(r);
// Render filter bar then hit cards
renderFilterBar(r.structured);
if (r.structured && r.structured.length) {
renderHits(r.structured);
} else {
const div = document.createElement("div");
div.className = "empty no-match-detail";
div.innerHTML = `${escapeHtml(r.no_match_title || "No matches")} ${r.no_match_detail ? `${escapeHtml(r.no_match_detail)}
` : ""}`;
$output.insertBefore(div, $output.firstChild);
}
const canDelete = settings && settings.enableDelete && r.structured && r.structured.length > 1;
$deleteBtn.style.display = canDelete ? "" : "none";
$deleteBtn.textContent = "DELETE";
$undoBtn.style.display = (settings && settings.enableDelete && settings.deleteMode === "trash") ? "" : "none";
}
// Monotonic counter for in-flight searches. Profile selector changes or rapid
// re-searches can leave older RPCs inflight whose callbacks fire AFTER a newer
// search has started — without this gate, the stale callback would overwrite
// fresh UI with wrong-profile results. runCheck and runManualSearch both bump
// the counter on entry and capture their own id; callbacks compare against
// the current counter and bail if newer search has started.
let _currentSearchId = 0;
function runCheck(force = false) {
// Bump BEFORE the paused early-exit so any older inflight callback compares
// stale myId against the new counter and bails — otherwise pausing while a
// search is inflight would let the old callback overwrite the paused UI.
const myId = ++_currentSearchId;
if (settings && settings.scanPaused) { renderPausedState(); return; }
setStatus("Scanning…", "loading");
showSkeleton(2);
$deleteBtn.style.display = "none";
$undoBtn.style.display = "none";
chrome.runtime.sendMessage({ type: "check-tab", force }, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = "";
return;
}
renderResult(r);
});
}
// ----- delete modal -----
const $overlay = document.getElementById("modal-overlay");
const $list = document.getElementById("modal-list");
const $confirmWrap = document.getElementById("modal-confirm");
const $target = document.getElementById("modal-target");
const $confirmId = document.getElementById("confirm-id");
const $confirmInput = document.getElementById("modal-confirm-input");
const $mode = document.getElementById("modal-mode");
const $modalDelete = document.getElementById("modal-delete");
const $modalStatus = document.getElementById("modal-status");
let chosenHit = null;
let expectedId = "";
function openDeleteModal() {
if (!lastResult || !lastResult.structured) return;
$list.innerHTML = "";
chosenHit = null;
$confirmWrap.style.display = "none";
$modalDelete.disabled = true;
$modalStatus.textContent = "";
$confirmInput.value = "";
for (const h of lastResult.structured) {
const filename = h.path.split("/").pop();
const idx = h.full_path.lastIndexOf("/");
const dir = idx >= 0 ? h.full_path.slice(0, idx) : h.full_path;
const srcCls = h.source === "Source" ? "src source"
: h.source === "Catalog" ? "src catalog" : "src";
const row = document.createElement("div");
row.className = "hit";
row.innerHTML = `
${escapeHtml(filename)}
Path: ${escapeHtml(dir)}
${escapeHtml(h.source.toUpperCase())} ${escapeHtml(h.size_human)}
`;
row.addEventListener("click", () => selectHit(h, row));
$list.appendChild(row);
}
$overlay.style.display = "block";
}
function selectHit(h, row) {
chosenHit = h;
for (const el of $list.children) el.classList.remove("selected");
row.classList.add("selected");
$confirmWrap.style.display = "";
$target.textContent = h.full_path;
// Require typing the full filename (basename incl. extension) — strongest unique signal.
expectedId = h.path.split("/").pop();
$confirmId.textContent = expectedId;
const modeText = settings.deleteMode === "permanent"
? "PERMANENTLY DELETE"
: `move to trash: ${settings.trashDir}`;
$mode.innerHTML = `
Mode: ${escapeHtml(modeText)}
Source: ${escapeHtml(h.source || "?")}
Remote/prefix: ${escapeHtml(h.remote || "?")}
Host safety: path must be inside configured rc-jav source/target or trash prefixes.
`;
$confirmInput.value = "";
$confirmInput.focus();
$modalDelete.disabled = true;
}
$confirmInput.addEventListener("input", () => {
// Case-insensitive, exact filename match.
$modalDelete.disabled = $confirmInput.value.trim().toLowerCase() !== expectedId.toLowerCase();
});
document.getElementById("modal-cancel").addEventListener("click", () => {
$overlay.style.display = "none";
});
$modalDelete.addEventListener("click", () => {
if (!chosenHit) return;
$modalDelete.disabled = true;
$modalStatus.textContent = "deleting…";
chrome.runtime.sendMessage({ type: "delete-file", path: chosenHit.full_path }, (r) => {
if (!r) { $modalStatus.textContent = "no response"; return; }
if (!r.ok) { $modalStatus.textContent = "error: " + (r.error || r.stderr || "unknown"); return; }
$modalStatus.textContent = (settings.deleteMode === "permanent" ? "deleted" : "moved to: " + r.dst);
// Refresh underlying tab result
setTimeout(() => {
$overlay.style.display = "none";
runCheck(true);
}, 800);
});
});
// ----- search history -----
const HISTORY_KEY = "searchHistory";
const HISTORY_MAX = 20;
const $historyBar = document.getElementById("history-bar");
const $historyChips = document.getElementById("history-chips");
async function loadHistory() {
const got = await chrome.storage.local.get(HISTORY_KEY);
return Array.isArray(got[HISTORY_KEY]) ? got[HISTORY_KEY] : [];
}
async function pushHistory(id) {
const list = await loadHistory();
const filtered = list.filter((x) => x.toLowerCase() !== id.toLowerCase());
const updated = [id, ...filtered].slice(0, HISTORY_MAX);
await chrome.storage.local.set({ [HISTORY_KEY]: updated });
renderHistory(updated);
}
function renderHistory(list) {
$historyChips.innerHTML = "";
if (!list || !list.length) { $historyBar.style.display = "none"; return; }
$historyBar.style.display = "";
for (const id of list) {
const chip = document.createElement("span");
chip.className = "history-chip";
chip.textContent = id;
chip.title = `Search ${id}`;
chip.addEventListener("click", () => {
$searchInput.value = id;
runManualSearch();
});
$historyChips.appendChild(chip);
}
}
document.getElementById("history-clear").addEventListener("click", async () => {
await chrome.storage.local.set({ [HISTORY_KEY]: [] });
renderHistory([]);
});
// ----- manual search -----
const $searchInput = document.getElementById("search-input");
const $searchGo = document.getElementById("search-go");
const $searchClear = document.getElementById("search-clear");
let manualMode = false; // true while popup is showing manual-search results
function runManualSearch() {
const raw = $searchInput.value.trim();
if (!raw) return;
// Bump BEFORE the paused early-exit (see runCheck comment) so older inflight
// callbacks can't render after the user has triggered the paused state.
const myId = ++_currentSearchId;
if (settings && settings.scanPaused) { renderPausedState(); return; }
manualMode = true;
setStatus(`Searching ${raw}…`, "loading");
showSkeleton(2);
$deleteBtn.style.display = "none";
$undoBtn.style.display = "none";
const t0 = performance.now();
chrome.runtime.sendMessage({
type: "manual-query",
action: "search",
id: raw,
quick: !!(settings && settings.quickMode),
}, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = "";
return;
}
// The host search response uses the same shape as check-tab, so reuse the
// renderer. Synthesize a `timings.total_ms` so the timing chip still works.
const result = Object.assign({}, r, {
id: raw,
timings: Object.assign({ total_ms: Math.round(performance.now() - t0) }, r && r.timings ? r.timings : {}),
});
renderResult(result);
if (result && result.ok) pushHistory(raw);
});
}
$searchGo.addEventListener("click", runManualSearch);
$searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); runManualSearch(); }
});
$searchClear.addEventListener("click", () => {
$searchInput.value = "";
manualMode = false;
runCheck(false);
$searchInput.focus();
});
// ----- undo modal -----
const $undoOverlay = document.getElementById("undo-overlay");
const $undoList = document.getElementById("undo-list");
const $undoStatus = document.getElementById("undo-status");
function openUndoModal() {
$undoList.innerHTML = `Loading…
`;
$undoStatus.textContent = "";
$undoOverlay.style.display = "block";
chrome.runtime.sendMessage({ type: "recent-deletes", limit: 20 }, (r) => {
if (chrome.runtime.lastError) {
$undoList.innerHTML = `${escapeHtml(chrome.runtime.lastError.message)}
`;
return;
}
if (!r || !r.ok) {
$undoList.innerHTML = `${escapeHtml((r && r.error) || "unknown error")}
`;
return;
}
const entries = (r.entries || []);
if (!entries.length) {
$undoList.innerHTML = `No recent trash deletes found.
`;
return;
}
$undoList.innerHTML = "";
for (const e of entries) {
const origName = (e.path || "").split("/").pop();
const age = fmtAge(e.ts ? new Date(e.ts).getTime() : null);
const row = document.createElement("div");
row.className = "undo-entry";
row.innerHTML = `
${escapeHtml(origName)}
Orig: ${escapeHtml(e.path || "?")}
Trash: ${escapeHtml(e.dst || "?")}
${escapeHtml(age)} ↶ Restore
`;
row.querySelector(".undo-row-btn").addEventListener("click", () => doUndo(e, row));
$undoList.appendChild(row);
}
});
}
function doUndo(e, row) {
const btn = row.querySelector(".undo-row-btn");
btn.disabled = true;
btn.textContent = "…";
$undoStatus.textContent = "";
chrome.runtime.sendMessage({ type: "undo-delete", dst: e.dst, path: e.path }, (r) => {
if (!r) { btn.disabled = false; btn.textContent = "↶ Restore"; $undoStatus.textContent = "no response"; return; }
if (!r.ok) { btn.disabled = false; btn.textContent = "↶ Restore"; $undoStatus.textContent = "error: " + (r.error || r.stderr || "unknown"); return; }
btn.textContent = "✓ restored";
row.style.opacity = "0.5";
$undoStatus.textContent = "Restored to: " + (e.path || "?");
// Refresh the main result after a short pause
setTimeout(() => {
$undoOverlay.style.display = "none";
runCheck(true);
}, 1200);
});
}
document.getElementById("undo-cancel").addEventListener("click", () => {
$undoOverlay.style.display = "none";
});
$undoBtn.addEventListener("click", openUndoModal);
// ----- wiring -----
document.getElementById("recheck").addEventListener("click", async () => {
if (settings && settings.scanPaused) {
await setScanPaused(false);
return;
}
if (manualMode && $searchInput.value.trim()) runManualSearch();
else runCheck(true);
});
document.getElementById("open-options").addEventListener("click", () => chrome.runtime.openOptionsPage());
document.getElementById("open-bulk").addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "open-bulk-check" });
window.close();
});
$modeLive.addEventListener("click", () => setSearchMode("live"));
$modeCache.addEventListener("click", () => setSearchMode("cache"));
$pauseScan.addEventListener("click", () => setScanPaused(!(settings && settings.scanPaused)));
$deleteBtn.addEventListener("click", openDeleteModal);
document.getElementById("ping").addEventListener("click", () => {
setStatus("pinging host…");
$output.textContent = "";
chrome.runtime.sendMessage({ type: "ping-host" }, (r) => {
if (!r || !r.ok) { setStatus("host unreachable", "err"); $output.textContent = r?.error || ""; return; }
setStatus("host ok: " + (r.version || "unknown"), "hit");
const rows = [
["Host version", r.version || "?"],
["rc-jav path", r.rc_jav || "?"],
["Script exists", r.rc_jav_exists ? "✓ yes" : "✗ not found"],
["Path source", r.rc_jav_overridden ? "options override" : "built-in default"],
["Python", r.python || "?"],
];
const div = document.createElement("div");
div.style.cssText = "font-size:12px;line-height:1.7;";
div.innerHTML = rows.map(([k, v]) =>
`${k} ${v}
`
).join("");
$output.appendChild(div);
});
});
function loadCacheBanner() {
chrome.runtime.sendMessage({ type: "cache-status" }, (r) => {
if (chrome.runtime.lastError || !r || !r.ok) return; // silent fail — banner optional
if (!r.cache_exists) {
$cacheBanner.className = "no-cache";
$cacheBanner.textContent = "⚠ No cache — searches use quick (live) mode only. Run --scan to build.";
$cacheBanner.style.display = "";
return;
}
const staleRemotes = (r.remotes || []).filter((x) => x.stale);
if (!staleRemotes.length) { $cacheBanner.style.display = "none"; return; }
const oldest = staleRemotes.reduce((a, b) => (b.age_hours || 0) > (a.age_hours || 0) ? b : a, staleRemotes[0]);
const ageH = oldest.age_hours != null ? Math.round(oldest.age_hours) : "?";
$cacheBanner.className = "";
$cacheBanner.textContent = `⚠ Cache is ${ageH}h old (${staleRemotes.length} remote${staleRemotes.length > 1 ? "s" : ""} stale). Consider running --scan.`;
$cacheBanner.style.display = "";
});
}
const $profileSelect = document.getElementById("profile-select");
$profileSelect.addEventListener("change", async () => {
const newProfile = $profileSelect.value;
// Save activeProfile to storage so background.js picks it up on next search.
const s = await chrome.runtime.sendMessage({ type: "get-settings" });
await chrome.storage.sync.set({ settings: Object.assign({}, s, { activeProfile: newProfile }) });
settings = Object.assign({}, s, { activeProfile: newProfile });
// Re-run current search with new profile
if (manualMode && $searchInput.value.trim()) runManualSearch();
else runCheck(true);
});
function renderProfileSelector(s) {
const profiles = s.profiles || [];
if (!profiles.length) { $profileSelect.style.display = "none"; return; }
$profileSelect.innerHTML = "";
const def = document.createElement("option");
def.value = "";
def.textContent = "Default";
$profileSelect.appendChild(def);
for (const p of profiles) {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name;
$profileSelect.appendChild(opt);
}
$profileSelect.value = s.activeProfile || "";
$profileSelect.style.display = "";
}
(async () => {
settings = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
syncModeToggle();
syncPauseButton();
renderProfileSelector(settings);
loadCacheBanner();
renderHistory(await loadHistory());
if (settings.scanPaused) {
renderPausedState();
return;
}
if (settings.triggers?.toolbarClick !== false) {
runCheck(false);
} else {
setStatus("toolbar auto-check disabled");
$output.innerHTML = "";
const div = document.createElement("div");
div.className = "empty";
div.textContent = "Use Re-Scan to check this page.";
$output.appendChild(div);
}
})();