diff --git a/tools/cargo-run/src/lib.rs b/tools/cargo-run/src/lib.rs index 46de74f6a2..89d01e069b 100644 --- a/tools/cargo-run/src/lib.rs +++ b/tools/cargo-run/src/lib.rs @@ -6,6 +6,7 @@ pub mod requirements; pub enum Action { Run, Build, + Explore(Option), } pub enum Target { @@ -36,6 +37,7 @@ impl Task { let (action, args) = match args.first() { Some(&"build") => (Action::Build, &args[1..]), Some(&"run") => (Action::Run, &args[1..]), + Some(&"explore") => (Action::Explore(args.get(1).map(|s| s.to_string())), &[] as &[&str]), Some(&"help") => return None, _ => (Action::Run, args), }; @@ -74,6 +76,34 @@ pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> { run_from(&format!("{npm} run {args}"), Some(&frontend_dir)) } +pub fn open_url(url: &str) -> Result<(), Error> { + #[cfg(target_os = "windows")] + let mut cmd = process::Command::new("cmd"); + #[cfg(target_os = "windows")] + cmd.args(["/c", "start", url]); + + #[cfg(target_os = "macos")] + let mut cmd = process::Command::new("open"); + #[cfg(target_os = "macos")] + cmd.arg(url); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let mut cmd = process::Command::new("xdg-open"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + cmd.arg(url); + + let command_str = format!("{:?}", cmd); + let exit_code = cmd + .spawn() + .map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))? + .wait() + .map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?; + if !exit_code.success() { + return Err(Error::Command(command_str, exit_code)); + } + Ok(()) +} + fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> { let command = command.split_whitespace().collect::>(); let mut cmd = process::Command::new(command[0]); diff --git a/tools/cargo-run/src/main.rs b/tools/cargo-run/src/main.rs index 7527de06ff..f667df9f7c 100644 --- a/tools/cargo-run/src/main.rs +++ b/tools/cargo-run/src/main.rs @@ -15,6 +15,7 @@ fn usage() { println!(":"); println!(" [run] Run the selected target (default)"); println!(" build Build the selected target"); + println!(" explore Open an assortment of tools for exploring the codebase"); println!(" help Show this message"); println!(":"); println!(" [web] Web app (default)"); @@ -50,7 +51,35 @@ fn main() -> ExitCode { ExitCode::SUCCESS } +fn explore_usage() { + println!(); + println!("USAGE:"); + println!(" cargo run explore "); + println!(); + println!("OPTIONS:"); + println!(":"); + println!(" bisect Binary search through recent commits to find which introduced a bug or feature"); + println!(" editor View an interactive outline of the editor's message system architecture"); + println!(); +} + fn run_task(task: &Task) -> Result<(), Error> { + if let Action::Explore(tool) = &task.action { + match tool.as_deref() { + Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"), + Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"), + None | Some("--help") => { + explore_usage(); + return Ok(()); + } + Some(other) => { + eprintln!("Unknown explore tool: '{other}'"); + explore_usage(); + return Ok(()); + } + } + } + requirements::check(task)?; match (&task.action, &task.target, &task.profile) { @@ -65,6 +94,7 @@ fn run_task(task: &Task) -> Result<(), Error> { profile = match action { Action::Run => &Profile::Debug, Action::Build => &Profile::Release, + Action::Explore(_) => unreachable!(), } } @@ -94,6 +124,8 @@ fn run_task(task: &Task) -> Result<(), Error> { (Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?, (Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?, + + (Action::Explore(_), _, _) => unreachable!(), } Ok(()) } diff --git a/website/content/about.md b/website/content/about.md index beeea0c7a5..1ef23e4f70 100644 --- a/website/content/about.md +++ b/website/content/about.md @@ -166,7 +166,7 @@ Adam is a pragmatic problem solver with a talent for simplifying complexity. He
-
+
diff --git a/website/content/volunteer/guide/codebase-overview/debugging-tips.md b/website/content/volunteer/guide/codebase-overview/debugging-tips.md index 2f6b25f1e7..4ada88224a 100644 --- a/website/content/volunteer/guide/codebase-overview/debugging-tips.md +++ b/website/content/volunteer/guide/codebase-overview/debugging-tips.md @@ -3,31 +3,103 @@ title = "Debugging tips" [extra] order = 2 # Page number after chapter intro +css = ["/page/contributor-guide/bisect-tool.css"] +js = ["/js/page/contributor-guide/bisect-tool.js"] +++ The Wasm-based editor has some unique limitations about how you are able to debug it. This page offers tips and best practices to get the most out of your problem-solving efforts. ## Comparing with deployed builds -When tracking down a bug, first check if the issue you are noticing also exists in `master` or just in your branch. Open up [dev.graphite.art](https://dev.graphite.art) which always deploys the lastest commit, as opposed to [editor.graphite.art](https://editor.graphite.art) which deploys the latest stable release. Build links for any commit may be found by clicking the "comment" icon on the right side of any commit in the [GitHub repo commits list](https://github.com/GraphiteEditor/Graphite/commits/master/). - -Use *Help* > *About Graphite* in the editor to view any build's Git commit hash. - -Beware of one potential pitfall: all deploys and build links are built with release optimizations enabled. This means some bugs (like crashes from bounds checks or debug assertions) may exist in `master` and would appear if run locally, but not in the deployed version. +When tracking down a bug, first check if the issue you are noticing also exists in `master` or just in your branch. Open up [dev.graphite.art](https://dev.graphite.art) which always deploys the lastest commit, as opposed to [editor.graphite.art](https://editor.graphite.art) which deploys the latest stable release. Build links for any commit may be found by clicking the "comment" icon on the right side of any commit in the GitHub repo [commits list](https://github.com/GraphiteEditor/Graphite/commits/master/). + +Use *Help* > *About Graphite…* in the editor to view any build's Git commit hash. + +Beware of a potential pitfall: all deploys and build links are built with release optimizations enabled. This means some bugs (like crashes from bounds checks or debug assertions) may exist in `master` and would appear if run locally, but not in the deployed version. + +## Build bisect tool + +```sh +# Access this quickly in the future: +cargo run explore bisect +``` + +This interactive tool helps you binary search through recent commits, test the build links of each, and pinpoint which change introduced a regression or added a feature. + +
+ +
+
+ + + +
+
+ + + +
+
+
+ +
+ + Begin bisect +
+
+ +
+
+
+ Bisect step 1 + +
+
+
+ Test this build + After testing, what have you found? +
+ + +
+
+
+ +
+ +
## Printing to the console -Use the browser console (F12) to check for warnings and errors. Use the Rust macro `debug!("The number is {}", some_number);` to print to the browser console. These statements should be for temporary debugging. Remove them before your code is reviewed. Print-based debugging is necessary because breakpoints are not supported in WebAssembly. +Use the browser console (F12) to check for warnings and errors. In Rust, use `log::debug!("The number is {some_number}");` to print to the browser console. These statements should be for temporary debugging. Remove them before your code is reviewed. Print-based debugging is necessary because breakpoints are not supported in WebAssembly. Additional print statements are available that *should* be committed: -- `error!()` is for descriptive user-facing error messages arising from a bug -- `warn!()` is for non-critical problems that likely indicate a bug somewhere -- `trace!()` is for verbose logs of ordinary internal activity, hidden by default but viewable by activating *Help* > *Debug: Print Trace Logs* +- `log::error!()` is for descriptive user-facing error messages arising from a bug +- `log::warn!()` is for non-critical problems that likely indicate a bug somewhere +- `log::trace!()` is for verbose logs of ordinary internal activity, hidden by default but viewable by activating *Help* > *Debug: Print Trace Logs* ## Message system logs -To also view logs of the messages dispatched by the message system, activate *Help* > *Debug: Print Messages* > *Only Names*. Or use *Full Contents* for a more verbose view containing the actual data being passed. This is an invaluable window into the activity of the message flow and works well together with `debug!()` printouts for tracking down message-related defects. +To also view logs of the messages dispatched by the message system, activate *Help* > *Debug: Print Messages* > *Only Names*. Or use *Full Contents* for a more verbose view containing the actual data being passed. This is an invaluable window into the activity of the message flow and works well together with `log::debug!()` printouts for tracking down message-related defects. ## Node/layer and document IDs diff --git a/website/content/volunteer/guide/codebase-overview/editor-structure.md b/website/content/volunteer/guide/codebase-overview/editor-structure.md index 250eea74cd..e7bb07aeb9 100644 --- a/website/content/volunteer/guide/codebase-overview/editor-structure.md +++ b/website/content/volunteer/guide/codebase-overview/editor-structure.md @@ -17,7 +17,12 @@ The dispatcher lives at the root of the editor hierarchy and acts as the owner o ## Editor outline -Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions. Bookmark this page to reference it later. +```sh +# Access this quickly in the future: +cargo run explore editor +``` + +Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions.
diff --git a/website/content/volunteer/guide/starting-a-task/code-quality-guidelines.md b/website/content/volunteer/guide/starting-a-task/code-quality-guidelines.md index 57ab45c77b..619608ae6e 100644 --- a/website/content/volunteer/guide/starting-a-task/code-quality-guidelines.md +++ b/website/content/volunteer/guide/starting-a-task/code-quality-guidelines.md @@ -13,7 +13,7 @@ Please ensure Clippy is enabled. This should be set up automatically in VS Code. ## Naming -Please use descriptive variable/function/symbol names and keep abbreviations to a minimum. Prefer spelling out full words most of the time, so `gen_doc_fmt` should be written out as `generate_document_format` instead. +Please use descriptive variable/function/symbol names and keep abbreviations to a minimum. Prefer spelling out full words most of the time, such as `generate_document_format` instead of `gen_doc_fmt`. This avoids the mental burden of expanding abbreviations into semantic meaning. Monitors are wide enough to display long variable/function names, so descriptive is better than cryptic. diff --git a/website/sass/page/about.scss b/website/sass/page/about.scss index 463d577918..ea15482e11 100644 --- a/website/sass/page/about.scss +++ b/website/sass/page/about.scss @@ -45,3 +45,7 @@ } } } + +#extras .button { + margin-top: 0; +} diff --git a/website/sass/page/contributor-guide/bisect-tool.scss b/website/sass/page/contributor-guide/bisect-tool.scss new file mode 100644 index 0000000000..7cf8a5e838 --- /dev/null +++ b/website/sass/page/contributor-guide/bisect-tool.scss @@ -0,0 +1,140 @@ +.bisect-tool { + margin-top: 20px; + + .phase:not(.active) { + display: none; + } + + .setup-section { + margin-bottom: 20px; + + label { + display: block; + cursor: pointer; + } + } + + .commit-inputs { + display: flex; + gap: 20px; + flex-wrap: wrap; + align-items: center; + + .start-input { + margin: 0; + + input[type="text"], + input[type="date"] { + background: none; + height: calc(var(--font-size-link) * 2); + font-size: calc(var(--font-size-link) * 0.9); + padding: 0 var(--font-size-link); + margin: 0; + outline: none; + color: inherit; + border: var(--border-thickness) solid currentColor; + border-radius: 0; + font-family: inherit; + font-weight: inherit; + box-sizing: border-box; + + &:focus { + border-color: var(--color-ale); + } + } + + input[type="text"] { + font-family: monospace; + width: 320px; + max-width: 100%; + } + + &.hidden { + display: none; + } + } + } + + .button, + .link { + cursor: pointer; + user-select: none; + + + .button { + margin-left: 10px; + } + + &.disabled { + opacity: 0.4; + pointer-events: none; + } + } + + .feature-box-narrow { + padding: calc(var(--feature-box-padding) / 2 * var(--variable-px)); + background: var(--color-fog); + border-radius: 2px; + box-sizing: border-box; + + .step-label { + font-size: 1rem; + } + + .go-back { + margin-left: 4px; + + &.hidden { + display: none; + } + + a { + text-decoration: underline; + cursor: pointer; + } + } + + .progress-info { + color: var(--color-storm); + } + + .commit-info { + margin-top: 40px; + + a { + font-family: monospace; + color: var(--color-crimson); + } + } + + .button.arrow { + margin-top: 20px; + } + + .findings { + margin-top: 40px; + } + + .bisect-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-top: 20px; + + .button:empty { + visibility: hidden; + } + } + } + + .error-message { + display: none; + margin-top: 20px; + padding: 10px 20px; + background: var(--color-lemon); + + &.visible { + display: block; + } + } +} diff --git a/website/static/js/page/contributor-guide/bisect-tool.js b/website/static/js/page/contributor-guide/bisect-tool.js new file mode 100644 index 0000000000..d48f88be0d --- /dev/null +++ b/website/static/js/page/contributor-guide/bisect-tool.js @@ -0,0 +1,586 @@ +document.addEventListener("DOMContentLoaded", () => { + const REPO = "GraphiteEditor/Graphite"; + const API = "https://api.github.com"; + + // ========= + // API LAYER + // ========= + + const cache = new Map(); + let rateLimitRemaining = -1; + let rateLimitReset = 0; + + async function fetchJSON(/** @type {string} */ url) { + if (cache.has(url)) return cache.get(url); + + const response = await fetch(url); + + // Track rate limit + const remaining = response.headers.get("X-RateLimit-Remaining"); + const reset = response.headers.get("X-RateLimit-Reset"); + if (remaining) rateLimitRemaining = parseInt(remaining); + if (reset) rateLimitReset = parseInt(reset); + updateRateLimitWarning(); + + if (response.status === 404) { + cache.set(url, undefined); + return undefined; + } + + if (response.status === 403) { + const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000).toLocaleTimeString() : undefined; + const suffix = resetTime ? ` Resets at ${resetTime}.` : ""; + throw new Error(`GitHub API rate limit exceeded.${suffix}`); + } + + if (!response.ok) { + const body = await response.json().catch(() => undefined); + throw new Error(body?.message || `GitHub API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + cache.set(url, data); + return data; + } + + async function fetchCommitList(/** @type {string | undefined} */ since, /** @type {string | undefined} */ until, /** @type {number | undefined} */ page) { + let url = `${API}/repos/${REPO}/commits?sha=master&per_page=100`; + if (since) url += `&since=${since}`; + if (until) url += `&until=${until}`; + if (page && page > 1) url += `&page=${page}`; + return fetchJSON(url); + } + + async function fetchDeployUrl(/** @type {string} */ sha) { + const comments = await fetchJSON(`${API}/repos/${REPO}/commits/${sha}/comments`); + if (!comments || !Array.isArray(comments)) return undefined; + + // Find bot comments, use the last one + const botComments = comments.filter((c) => c.user && c.user.login === "github-actions[bot]"); + if (botComments.length === 0) return undefined; + + const lastComment = botComments[botComments.length - 1]; + const match = lastComment.body.match(/\|\s*(https:\/\/[^\s|]+)\s*\|/); + return match ? match[1] : undefined; + } + + // ============== + // DOM REFERENCES + // ============== + + const tool = document.querySelector(".bisect-tool"); + if (!tool) return; + + const phases = { + // eslint-disable-next-line quotes + setup: tool.querySelector('[data-phase="setup"]'), + // eslint-disable-next-line quotes + bisect: tool.querySelector('[data-phase="bisect"]'), + }; + + const elements = { + messageBox: tool.querySelector("[data-message-box]"), + + hashInput: tool.querySelector("[data-input='hash']"), + dateInput: tool.querySelector("[data-input='date']"), + commitHash: tool.querySelector("[data-commit-hash]"), + commitDate: tool.querySelector("[data-commit-date]"), + startButton: tool.querySelector("[data-start-button]"), + + stepLabel: tool.querySelector("[data-step-label]"), + commitInfo: tool.querySelector("[data-commit-info]"), + progressInfo: tool.querySelector("[data-progress-info]"), + testBuildButton: tool.querySelector("[data-test-build-button]"), + issuePresentButton: tool.querySelector("[data-issue-present-button]"), + issueAbsentButton: tool.querySelector("[data-issue-absent-button]"), + goBackButton: tool.querySelector("[data-go-back-button]"), + findings: tool.querySelector(".findings"), + bisectActions: tool.querySelector(".bisect-actions"), + }; + + // ===== + // STATE + // ===== + + /** + * @typedef {{ sha: string, date: Date, message: string }} Commit + * @typedef {{ goodIndex: number, badIndex: number, currentIndex: number, stepCount: number, bisectPhase: string, boundaryOffset: number, boundarySearching: boolean }} HistorySnapshot + */ + + let mode = "regression"; // "regression" or "feature" + let /** @type {Commit[]} */ commits = []; // Ordered oldest-first + let goodIndex = -1; // Index where issue is absent (older side) + let badIndex = -1; // Index where issue is present (newer side) + let currentIndex = -1; + let /** @type {string | undefined} */ currentDeployUrl; + let stepCount = 0; + let /** @type {HistorySnapshot[]} */ history = []; // Snapshots for undo + let bisectPhase = "boundary"; // "boundary" or "binary" + let boundaryOffset = 1; // For exponential boundary search + let boundarySearching = false; // Whether we're in exponential backward search + let startIndex = -1; // Where user started + + // ======= + // HELPERS + // ======= + + function commitToHtml(/** @type {Commit} */ commit) { + const shortHash = (/** @type {string} */ sha) => sha.slice(0, 7); + const commitUrl = (/** @type {string} */ sha) => `https://github.com/${REPO}/commit/${sha}`; + + const hash = `${shortHash(commit.sha)}`; + const date = commit.date.toISOString().slice(0, 10); + const message = messageToHtml(commit.message); + return `${hash} (${date}): ${message}`; + } + + function messageToHtml(/** @type {string} */ message) { + if (!message) return ""; + const escaped = message.replace(/&/g, "&").replace(//g, ">"); + const prMatch = message.match(/\(#(\d+)\)$/); + if (prMatch) return escaped.replace(`(#${prMatch[1]})`, `(#${prMatch[1]})`); + return escaped; + } + + function parseCommits(/** @type {any[]} */ apiCommits) { + return apiCommits.map((/** @type {any} */ c) => ({ + sha: c.sha, + date: new Date(c.commit.committer.date), + message: c.commit.message.split("\n")[0], + })); + } + + function setDisabled(/** @type {Element | null} */ element, /** @type {boolean} */ disabled) { + element?.classList.toggle("disabled", disabled); + } + + function isDisabled(/** @type {Element | null} */ element) { + return element?.classList.contains("disabled") ?? false; + } + + function showPhase(/** @type {string} */ name) { + Object.entries(phases).forEach(([key, phase]) => { + phase?.classList.toggle("active", key === name); + }); + } + + function showMessage(/** @type {string} */ html) { + if (elements.messageBox) elements.messageBox.innerHTML = html; + elements.messageBox?.classList.add("visible"); + } + + function hideMessage() { + elements.messageBox?.classList.remove("visible"); + } + + function updateRateLimitWarning() { + if (rateLimitRemaining >= 0 && rateLimitRemaining < 15) { + const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000).toLocaleTimeString() : "unknown"; + const plural = rateLimitRemaining === 1 ? "" : "s"; + showMessage(`API rate limit: ${rateLimitRemaining} request${plural} remaining. Resets at ${resetTime}.`); + } + } + + // ====================== + // COMMIT LIST MANAGEMENT + // ====================== + + async function loadCommitsAroundDate(/** @type {Date} */ targetDate) { + const windowDays = 30; + const since = new Date(targetDate.getTime() - windowDays * 24 * 60 * 60 * 1000).toISOString(); + const until = new Date(targetDate.getTime() + windowDays * 24 * 60 * 60 * 1000).toISOString(); + + // Paginate to load all commits in the window (API returns max 100 per page) + + let /** @type {any[]} */ allRaw = []; + let page = 1; + + while (true) { + const raw = await fetchCommitList(since, until, page); + if (!raw || raw.length === 0) break; + allRaw = allRaw.concat(raw); + if (raw.length < 100) break; + page++; + } + + if (allRaw.length === 0) { + throw new Error("No commits found near that date. Try a different date."); + } + + // GitHub returns newest-first, reverse to oldest-first + const fetched = parseCommits(allRaw); + fetched.reverse(); + + commits = fetched; + } + + async function extendCommitsBackward() { + if (commits.length === 0) return false; + + const oldest = commits[0]; + const until = new Date(oldest.date.getTime() - 1000).toISOString(); + const raw = await fetchCommitList(undefined, until, undefined); + if (!raw || raw.length === 0) return false; + + let fetched = parseCommits(raw); + fetched.reverse(); + + const existingShas = new Set(commits.map((c) => c.sha)); + fetched = fetched.filter((c) => !existingShas.has(c.sha)); + if (fetched.length === 0) return false; + + commits = [...fetched, ...commits]; + + // Adjust indices to account for prepended commits + const shift = fetched.length; + if (goodIndex >= 0) goodIndex += shift; + if (badIndex >= 0) badIndex += shift; + if (currentIndex >= 0) currentIndex += shift; + if (startIndex >= 0) startIndex += shift; + + return true; + } + + function findCommitIndex(/** @type {string} */ sha) { + return commits.findIndex((c) => c.sha.startsWith(sha) || sha.startsWith(c.sha)); + } + + // ============ + // BISECT LOGIC + // ============ + + function pushHistory() { + history.push({ + goodIndex, + badIndex, + currentIndex, + stepCount, + bisectPhase, + boundaryOffset, + boundarySearching, + }); + elements.goBackButton?.classList.remove("hidden"); + } + + function popHistory() { + const snap = history.pop(); + if (!snap) return; + goodIndex = snap.goodIndex; + badIndex = snap.badIndex; + currentIndex = snap.currentIndex; + stepCount = snap.stepCount; + bisectPhase = snap.bisectPhase; + boundaryOffset = snap.boundaryOffset; + boundarySearching = snap.boundarySearching; + elements.goBackButton?.classList.remove("hidden"); + } + + async function presentCommit(/** @type {number} */ index) { + currentIndex = index; + const commit = commits[index]; + const deployUrl = await fetchDeployUrl(commit.sha); + currentDeployUrl = deployUrl; + + if (elements.stepLabel) elements.stepLabel.innerHTML = `Bisect step ${stepCount + 1}`; + if (elements.commitInfo) { + elements.commitInfo.innerHTML = commitToHtml(commit); + } + + if (goodIndex >= 0 && badIndex >= 0) { + const remaining = badIndex - goodIndex; + const stepsLeft = Math.max(1, Math.ceil(Math.log2(remaining))); + if (elements.progressInfo) { + elements.progressInfo.innerHTML = `${remaining} commit${remaining === 1 ? "" : "s"} in range, ~${stepsLeft} step${stepsLeft === 1 ? "" : "s"} remaining`; + } + } else { + if (elements.progressInfo) elements.progressInfo.innerHTML = "Locating starting point"; + } + + if (deployUrl) { + setDisabled(elements.testBuildButton, false); + if (elements.testBuildButton) elements.testBuildButton.textContent = "Test this build"; + } else { + setDisabled(elements.testBuildButton, true); + if (elements.testBuildButton) elements.testBuildButton.textContent = "No build available"; + } + + // Set mode-specific button labels + if (mode === "regression") { + if (elements.issuePresentButton) elements.issuePresentButton.textContent = "Regression is present"; + if (elements.issueAbsentButton) elements.issueAbsentButton.textContent = "Regression is absent"; + } else { + if (elements.issuePresentButton) elements.issuePresentButton.textContent = "Feature is present"; + if (elements.issueAbsentButton) elements.issueAbsentButton.textContent = "Feature is absent"; + } + } + + async function handleUserResponse(/** @type {boolean} */ issuePresent) { + pushHistory(); + stepCount++; + + if (bisectPhase === "boundary") { + await handleBoundaryResponse(issuePresent); + return; + } + + // Binary search: narrow the range + if (issuePresent) badIndex = currentIndex; + else goodIndex = currentIndex; + + if (badIndex - goodIndex <= 1) showResult(); + else await doBinaryStep(); + } + + async function handleBoundaryResponse(/** @type {boolean} */ issuePresent) { + // "present" means the feature/regression exists at this commit (bad/newer side) + if (!boundarySearching) { + // First step: user tested the starting commit + if (issuePresent) { + // Exists at starting commit, so it was introduced earlier. Search backward (doubling). + badIndex = currentIndex; + boundarySearching = true; + } else { + // Absent at starting commit. The newest commit should have it (user assumes master has it). + goodIndex = currentIndex; + badIndex = commits.length - 1; + bisectPhase = "binary"; + } + } else if (issuePresent) { + badIndex = currentIndex; + boundaryOffset *= 2; + } else { + goodIndex = currentIndex; + bisectPhase = "binary"; + } + + if (bisectPhase === "binary") { + await doBinaryStep(); + return; + } + + // Continue boundary search backward + await doBoundaryStep(); + } + + async function doBoundaryStep() { + let targetIndex = startIndex - boundaryOffset; + while (targetIndex < 0) { + const extended = await extendCommitsBackward(); + if (!extended) { + targetIndex = 0; + break; + } + targetIndex = startIndex - boundaryOffset; + } + + // If we've hit the oldest commit and it's still marked bad, we've exhausted history + if (targetIndex <= 0 && badIndex === 0) { + showResult(); + // Override the result message — we never confirmed a good baseline, so we can't pinpoint the introducing commit + if (elements.progressInfo) { + const label = mode === "regression" ? "regression" : "feature"; + elements.progressInfo.innerHTML = `The ${label} was already present in the oldest available commit`; + } + return; + } + + await presentCommit(targetIndex); + } + + async function doBinaryStep() { + const mid = Math.floor((goodIndex + badIndex) / 2); + + // Try to find a testable commit near the midpoint + let testIndex = mid; + let offset = 0; + while (testIndex > goodIndex && testIndex < badIndex) { + const url = await fetchDeployUrl(commits[testIndex].sha); + if (url) break; + // Try alternating sides + offset++; + if (offset % 2 === 1) testIndex = mid + Math.ceil(offset / 2); + else testIndex = mid - Math.ceil(offset / 2); + } + + // If no testable commit found in range, show result as a range + if (testIndex <= goodIndex || testIndex >= badIndex) { + showResult(); + return; + } + + await presentCommit(testIndex); + } + + function showResult() { + const heading = "Bisect complete"; + + // Hide interactive elements, keep the bisect phase visible + if (elements.progressInfo) elements.progressInfo.innerHTML = ""; + setDisabled(elements.testBuildButton, true); + if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = "none"; + if (elements.findings instanceof HTMLElement) elements.findings.style.display = "none"; + if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = "none"; + if (history.length > 0) elements.goBackButton?.classList.remove("hidden"); + + const label = mode === "regression" ? "regression" : "feature"; + const single = badIndex - goodIndex <= 1; + + if (elements.stepLabel) elements.stepLabel.innerHTML = `${heading}`; + if (elements.progressInfo) { + elements.progressInfo.innerHTML = single + ? `The ${label} was introduced in the following commit` + : `The ${label} was introduced in one of the following commits (not all have build links)`; + } + + const start = single ? badIndex : goodIndex + 1; + let html = ""; + for (let i = start; i <= badIndex; i++) { + const c = commits[i]; + html += single ? `${commitToHtml(c)}` : `
${commitToHtml(c)}
`; + } + if (elements.commitInfo) elements.commitInfo.innerHTML = html; + } + + // ============== + // EVENT HANDLERS + // ============== + + // Toggle start input visibility + function syncStartInputVisibility() { + // eslint-disable-next-line quotes + const selected = tool?.querySelector('input[name="start-method"]:checked'); + const method = selected instanceof HTMLInputElement ? selected.value : "date"; + elements.hashInput?.classList.toggle("hidden", method !== "hash"); + elements.dateInput?.classList.toggle("hidden", method !== "date"); + } + syncStartInputVisibility(); + // eslint-disable-next-line quotes + tool.querySelectorAll('input[name="start-method"]').forEach((radio) => { + radio.addEventListener("change", syncStartInputVisibility); + }); + + // Start bisect + elements.startButton?.addEventListener("click", async () => { + if (isDisabled(elements.startButton)) return; + hideMessage(); + // eslint-disable-next-line quotes + const modeInput = tool.querySelector('input[name="bisect-mode"]:checked'); + // eslint-disable-next-line quotes + const methodInput = tool.querySelector('input[name="start-method"]:checked'); + if (!(modeInput instanceof HTMLInputElement) || !(methodInput instanceof HTMLInputElement)) return; + mode = modeInput.value; + const method = methodInput.value; + + try { + setDisabled(elements.startButton, true); + + if (method === "hash") { + const hash = elements.commitHash instanceof HTMLInputElement ? elements.commitHash.value.trim() : ""; + if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) { + throw new Error("Please enter a valid commit hash (7-40 hex characters)."); + } + + // Fetch the commit to get its date + const commitData = await fetchJSON(`${API}/repos/${REPO}/commits/${hash}`); + if (!commitData) { + throw new Error("Commit not found. Check the hash and try again."); + } + + const commitDate = new Date(commitData.commit.committer.date); + await loadCommitsAroundDate(commitDate); + + startIndex = findCommitIndex(hash); + if (startIndex < 0) { + throw new Error("Commit not found in the master branch history."); + } + } else { + const dateStr = elements.commitDate instanceof HTMLInputElement ? elements.commitDate.value : ""; + if (!dateStr) { + throw new Error("Please select a date."); + } + + const date = new Date(dateStr + "T12:00:00Z"); + if (date > new Date()) { + throw new Error("Date cannot be in the future."); + } + + await loadCommitsAroundDate(date); + + // Find the commit closest to the selected date + let closestIndex = 0; + let closestDiff = Infinity; + for (let i = 0; i < commits.length; i++) { + const diff = Math.abs(commits[i].date.getTime() - date.getTime()); + if (diff < closestDiff) { + closestDiff = diff; + closestIndex = i; + } + } + startIndex = closestIndex; + } + + // Reset state + goodIndex = -1; + badIndex = -1; + currentIndex = -1; + currentDeployUrl = undefined; + stepCount = 0; + history = []; + bisectPhase = "boundary"; + boundaryOffset = 1; + boundarySearching = false; + elements.goBackButton?.classList.remove("hidden"); + if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = ""; + if (elements.findings instanceof HTMLElement) elements.findings.style.display = ""; + if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = ""; + + // Show bisect phase and present the starting commit + showPhase("bisect"); + await presentCommit(startIndex); + } catch (err) { + if (err instanceof Error) showMessage(err.message); + } finally { + setDisabled(elements.startButton, false); + } + }); + + // Test build button + elements.testBuildButton?.addEventListener("click", () => { + if (isDisabled(elements.testBuildButton)) return; + if (currentDeployUrl) { + window.open(currentDeployUrl, "_blank", "noopener"); + } + }); + + // Issue response buttons + function onIssueResponse(/** @type {Element | null} */ button, /** @type {boolean} */ issuePresent) { + button?.addEventListener("click", async () => { + if (isDisabled(button)) return; + hideMessage(); + try { + await handleUserResponse(issuePresent); + } catch (err) { + if (err instanceof Error) showMessage(err.message); + } + }); + } + onIssueResponse(elements.issuePresentButton, true); + onIssueResponse(elements.issueAbsentButton, false); + + // Go back + elements.goBackButton?.querySelector("a")?.addEventListener("click", async () => { + hideMessage(); + + if (history.length === 0) { + showPhase("setup"); + return; + } + + // Restore interactive elements that may have been hidden by showResult + if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = ""; + if (elements.findings instanceof HTMLElement) elements.findings.style.display = ""; + if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = ""; + popHistory(); + await presentCommit(currentIndex); + }); +});