(() => { // 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 = `

${escapeHtml(c.message)}

${escapeHtml(c.author)} ${c.id.slice(-6)} parents: ${parents} branches: ${escapeHtml(branches)}
`; card.addEventListener('click', (e) => { const action = e.target?.dataset?.action; if (!action) return; if (action === 'checkout') checkoutCommit(c.id); if (action === 'branch') { branchNameEl.value = suggestBranchName(); branchNameEl.dataset.from = c.id; } if (action === 'reset') resetHardTo(c.id); if (action === 'blame') { blameCommitEl.value = c.id; showBlame(c.id); card.remove(); } }); svg.parentElement.appendChild(card); state.cardEl = card; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } // Actions function commit(message, { amend = false } = {}) { const head = getHeadCommit(); const parent = head ? head.id : null; const branchName = state.repo.head.type === 'branch' ? state.repo.head.name : null; if (amend && parent) { // Modify last commit (keep same id) const last = getCommit(parent); if (last) { last.message = message || last.message; last.author = 'You'; last.timestamp = Date.now(); touchRefresh(); return; } } const c = newCommit(message || defaultCommitMsg(), 'You'); c.parents = parent ? [parent] : []; if (branchName) c.branch = branchName; if (state.repo.head.type === 'branch') { const br = getBranchByName(branchName); br.head = c.id; } else if (state.repo.head.type === 'detached') { state.repo.head.commitId = c.id; } state.repo.commits.set(c.id, c); touchRefresh(); } function defaultCommitMsg() { const n = (state.nextCommitIndex % 8); const msgs = [ 'Make it work', 'Improve naming', 'Add tests', 'Refactor utils', 'Optimize performance', 'Fix edge cases', 'Polish UI', 'Document code' ]; return msgs[n]; } function createBranch(name, fromCommitId) { if (!name) return alert('Enter a branch name.'); if (state.repo.branches.has(name)) return alert('Branch already exists.'); const from = fromCommitId || getHeadCommit()?.id || null; const br = newBranch(name, from); state.repo.branches.set(name, br); touchRefresh(); } function checkout(branchName) { if (!state.repo.branches.has(branchName)) return alert('Branch not found.'); state.repo.head = { type: 'branch', name: branchName }; touchRefresh(); } function checkoutCommit(commitId) { if (!getCommit(commitId)) return alert('Commit not found.'); state.repo.head = { type: 'detached', commitId }; touchRefresh(); } function mergeIntoCurrent(targetBranchName) { const curName = state.repo.head.type === 'branch' ? state.repo.head.name : null; if (!curName) return alert('You can only merge while on a branch.'); const target = getBranchByName(targetBranchName); if (!target || !target.head) return alert('Target branch has no commits.'); const curHead = getBranchByName(curName).head; const targetHead = target.head; if (curHead