@filipc77/cowrite 0.1.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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/dist/bin/cowrite.js +581 -0
- package/dist/bin/cowrite.js.map +1 -0
- package/package.json +37 -0
- package/ui/client.js +338 -0
- package/ui/index.html +39 -0
- package/ui/styles.css +451 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@filipc77/cowrite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live commenting plugin for coding agent sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cowrite": "./dist/bin/cowrite.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx bin/cowrite.ts preview",
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"lint": "tsc --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"ui"
|
|
19
|
+
],
|
|
20
|
+
"keywords": ["mcp", "claude", "comments", "live-preview"],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
24
|
+
"chokidar": "^4.0.3",
|
|
25
|
+
"marked": "^15.0.7",
|
|
26
|
+
"ws": "^8.18.1",
|
|
27
|
+
"zod": "^3.24.2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.13.5",
|
|
31
|
+
"@types/ws": "^8.18.0",
|
|
32
|
+
"tsup": "^8.4.0",
|
|
33
|
+
"tsx": "^4.19.3",
|
|
34
|
+
"typescript": "^5.7.3",
|
|
35
|
+
"vitest": "^3.0.7"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/ui/client.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../src/types.js').Comment} Comment */
|
|
4
|
+
/** @typedef {import('../src/types.js').WSServerMessage} WSServerMessage */
|
|
5
|
+
|
|
6
|
+
const $ = (sel) => document.querySelector(sel);
|
|
7
|
+
const fileContentEl = $("#fileContent");
|
|
8
|
+
const commentListEl = $("#commentList");
|
|
9
|
+
const commentCountEl = $("#commentCount");
|
|
10
|
+
const filePathEl = $("#filePath");
|
|
11
|
+
const statusEl = $("#status");
|
|
12
|
+
const popup = $("#commentPopup");
|
|
13
|
+
const popupSelection = $("#popupSelection");
|
|
14
|
+
const commentInput = $("#commentInput");
|
|
15
|
+
|
|
16
|
+
/** @type {Comment[]} */
|
|
17
|
+
let comments = [];
|
|
18
|
+
let currentFile = "";
|
|
19
|
+
let currentContent = "";
|
|
20
|
+
let ws = null;
|
|
21
|
+
let selectionInfo = null;
|
|
22
|
+
|
|
23
|
+
// --- WebSocket ---
|
|
24
|
+
|
|
25
|
+
function connect() {
|
|
26
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
27
|
+
ws = new WebSocket(`${protocol}//${location.host}`);
|
|
28
|
+
|
|
29
|
+
ws.onopen = () => {
|
|
30
|
+
statusEl.textContent = "Connected";
|
|
31
|
+
statusEl.className = "status connected";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
ws.onclose = () => {
|
|
35
|
+
statusEl.textContent = "Disconnected";
|
|
36
|
+
statusEl.className = "status";
|
|
37
|
+
setTimeout(connect, 2000);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
ws.onmessage = (event) => {
|
|
41
|
+
/** @type {WSServerMessage} */
|
|
42
|
+
const msg = JSON.parse(event.data);
|
|
43
|
+
switch (msg.type) {
|
|
44
|
+
case "file_update":
|
|
45
|
+
currentFile = msg.file;
|
|
46
|
+
currentContent = msg.content;
|
|
47
|
+
filePathEl.textContent = msg.file;
|
|
48
|
+
fileContentEl.innerHTML = msg.html;
|
|
49
|
+
applyHighlights();
|
|
50
|
+
break;
|
|
51
|
+
case "comments_update":
|
|
52
|
+
comments = msg.comments;
|
|
53
|
+
commentCountEl.textContent = String(comments.filter(c => c.status === "pending").length);
|
|
54
|
+
renderComments();
|
|
55
|
+
applyHighlights();
|
|
56
|
+
break;
|
|
57
|
+
case "error":
|
|
58
|
+
console.error("Server error:", msg.message);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function send(msg) {
|
|
65
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
66
|
+
ws.send(JSON.stringify(msg));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Selection & Comment Creation ---
|
|
71
|
+
|
|
72
|
+
document.addEventListener("mouseup", (e) => {
|
|
73
|
+
// Don't trigger when clicking inside popup or sidebar
|
|
74
|
+
if (popup.contains(e.target) || $("#sidebar").contains(e.target)) return;
|
|
75
|
+
|
|
76
|
+
const selection = window.getSelection();
|
|
77
|
+
if (!selection || selection.isCollapsed) {
|
|
78
|
+
hidePopup();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = selection.toString().trim();
|
|
83
|
+
if (!text) {
|
|
84
|
+
hidePopup();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Compute character offset in the source content
|
|
89
|
+
const offset = computeOffset(selection, text);
|
|
90
|
+
if (offset === -1) {
|
|
91
|
+
hidePopup();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
selectionInfo = { offset, length: text.length, selectedText: text };
|
|
96
|
+
|
|
97
|
+
// Position popup near the selection
|
|
98
|
+
const range = selection.getRangeAt(0);
|
|
99
|
+
const rect = range.getBoundingClientRect();
|
|
100
|
+
popup.style.left = `${Math.min(rect.left, window.innerWidth - 340)}px`;
|
|
101
|
+
popup.style.top = `${rect.bottom + 8}px`;
|
|
102
|
+
popupSelection.textContent = text.length > 120 ? text.slice(0, 120) + "..." : text;
|
|
103
|
+
commentInput.value = "";
|
|
104
|
+
popup.hidden = false;
|
|
105
|
+
commentInput.focus();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function computeOffset(selection, text) {
|
|
109
|
+
// For plain text: use data-offset on span.line elements
|
|
110
|
+
const anchor = selection.anchorNode;
|
|
111
|
+
if (!anchor) return -1;
|
|
112
|
+
|
|
113
|
+
// Walk up to find a [data-offset] element
|
|
114
|
+
let node = anchor.nodeType === Node.TEXT_NODE ? anchor.parentElement : anchor;
|
|
115
|
+
while (node && !node.dataset?.offset && node !== fileContentEl) {
|
|
116
|
+
node = node.parentElement;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node?.dataset?.offset !== undefined) {
|
|
120
|
+
// Plain text mode: compute from data-offset + text offset within the line
|
|
121
|
+
const lineOffset = parseInt(node.dataset.offset, 10);
|
|
122
|
+
const nodeText = node.textContent || "";
|
|
123
|
+
const idx = nodeText.indexOf(text);
|
|
124
|
+
if (idx !== -1) return lineOffset + idx;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback: search for the text in the raw content
|
|
128
|
+
const idx = currentContent.indexOf(text);
|
|
129
|
+
return idx;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hidePopup() {
|
|
133
|
+
popup.hidden = true;
|
|
134
|
+
selectionInfo = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
$("#commentSubmit").addEventListener("click", submitComment);
|
|
138
|
+
$("#commentCancel").addEventListener("click", hidePopup);
|
|
139
|
+
|
|
140
|
+
commentInput.addEventListener("keydown", (e) => {
|
|
141
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
142
|
+
submitComment();
|
|
143
|
+
}
|
|
144
|
+
if (e.key === "Escape") {
|
|
145
|
+
hidePopup();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function submitComment() {
|
|
150
|
+
const text = commentInput.value.trim();
|
|
151
|
+
if (!text || !selectionInfo) return;
|
|
152
|
+
|
|
153
|
+
send({
|
|
154
|
+
type: "comment_add",
|
|
155
|
+
file: currentFile,
|
|
156
|
+
offset: selectionInfo.offset,
|
|
157
|
+
length: selectionInfo.length,
|
|
158
|
+
selectedText: selectionInfo.selectedText,
|
|
159
|
+
comment: text,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
hidePopup();
|
|
163
|
+
window.getSelection()?.removeAllRanges();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Comment Sidebar Rendering ---
|
|
167
|
+
|
|
168
|
+
function renderComments() {
|
|
169
|
+
if (comments.length === 0) {
|
|
170
|
+
commentListEl.innerHTML = `
|
|
171
|
+
<div class="empty-state">
|
|
172
|
+
<p>No comments yet.</p>
|
|
173
|
+
<p>Select text in the preview and add a comment.</p>
|
|
174
|
+
</div>
|
|
175
|
+
`;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
commentListEl.innerHTML = comments.map((c) => `
|
|
180
|
+
<div class="comment-card ${c.status}" data-id="${c.id}">
|
|
181
|
+
<div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>
|
|
182
|
+
<div class="comment-text">${escapeHtml(c.comment)}</div>
|
|
183
|
+
${c.replies.length > 0 ? `
|
|
184
|
+
<div class="comment-replies">
|
|
185
|
+
${c.replies.map((r) => `
|
|
186
|
+
<div class="reply ${r.from}">
|
|
187
|
+
<div class="reply-from ${r.from}">${r.from}</div>
|
|
188
|
+
<div>${escapeHtml(r.text)}</div>
|
|
189
|
+
</div>
|
|
190
|
+
`).join("")}
|
|
191
|
+
</div>
|
|
192
|
+
` : ""}
|
|
193
|
+
<div class="comment-meta">
|
|
194
|
+
<span>${timeAgo(c.createdAt)}</span>
|
|
195
|
+
<span class="comment-status ${c.status}">${c.status}</span>
|
|
196
|
+
</div>
|
|
197
|
+
${c.status === "pending" ? `
|
|
198
|
+
<div class="comment-actions">
|
|
199
|
+
<button onclick="showReplyForm('${c.id}')">Reply</button>
|
|
200
|
+
<button onclick="resolveComment('${c.id}')">Resolve</button>
|
|
201
|
+
</div>
|
|
202
|
+
` : ""}
|
|
203
|
+
<div class="reply-form" id="reply-form-${c.id}" hidden>
|
|
204
|
+
<textarea rows="2" placeholder="Reply..."></textarea>
|
|
205
|
+
<div class="popup-actions" style="margin-top:6px;">
|
|
206
|
+
<button onclick="submitReply('${c.id}')" style="font-size:11px;padding:4px 10px;border-radius:4px;border:none;background:var(--accent);color:var(--bg);cursor:pointer;">Send</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
`).join("");
|
|
211
|
+
|
|
212
|
+
// Click to scroll to highlight
|
|
213
|
+
for (const card of commentListEl.querySelectorAll(".comment-card")) {
|
|
214
|
+
card.addEventListener("click", (e) => {
|
|
215
|
+
if (e.target.tagName === "BUTTON" || e.target.tagName === "TEXTAREA") return;
|
|
216
|
+
const id = card.dataset.id;
|
|
217
|
+
const highlight = fileContentEl.querySelector(`[data-comment-id="${id}"]`);
|
|
218
|
+
if (highlight) {
|
|
219
|
+
highlight.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
220
|
+
highlight.style.outline = "2px solid var(--accent)";
|
|
221
|
+
setTimeout(() => highlight.style.outline = "", 1500);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Global functions for inline onclick handlers
|
|
228
|
+
window.showReplyForm = function (id) {
|
|
229
|
+
const form = document.getElementById(`reply-form-${id}`);
|
|
230
|
+
if (form) {
|
|
231
|
+
form.hidden = !form.hidden;
|
|
232
|
+
if (!form.hidden) form.querySelector("textarea").focus();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
window.resolveComment = function (id) {
|
|
237
|
+
send({ type: "comment_resolve", commentId: id });
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
window.submitReply = function (id) {
|
|
241
|
+
const form = document.getElementById(`reply-form-${id}`);
|
|
242
|
+
const textarea = form?.querySelector("textarea");
|
|
243
|
+
const text = textarea?.value.trim();
|
|
244
|
+
if (!text) return;
|
|
245
|
+
send({ type: "comment_reply", commentId: id, text });
|
|
246
|
+
textarea.value = "";
|
|
247
|
+
form.hidden = true;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// --- Comment Highlights ---
|
|
251
|
+
|
|
252
|
+
function applyHighlights() {
|
|
253
|
+
// Remove existing highlights
|
|
254
|
+
for (const el of fileContentEl.querySelectorAll(".comment-highlight")) {
|
|
255
|
+
const parent = el.parentNode;
|
|
256
|
+
parent.replaceChild(document.createTextNode(el.textContent), el);
|
|
257
|
+
parent.normalize();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (comments.length === 0) return;
|
|
261
|
+
|
|
262
|
+
// Build a text-node map
|
|
263
|
+
const walker = document.createTreeWalker(fileContentEl, NodeFilter.SHOW_TEXT);
|
|
264
|
+
const textNodes = [];
|
|
265
|
+
let totalOffset = 0;
|
|
266
|
+
|
|
267
|
+
while (walker.nextNode()) {
|
|
268
|
+
const node = walker.currentNode;
|
|
269
|
+
const text = node.textContent || "";
|
|
270
|
+
textNodes.push({ node, start: totalOffset, end: totalOffset + text.length });
|
|
271
|
+
totalOffset += text.length;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const fullText = textNodes.map((n) => n.node.textContent).join("");
|
|
275
|
+
|
|
276
|
+
// For each comment, find the selected text and wrap in highlight
|
|
277
|
+
for (const comment of comments) {
|
|
278
|
+
const searchText = comment.selectedText;
|
|
279
|
+
// Search near the expected offset first, then globally
|
|
280
|
+
let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
|
|
281
|
+
if (textIdx === -1 || Math.abs(textIdx - comment.offset) > 200) {
|
|
282
|
+
textIdx = fullText.indexOf(searchText);
|
|
283
|
+
}
|
|
284
|
+
if (textIdx === -1) continue;
|
|
285
|
+
|
|
286
|
+
wrapRange(textNodes, textIdx, textIdx + searchText.length, comment);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function wrapRange(textNodes, start, end, comment) {
|
|
291
|
+
for (let i = 0; i < textNodes.length; i++) {
|
|
292
|
+
const tn = textNodes[i];
|
|
293
|
+
if (tn.end <= start || tn.start >= end) continue;
|
|
294
|
+
|
|
295
|
+
const nodeStart = Math.max(start - tn.start, 0);
|
|
296
|
+
const nodeEnd = Math.min(end - tn.start, tn.node.textContent.length);
|
|
297
|
+
|
|
298
|
+
const range = document.createRange();
|
|
299
|
+
range.setStart(tn.node, nodeStart);
|
|
300
|
+
range.setEnd(tn.node, nodeEnd);
|
|
301
|
+
|
|
302
|
+
const span = document.createElement("span");
|
|
303
|
+
span.className = `comment-highlight ${comment.status}`;
|
|
304
|
+
span.dataset.commentId = comment.id;
|
|
305
|
+
span.title = comment.comment;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
range.surroundContents(span);
|
|
309
|
+
// After wrapping, we need to update remaining text nodes
|
|
310
|
+
// Simply break — for simplicity, one highlight per pass is fine
|
|
311
|
+
return;
|
|
312
|
+
} catch {
|
|
313
|
+
// Range may cross element boundaries; skip this highlight
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- Utilities ---
|
|
320
|
+
|
|
321
|
+
function escapeHtml(text) {
|
|
322
|
+
const div = document.createElement("div");
|
|
323
|
+
div.textContent = text;
|
|
324
|
+
return div.innerHTML;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function timeAgo(iso) {
|
|
328
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
329
|
+
const mins = Math.floor(diff / 60000);
|
|
330
|
+
if (mins < 1) return "just now";
|
|
331
|
+
if (mins < 60) return `${mins}m ago`;
|
|
332
|
+
const hours = Math.floor(mins / 60);
|
|
333
|
+
if (hours < 24) return `${hours}h ago`;
|
|
334
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// --- Init ---
|
|
338
|
+
connect();
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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.0">
|
|
6
|
+
<title>Cowrite — Live Preview</title>
|
|
7
|
+
<link rel="stylesheet" href="styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header>
|
|
11
|
+
<h1>Cowrite</h1>
|
|
12
|
+
<span class="file-path" id="filePath">—</span>
|
|
13
|
+
<span class="status" id="status">Connecting...</span>
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<main>
|
|
17
|
+
<div class="content-panel" id="contentPanel">
|
|
18
|
+
<div id="fileContent"></div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div class="sidebar" id="sidebar">
|
|
22
|
+
<h2>Comments <span class="comment-count" id="commentCount">0</span></h2>
|
|
23
|
+
<div id="commentList"></div>
|
|
24
|
+
</div>
|
|
25
|
+
</main>
|
|
26
|
+
|
|
27
|
+
<!-- Floating comment button/form -->
|
|
28
|
+
<div class="comment-popup" id="commentPopup" hidden>
|
|
29
|
+
<div class="popup-selection" id="popupSelection"></div>
|
|
30
|
+
<textarea id="commentInput" placeholder="Leave a comment..." rows="3"></textarea>
|
|
31
|
+
<div class="popup-actions">
|
|
32
|
+
<button id="commentSubmit">Submit</button>
|
|
33
|
+
<button id="commentCancel" class="secondary">Cancel</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<script src="client.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|