<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Patch Viewer</title> <meta name="viewport" content="width=device-width"> <style> :root { /* body */ --fg-normal: black; --bg-normal: white; /* diff color */ --fg-context: #606060; --bg-sector: #DDDDCC; --bg-add: #C0FFC0; --bg-del: #FFD0D0; --bg-mod: #D0D0FF; --bg-pad: #EEEEEE; /* gutter */ --fg-gutter: #999999; --bg-gutter: #F7F7F7; /* misc */ --bg-bar: #EEEEEE; --border: #BFBFBF; } @media (prefers-color-scheme: dark) { html { filter: invert(95%) hue-rotate(180deg); } } html { background-color: var(--bg-normal); color: var(--fg-normal); font-family: sans-serif; } pre { white-space: pre-wrap; word-break: break-all; word-wrap: break-word; padding: 0; margin: 0; } td { padding: 0; margin: 0; /* Default: prevent selection */ -webkit-user-select: none; user-select: none; } .selectable { -webkit-user-select: text; user-select: text; } textarea { margin: 0; padding: 0; width: 100%; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; } .result_table { padding: 0; margin: 0; border-spacing: 0; border: 0.1em none; border-color: var(--border); border-bottom-style: solid; border-left-style: solid; color: black; width: 100%; } .code_row { width: 100%; } .linenum_cell { width: 0; border-right-style: solid; border-right-width: 0.1em; border-color: var(--bg-pad); background-color: var(--bg-gutter); color: var(--fg-gutter); text-align: right; padding-left: 0.2em; padding-right: 0.2em; -webkit-user-select: none; user-select: none; } .linenum_text { white-space: nowrap; word-break: normal; word-wrap: normal; } .diff_basic { width: 50%; border-right-style: solid; border-right-width: 0.1em; border-color: var(--border); } .diff_context { background-color: var(--bg-normal); color: var(--fg-context); } .diff_add { background-color: var(--bg-add); } .diff_mod { background-color: var(--bg-mod); } .diff_del { background-color: var(--bg-del); color: var(--fg-context); } .diff_pad { background-color: var(--bg-pad); } .diff_info { padding: 0.1em 0.4em; } .diff_split { background-color: var(--bg-pad); color: var(--fg-context); text-align: center; border-top-style: solid; border-top-width: 0.1em; border-bottom-style: solid; border-bottom-width: 0.1em; } .diff_sec_title { width: 100%; background-color: var(--bg-sector); color: var(--fg-context); font-weight: bold; border-top-style: solid; border-top-width: 0.1em; } .toolbar { background-color: var(--bg-bar); padding: 0.6em; border-radius: 0.6em; } .nav_filename { font-weight: bold; font-size: large; } .nav_filename_bar { padding-top: 0.5em; padding-bottom: 0.5em; } .nav_link { text-decoration: none; color: var(--fg-normal); font-family: monospace; padding-left: 0.3em; padding-right: 0.3em; } .nav_link_bar { background-color: var(--bg-bar); border-radius: 0.4em; border-style: solid; border-width: 0.1em; border-color: var(--border); } .nav_filelist { font-family: monospace; } .nav_filelist_bar { padding-top: 0.5em; } </style> <script> function diffRender(diff, node) { // Common rendering engine STM states const LineState = { CONTEXT : 0, // unmodified context block, should copy to both sides ADD : 1, // + block, sideB only DEL : 2, // - block, sideA only MOD : 3, // ! block SPLIT : 4, // section split, contains line range SEC_TITLE: 5, // section title, sideA only }; // insert file name to file selector function appendFileList(node, fileName) { let title = document.createElement("span"); title.setAttribute("class", "nav_filename"); title.appendChild(document.createTextNode((fileName === "") ? "Unknown" : fileName)); let bar = document.createElement("div"); bar.setAttribute("class", "nav_filename_bar"); bar.appendChild(title); node.appendChild(bar); } // Add Navigation bar into DOM node. function buildFileNavigator(node, prefix) { let fileNames = node.getElementsByClassName("nav_filename"); if (fileNames.length <= 1) { return; } // create file navigation bar let defaultItem = document.createElement("option"); defaultItem.setAttribute("value", `#${prefix}TOP`); defaultItem.setAttribute("selected", "selected"); defaultItem.appendChild(document.createTextNode("< Jump to ... >")); let title = document.createElement("span"); title.innerHTML = `<a id=${prefix}TOP></a><b>File List: </b>`; let list = document.createElement("select"); list.setAttribute("onchange", "location.href = this.options[this.selectedIndex].value;"); list.setAttribute("class", "nav_filelist"); list.appendChild(defaultItem); let bar = document.createElement("div"); bar.setAttribute("class", "nav_filelist_bar"); bar.appendChild(list); bar.insertBefore(title, list); for (let i = fileNames.length - 1; i >= 0; i--) { let fileName = fileNames[i]; let fileNum = i + 1; // Add file name into select list let item = document.createElement("option"); item.setAttribute("value", `#${prefix}${fileNum}`); item.appendChild(document.createTextNode(fileName.textContent)); list.insertBefore(item, list.firstChild); // Add Prev/Next/Tops links before each title, set anchor on TOPS link let smallbar = document.createElement("span"); smallbar.setAttribute("class", "nav_link_bar"); function newNavLink(href, inner, name) { let link = document.createElement("a"); link.setAttribute("href", href); link.innerHTML = inner; link.setAttribute("class", "nav_link"); if (name != null) { link.setAttribute("name", name); } return link; } smallbar.appendChild(newNavLink(`#${prefix}${fileNum - 1}`, "«", null)); smallbar.appendChild(newNavLink(`#${prefix}${fileNum + 1}`, "»", null)); smallbar.appendChild(newNavLink(`#${prefix}TOP`, "⇑", prefix + fileNum)); // insert the small nav bar before the file name, and add a space between them fileName.parentNode.insertBefore(smallbar, fileName); fileName.insertBefore(document.createTextNode(" "), fileName.firstChild); } // Insert the navigation bar before the first item, and place the default item to first list.removeChild(defaultItem); list.insertBefore(defaultItem, list.firstChild); node.insertBefore(bar, node.firstChild); } // buildFileNavigator // create a line number table cell function newLineNumCell(count) { let td = document.createElement("td"); let pre = document.createElement("pre"); td.setAttribute("class", "linenum_cell"); pre.setAttribute("class", "linenum_text"); pre.appendChild(document.createTextNode(count)); td.appendChild(pre); return td; } // create a table cell for code function newCodeCell(isSideB, text, type) { let td = document.createElement("td"); let pre = document.createElement("pre"); switch (type) { case LineState.CONTEXT: td.setAttribute("class", "diff_basic diff_context"); break; case LineState.SPLIT: td.setAttribute("class", "diff_basic diff_info diff_split"); td.setAttribute("colspan", "2"); break; case LineState.SEC_TITLE: td.setAttribute("class", "diff_basic diff_info diff_sec_title"); td.setAttribute("colspan", "4"); text = "Section: " + text; break; case LineState.ADD: if (isSideB) { td.setAttribute("class", "diff_basic diff_add"); } else { td.setAttribute("class", "diff_basic diff_pad"); td.setAttribute("colspan", "2"); } break; case LineState.DEL: if (isSideB) { td.setAttribute("class", "diff_basic diff_pad"); td.setAttribute("colspan", "2"); } else { td.setAttribute("class", "diff_basic diff_del"); } break; case LineState.MOD: if (text == null) { td.setAttribute("class", "diff_basic diff_pad"); td.setAttribute("colspan", "2"); } else { td.setAttribute("class", "diff_basic diff_mod"); } break; default: break; } if (text == null) { text = ""; } pre.appendChild(document.createTextNode(text)); td.appendChild(pre); return td; } // newCodeCell() // Line buffers to hold the file parse result let sideA = [], sideB = []; // Push a line to file buffer function pushLineTo(side, lineText, lineState) { side.push({text: lineText, state: lineState}); } // Check if file buffer is empty function bufferEmpty() { return (sideA.length === 0) && (sideB.length === 0); } // Render a file buffer function renderFile(fileName, node) { let sect_title = null; function LineRange(no, count) { return {no: no, count: count, next() {this.no++; this.count--;}}; } let rngA = LineRange(1, 0), rngB = LineRange(1, 0); // render a row function insertRow(tb, codeA, codeB, type) { // skip the dummy end mark if ((type === LineState.SPLIT) && (codeA == null) && (codeB == null)) { return; } let tr = document.createElement("tr"); tr.setAttribute("class", "code_row"); function appendLine(isSideB, rng, text) { if ((type !== LineState.SPLIT) && (type !== LineState.SEC_TITLE) && (text != null)) { // diff content, print with line number if (rng.count <= 0) { return; } tr.appendChild(newLineNumCell(rng.no)); rng.next(); } tr.appendChild(newCodeCell(isSideB, text, type)); } appendLine(false, rngA, codeA); if (type !== LineState.SEC_TITLE) { appendLine(true, rngB, codeB); } tb.appendChild(tr); } // insertRow() // rendering STM state handler function processState(tb, parseState) { // the processed lines will be shifted, so always fetch the first line let lineA = sideA[0], lineB = sideB[0]; // process current work first switch (parseState) { // Just sync between diff segments case LineState.SPLIT: // print sec title if we have one const hasTitle = (sect_title != null); if (hasTitle) { insertRow(tb, sect_title, null, LineState.SEC_TITLE); } sect_title = null; // Update the line number and count for current section function parseRange(text) { const arr = String(text).split(",").map(Number); return LineRange(arr[0], (arr.length > 1) ? arr[1] : 1); } let newRngA = parseRange(lineA.text), newRngB = parseRange(lineB.text); // check if we can convert line number to skipped line const skipped = newRngA.no - rngA.no; if ((skipped > 0) && (skipped === (newRngB.no - rngB.no))) { let split_text = `${skipped} unchanged line${(skipped === 1) ? "" : "s"} skipped...`; insertRow(tb, split_text, split_text, parseState); } else { if ((newRngA.no <= 1) || (newRngB.no <= 1)) { // first line, don't print the split if (!hasTitle) { tb.setAttribute("style", "border-top-style: solid"); } } else { insertRow(tb, lineA.text, lineB.text, parseState); } } rngA = newRngA; rngB = newRngB; sideA.shift(); sideB.shift(); break; // section title (diffs attached in DDTS have such title) case LineState.SEC_TITLE: // cache the section title, will print it after reached next split sect_title = lineA.text; sideA.shift(); break; // we are in the context section of diff case LineState.CONTEXT: if (lineB.state !== LineState.CONTEXT) { // only left side has the context (del only) insertRow(tb, lineA.text, lineA.text, parseState); sideA.shift(); break; } if (lineA.state !== LineState.CONTEXT) { // only right side has the context (add only) insertRow(tb, lineB.text, lineB.text, parseState); sideB.shift(); break; } // both have context (modify) // in normal the context should be the same, but we'd better // to check that if (lineA.text === lineB.text) { insertRow(tb, lineA.text, lineB.text, parseState); sideA.shift(); sideB.shift(); } else { // not quite sure what happened, but just display context // from left first insertRow(tb, lineA.text, lineA.text, parseState); sideA.shift(); } break; // we are in the add section of diff, it can only on the right side case LineState.ADD: insertRow(tb, null, lineB.text, parseState); sideB.shift(); break; // we are in the del section of diff, it can only on the left side case LineState.DEL: insertRow(tb, lineA.text, null, parseState); sideA.shift(); break; // we are in the modified section of diff case LineState.MOD: let modA = null, modB = null; if (lineA.state === LineState.MOD) { modA = lineA.text; sideA.shift(); } if (lineB.state === LineState.MOD) { modB = lineB.text; sideB.shift(); } insertRow(tb, modA, modB, parseState); break; default: break; } } // processState() // detect the next FSM state for rendering engine function detectNextState() { // now let's decide next state, make sure we have end-marks in both sides if (sideA.length === 0) { pushLineTo(sideA, null, LineState.SPLIT); } if (sideB.length === 0) { pushLineTo(sideB, null, LineState.SPLIT); } let lineA = sideA[0], lineB = sideB[0]; // left reached the end if (lineA.state === LineState.SPLIT) { return lineB.state; } // right reached the end if (lineB.state === LineState.SPLIT) { return lineA.state; } // the priority is : // SectionTitle > Del > Add > Modify > Context if ((lineA.state === LineState.SEC_TITLE) || (lineA.state === LineState.DEL)) { return lineA.state; } if (lineB.state === LineState.ADD) { return lineB.state; } if ((lineA.state === LineState.MOD) || (lineB.state === LineState.MOD)) { return LineState.MOD; } return LineState.CONTEXT; } // detectNextState() if (bufferEmpty()) { return; } let tb = document.createElement("table"); while ((sideA.length > 0) || (sideB.length > 0)) { processState(tb, detectNextState()); } tb.setAttribute("class", "result_table"); appendFileList(node, fileName); node.appendChild(tb); } // renderFile() // Parse the diff generated by Cisco acme/cctools // Supports common context diff format as well function contextDiffParser(diff, node) { // Patch file parser STM states const State = { META: 1, // meta (Index: xxx, ***, etc) ORG : 2, // original file (sideA) NEW : 3, // new file (sideB) }; let state = State.META; // push line to the correct side by current parser FSM state function pushLine(lineText, lineState) { if (lineState === LineState.SEC_TITLE) { pushLineTo(sideA, lineText, lineState); return; } switch (state) { case State.ORG: pushLineTo(sideA, lineText, lineState); break; case State.NEW: pushLineTo(sideB, lineText, lineState); break; default: break; } } let lines = diff.split("\n"); let fileName = ""; let fileNameSrc = ""; // Where the file name from for (let i = 0; i < lines.length; i++) { let line = lines[i]; let result; // - xxxxx (removed lines) if (((result = /^- ([^\n]*)/.exec(line)) != null) || ((result = /^-($)/.exec(line)) != null)) { pushLine(result[1], LineState.DEL); continue; } // + xxxxx (added lines) if (((result = /^\+ ([^\n]*)/.exec(line)) != null) || ((result = /^\+($)/.exec(line)) != null)) { pushLine(result[1], LineState.ADD); continue; } // ! xxxxx (modified lines) if (((result = /^! ([^\n]*)/.exec(line)) != null) || ((result = /^!($)/.exec(line)) != null)) { pushLine(result[1], LineState.MOD); continue; } // *** 5,20 **** (sideA line number,count) if ((result = /^\*{3} ([0-9]+[0-9,]*) \*{3}/.exec(line)) != null) { state = State.ORG; pushLine(result[1], LineState.SPLIT); continue; } // --- 11,20 ---- (sideB line number,count) if ((result = /^-{3} ([0-9]+[0-9,]*) -{3}/.exec(line)) != null) { state = State.NEW; pushLine(result[1], LineState.SPLIT); continue; } // For indexed diff: // Index: filename // // For unified diff filename: // *** /path/to/original // --- /path/to/new if (((result = /^Index: ([^\n]*)/.exec(line)) != null) || ((result = /^\*{3} ([^\n]*)/.exec(line)) != null) || ((result = /^-{3} ([^\n]*)/.exec(line)) != null)) { if (!bufferEmpty()) { // this should be the file split renderFile(fileName, node); fileName = ""; fileNameSrc = ""; } // if we have not got the file name yet, or the previous file name // was parsed from the same source (index/left/right), and current // file name is valid, use it. if (((fileName === "") || (fileNameSrc === line.charAt(0))) && (result[1] !== "") && (result[1] !== "/dev/null")) { fileName = result[1]; fileNameSrc = line.charAt(0); } state = State.META; continue; } // context lines if (((result = /^ {2}([^\n]*)/.exec(line)) != null) || ((result = /^ ($)/.exec(line)) != null)) { pushLine(result[1], LineState.CONTEXT); continue; } // *************** (optional section title) if ((result = /^\*{15} ([^\n]*)/.exec(line)) != null) { pushLine(result[1], LineState.SEC_TITLE); continue; } state = State.META; } // for last file renderFile(fileName, node); } // contextDiffParser() // Parse the diff generated by git // Supports common unified diff format as well function unifiedDiffParser(diff, node) { // Patch file parser STM states const State = { META: 1, // meta (Index: xxx, +++, ---, etc) DIFF: 2, // valid diff (incl. @@, +, -, etc) }; // Substate for diff part const DiffState = { NA : 0, // not in State.DIFF CTX: 1, // context lines ADD: 2, // +xxxxx DEL: 3, // -xxxxx MOD: 4, // modified block (- block followed by a + block) }; let state = State.META, subState = DiffState.NA; // Push buffered - block to sideA with final block property function pushDels(endDel, isModify) { if (lastDel === -1) { return; } for (let i = lastDel; i < endDel; i++) { if (lines[i].charAt(0) === "-") { pushLineTo(sideA, lines[i].substring(1), (isModify ? LineState.MOD : LineState.DEL)); } } lastDel = -1; } // Set the substate of parser STM. Will push buffered del block to file buffer // when needed, or just update the del block start line. function setSubState(newSubState, currLine) { if (subState === newSubState) { return; } if (subState === DiffState.DEL) { pushDels(currLine, (newSubState === DiffState.MOD)); } else if ((state === State.DIFF) && (newSubState === DiffState.DEL)) { lastDel = currLine; } subState = newSubState; } let lines = diff.split("\n"); let fileName = ""; let fileNameSrc = ""; // Where the file name from let lastDel = -1; for (let i = 0; i < lines.length; i++) { let line = lines[i]; let result; // For indexed diff: // Index: filename // // For git diff: // diff --git a/filepath b/filepath // // For unified diff filename: // --- /path/to/original ''timestamp'' // +++ /path/to/new ''timestamp'' if (((result = /^Index: ([^\n]*)/.exec(line)) != null) || ((result = /^diff --git .*? b\/([^\n]*)/.exec(line)) != null) || ((state === State.META) // avoid conflict with the real +/- lines && (((result = /^-{3} ([^\n]*)/.exec(line)) != null) || ((result = /^\+{3} ([^\n]*)/.exec(line)) != null)))) { setSubState(DiffState.NA, i); if (!bufferEmpty()) { // this should be the file split renderFile(fileName, node); fileName = ""; fileNameSrc = ""; } // if we have not got the file name yet, or the previous file name // was parsed from the same source (index/left/right), and current // file name is valid, use it. if (((fileName === "") || (fileNameSrc === line.charAt(0))) && (result[1] !== "") && (result[1] !== "/dev/null")) { fileName = result[1]; fileNameSrc = line.charAt(0); } state = State.META; continue; } // -xxxxxx (removed lines) if (((result = /^-([^\n]*)/.exec(line)) != null) && (state === State.DIFF)) { // the unified diff only use -/+ but no modified sign (!), so // we have to see if there is + block next to current - block. // If so, it should be rendered as modified block. We have to // cache the - lines until we know the final block property. setSubState(DiffState.DEL, i); continue; } // +xxxxx (added lines) if (((result = /^\+([^\n]*)/.exec(line)) != null) && (state === State.DIFF)) { // If current + block just next to previous - block, both should be // modified blocks. Or it just a normal + block. let isModify = ((subState === DiffState.DEL) || (subState === DiffState.MOD)); setSubState((isModify ? DiffState.MOD : DiffState.ADD), i); pushLineTo(sideB, result[1], (isModify ? LineState.MOD : LineState.ADD)); continue; } // \ No newline at end of file if (((result = /^\\ ([^\n]*)/.exec(line)) != null) && (state === State.DIFF)) { continue; } // @@ -184,3 +184,41 @@ (Optional section title) if ((result = /^@@ -(?<lnA>[0-9]+[0-9,]*) \+(?<lnB>[0-9]+[0-9,]*) @@ *(?<sect>[^\n]*)/.exec(line)) != null) { setSubState(DiffState.NA, i); state = State.DIFF; // parse section title if (result.groups.sect !== "") { pushLineTo(sideA, result.groups.sect, LineState.SEC_TITLE); } // parse line number,count pushLineTo(sideA, result.groups.lnA, LineState.SPLIT); pushLineTo(sideB, result.groups.lnB, LineState.SPLIT); continue; } // context lines if (((result = /^ ([^\n]*)/.exec(line)) != null) && (state === State.DIFF)) { setSubState(DiffState.CTX, i); pushLineTo(sideA, result[1], LineState.CONTEXT); pushLineTo(sideB, result[1], LineState.CONTEXT); continue; } setSubState(DiffState.NA, i); state = State.META; } // for last file renderFile(fileName, node); } // unifiedDiffParser() // normalize the line endings diff = diff.replace(/\r\n?/g, "\n"); node.innerHTML = ""; if (diff.search(/(^|\n)@@ -[0-9,]+ \+[0-9,]+ @@/) !== -1) { unifiedDiffParser(diff, node); } else { contextDiffParser(diff, node); } // Generate navigation bar and file list buildFileNavigator(node, "navFile"); } // diffRender() function loadDiff(node) { if (!window.FileReader) { alert("This feature is not supported in this browser. \n" + "Run in Chrome/Firefox/Opera is highly recommended."); return; } let files = document.getElementById("diff_file").files; if (files.length === 0) { alert("Please select a valid patch file."); return; } let file = files[0]; let reader = new FileReader(); reader.readAsText(file); reader.onload = function() { diffRender(this.result, node); } } </script> </head> <body onload = "form_diff.reset()"> <h2>Side-by-Side Patch Viewer v0.7</h2> <form id = "form_diff"> <div class = "toolbar"> Paste your diff snippet below then <button type = "button" onclick = "diffRender(document.getElementById('diffText').value, document.getElementById('diffResult'))"> START </button> ; or load from a file: <input id = "diff_file" type = "file" autocomplete = "off" onchange = "loadDiff(document.getElementById('diffResult'))"/> <div style = "padding-top: 0.3em"> <textarea rows = "8" id = "diffText"></textarea> </div> </div> </form> <div id = "diffResult" class = "context"></div> <div> <ul> <li>Use the "Save As..." to save the side-by-side view of current patch.</li> <li>Check the project repository for updates: <a href=https://github.com/megatops/PatchViewer>https://github.com/megatops/PatchViewer</a></li> <li>For any issues please contact Ding Zhaojie (<a href = "mailto:zhaojie_ding@msn.com">zhaojie_ding@msn.com</a>).</li> </ul> </div> <script> function getColSpan(cell) { // Use 1 as default value return parseInt(cell.getAttribute("colspan") || 1, 10); } // Get the column index considering colspan function getColumnIndex(cell) { let columnIndex = 0; for (const sibling of cell.parentNode.children) { if (sibling === cell) { break; } columnIndex += getColSpan(sibling); } return columnIndex; } // Get the column index considering colspan function getCellByIndex(row, targetIndex) { let columnIndex = 0; for (const cell of row.children) { const colspan = getColSpan(cell); if (columnIndex <= targetIndex && targetIndex < columnIndex + colspan) { return cell; } columnIndex += colspan; } return null; } // https://typeofnan.dev/how-to-bind-event-listeners-on-dynamically-created-elements-in-javascript/ // Use event bubbling to alert dynamically added elements, // i.e. bind the event listener to the static parent element. document.addEventListener("DOMContentLoaded", () => { const diffResult = document.getElementById("diffResult"); diffResult.addEventListener("mousedown", (e) => { const cell = e.target.closest("td"); // Ensure click is inside the table if (!cell || !diffResult.contains(cell)) { return; } // Make only the clicked column selectable diffResult.querySelectorAll("td").forEach(cell => { cell.classList.remove("selectable"); }); const columnIndex = getColumnIndex(cell); diffResult.querySelectorAll("tr").forEach(row => { const targetCell = getCellByIndex(row, columnIndex); if (targetCell && !targetCell.classList.contains('diff_pad')) { targetCell.classList.add("selectable"); } }); }); }); document.addEventListener("copy", (e) => { const selection = window.getSelection(); if (selection.rangeCount === 0) { return; } const diffResult = document.getElementById("diffResult"); const selectableCells = Array.from(diffResult.querySelectorAll("td.selectable")); let selectedCells = []; for (let i = 0; i < selection.rangeCount; i++) { const range = selection.getRangeAt(i); selectedCells = selectedCells.concat(selectableCells.filter(cell => range.intersectsNode(cell))); } // only need to handle multi-line selection if (selectedCells.length <= 1) { return; } // Override the clipboard content e.clipboardData.setData("text/plain", selectedCells.map(cell => { const preElement = cell.querySelector("pre"); return preElement && preElement.textContent + "\n"; }).join("")); e.preventDefault(); }); </script> </body> </html>