Ben-S commited on
Commit
ec7b4bb
·
verified ·
1 Parent(s): ebe8d5e

A visual and interactive representation of the core features of git. Branch, merge, rebase, detached head, blame, commits, issues, pull requests, etc. Should be beautiful and easy to understand

Browse files
Files changed (4) hide show
  1. README.md +7 -4
  2. index.html +211 -19
  3. script.js +456 -0
  4. style.css +333 -18
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
  title: Git Visualizer
3
- emoji: 🌖
4
- colorFrom: pink
5
- colorTo: yellow
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
  title: Git Visualizer
3
+ colorFrom: green
4
+ colorTo: gray
5
+ emoji: 🐳
6
  sdk: static
7
  pinned: false
8
+ tags:
9
+ - deepsite-v3
10
  ---
11
 
12
+ # Welcome to your new DeepSite project!
13
+ This project was created with [DeepSite](https://huggingface.co/deepsite).
index.html CHANGED
@@ -1,19 +1,211 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Git Visualizer — Interactive Git Core Concepts</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 24 24' fill='none' stroke='%236366f1' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8.5 7.5a3.5 3.5 0 0 1 4.5 3.9V14a3 3 0 1 1-1.5-2.6'/%3E%3Cpath d='M6 3a5 5 0 0 0 5 5h3'/%3E%3Cpath d='M2 3v5.5A3.5 3.5 0 0 0 5.5 12H8'/%3E%3Cpath d='M2 12h3a5 5 0 0 1 5 5v3'/%3E%3Cpath d='M10 20v-3a5 5 0 0 1 5-5h3'/%3E%3C/svg%3E" />
8
+ <link rel="stylesheet" href="style.css" />
9
+ <script defer src="script.js"></script>
10
+ </head>
11
+ <body>
12
+ <header class="app-header">
13
+ <div class="brand">
14
+ <div class="logo">⧉</div>
15
+ <div class="title">
16
+ <h1>Git Visualizer</h1>
17
+ <p>Explore commits, branches, merge, rebase, detached HEAD, blame, issues, and pull requests.</p>
18
+ </div>
19
+ </div>
20
+ <div class="customization">
21
+ <div class="chip">
22
+ <label for="primaryColor">Primary</label>
23
+ <input id="primaryColor" type="color" value="#6366f1" />
24
+ </div>
25
+ <div class="chip">
26
+ <label for="secondaryColor">Secondary</label>
27
+ <input id="secondaryColor" type="color" value="#22c55e" />
28
+ </div>
29
+ <div class="chip">
30
+ <label for="themeMode">Theme</label>
31
+ <select id="themeMode">
32
+ <option value="system">System</option>
33
+ <option value="light">Light</option>
34
+ <option value="dark">Dark</option>
35
+ </select>
36
+ </div>
37
+ </div>
38
+ </header>
39
+
40
+ <main class="app">
41
+ <aside class="sidebar">
42
+ <section class="panel">
43
+ <h2>Repository</h2>
44
+ <div class="row">
45
+ <div>
46
+ <div class="label">Current HEAD</div>
47
+ <div id="currentHead" class="value">-</div>
48
+ </div>
49
+ <div>
50
+ <div class="label">Detached</div>
51
+ <div id="detachedState" class="value">No</div>
52
+ </div>
53
+ </div>
54
+ <div class="row">
55
+ <div>
56
+ <div class="label">Branches</div>
57
+ <ul id="branchList" class="list"></ul>
58
+ </div>
59
+ </div>
60
+ <div class="row">
61
+ <div>
62
+ <div class="label">Stash</div>
63
+ <ul id="stashList" class="list compact"></ul>
64
+ </div>
65
+ </div>
66
+ </section>
67
+
68
+ <section class="panel">
69
+ <h2>Actions</h2>
70
+ <div class="grid">
71
+ <div class="field">
72
+ <label for="commitMsg">Commit message</label>
73
+ <input id="commitMsg" type="text" placeholder="Describe your change" />
74
+ </div>
75
+ <button id="btnCommit" class="btn primary">Commit</button>
76
+ <button id="btnCommitAmend" class="btn ghost">Amend</button>
77
+ <button id="btnUndoCommit" class="btn ghost">Undo last commit</button>
78
+ </div>
79
+ <div class="grid">
80
+ <div class="field">
81
+ <label for="branchName">New branch name</label>
82
+ <input id="branchName" type="text" placeholder="feature/..." />
83
+ </div>
84
+ <button id="btnCreateBranch" class="btn">Create branch</button>
85
+ </div>
86
+ <div class="grid">
87
+ <div class="field">
88
+ <label for="switchBranch">Switch to branch</label>
89
+ <select id="switchBranch"></select>
90
+ </div>
91
+ <button id="btnCheckout" class="btn">Checkout</button>
92
+ </div>
93
+ <div class="grid">
94
+ <div class="field">
95
+ <label for="checkoutCommit">Checkout commit</label>
96
+ <select id="checkoutCommit"></select>
97
+ </div>
98
+ <button id="btnDetach" class="btn warn">Detach HEAD</button>
99
+ </div>
100
+ <div class="grid">
101
+ <button id="btnMerge" class="btn">Merge selected into current</button>
102
+ <button id="btnRebase" class="btn">Rebase current on selected</button>
103
+ </div>
104
+ <div class="grid">
105
+ <div class="field">
106
+ <label for="mergeTarget">Merge target</label>
107
+ <select id="mergeTarget"></select>
108
+ </div>
109
+ <div class="field">
110
+ <label for="rebaseTarget">Rebase target</label>
111
+ <select id="rebaseTarget"></select>
112
+ </div>
113
+ </div>
114
+ <div class="grid">
115
+ <button id="btnStash" class="btn">Stash</button>
116
+ <button id="btnStashPop" class="btn">Stash pop</button>
117
+ </div>
118
+ <div class="grid">
119
+ <button id="btnResetHard" class="btn danger">Reset --hard (to selected)</button>
120
+ <select id="resetTarget"></select>
121
+ </div>
122
+ </section>
123
+
124
+ <section class="panel">
125
+ <h2>Issues & Pull Requests</h2>
126
+ <div class="grid">
127
+ <button id="btnNewIssue" class="btn">New Issue</button>
128
+ <button id="btnNewPR" class="btn">New Pull Request</button>
129
+ </div>
130
+ <div class="row">
131
+ <div>
132
+ <div class="label">Issues</div>
133
+ <ul id="issuesList" class="list"></ul>
134
+ </div>
135
+ </div>
136
+ <div class="row">
137
+ <div>
138
+ <div class="label">Pull Requests</div>
139
+ <ul id="prsList" class="list"></ul>
140
+ </div>
141
+ </div>
142
+ </section>
143
+
144
+ <section class="panel">
145
+ <h2>Blame</h2>
146
+ <div class="grid">
147
+ <div class="field">
148
+ <label for="blameCommit">Select commit</label>
149
+ <select id="blameCommit"></select>
150
+ </div>
151
+ <button id="btnBlame" class="btn ghost">Show blame</button>
152
+ </div>
153
+ <div id="blamePanel" class="code hidden"></div>
154
+ </section>
155
+
156
+ <section class="panel">
157
+ <h2>Demo Scenarios</h2>
158
+ <div class="grid">
159
+ <button id="btnDemoBasic" class="btn">Init demo</button>
160
+ <button id="btnDemoPR" class="btn">Open PR scenario</button>
161
+ </div>
162
+ </section>
163
+ </aside>
164
+
165
+ <section class="graph">
166
+ <div class="graph-toolbar">
167
+ <div class="left">
168
+ <button id="btnFit" class="btn ghost">Fit</button>
169
+ <button id="btnCenterMain" class="btn ghost">Center main</button>
170
+ <button id="btnCenterHead" class="btn ghost">Center HEAD</button>
171
+ </div>
172
+ <div class="right">
173
+ <span class="legend"><span class="legend-dot"></span> Commits</span>
174
+ <span class="legend"><span class="legend-branch"></span> Branch head</span>
175
+ <span class="legend"><span class="legend-head"></span> HEAD</span>
176
+ </div>
177
+ </div>
178
+ <div class="canvas-wrapper">
179
+ <svg id="graphSvg" xmlns="http://www.w3.org/2000/svg"></svg>
180
+ </div>
181
+ </section>
182
+ </main>
183
+
184
+ <footer class="app-footer">
185
+ <div>Tip: Start with “Init demo”, create a branch, make commits, then try Merge and Rebase. Toggle theme and colors above.</div>
186
+ <div class="small">Built for interactive learning of core Git concepts.</div>
187
+ </footer>
188
+
189
+ <script>
190
+ // Configure CSS variables from inputs (supports "undefined" placeholder too)
191
+ const primaryInput = document.getElementById('primaryColor');
192
+ const secondaryInput = document.getElementById('secondaryColor');
193
+ const themeMode = document.getElementById('themeMode');
194
+
195
+ const applyVars = () => {
196
+ const getOrFallback = (v, fallback) => (!v || v === 'undefined') ? fallback : v;
197
+ document.documentElement.style.setProperty('--primary', getOrFallback(primaryInput.value, '#6366f1'));
198
+ document.documentElement.style.setProperty('--secondary', getOrFallback(secondaryInput.value, '#22c55e'));
199
+ };
200
+ primaryInput.addEventListener('input', applyVars);
201
+ secondaryInput.addEventListener('input', applyVars);
202
+ themeMode.addEventListener('change', () => {
203
+ const mode = themeMode.value;
204
+ if (mode === 'system') document.documentElement.removeAttribute('data-theme');
205
+ else document.documentElement.setAttribute('data-theme', mode);
206
+ });
207
+ applyVars();
208
+ </script>
209
+ <script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
210
+ </body>
211
+ </html>
script.js ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (() => {
2
+ // Utility short-hands
3
+ const $ = (sel) => document.querySelector(sel);
4
+ const $$ = (sel) => document.querySelectorAll(sel);
5
+
6
+ // DOM refs
7
+ const svg = $('#graphSvg');
8
+ const branchListEl = $('#branchList');
9
+ const stashListEl = $('#stashList');
10
+ const currentHeadEl = $('#currentHead');
11
+ const detachedStateEl = $('#detachedState');
12
+ const commitMsgEl = $('#commitMsg');
13
+ const btnCommit = $('#btnCommit');
14
+ const btnCommitAmend = $('#btnCommitAmend');
15
+ const btnUndoCommit = $('#btnUndoCommit');
16
+ const branchNameEl = $('#branchName');
17
+ const btnCreateBranch = $('#btnCreateBranch');
18
+ const switchBranchEl = $('#switchBranch');
19
+ const checkoutCommitEl = $('#checkoutCommit');
20
+ const btnCheckout = $('#btnCheckout');
21
+ const btnDetach = $('#btnDetach');
22
+ const btnMerge = $('#btnMerge');
23
+ const btnRebase = $('#btnRebase');
24
+ const mergeTargetEl = $('#mergeTarget');
25
+ const rebaseTargetEl = $('#rebaseTarget');
26
+ const btnStash = $('#btnStash');
27
+ const btnStashPop = $('#btnStashPop');
28
+ const btnResetHard = $('#btnResetHard');
29
+ const resetTargetEl = $('#resetTarget');
30
+ const btnNewIssue = $('#btnNewIssue');
31
+ const btnNewPR = $('#btnNewPR');
32
+ const issuesListEl = $('#issuesList');
33
+ const prsListEl = $('#prsList');
34
+ const btnFit = $('#btnFit');
35
+ const btnCenterMain = $('#btnCenterMain');
36
+ const btnCenterHead = $('#btnCenterHead');
37
+ const btnDemoBasic = $('#btnDemoBasic');
38
+ const btnDemoPR = $('#btnDemoPR');
39
+ const blameCommitEl = $('#blameCommit');
40
+ const btnBlame = $('#btnBlame');
41
+ const blamePanel = $('#blamePanel');
42
+
43
+ // Graph rendering config
44
+ const Lane = {
45
+ width: 140,
46
+ marginX: 90,
47
+ marginY: 36,
48
+ commitRadius: 6
49
+ };
50
+
51
+ // Colors
52
+ const cssVar = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
53
+
54
+ // State
55
+ const state = {
56
+ repo: null,
57
+ layout: null,
58
+ nextCommitIndex: 0,
59
+ selectedNodeId: null,
60
+ cardEl: null,
61
+ };
62
+
63
+ // Init state
64
+ const initRepo = () => {
65
+ const main = newBranch('main', null);
66
+ const root = newCommit('Initial commit', 'System', { isRoot: true });
67
+ main.head = root.id;
68
+
69
+ return {
70
+ commits: new Map([[root.id, root]]),
71
+ branches: new Map([['main', main]]),
72
+ head: { type: 'branch', name: 'main' },
73
+ detachedHeadCommit: null,
74
+ stash: [],
75
+ issues: [],
76
+ prs: [],
77
+ nextId: 1
78
+ };
79
+ };
80
+
81
+ // Helpers
82
+ function newId(prefix = 'c') {
83
+ return `${prefix}${String(state.repo.nextId++).padStart(3, '0')}`;
84
+ }
85
+ function newBranch(name, fromCommitId) {
86
+ return {
87
+ name,
88
+ head: fromCommitId || null,
89
+ color: pickBranchColor()
90
+ };
91
+ }
92
+ function pickBranchColor() {
93
+ // Generates an HSL color based on repo state
94
+ const n = state.repo.branches.size + 1;
95
+ const hue = (n * 67) % 360;
96
+ return `hsl(${hue} 72% 60%)`;
97
+ }
98
+ function newCommit(message, author, extra = {}) {
99
+ const id = newId('c');
100
+ const idx = state.nextCommitIndex++;
101
+ return {
102
+ id,
103
+ idx,
104
+ message,
105
+ author,
106
+ parents: extra.parents || [],
107
+ branch: extra.branch || null,
108
+ isRoot: !!extra.isRoot,
109
+ timestamp: Date.now()
110
+ };
111
+ }
112
+
113
+ function getCommit(id) { return state.repo.commits.get(id); }
114
+ function getHeadCommit() {
115
+ const head = state.repo.head;
116
+ if (head.type === 'branch') {
117
+ const br = state.repo.branches.get(head.name);
118
+ return br && br.head ? getCommit(br.head) : null;
119
+ } else if (head.type === 'detached') {
120
+ return getCommit(head.commitId);
121
+ }
122
+ return null;
123
+ }
124
+ function getBranchByName(name) { return state.repo.branches.get(name); }
125
+
126
+ function isMergedInto(commitId, targetBranchName) {
127
+ // Check if commitId is ancestor of targetBranch head
128
+ const target = getBranchByName(targetBranchName)?.head;
129
+ if (!target) return false;
130
+ let cur = target;
131
+ const visited = new Set();
132
+ while (cur) {
133
+ if (cur === commitId) return true;
134
+ const node = getCommit(cur);
135
+ if (!node) break;
136
+ if (visited.has(cur)) break;
137
+ visited.add(cur);
138
+ cur = node.parents[0] || null;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ // History navigation
144
+ function walk(commitId, fn) {
145
+ const visited = new Set();
146
+ let cur = commitId;
147
+ while (cur) {
148
+ if (visited.has(cur)) break;
149
+ visited.add(cur);
150
+ const node = getCommit(cur);
151
+ if (!node) break;
152
+ if (fn(node) === false) break;
153
+ cur = node.parents[0] || null;
154
+ }
155
+ }
156
+
157
+ // Index commit for linear layout per first-parent chain
158
+ function buildLayout() {
159
+ let idx = -1;
160
+ const visited = new Set();
161
+ let cur = null;
162
+ // find a head
163
+ const heads = [];
164
+ for (const br of state.repo.branches.values()) {
165
+ if (br.head) heads.push(br.head);
166
+ }
167
+ if (state.repo.head.type === 'detached') heads.push(state.repo.head.commitId);
168
+ // sort heads by existing idx
169
+ heads.sort((a, b) => (getCommit(a)?.idx ?? 0) - (getCommit(b)?.idx ?? 0));
170
+ // Walk each chain to assign indices topologically
171
+ for (const h of heads) {
172
+ cur = h;
173
+ const chain = [];
174
+ while (cur && !visited.has(cur)) {
175
+ visited.add(cur);
176
+ chain.push(cur);
177
+ const c = getCommit(cur);
178
+ cur = c?.parents?.[0] || null;
179
+ }
180
+ // Assign idx descending from 0 upwards
181
+ for (let i = chain.length - 1; i >= 0; i--) {
182
+ const id = chain[i];
183
+ const node = getCommit(id);
184
+ if (node && node.idx === -1) {
185
+ idx += 1;
186
+ node.idx = idx;
187
+ }
188
+ }
189
+ }
190
+ // Keep nextCommitIndex aligned with max idx
191
+ state.nextCommitIndex = idx + 1;
192
+
193
+ // Assign lanes (first-come-first-serve)
194
+ const lanes = new Map(); // branchName -> laneIndex
195
+ let laneCursor = 0;
196
+ for (const [name, br] of state.repo.branches) {
197
+ if (!br.head) continue;
198
+ if (!lanes.has(name)) {
199
+ lanes.set(name, laneCursor++);
200
+ }
201
+ }
202
+
203
+ // Compute x,y positions
204
+ const nodes = Array.from(state.repo.commits.values());
205
+ nodes.sort((a,b) => a.idx - b.idx);
206
+
207
+ const coords = new Map(); // id -> {x,y}
208
+ const xForLane = (i) => Lane.marginX + i * Lane.width;
209
+ const yForIdx = (i) => Lane.marginY + i * 36;
210
+
211
+ // First pass: assign rough positions
212
+ for (const c of nodes) {
213
+ const lane = lanes.get(c.branch || '') ?? 0;
214
+ coords.set(c.id, { x: xForLane(lane), y: yForIdx(c.idx) });
215
+ }
216
+
217
+ // Second pass: route merges with intermediate waypoints to avoid crossings
218
+ const edges = []; // {from,to,type}
219
+ for (const c of nodes) {
220
+ const a = coords.get(c.id);
221
+ for (const p of (c.parents || [])) {
222
+ const b = coords.get(p);
223
+ const type = c.parents.length > 1 ? 'merge' : 'link';
224
+ edges.push({ from: a, to: b, type });
225
+ }
226
+ }
227
+
228
+ // Compute bounding box
229
+ let maxY = 0;
230
+ nodes.forEach(n => {
231
+ const { y } = coords.get(n.id);
232
+ if (y > maxY) maxY = y;
233
+ });
234
+
235
+ state.layout = { coords, edges, maxY, lanes: lanes.size, nodes };
236
+ }
237
+
238
+ // SVG utilities
239
+ function clearSvg() {
240
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
241
+ }
242
+ function make(tag, attrs = {}, children = []) {
243
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
244
+ for (const [k, v] of Object.entries(attrs)) {
245
+ if (v !== null && v !== undefined) el.setAttribute(k, v);
246
+ }
247
+ if (!Array.isArray(children)) children = [children];
248
+ children.forEach(ch => el.appendChild(ch));
249
+ return el;
250
+ }
251
+
252
+ // Draw graph
253
+ function renderGraph() {
254
+ clearSvg();
255
+ buildLayout();
256
+
257
+ // Size SVG
258
+ const width = Math.max(900, 2 * Lane.marginX + (state.layout.lanes * Lane.width));
259
+ const height = Math.max(420, state.layout.maxY + Lane.marginY + 40);
260
+ svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
261
+ svg.setAttribute('width', '100%');
262
+ svg.setAttribute('height', '100%');
263
+
264
+ // Draw links
265
+ for (const [id, c] of state.repo.commits) {
266
+ for (const p of (c.parents || [])) {
267
+ const a = state.layout.coords.get(id);
268
+ const b = state.layout.coords.get(p);
269
+ if (!a || !b) continue;
270
+ const d = curvePath(a, b);
271
+ const cl = make('path', { d, class: `link ${c.parents.length > 1 ? 'merge' : ''}` });
272
+ svg.appendChild(cl);
273
+ }
274
+ }
275
+
276
+ // Draw nodes
277
+ const nodes = state.layout.nodes;
278
+ for (const c of nodes) {
279
+ const { x, y } = state.layout.coords.get(c.id);
280
+ const g = make('g', { class: 'node', transform: `translate(${x},${y})`, 'data-id': c.id });
281
+ const isHeadOfBranch = [...state.repo.branches.values()].some(b => b.head === c.id);
282
+ const isHead = (state.repo.head.type === 'branch' && getBranchByName(state.repo.head.name)?.head === c.id)
283
+ || (state.repo.head.type === 'detached' && state.repo.head.commitId === c.id);
284
+
285
+ // circle
286
+ const circle = make('circle', { r: Lane.commitRadius });
287
+ g.appendChild(circle);
288
+
289
+ // label (commit short id and idx)
290
+ const short = c.id.slice(-6);
291
+ const label = make('text', { class: 'id', x: 12, y: -10 }, document.createTextNode(`${short} · idx:${c.idx}`));
292
+ g.appendChild(label);
293
+
294
+ // branch head marker
295
+ const branchNames = [];
296
+ for (const [name, br] of state.repo.branches) {
297
+ if (br.head === c.id) branchNames.push(name);
298
+ }
299
+ const branchLabel = make('text', { class: 'branch', x: 12, y: -22 }, document.createTextNode(branchNames.join(' · ')));
300
+ g.appendChild(branchLabel);
301
+
302
+ // HEAD marker
303
+ if (isHead) {
304
+ g.appendChild(make('circle', { r: Lane.commitRadius + 4, class: 'head' }));
305
+ }
306
+
307
+ // Merge node indicator: diamond
308
+ if (c.parents.length > 1) {
309
+ g.appendChild(make('rect', { x: -4, y: -4, width: 8, height: 8, class: 'diamond', transform: 'rotate(45)' }));
310
+ }
311
+
312
+ // events
313
+ g.addEventListener('click', () => showCommitCard(c.id, x, y));
314
+
315
+ svg.appendChild(g);
316
+ }
317
+ }
318
+
319
+ function curvePath(a, b) {
320
+ // Cubic Bezier curve between points
321
+ const dx = Math.max(30, Math.abs(a.x - b.x) * 0.35);
322
+ return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`;
323
+ }
324
+
325
+ // Commit card UI
326
+ function showCommitCard(commitId, x, y) {
327
+ if (state.cardEl && state.cardEl.parentNode) state.cardEl.parentNode.removeChild(state.cardEl);
328
+ const c = getCommit(commitId);
329
+ const card = document.createElement('div');
330
+ card.className = 'commit-card';
331
+ card.style.left = Math.min(Math.max(10, x + 14), svg.clientWidth - 280) + 'px';
332
+ card.style.top = Math.min(Math.max(10, y - 20), svg.clientHeight - 160) + 'px';
333
+
334
+ const parents = c.parents.length ? c.parents.map(id => getCommit(id)?.id.slice(-6)).join(', ') : 'none';
335
+ const branches = [...state.repo.branches.entries()]
336
+ .filter(([_, br]) => br.head === c.id)
337
+ .map(([name, _]) => name)
338
+ .join(', ') || '-';
339
+
340
+ card.innerHTML = `
341
+ <h3>${escapeHtml(c.message)}</h3>
342
+ <div class="meta">
343
+ <span class="tag">${escapeHtml(c.author)}</span>
344
+ <span class="tag">${c.id.slice(-6)}</span>
345
+ <span class="tag">parents: ${parents}</span>
346
+ <span class="tag">branches: ${escapeHtml(branches)}</span>
347
+ </div>
348
+ <div class="actions">
349
+ <button class="btn" data-action="checkout">Checkout</button>
350
+ <button class="btn" data-action="branch">New branch here</button>
351
+ <button class="btn warn" data-action="reset">Reset --hard here</button>
352
+ <button class="btn ghost" data-action="blame">Blame</button>
353
+ </div>
354
+ `;
355
+ card.addEventListener('click', (e) => {
356
+ const action = e.target?.dataset?.action;
357
+ if (!action) return;
358
+ if (action === 'checkout') checkoutCommit(c.id);
359
+ if (action === 'branch') {
360
+ branchNameEl.value = suggestBranchName();
361
+ branchNameEl.dataset.from = c.id;
362
+ }
363
+ if (action === 'reset') resetHardTo(c.id);
364
+ if (action === 'blame') {
365
+ blameCommitEl.value = c.id;
366
+ showBlame(c.id);
367
+ card.remove();
368
+ }
369
+ });
370
+
371
+ svg.parentElement.appendChild(card);
372
+ state.cardEl = card;
373
+ }
374
+
375
+ function escapeHtml(s) {
376
+ return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[m]));
377
+ }
378
+
379
+ // Actions
380
+ function commit(message, { amend = false } = {}) {
381
+ const head = getHeadCommit();
382
+ const parent = head ? head.id : null;
383
+ const branchName = state.repo.head.type === 'branch' ? state.repo.head.name : null;
384
+
385
+ if (amend && parent) {
386
+ // Modify last commit (keep same id)
387
+ const last = getCommit(parent);
388
+ if (last) {
389
+ last.message = message || last.message;
390
+ last.author = 'You';
391
+ last.timestamp = Date.now();
392
+ touchRefresh();
393
+ return;
394
+ }
395
+ }
396
+
397
+ const c = newCommit(message || defaultCommitMsg(), 'You');
398
+ c.parents = parent ? [parent] : [];
399
+ if (branchName) c.branch = branchName;
400
+ if (state.repo.head.type === 'branch') {
401
+ const br = getBranchByName(branchName);
402
+ br.head = c.id;
403
+ } else if (state.repo.head.type === 'detached') {
404
+ state.repo.head.commitId = c.id;
405
+ }
406
+
407
+ state.repo.commits.set(c.id, c);
408
+ touchRefresh();
409
+ }
410
+
411
+ function defaultCommitMsg() {
412
+ const n = (state.nextCommitIndex % 8);
413
+ const msgs = [
414
+ 'Make it work',
415
+ 'Improve naming',
416
+ 'Add tests',
417
+ 'Refactor utils',
418
+ 'Optimize performance',
419
+ 'Fix edge cases',
420
+ 'Polish UI',
421
+ 'Document code'
422
+ ];
423
+ return msgs[n];
424
+ }
425
+
426
+ function createBranch(name, fromCommitId) {
427
+ if (!name) return alert('Enter a branch name.');
428
+ if (state.repo.branches.has(name)) return alert('Branch already exists.');
429
+ const from = fromCommitId || getHeadCommit()?.id || null;
430
+ const br = newBranch(name, from);
431
+ state.repo.branches.set(name, br);
432
+ touchRefresh();
433
+ }
434
+
435
+ function checkout(branchName) {
436
+ if (!state.repo.branches.has(branchName)) return alert('Branch not found.');
437
+ state.repo.head = { type: 'branch', name: branchName };
438
+ touchRefresh();
439
+ }
440
+
441
+ function checkoutCommit(commitId) {
442
+ if (!getCommit(commitId)) return alert('Commit not found.');
443
+ state.repo.head = { type: 'detached', commitId };
444
+ touchRefresh();
445
+ }
446
+
447
+ function mergeIntoCurrent(targetBranchName) {
448
+ const curName = state.repo.head.type === 'branch' ? state.repo.head.name : null;
449
+ if (!curName) return alert('You can only merge while on a branch.');
450
+ const target = getBranchByName(targetBranchName);
451
+ if (!target || !target.head) return alert('Target branch has no commits.');
452
+
453
+ const curHead = getBranchByName(curName).head;
454
+ const targetHead = target.head;
455
+
456
+ if (curHead
style.css CHANGED
@@ -1,28 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
 
 
 
9
  }
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
16
  }
 
 
 
 
 
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary: #6366f1;
3
+ --primary-600: #4f46e5;
4
+ --primary-50: #eef2ff;
5
+ --bg: #0b1020;
6
+ --bg-elev: #11182c;
7
+ --bg-soft: #0f172a;
8
+ --text: #e2e8f0;
9
+ --muted: #94a3b8;
10
+ --card: #0f172a;
11
+ --border: #1f2a44;
12
+ --success: #16a34a;
13
+ --warn: #f59e0b;
14
+ --danger: #ef4444;
15
+ }
16
+
17
+ :root[data-theme="light"] {
18
+ --bg: #f8fafc;
19
+ --bg-elev: #ffffff;
20
+ --bg-soft: #f1f5f9;
21
+ --text: #0f172a;
22
+ --muted: #475569;
23
+ --card: #ffffff;
24
+ --border: #e2e8f0;
25
+ }
26
+
27
+ * { box-sizing: border-box; }
28
+ html, body { height: 100%; }
29
  body {
30
+ margin: 0;
31
+ color: var(--text);
32
+ background: linear-gradient(180deg, var(--bg) 0%, var(--bg-soft) 100%);
33
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
34
+ line-height: 1.35;
35
+ }
36
+
37
+ .app-header {
38
+ position: sticky;
39
+ top: 0;
40
+ z-index: 30;
41
+ display: flex;
42
+ gap: 16px;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ padding: 14px 18px;
46
+ background: linear-gradient(90deg, color-mix(in oklab, var(--primary) 14%, transparent), transparent 60%),
47
+ var(--bg-elev);
48
+ border-bottom: 1px solid var(--border);
49
+ backdrop-filter: blur(6px);
50
+ }
51
+
52
+ .brand {
53
+ display: flex;
54
+ gap: 12px;
55
+ align-items: center;
56
+ }
57
+ .logo {
58
+ width: 40px; height: 40px;
59
+ border-radius: 10px;
60
+ display: grid; place-items: center;
61
+ background: radial-gradient(75% 75% at 30% 30%, var(--primary) 0%, transparent 70%),
62
+ radial-gradient(65% 65% at 70% 70%, var(--secondary) 0%, transparent 70%);
63
+ color: white;
64
+ font-weight: 800;
65
+ letter-spacing: -1px;
66
+ box-shadow: 0 4px 18px rgba(0,0,0,.25), inset 0 0 0 1px rgba(255,255,255,.08);
67
+ }
68
+ .title h1 {
69
+ margin: 0; font-size: 18px; letter-spacing: .2px;
70
+ }
71
+ .title p {
72
+ margin: 2px 0 0;
73
+ color: var(--muted); font-size: 12px;
74
+ }
75
+
76
+ .customization {
77
+ display: flex; gap: 10px; align-items: center;
78
+ }
79
+ .chip {
80
+ display: inline-flex; gap: 8px; align-items: center;
81
+ padding: 6px 8px; border-radius: 999px;
82
+ border: 1px solid var(--border);
83
+ background: var(--bg-elev);
84
+ }
85
+ .chip label { font-size: 12px; color: var(--muted); }
86
+ .chip input[type="color"], .chip select {
87
+ appearance: none;
88
+ width: 28px; height: 24px; border: none; background: transparent; padding: 0;
89
+ cursor: pointer;
90
+ }
91
+
92
+ .app {
93
+ display: grid;
94
+ grid-template-columns: 360px 1fr;
95
+ gap: 14px;
96
+ padding: 14px;
97
+ min-height: calc(100vh - 88px);
98
+ }
99
+
100
+ .sidebar {
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 14px;
104
+ max-height: calc(100vh - 120px);
105
+ overflow: auto;
106
+ }
107
+
108
+ .panel {
109
+ background: var(--card);
110
+ border: 1px solid var(--border);
111
+ border-radius: 12px;
112
+ padding: 12px;
113
+ box-shadow: 0 6px 20px rgba(0,0,0,.18);
114
+ }
115
+ .panel h2 {
116
+ margin: 0 0 8px 0;
117
+ font-size: 14px;
118
+ color: var(--muted);
119
+ font-weight: 700;
120
+ letter-spacing: .3px;
121
+ }
122
+ .row {
123
+ display: grid;
124
+ grid-template-columns: 1fr 1fr;
125
+ gap: 8px;
126
+ margin-top: 6px;
127
+ }
128
+ .field { display: grid; gap: 6px; }
129
+ .field label { font-size: 12px; color: var(--muted); }
130
+ .field input, .field select {
131
+ border: 1px solid var(--border);
132
+ background: var(--bg-soft);
133
+ color: var(--text);
134
+ border-radius: 8px;
135
+ padding: 8px 10px;
136
+ font-size: 13px;
137
+ outline: none;
138
+ }
139
+ .field input:focus, .field select:focus {
140
+ border-color: color-mix(in oklab, var(--primary) 50%, var(--border));
141
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 20%, transparent);
142
+ }
143
+
144
+ .grid {
145
+ display: grid;
146
+ grid-template-columns: 1fr auto auto;
147
+ gap: 8px;
148
+ align-items: end;
149
+ margin-top: 8px;
150
  }
151
 
152
+ .btn {
153
+ appearance: none;
154
+ border: 1px solid var(--border);
155
+ background: linear-gradient(180deg, var(--bg-elev), var(--bg-soft));
156
+ color: var(--text);
157
+ padding: 8px 12px;
158
+ border-radius: 10px;
159
+ font-size: 13px;
160
+ cursor: pointer;
161
+ transition: transform .06s ease, background .2s ease, border-color .2s ease;
162
  }
163
+ .btn:hover { transform: translateY(-1px); }
164
+ .btn:active { transform: translateY(0); }
165
+ .btn.primary {
166
+ background: linear-gradient(180deg, color-mix(in oklab, var(--primary) 35%, var(--bg-elev)), color-mix(in oklab, var(--primary) 22%, var(--bg-soft)));
167
+ border-color: color-mix(in oklab, var(--primary) 55%, var(--border));
168
+ color: white;
169
+ }
170
+ .btn.ghost {
171
+ background: transparent;
172
+ }
173
+ .btn.warn { border-color: color-mix(in oklab, var(--warn) 60%, var(--border)); color: color-mix(in oklab, var(--warn) 90%, white); }
174
+ .btn.danger { border-color: color-mix(in oklab, var(--danger) 60%, var(--border)); color: color-mix(in oklab, var(--danger) 90%, white); }
175
 
176
+ .list { list-style: none; margin: 6px 0 0; padding: 0; }
177
+ .list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-soft); margin-bottom: 6px; }
178
+ .list.compact li { padding: 4px 6px; font-size: 12px; }
179
+ .list .left { display: flex; gap: 8px; align-items: center; overflow: hidden; }
180
+ .badge {
181
+ display: inline-flex; gap: 6px; align-items: center;
182
+ padding: 2px 6px; border-radius: 999px;
183
+ border: 1px solid var(--border);
184
+ background: var(--bg-elev);
185
+ font-size: 12px;
186
  }
187
+ .dot {
188
+ width: 10px; height: 10px; border-radius: 50%;
189
+ box-shadow: 0 0 0 2px rgba(255,255,255,.1) inset;
190
+ }
191
+ .small { font-size: 12px; color: var(--muted); }
192
 
193
+ .graph {
194
+ position: relative;
195
+ background: radial-gradient(1200px 600px at 30% 0%, color-mix(in oklab, var(--primary) 9%, transparent), transparent),
196
+ radial-gradient(900px 900px at 90% 10%, color-mix(in oklab, var(--secondary) 10%, transparent), transparent),
197
+ var(--card);
198
+ border: 1px solid var(--border);
199
+ border-radius: 12px;
200
+ box-shadow: 0 6px 20px rgba(0,0,0,.18);
201
+ min-height: 520px;
202
+ display: grid;
203
+ grid-template-rows: auto 1fr;
204
+ }
205
+ .graph-toolbar {
206
+ display: flex;
207
+ align-items: center;
208
+ justify-content: space-between;
209
+ padding: 8px 10px;
210
+ border-bottom: 1px solid var(--border);
211
+ background: linear-gradient(180deg, var(--bg-elev), var(--bg-soft));
212
+ border-radius: 12px 12px 0 0;
213
+ }
214
+ .legend { display: inline-flex; gap: 8px; align-items: center; font-size: 12px; color: var(--muted); margin-left: 10px; }
215
+ .legend-dot {
216
+ width: 10px; height: 10px; border-radius: 50%;
217
+ background: var(--text);
218
+ }
219
+ .legend-branch {
220
+ width: 12px; height: 2px; background: var(--primary);
221
+ }
222
+ .legend-head {
223
+ width: 10px; height: 10px; border-radius: 50%;
224
+ outline: 2px solid var(--secondary);
225
+ background: transparent;
226
+ }
227
+ .canvas-wrapper {
228
+ position: relative; overflow: hidden;
229
+ }
230
+ #graphSvg {
231
+ width: 100%; height: 100%;
232
+ background-image:
233
+ radial-gradient(circle at 25px 25px, rgba(255,255,255,.06) 1px, transparent 0),
234
+ radial-gradient(circle at 25px 25px, rgba(255,255,255,.05) 1px, transparent 0);
235
+ background-size: 50px 50px, 10px 10px;
236
  }
237
 
238
+ /* Graph nodes and links */
239
+ .link {
240
+ stroke: color-mix(in oklab, var(--muted) 25%, var(--text));
241
+ stroke-opacity: .5;
242
+ stroke-width: 2.5;
243
+ fill: none;
244
+ transition: stroke .15s ease;
245
+ }
246
+ .link.merge {
247
+ stroke: color-mix(in oklab, var(--secondary) 65%, var(--muted));
248
+ stroke-opacity: .7;
249
+ stroke-dasharray: 4 3;
250
+ }
251
+ .node {
252
+ cursor: pointer;
253
+ transition: transform .08s ease;
254
+ }
255
+ .node:hover { transform: scale(1.06); }
256
+ .node circle {
257
+ fill: var(--text);
258
+ stroke: var(--bg-elev);
259
+ stroke-width: 2;
260
+ }
261
+ .node .label {
262
+ font-size: 11px;
263
+ fill: var(--muted);
264
+ }
265
+ .node .branch {
266
+ font-size: 11px;
267
+ fill: var(--muted);
268
  }
269
+ .node .head {
270
+ fill: var(--secondary);
271
+ stroke: var(--bg-elev);
272
+ stroke-width: 2.5;
273
+ }
274
+ .node .diamond {
275
+ fill: var(--secondary);
276
+ opacity: .85;
277
+ stroke: var(--bg-elev);
278
+ stroke-width: 2;
279
+ }
280
+ .node .id {
281
+ font-size: 10px;
282
+ fill: var(--muted);
283
+ }
284
+
285
+ /* Commit card */
286
+ .commit-card {
287
+ position: absolute;
288
+ background: var(--bg-elev);
289
+ border: 1px solid var(--border);
290
+ border-radius: 10px;
291
+ padding: 10px;
292
+ width: 260px;
293
+ box-shadow: 0 8px 26px rgba(0,0,0,.25);
294
+ }
295
+ .commit-card h3 {
296
+ margin: 0 0 4px 0;
297
+ font-size: 13px;
298
+ }
299
+ .commit-card .meta { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
300
+ .commit-card .meta .tag {
301
+ background: var(--bg-soft);
302
+ border: 1px solid var(--border);
303
+ border-radius: 999px;
304
+ padding: 1px 6px;
305
+ }
306
+ .commit-card .message {
307
+ font-size: 12px;
308
+ margin-bottom: 8px;
309
+ }
310
+ .commit-card .actions { display: flex; gap: 6px; flex-wrap: wrap; }
311
+ .commit-card .actions .btn { padding: 6px 8px; font-size: 12px; }
312
+
313
+ /* Code/blame */
314
+ .code {
315
+ background: var(--bg-soft);
316
+ border: 1px dashed var(--border);
317
+ border-radius: 10px;
318
+ padding: 10px;
319
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
320
+ font-size: 12px;
321
+ line-height: 1.4;
322
+ white-space: pre-wrap;
323
+ color: var(--text);
324
+ }
325
+ .hidden { display: none; }
326
+
327
+ .app-footer {
328
+ padding: 12px 16px;
329
+ border-top: 1px solid var(--border);
330
+ background: var(--bg-elev);
331
+ display: flex;
332
+ align-items: center;
333
+ justify-content: space-between;
334
+ color: var(--muted);
335
+ }
336
+
337
+ /* Responsive */
338
+ @media (max-width: 1080px) {
339
+ .app {
340
+ grid-template-columns: 1fr;
341
+ }
342
+ .sidebar { max-height: unset; }
343
+ }