Spaces:
Running
Running
| (() => { | |
| // 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 = ` | |
| <h3>${escapeHtml(c.message)}</h3> | |
| <div class="meta"> | |
| <span class="tag">${escapeHtml(c.author)}</span> | |
| <span class="tag">${c.id.slice(-6)}</span> | |
| <span class="tag">parents: ${parents}</span> | |
| <span class="tag">branches: ${escapeHtml(branches)}</span> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn" data-action="checkout">Checkout</button> | |
| <button class="btn" data-action="branch">New branch here</button> | |
| <button class="btn warn" data-action="reset">Reset --hard here</button> | |
| <button class="btn ghost" data-action="blame">Blame</button> | |
| </div> | |
| `; | |
| 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 |