(() => { // Utility short-hands const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); // DOM refs const svg = $('#graphSvg'); const branchListEl = $('#branchList'); const stashListEl = $('#stashList'); const currentHeadEl = $('#currentHead'); const detachedStateEl = $('#detachedState'); const commitMsgEl = $('#commitMsg'); const btnCommit = $('#btnCommit'); const btnCommitAmend = $('#btnCommitAmend'); const btnUndoCommit = $('#btnUndoCommit'); const branchNameEl = $('#branchName'); const btnCreateBranch = $('#btnCreateBranch'); const switchBranchEl = $('#switchBranch'); const checkoutCommitEl = $('#checkoutCommit'); const btnCheckout = $('#btnCheckout'); const btnDetach = $('#btnDetach'); const btnMerge = $('#btnMerge'); const btnRebase = $('#btnRebase'); const mergeTargetEl = $('#mergeTarget'); const rebaseTargetEl = $('#rebaseTarget'); const btnStash = $('#btnStash'); const btnStashPop = $('#btnStashPop'); const btnResetHard = $('#btnResetHard'); const resetTargetEl = $('#resetTarget'); const btnNewIssue = $('#btnNewIssue'); const btnNewPR = $('#btnNewPR'); const issuesListEl = $('#issuesList'); const prsListEl = $('#prsList'); const btnFit = $('#btnFit'); const btnCenterMain = $('#btnCenterMain'); const btnCenterHead = $('#btnCenterHead'); const btnDemoBasic = $('#btnDemoBasic'); const btnDemoPR = $('#btnDemoPR'); const blameCommitEl = $('#blameCommit'); const btnBlame = $('#btnBlame'); const blamePanel = $('#blamePanel'); // Graph rendering config const Lane = { width: 140, marginX: 90, marginY: 36, commitRadius: 6 }; // Colors const cssVar = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; // State const state = { repo: null, layout: null, nextCommitIndex: 0, selectedNodeId: null, cardEl: null, }; // Init state const initRepo = () => { const main = newBranch('main', null); const root = newCommit('Initial commit', 'System', { isRoot: true }); main.head = root.id; return { commits: new Map([[root.id, root]]), branches: new Map([['main', main]]), head: { type: 'branch', name: 'main' }, detachedHeadCommit: null, stash: [], issues: [], prs: [], nextId: 1 }; }; // Helpers function newId(prefix = 'c') { return `${prefix}${String(state.repo.nextId++).padStart(3, '0')}`; } function newBranch(name, fromCommitId) { return { name, head: fromCommitId || null, color: pickBranchColor() }; } function pickBranchColor() { // Generates an HSL color based on repo state const n = state.repo.branches.size + 1; const hue = (n * 67) % 360; return `hsl(${hue} 72% 60%)`; } function newCommit(message, author, extra = {}) { const id = newId('c'); const idx = state.nextCommitIndex++; return { id, idx, message, author, parents: extra.parents || [], branch: extra.branch || null, isRoot: !!extra.isRoot, timestamp: Date.now() }; } function getCommit(id) { return state.repo.commits.get(id); } function getHeadCommit() { const head = state.repo.head; if (head.type === 'branch') { const br = state.repo.branches.get(head.name); return br && br.head ? getCommit(br.head) : null; } else if (head.type === 'detached') { return getCommit(head.commitId); } return null; } function getBranchByName(name) { return state.repo.branches.get(name); } function isMergedInto(commitId, targetBranchName) { // Check if commitId is ancestor of targetBranch head const target = getBranchByName(targetBranchName)?.head; if (!target) return false; let cur = target; const visited = new Set(); while (cur) { if (cur === commitId) return true; const node = getCommit(cur); if (!node) break; if (visited.has(cur)) break; visited.add(cur); cur = node.parents[0] || null; } return false; } // History navigation function walk(commitId, fn) { const visited = new Set(); let cur = commitId; while (cur) { if (visited.has(cur)) break; visited.add(cur); const node = getCommit(cur); if (!node) break; if (fn(node) === false) break; cur = node.parents[0] || null; } } // Index commit for linear layout per first-parent chain function buildLayout() { let idx = -1; const visited = new Set(); let cur = null; // find a head const heads = []; for (const br of state.repo.branches.values()) { if (br.head) heads.push(br.head); } if (state.repo.head.type === 'detached') heads.push(state.repo.head.commitId); // sort heads by existing idx heads.sort((a, b) => (getCommit(a)?.idx ?? 0) - (getCommit(b)?.idx ?? 0)); // Walk each chain to assign indices topologically for (const h of heads) { cur = h; const chain = []; while (cur && !visited.has(cur)) { visited.add(cur); chain.push(cur); const c = getCommit(cur); cur = c?.parents?.[0] || null; } // Assign idx descending from 0 upwards for (let i = chain.length - 1; i >= 0; i--) { const id = chain[i]; const node = getCommit(id); if (node && node.idx === -1) { idx += 1; node.idx = idx; } } } // Keep nextCommitIndex aligned with max idx state.nextCommitIndex = idx + 1; // Assign lanes (first-come-first-serve) const lanes = new Map(); // branchName -> laneIndex let laneCursor = 0; for (const [name, br] of state.repo.branches) { if (!br.head) continue; if (!lanes.has(name)) { lanes.set(name, laneCursor++); } } // Compute x,y positions const nodes = Array.from(state.repo.commits.values()); nodes.sort((a,b) => a.idx - b.idx); const coords = new Map(); // id -> {x,y} const xForLane = (i) => Lane.marginX + i * Lane.width; const yForIdx = (i) => Lane.marginY + i * 36; // First pass: assign rough positions for (const c of nodes) { const lane = lanes.get(c.branch || '') ?? 0; coords.set(c.id, { x: xForLane(lane), y: yForIdx(c.idx) }); } // Second pass: route merges with intermediate waypoints to avoid crossings const edges = []; // {from,to,type} for (const c of nodes) { const a = coords.get(c.id); for (const p of (c.parents || [])) { const b = coords.get(p); const type = c.parents.length > 1 ? 'merge' : 'link'; edges.push({ from: a, to: b, type }); } } // Compute bounding box let maxY = 0; nodes.forEach(n => { const { y } = coords.get(n.id); if (y > maxY) maxY = y; }); state.layout = { coords, edges, maxY, lanes: lanes.size, nodes }; } // SVG utilities function clearSvg() { while (svg.firstChild) svg.removeChild(svg.firstChild); } function make(tag, attrs = {}, children = []) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [k, v] of Object.entries(attrs)) { if (v !== null && v !== undefined) el.setAttribute(k, v); } if (!Array.isArray(children)) children = [children]; children.forEach(ch => el.appendChild(ch)); return el; } // Draw graph function renderGraph() { clearSvg(); buildLayout(); // Size SVG const width = Math.max(900, 2 * Lane.marginX + (state.layout.lanes * Lane.width)); const height = Math.max(420, state.layout.maxY + Lane.marginY + 40); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); // Draw links for (const [id, c] of state.repo.commits) { for (const p of (c.parents || [])) { const a = state.layout.coords.get(id); const b = state.layout.coords.get(p); if (!a || !b) continue; const d = curvePath(a, b); const cl = make('path', { d, class: `link ${c.parents.length > 1 ? 'merge' : ''}` }); svg.appendChild(cl); } } // Draw nodes const nodes = state.layout.nodes; for (const c of nodes) { const { x, y } = state.layout.coords.get(c.id); const g = make('g', { class: 'node', transform: `translate(${x},${y})`, 'data-id': c.id }); const isHeadOfBranch = [...state.repo.branches.values()].some(b => b.head === c.id); const isHead = (state.repo.head.type === 'branch' && getBranchByName(state.repo.head.name)?.head === c.id) || (state.repo.head.type === 'detached' && state.repo.head.commitId === c.id); // circle const circle = make('circle', { r: Lane.commitRadius }); g.appendChild(circle); // label (commit short id and idx) const short = c.id.slice(-6); const label = make('text', { class: 'id', x: 12, y: -10 }, document.createTextNode(`${short} · idx:${c.idx}`)); g.appendChild(label); // branch head marker const branchNames = []; for (const [name, br] of state.repo.branches) { if (br.head === c.id) branchNames.push(name); } const branchLabel = make('text', { class: 'branch', x: 12, y: -22 }, document.createTextNode(branchNames.join(' · '))); g.appendChild(branchLabel); // HEAD marker if (isHead) { g.appendChild(make('circle', { r: Lane.commitRadius + 4, class: 'head' })); } // Merge node indicator: diamond if (c.parents.length > 1) { g.appendChild(make('rect', { x: -4, y: -4, width: 8, height: 8, class: 'diamond', transform: 'rotate(45)' })); } // events g.addEventListener('click', () => showCommitCard(c.id, x, y)); svg.appendChild(g); } } function curvePath(a, b) { // Cubic Bezier curve between points const dx = Math.max(30, Math.abs(a.x - b.x) * 0.35); return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; } // Commit card UI function showCommitCard(commitId, x, y) { if (state.cardEl && state.cardEl.parentNode) state.cardEl.parentNode.removeChild(state.cardEl); const c = getCommit(commitId); const card = document.createElement('div'); card.className = 'commit-card'; card.style.left = Math.min(Math.max(10, x + 14), svg.clientWidth - 280) + 'px'; card.style.top = Math.min(Math.max(10, y - 20), svg.clientHeight - 160) + 'px'; const parents = c.parents.length ? c.parents.map(id => getCommit(id)?.id.slice(-6)).join(', ') : 'none'; const branches = [...state.repo.branches.entries()] .filter(([_, br]) => br.head === c.id) .map(([name, _]) => name) .join(', ') || '-'; card.innerHTML = `