ligarb 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/assets/review.css +665 -0
- data/assets/review.js +681 -0
- data/assets/serve.js +76 -0
- data/assets/style.css +95 -0
- data/lib/ligarb/asset_manager.rb +1 -1
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +45 -2
- data/lib/ligarb/claude_runner.rb +185 -0
- data/lib/ligarb/cli.rb +169 -3
- data/lib/ligarb/config.rb +39 -1
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +112 -0
- data/lib/ligarb/server.rb +1091 -0
- data/lib/ligarb/template.rb +4 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +226 -0
- data/templates/book.html.erb +141 -13
- metadata +37 -1
data/assets/review.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// ligarb serve — review/comment system (SSE-driven)
|
|
2
|
+
(function() {
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
var API = window._ligarbAPI || '/_ligarb';
|
|
6
|
+
var panel = null;
|
|
7
|
+
var listPanel = null;
|
|
8
|
+
var currentReviewId = null;
|
|
9
|
+
|
|
10
|
+
// ── Utility ──
|
|
11
|
+
|
|
12
|
+
function fetchJSON(url, opts) {
|
|
13
|
+
opts = opts || {};
|
|
14
|
+
opts.headers = opts.headers || {};
|
|
15
|
+
if (opts.body && typeof opts.body === 'object') {
|
|
16
|
+
opts.body = JSON.stringify(opts.body);
|
|
17
|
+
opts.headers['Content-Type'] = 'application/json';
|
|
18
|
+
}
|
|
19
|
+
return fetch(url, opts).then(function(r) { return r.json(); });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeHTML(str) {
|
|
23
|
+
var div = document.createElement('div');
|
|
24
|
+
div.textContent = str;
|
|
25
|
+
return div.innerHTML;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatTime(iso) {
|
|
29
|
+
if (!iso) return '';
|
|
30
|
+
var d = new Date(iso);
|
|
31
|
+
return d.toLocaleString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── SSE: listen for review updates ──
|
|
35
|
+
|
|
36
|
+
function waitForSSE() {
|
|
37
|
+
if (window._ligarbEvents) {
|
|
38
|
+
window._ligarbEvents.addEventListener('review_updated', function(e) {
|
|
39
|
+
var data = JSON.parse(e.data);
|
|
40
|
+
// Update open panel if it matches
|
|
41
|
+
if (currentReviewId && data.id === currentReviewId) {
|
|
42
|
+
loadReview(currentReviewId);
|
|
43
|
+
}
|
|
44
|
+
// Update badge
|
|
45
|
+
updateBadge();
|
|
46
|
+
});
|
|
47
|
+
} else {
|
|
48
|
+
setTimeout(waitForSSE, 100);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
waitForSSE();
|
|
52
|
+
|
|
53
|
+
// ── Comment Button on Text Selection ──
|
|
54
|
+
|
|
55
|
+
var commentBtn = document.createElement('button');
|
|
56
|
+
commentBtn.id = 'ligarb-comment-btn';
|
|
57
|
+
commentBtn.textContent = 'Comment';
|
|
58
|
+
commentBtn.style.display = 'none';
|
|
59
|
+
document.body.appendChild(commentBtn);
|
|
60
|
+
|
|
61
|
+
var selectionData = null;
|
|
62
|
+
|
|
63
|
+
document.addEventListener('mouseup', function(e) {
|
|
64
|
+
if (e.target.closest('#ligarb-panel, #ligarb-list-panel, #ligarb-comment-btn')) return;
|
|
65
|
+
|
|
66
|
+
var sel = window.getSelection();
|
|
67
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
|
|
68
|
+
commentBtn.style.display = 'none';
|
|
69
|
+
selectionData = null;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
var anchor = sel.anchorNode;
|
|
74
|
+
var chapter = anchor ? anchor.parentElement.closest('.chapter') : null;
|
|
75
|
+
if (!chapter) {
|
|
76
|
+
commentBtn.style.display = 'none';
|
|
77
|
+
selectionData = null;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
var chapterSlug = chapter.id.replace('chapter-', '');
|
|
82
|
+
var selectedText = sel.toString().trim();
|
|
83
|
+
|
|
84
|
+
// Find nearest heading before selection
|
|
85
|
+
var headingId = '';
|
|
86
|
+
var headings = chapter.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
|
|
87
|
+
var range = sel.getRangeAt(0);
|
|
88
|
+
for (var i = headings.length - 1; i >= 0; i--) {
|
|
89
|
+
var headingRange = document.createRange();
|
|
90
|
+
headingRange.selectNode(headings[i]);
|
|
91
|
+
if (range.compareBoundaryPoints(Range.START_TO_START, headingRange) >= 0) {
|
|
92
|
+
headingId = headings[i].id;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
selectionData = {
|
|
98
|
+
chapter_slug: chapterSlug,
|
|
99
|
+
heading_id: headingId,
|
|
100
|
+
selected_text: selectedText.substring(0, 500)
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
var rect = sel.getRangeAt(0).getBoundingClientRect();
|
|
104
|
+
commentBtn.style.display = 'block';
|
|
105
|
+
commentBtn.style.top = (window.scrollY + rect.bottom + 5) + 'px';
|
|
106
|
+
commentBtn.style.left = (window.scrollX + rect.left) + 'px';
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
commentBtn.addEventListener('click', function(e) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
e.stopPropagation();
|
|
112
|
+
if (!selectionData) return;
|
|
113
|
+
commentBtn.style.display = 'none';
|
|
114
|
+
openNewCommentPanel(selectionData);
|
|
115
|
+
selectionData = null;
|
|
116
|
+
window.getSelection().removeAllRanges();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ── Review Panel ──
|
|
120
|
+
|
|
121
|
+
function createPanel() {
|
|
122
|
+
if (panel) return;
|
|
123
|
+
panel = document.createElement('div');
|
|
124
|
+
panel.id = 'ligarb-panel';
|
|
125
|
+
panel.innerHTML =
|
|
126
|
+
'<div class="ligarb-panel-header">' +
|
|
127
|
+
'<span class="ligarb-panel-title">Review</span>' +
|
|
128
|
+
'<button class="ligarb-panel-close">×</button>' +
|
|
129
|
+
'</div>' +
|
|
130
|
+
'<div class="ligarb-panel-body">' +
|
|
131
|
+
'<div class="ligarb-context"></div>' +
|
|
132
|
+
'<div class="ligarb-messages"></div>' +
|
|
133
|
+
'<div class="ligarb-input-area">' +
|
|
134
|
+
'<textarea class="ligarb-input" placeholder="Type a message..." rows="3"></textarea>' +
|
|
135
|
+
'<div class="ligarb-file-area">' +
|
|
136
|
+
'<input type="file" class="ligarb-file-input" multiple style="display:none">' +
|
|
137
|
+
'<button class="ligarb-file-btn" title="Attach files">📎</button>' +
|
|
138
|
+
'<span class="ligarb-file-names"></span>' +
|
|
139
|
+
'</div>' +
|
|
140
|
+
'<div class="ligarb-actions">' +
|
|
141
|
+
'<button class="ligarb-btn ligarb-btn-send">Send</button>' +
|
|
142
|
+
'<button class="ligarb-btn ligarb-btn-approve">Approve</button>' +
|
|
143
|
+
'<button class="ligarb-btn ligarb-btn-close-thread">Dismiss</button>' +
|
|
144
|
+
'</div>' +
|
|
145
|
+
'</div>' +
|
|
146
|
+
'</div>';
|
|
147
|
+
document.body.appendChild(panel);
|
|
148
|
+
|
|
149
|
+
panel.querySelector('.ligarb-panel-close').addEventListener('click', closePanel);
|
|
150
|
+
panel.querySelector('.ligarb-btn-send').addEventListener('click', sendMessage);
|
|
151
|
+
panel.querySelector('.ligarb-btn-approve').addEventListener('click', approveReview);
|
|
152
|
+
panel.querySelector('.ligarb-btn-close-thread').addEventListener('click', closeThread);
|
|
153
|
+
|
|
154
|
+
panel.querySelector('.ligarb-input').addEventListener('keydown', function(e) {
|
|
155
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
sendMessage();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// File attachment
|
|
162
|
+
var fileBtn = panel.querySelector('.ligarb-file-btn');
|
|
163
|
+
var fileInput = panel.querySelector('.ligarb-file-input');
|
|
164
|
+
var fileNames = panel.querySelector('.ligarb-file-names');
|
|
165
|
+
panel._pendingFiles = [];
|
|
166
|
+
|
|
167
|
+
fileBtn.addEventListener('click', function() { fileInput.click(); });
|
|
168
|
+
fileInput.addEventListener('change', function() {
|
|
169
|
+
addFiles(fileInput.files);
|
|
170
|
+
fileInput.value = '';
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Drag & drop on textarea
|
|
174
|
+
var textarea = panel.querySelector('.ligarb-input');
|
|
175
|
+
textarea.addEventListener('dragover', function(e) {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
textarea.classList.add('dragover');
|
|
178
|
+
});
|
|
179
|
+
textarea.addEventListener('dragleave', function() {
|
|
180
|
+
textarea.classList.remove('dragover');
|
|
181
|
+
});
|
|
182
|
+
textarea.addEventListener('drop', function(e) {
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
textarea.classList.remove('dragover');
|
|
185
|
+
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
function addFiles(fileList) {
|
|
189
|
+
for (var i = 0; i < fileList.length; i++) panel._pendingFiles.push(fileList[i]);
|
|
190
|
+
renderPendingFiles();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderPendingFiles() {
|
|
194
|
+
fileNames.innerHTML = '';
|
|
195
|
+
panel._pendingFiles.forEach(function(f, i) {
|
|
196
|
+
var tag = document.createElement('span');
|
|
197
|
+
tag.className = 'ligarb-file-tag';
|
|
198
|
+
tag.innerHTML = escapeHTML(f.name) + ' <a href="#" data-idx="' + i + '">×</a>';
|
|
199
|
+
tag.querySelector('a').addEventListener('click', function(e) {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
panel._pendingFiles.splice(parseInt(this.dataset.idx), 1);
|
|
202
|
+
renderPendingFiles();
|
|
203
|
+
});
|
|
204
|
+
fileNames.appendChild(tag);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
panel._renderPendingFiles = renderPendingFiles;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function closePanel() {
|
|
212
|
+
if (panel) {
|
|
213
|
+
panel.classList.remove('open');
|
|
214
|
+
currentReviewId = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function openNewCommentPanel(context) {
|
|
219
|
+
createPanel();
|
|
220
|
+
currentReviewId = null;
|
|
221
|
+
panel._pendingFiles = [];
|
|
222
|
+
if (panel._renderPendingFiles) panel._renderPendingFiles();
|
|
223
|
+
|
|
224
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'New Comment';
|
|
225
|
+
panel.querySelector('.ligarb-context').innerHTML =
|
|
226
|
+
'<div class="ligarb-selected-text">"' + escapeHTML(context.selected_text) + '"</div>';
|
|
227
|
+
panel.querySelector('.ligarb-messages').innerHTML = '';
|
|
228
|
+
panel.querySelector('.ligarb-input').value = '';
|
|
229
|
+
panel.querySelector('.ligarb-input').placeholder = 'Write your comment...';
|
|
230
|
+
panel.querySelector('.ligarb-input').disabled = false;
|
|
231
|
+
panel.querySelector('.ligarb-btn-send').disabled = false;
|
|
232
|
+
panel.querySelector('.ligarb-btn-approve').style.display = 'none';
|
|
233
|
+
panel.querySelector('.ligarb-btn-close-thread').style.display = 'none';
|
|
234
|
+
panel.querySelector('.ligarb-btn-send').textContent = 'Comment';
|
|
235
|
+
|
|
236
|
+
panel._createContext = context;
|
|
237
|
+
|
|
238
|
+
panel.classList.add('open');
|
|
239
|
+
panel.addEventListener('transitionend', function onEnd() {
|
|
240
|
+
panel.removeEventListener('transitionend', onEnd);
|
|
241
|
+
panel.querySelector('.ligarb-input').focus();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function openReviewPanel(reviewId) {
|
|
246
|
+
createPanel();
|
|
247
|
+
currentReviewId = reviewId;
|
|
248
|
+
panel._createContext = null;
|
|
249
|
+
panel._pendingFiles = [];
|
|
250
|
+
if (panel._renderPendingFiles) panel._renderPendingFiles();
|
|
251
|
+
|
|
252
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'Review';
|
|
253
|
+
panel.querySelector('.ligarb-messages').innerHTML = '<div class="ligarb-loading">Loading...</div>';
|
|
254
|
+
panel.querySelector('.ligarb-input').value = '';
|
|
255
|
+
panel.querySelector('.ligarb-btn-send').textContent = 'Reply';
|
|
256
|
+
panel.querySelector('.ligarb-btn-approve').style.display = '';
|
|
257
|
+
panel.querySelector('.ligarb-btn-close-thread').style.display = '';
|
|
258
|
+
|
|
259
|
+
panel.classList.add('open');
|
|
260
|
+
loadReview(reviewId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function loadReview(id) {
|
|
264
|
+
fetchJSON(API + '/reviews/' + id).then(function(review) {
|
|
265
|
+
if (currentReviewId !== id) return;
|
|
266
|
+
renderReview(review);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderReview(review) {
|
|
271
|
+
var ctx = review.context || {};
|
|
272
|
+
panel.querySelector('.ligarb-context').innerHTML =
|
|
273
|
+
'<div class="ligarb-selected-text">"' + escapeHTML(ctx.selected_text || '') + '"</div>' +
|
|
274
|
+
'<div class="ligarb-meta">Chapter: ' + escapeHTML(ctx.chapter_slug || '') + '</div>';
|
|
275
|
+
|
|
276
|
+
var msgsEl = panel.querySelector('.ligarb-messages');
|
|
277
|
+
msgsEl.innerHTML = '';
|
|
278
|
+
|
|
279
|
+
(review.messages || []).forEach(function(msg) {
|
|
280
|
+
var div = document.createElement('div');
|
|
281
|
+
div.className = 'ligarb-message ligarb-message-' + msg.role;
|
|
282
|
+
div.innerHTML =
|
|
283
|
+
'<div class="ligarb-message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div>' +
|
|
284
|
+
'<div class="ligarb-message-content">' + formatMessageContent(msg.content) + '</div>' +
|
|
285
|
+
'<div class="ligarb-message-time">' + formatTime(msg.timestamp) + '</div>';
|
|
286
|
+
msgsEl.appendChild(div);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Show processing indicator when waiting for Claude
|
|
290
|
+
var lastMsg = review.messages && review.messages[review.messages.length - 1];
|
|
291
|
+
var isApplying = review.status === 'applying';
|
|
292
|
+
var waitingForClaude = lastMsg && lastMsg.role === 'user' && review.status === 'open';
|
|
293
|
+
|
|
294
|
+
if (isApplying) {
|
|
295
|
+
msgsEl.appendChild(makeThinkingBubble('Applying changes...'));
|
|
296
|
+
} else if (waitingForClaude) {
|
|
297
|
+
msgsEl.appendChild(makeThinkingBubble('Claude is thinking...'));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
301
|
+
|
|
302
|
+
var isOpen = review.status === 'open';
|
|
303
|
+
panel.querySelector('.ligarb-input').disabled = !isOpen;
|
|
304
|
+
panel.querySelector('.ligarb-btn-send').disabled = !isOpen;
|
|
305
|
+
panel.querySelector('.ligarb-btn-approve').disabled = !isOpen;
|
|
306
|
+
panel.querySelector('.ligarb-btn-close-thread').disabled = isApplying;
|
|
307
|
+
|
|
308
|
+
if (review.status === 'applied') {
|
|
309
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'Review (Applied)';
|
|
310
|
+
} else if (review.status === 'closed') {
|
|
311
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'Review (Closed)';
|
|
312
|
+
} else if (isApplying) {
|
|
313
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'Review (Applying...)';
|
|
314
|
+
} else {
|
|
315
|
+
panel.querySelector('.ligarb-panel-title').textContent = 'Review';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatMessageContent(content) {
|
|
320
|
+
if (!content) return '';
|
|
321
|
+
|
|
322
|
+
// Highlight error messages
|
|
323
|
+
if (/^Error:\s/.test(content)) {
|
|
324
|
+
return '<div class="ligarb-error">' + escapeHTML(content) + '</div>';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Split on <patch> blocks
|
|
328
|
+
var parts = content.split(/(<patch(?:\s+file="[^"]*")?>[\s\S]*?<\/patch>)/g);
|
|
329
|
+
var hasPatch = false;
|
|
330
|
+
var html = '';
|
|
331
|
+
var patches = '';
|
|
332
|
+
|
|
333
|
+
parts.forEach(function(part) {
|
|
334
|
+
var m = part.match(/<patch(?:\s+file="([^"]*)")?>[\s\S]*?<<<\n([\s\S]*?)\n===\n([\s\S]*?)\n>>>\s*<\/patch>/);
|
|
335
|
+
if (m) {
|
|
336
|
+
hasPatch = true;
|
|
337
|
+
var fileLabel = m[1] ? '<div class="ligarb-patch-file">' + escapeHTML(m[1]) + '</div>' : '';
|
|
338
|
+
patches +=
|
|
339
|
+
'<div class="ligarb-patch">' +
|
|
340
|
+
fileLabel +
|
|
341
|
+
'<div class="ligarb-patch-del">' + escapeHTML(m[2]) + '</div>' +
|
|
342
|
+
'<div class="ligarb-patch-add">' + escapeHTML(m[3]) + '</div>' +
|
|
343
|
+
'</div>';
|
|
344
|
+
} else {
|
|
345
|
+
html += escapeHTML(part)
|
|
346
|
+
.replace(/\n/g, '<br>')
|
|
347
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
if (hasPatch) {
|
|
352
|
+
html += '<button class="ligarb-patch-toggle" onclick="this.nextElementSibling.classList.toggle(\'open\'); this.textContent = this.nextElementSibling.classList.contains(\'open\') ? \'Hide patch\' : \'Show patch\'">Show patch</button>';
|
|
353
|
+
html += '<div class="ligarb-patch-container">' + patches + '</div>';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return html;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function makeThinkingBubble(text) {
|
|
360
|
+
var div = document.createElement('div');
|
|
361
|
+
div.className = 'ligarb-message ligarb-message-assistant ligarb-thinking';
|
|
362
|
+
div.innerHTML =
|
|
363
|
+
'<div class="ligarb-message-role">Claude</div>' +
|
|
364
|
+
'<div class="ligarb-message-content"><span class="ligarb-dots"></span> ' + escapeHTML(text) + '</div>';
|
|
365
|
+
return div;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readFilesAsBase64(files) {
|
|
369
|
+
if (!files || files.length === 0) return Promise.resolve([]);
|
|
370
|
+
var promises = files.map(function(f) {
|
|
371
|
+
return new Promise(function(resolve) {
|
|
372
|
+
var reader = new FileReader();
|
|
373
|
+
reader.onload = function() {
|
|
374
|
+
var base64 = reader.result.split(',')[1] || '';
|
|
375
|
+
resolve({ name: f.name, data: base64 });
|
|
376
|
+
};
|
|
377
|
+
reader.readAsDataURL(f);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
return Promise.all(promises);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sendMessage() {
|
|
384
|
+
var input = panel.querySelector('.ligarb-input');
|
|
385
|
+
var message = input.value.trim();
|
|
386
|
+
if (!message) return;
|
|
387
|
+
|
|
388
|
+
input.value = '';
|
|
389
|
+
var pending = panel._pendingFiles || [];
|
|
390
|
+
panel._pendingFiles = [];
|
|
391
|
+
if (panel._renderPendingFiles) panel._renderPendingFiles();
|
|
392
|
+
|
|
393
|
+
readFilesAsBase64(pending).then(function(filesData) {
|
|
394
|
+
if (panel._createContext) {
|
|
395
|
+
var ctx = panel._createContext;
|
|
396
|
+
panel._createContext = null;
|
|
397
|
+
panel.querySelector('.ligarb-btn-send').textContent = 'Reply';
|
|
398
|
+
|
|
399
|
+
var body = { context: ctx, message: message };
|
|
400
|
+
if (filesData.length > 0) body.files = filesData;
|
|
401
|
+
|
|
402
|
+
fetchJSON(API + '/reviews', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
body: body
|
|
405
|
+
}).then(function(review) {
|
|
406
|
+
currentReviewId = review.id;
|
|
407
|
+
panel.querySelector('.ligarb-btn-approve').style.display = '';
|
|
408
|
+
panel.querySelector('.ligarb-btn-close-thread').style.display = '';
|
|
409
|
+
renderReview(review);
|
|
410
|
+
updateBadge();
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!currentReviewId) return;
|
|
416
|
+
|
|
417
|
+
var body = { message: message };
|
|
418
|
+
if (filesData.length > 0) body.files = filesData;
|
|
419
|
+
|
|
420
|
+
fetchJSON(API + '/reviews/' + currentReviewId + '/messages', {
|
|
421
|
+
method: 'POST',
|
|
422
|
+
body: body
|
|
423
|
+
}).then(function(review) {
|
|
424
|
+
renderReview(review);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function approveReview() {
|
|
430
|
+
if (!currentReviewId) return;
|
|
431
|
+
if (!confirm('Apply the discussed changes to the source file?')) return;
|
|
432
|
+
|
|
433
|
+
fetchJSON(API + '/reviews/' + currentReviewId + '/approve', {
|
|
434
|
+
method: 'POST'
|
|
435
|
+
}).then(function(review) {
|
|
436
|
+
renderReview(review);
|
|
437
|
+
if (review.status === 'applied') {
|
|
438
|
+
closePanel();
|
|
439
|
+
}
|
|
440
|
+
updateBadge();
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function closeThread() {
|
|
445
|
+
if (!currentReviewId) return;
|
|
446
|
+
|
|
447
|
+
fetchJSON(API + '/reviews/' + currentReviewId + '/close', {
|
|
448
|
+
method: 'POST'
|
|
449
|
+
}).then(function(review) {
|
|
450
|
+
renderReview(review);
|
|
451
|
+
updateBadge();
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── Review List Panel ──
|
|
456
|
+
|
|
457
|
+
var listBtn = document.createElement('button');
|
|
458
|
+
listBtn.id = 'ligarb-list-btn';
|
|
459
|
+
listBtn.innerHTML = '✉';
|
|
460
|
+
listBtn.title = 'Review threads';
|
|
461
|
+
document.body.appendChild(listBtn);
|
|
462
|
+
|
|
463
|
+
var badge = document.createElement('span');
|
|
464
|
+
badge.id = 'ligarb-badge';
|
|
465
|
+
badge.style.display = 'none';
|
|
466
|
+
listBtn.appendChild(badge);
|
|
467
|
+
|
|
468
|
+
listBtn.addEventListener('click', function() {
|
|
469
|
+
toggleListPanel();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
function toggleListPanel() {
|
|
473
|
+
if (listPanel && listPanel.classList.contains('open')) {
|
|
474
|
+
listPanel.classList.remove('open');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
createListPanel();
|
|
478
|
+
listPanel.classList.add('open');
|
|
479
|
+
loadReviewList();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function createListPanel() {
|
|
483
|
+
if (listPanel) return;
|
|
484
|
+
listPanel = document.createElement('div');
|
|
485
|
+
listPanel.id = 'ligarb-list-panel';
|
|
486
|
+
listPanel.innerHTML =
|
|
487
|
+
'<div class="ligarb-panel-header">' +
|
|
488
|
+
'<span class="ligarb-panel-title">Reviews</span>' +
|
|
489
|
+
'<button class="ligarb-panel-close">×</button>' +
|
|
490
|
+
'</div>' +
|
|
491
|
+
'<div class="ligarb-list-body"></div>';
|
|
492
|
+
document.body.appendChild(listPanel);
|
|
493
|
+
|
|
494
|
+
listPanel.querySelector('.ligarb-panel-close').addEventListener('click', function() {
|
|
495
|
+
listPanel.classList.remove('open');
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function loadReviewList() {
|
|
500
|
+
fetchJSON(API + '/reviews').then(function(reviews) {
|
|
501
|
+
var body = listPanel.querySelector('.ligarb-list-body');
|
|
502
|
+
if (!reviews || reviews.length === 0) {
|
|
503
|
+
body.innerHTML = '<div class="ligarb-list-empty">No reviews yet.</div>';
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
body.innerHTML = '';
|
|
508
|
+
reviews.forEach(function(r) {
|
|
509
|
+
var item = document.createElement('div');
|
|
510
|
+
item.className = 'ligarb-list-item ligarb-list-' + r.status;
|
|
511
|
+
var statusIcon = r.status === 'open' ? '●' : r.status === 'applied' ? '✓' : '✕';
|
|
512
|
+
item.innerHTML =
|
|
513
|
+
'<div class="ligarb-list-item-header">' +
|
|
514
|
+
'<span class="ligarb-list-status">' + statusIcon + '</span>' +
|
|
515
|
+
'<span class="ligarb-list-text">"' + escapeHTML((r.context && r.context.selected_text || '').substring(0, 60)) + '"</span>' +
|
|
516
|
+
'</div>' +
|
|
517
|
+
'<div class="ligarb-list-item-meta">' +
|
|
518
|
+
escapeHTML(r.context && r.context.chapter_slug || '') +
|
|
519
|
+
' · ' + r.message_count + ' messages' +
|
|
520
|
+
' · ' + formatTime(r.created_at) +
|
|
521
|
+
'</div>';
|
|
522
|
+
item.addEventListener('click', function() {
|
|
523
|
+
listPanel.classList.remove('open');
|
|
524
|
+
openReviewPanel(r.id);
|
|
525
|
+
});
|
|
526
|
+
body.appendChild(item);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function updateBadge() {
|
|
532
|
+
fetchJSON(API + '/reviews').then(function(reviews) {
|
|
533
|
+
var open = (reviews || []).filter(function(r) { return r.status === 'open' || r.status === 'applying'; }).length;
|
|
534
|
+
if (open > 0) {
|
|
535
|
+
badge.textContent = open;
|
|
536
|
+
badge.style.display = 'inline-block';
|
|
537
|
+
listBtn.classList.add('has-open');
|
|
538
|
+
} else {
|
|
539
|
+
badge.style.display = 'none';
|
|
540
|
+
listBtn.classList.remove('has-open');
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Highlight open reviews in the document ──
|
|
546
|
+
|
|
547
|
+
function updateHighlights() {
|
|
548
|
+
// Remove existing highlights
|
|
549
|
+
var existing = document.querySelectorAll('mark.ligarb-highlight');
|
|
550
|
+
existing.forEach(function(el) {
|
|
551
|
+
var parent = el.parentNode;
|
|
552
|
+
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
|
553
|
+
parent.removeChild(el);
|
|
554
|
+
parent.normalize();
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
fetchJSON(API + '/reviews').then(function(reviews) {
|
|
558
|
+
if (!reviews) return;
|
|
559
|
+
|
|
560
|
+
reviews.forEach(function(r) {
|
|
561
|
+
if (r.status !== 'open') return;
|
|
562
|
+
var ctx = r.context;
|
|
563
|
+
if (!ctx || !ctx.selected_text) return;
|
|
564
|
+
|
|
565
|
+
var chapter = document.getElementById('chapter-' + ctx.chapter_slug);
|
|
566
|
+
if (!chapter) return;
|
|
567
|
+
|
|
568
|
+
// Narrow scope using heading_id if available
|
|
569
|
+
var scope = chapter;
|
|
570
|
+
if (ctx.heading_id) {
|
|
571
|
+
var heading = document.getElementById(ctx.heading_id);
|
|
572
|
+
if (heading) {
|
|
573
|
+
// Use the heading's parent section or next sibling content
|
|
574
|
+
scope = heading.parentElement || chapter;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
highlightText(scope, ctx.selected_text, r.id);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function highlightText(root, text, reviewId) {
|
|
584
|
+
// Use TreeWalker to find text nodes containing the target text
|
|
585
|
+
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|
586
|
+
var nodes = [];
|
|
587
|
+
var node;
|
|
588
|
+
while ((node = walker.nextNode())) {
|
|
589
|
+
// Skip nodes inside review UI elements
|
|
590
|
+
if (node.parentElement.closest('#ligarb-panel, #ligarb-list-panel, #ligarb-comment-btn, #ligarb-list-btn')) continue;
|
|
591
|
+
nodes.push(node);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Try to find a contiguous range of text nodes that contain the selected text
|
|
595
|
+
// First, try single-node match
|
|
596
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
597
|
+
var content = nodes[i].textContent;
|
|
598
|
+
var idx = content.indexOf(text);
|
|
599
|
+
if (idx !== -1) {
|
|
600
|
+
wrapRange(nodes[i], idx, idx + text.length, reviewId);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Multi-node match: concatenate adjacent text and find the match
|
|
606
|
+
for (var start = 0; start < nodes.length; start++) {
|
|
607
|
+
var combined = '';
|
|
608
|
+
var segments = []; // {node, startInCombined, length}
|
|
609
|
+
for (var end = start; end < nodes.length && combined.length < text.length + 500; end++) {
|
|
610
|
+
segments.push({ node: nodes[end], startInCombined: combined.length, length: nodes[end].textContent.length });
|
|
611
|
+
combined += nodes[end].textContent;
|
|
612
|
+
var matchIdx = combined.indexOf(text);
|
|
613
|
+
if (matchIdx !== -1) {
|
|
614
|
+
wrapMultiNodeRange(segments, matchIdx, matchIdx + text.length, reviewId);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function wrapRange(textNode, startOffset, endOffset, reviewId) {
|
|
622
|
+
var range = document.createRange();
|
|
623
|
+
range.setStart(textNode, startOffset);
|
|
624
|
+
range.setEnd(textNode, endOffset);
|
|
625
|
+
var mark = createMark(reviewId);
|
|
626
|
+
range.surroundContents(mark);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function wrapMultiNodeRange(segments, matchStart, matchEnd, reviewId) {
|
|
630
|
+
// Collect the text node ranges that overlap with [matchStart, matchEnd)
|
|
631
|
+
var parts = [];
|
|
632
|
+
for (var i = 0; i < segments.length; i++) {
|
|
633
|
+
var seg = segments[i];
|
|
634
|
+
var segStart = seg.startInCombined;
|
|
635
|
+
var segEnd = segStart + seg.length;
|
|
636
|
+
// Check overlap with [matchStart, matchEnd)
|
|
637
|
+
var overlapStart = Math.max(segStart, matchStart);
|
|
638
|
+
var overlapEnd = Math.min(segEnd, matchEnd);
|
|
639
|
+
if (overlapStart < overlapEnd) {
|
|
640
|
+
parts.push({
|
|
641
|
+
node: seg.node,
|
|
642
|
+
start: overlapStart - segStart,
|
|
643
|
+
end: overlapEnd - segStart
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Wrap each part in reverse order to preserve node offsets
|
|
649
|
+
for (var j = parts.length - 1; j >= 0; j--) {
|
|
650
|
+
var p = parts[j];
|
|
651
|
+
var range = document.createRange();
|
|
652
|
+
range.setStart(p.node, p.start);
|
|
653
|
+
range.setEnd(p.node, p.end);
|
|
654
|
+
var mark = createMark(reviewId);
|
|
655
|
+
range.surroundContents(mark);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function createMark(reviewId) {
|
|
660
|
+
var mark = document.createElement('mark');
|
|
661
|
+
mark.className = 'ligarb-highlight';
|
|
662
|
+
mark.dataset.reviewId = reviewId;
|
|
663
|
+
mark.addEventListener('click', function(e) {
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
e.stopPropagation();
|
|
666
|
+
openReviewPanel(reviewId);
|
|
667
|
+
});
|
|
668
|
+
return mark;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Update highlights on review changes ──
|
|
672
|
+
|
|
673
|
+
var origUpdateBadge = updateBadge;
|
|
674
|
+
updateBadge = function() {
|
|
675
|
+
origUpdateBadge();
|
|
676
|
+
updateHighlights();
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// Initial badge + highlights
|
|
680
|
+
updateBadge();
|
|
681
|
+
})();
|