design-share 0.1.0 → 0.2.2
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/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +2 -2
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/package.json +1 -1
- package/public/app.js +157 -48
- package/public/index.html +4 -0
- package/public/inspect.js +233 -0
- package/public/style.css +48 -0
- package/src/github.js +24 -0
- package/src/previews.js +29 -5
- package/src/proxy.js +94 -0
- package/src/server.js +13 -0
|
Binary file
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<key>CFBundleIdentifier</key>
|
|
10
10
|
<string>com.design-share.menubar</string>
|
|
11
11
|
<key>CFBundleVersion</key>
|
|
12
|
-
<string>0.1.
|
|
12
|
+
<string>0.1.1</string>
|
|
13
13
|
<key>CFBundleShortVersionString</key>
|
|
14
|
-
<string>0.1.
|
|
14
|
+
<string>0.1.1</string>
|
|
15
15
|
<key>CFBundleExecutable</key>
|
|
16
16
|
<string>DesignShare</string>
|
|
17
17
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
|
|
5
5
|
"keywords": ["design", "preview", "branches", "collaboration", "cli"],
|
|
6
6
|
"license": "MIT",
|
package/public/app.js
CHANGED
|
@@ -15,10 +15,13 @@ const state = {
|
|
|
15
15
|
selected: null, // { user, branch }
|
|
16
16
|
viewport: 'desktop',
|
|
17
17
|
commentMode: false,
|
|
18
|
-
pendingPin: null, // { xPct, yPct }
|
|
18
|
+
pendingPin: null, // { xPct, yPct, selector?, elementLabel?, relX?, relY?, route? }
|
|
19
19
|
focusedComment: null,
|
|
20
20
|
previewStatus: null,
|
|
21
21
|
frameUrl: null,
|
|
22
|
+
bridge: false, // true when the injected inspector answered from inside the iframe
|
|
23
|
+
route: '/',
|
|
24
|
+
railFilter: '',
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
const VIEWPORTS = [
|
|
@@ -79,62 +82,81 @@ function renderRail() {
|
|
|
79
82
|
|
|
80
83
|
const sections = el('rail-sections');
|
|
81
84
|
const frag = document.createDocumentFragment();
|
|
85
|
+
const filter = (state.railFilter || '').trim().toLowerCase();
|
|
86
|
+
|
|
87
|
+
// One section per person. Shared branches render bright; branches that
|
|
88
|
+
// exist on origin without being shared render dim under the same author.
|
|
89
|
+
const people = new Map(); // slug -> { slug, name, items: [{user, branch, time, shared}] }
|
|
90
|
+
const person = (slug, name) => {
|
|
91
|
+
if (!people.has(slug)) people.set(slug, { slug, name, items: [] });
|
|
92
|
+
return people.get(slug);
|
|
93
|
+
};
|
|
82
94
|
|
|
83
95
|
const shares = b.shares.slice().sort((x, y) => (y.updatedAt || 0) - (x.updatedAt || 0));
|
|
84
|
-
const byUser = new Map();
|
|
85
96
|
for (const s of shares) {
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
person(s.user, s.name).items.push({
|
|
98
|
+
user: s.user, branch: s.branch, shared: true,
|
|
99
|
+
time: b.heads[s.branch] ? b.heads[s.branch].time : s.updatedAt / 1000,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const o of b.otherBranches) {
|
|
103
|
+
const slug = slugify(o.author);
|
|
104
|
+
person(slug, people.has(slug) ? people.get(slug).name : (o.author || 'someone'))
|
|
105
|
+
.items.push({ user: slug, branch: o.branch, shared: false, time: o.time });
|
|
88
106
|
}
|
|
89
107
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
const ownShared = byUser.has(b.me.slug) && byUser.get(b.me.slug).items.some((s) => s.branch === b.ownBranch);
|
|
108
|
+
const me = people.get(b.me.slug);
|
|
109
|
+
const ownShared = !!(me && me.items.some((i) => i.branch === b.ownBranch && i.shared));
|
|
94
110
|
if (b.ownBranch && !ownShared) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
frag.appendChild(branchRow({
|
|
100
|
-
user: b.me.slug, branch: b.ownBranch, own: true,
|
|
111
|
+
const mine = person(b.me.slug, b.me.name);
|
|
112
|
+
mine.items = mine.items.filter((i) => i.branch !== b.ownBranch);
|
|
113
|
+
mine.items.unshift({
|
|
114
|
+
user: b.me.slug, branch: b.ownBranch, shared: true, own: true,
|
|
101
115
|
time: b.heads[b.ownBranch] ? b.heads[b.ownBranch].time : null,
|
|
102
|
-
})
|
|
103
|
-
const cta = document.createElement('button');
|
|
104
|
-
cta.className = 'share-cta';
|
|
105
|
-
cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
|
|
106
|
-
cta.onclick = async () => {
|
|
107
|
-
await api('/api/share', { branch: b.ownBranch });
|
|
108
|
-
toast('Shared with the team board');
|
|
109
|
-
refresh();
|
|
110
|
-
};
|
|
111
|
-
frag.appendChild(cta);
|
|
116
|
+
});
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
const latest = (p) => Math.max(...p.items.map((i) => i.time || 0), 0);
|
|
120
|
+
const order = [...people.values()].sort((x, y) => {
|
|
121
|
+
if (x.slug === b.me.slug) return -1;
|
|
122
|
+
if (y.slug === b.me.slug) return 1;
|
|
123
|
+
const xs = x.items.some((i) => i.shared), ys = y.items.some((i) => i.shared);
|
|
124
|
+
if (xs !== ys) return xs ? -1 : 1;
|
|
125
|
+
return latest(y) - latest(x);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let shown = 0;
|
|
129
|
+
for (const p of order) {
|
|
130
|
+
const items = p.items.filter((i) =>
|
|
131
|
+
!filter || i.branch.toLowerCase().includes(filter) || p.name.toLowerCase().includes(filter));
|
|
132
|
+
if (!items.length) continue;
|
|
133
|
+
shown += items.length;
|
|
134
|
+
|
|
116
135
|
const label = document.createElement('div');
|
|
117
136
|
label.className = 'rail-section-label';
|
|
118
|
-
label.textContent =
|
|
137
|
+
label.textContent = p.slug === b.me.slug ? `${p.name} (you)` : p.name;
|
|
119
138
|
frag.appendChild(label);
|
|
120
|
-
for (const
|
|
121
|
-
frag.appendChild(branchRow({
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
for (const i of items) {
|
|
140
|
+
frag.appendChild(branchRow({ user: i.user, branch: i.branch, time: i.time, dim: !i.shared }));
|
|
141
|
+
}
|
|
142
|
+
if (p.slug === b.me.slug && b.ownBranch && !ownShared && (!filter || b.ownBranch.toLowerCase().includes(filter))) {
|
|
143
|
+
const cta = document.createElement('button');
|
|
144
|
+
cta.className = 'share-cta';
|
|
145
|
+
cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
|
|
146
|
+
cta.onclick = async () => {
|
|
147
|
+
await api('/api/share', { branch: b.ownBranch });
|
|
148
|
+
toast('Shared with the team board');
|
|
149
|
+
refresh();
|
|
150
|
+
};
|
|
151
|
+
frag.appendChild(cta);
|
|
125
152
|
}
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
if (
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
frag.appendChild(
|
|
133
|
-
for (const o of b.otherBranches.slice(0, 12)) {
|
|
134
|
-
frag.appendChild(branchRow({
|
|
135
|
-
user: slugify(o.author), branch: o.branch, time: o.time, dim: true,
|
|
136
|
-
}));
|
|
137
|
-
}
|
|
155
|
+
if (!shown) {
|
|
156
|
+
const empty = document.createElement('div');
|
|
157
|
+
empty.className = 'empty-note';
|
|
158
|
+
empty.textContent = filter ? 'Nothing matches this filter.' : 'No branches yet.';
|
|
159
|
+
frag.appendChild(empty);
|
|
138
160
|
}
|
|
139
161
|
|
|
140
162
|
sections.replaceChildren(frag);
|
|
@@ -166,12 +188,20 @@ function branchRow({ user, branch, time, dim, own }) {
|
|
|
166
188
|
else if (isOwnServing && prev && prev.status === 'ready') dotCls = 'live';
|
|
167
189
|
|
|
168
190
|
const unresolved = openCount(branch);
|
|
191
|
+
const pr = b.prs && b.prs[branch];
|
|
169
192
|
btn.innerHTML = `
|
|
170
193
|
<span class="dot ${dotCls}"></span>
|
|
171
194
|
<span class="branch-label">${escapeHtml(branch)}</span>
|
|
172
195
|
${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
|
|
196
|
+
${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
|
|
173
197
|
<span class="meta">${timeAgo(time)}</span>`;
|
|
174
198
|
btn.onclick = () => select(user, branch);
|
|
199
|
+
if (pr) {
|
|
200
|
+
btn.querySelector('.pr-link').onclick = (e) => {
|
|
201
|
+
e.stopPropagation();
|
|
202
|
+
window.open(pr.url, '_blank');
|
|
203
|
+
};
|
|
204
|
+
}
|
|
175
205
|
return btn;
|
|
176
206
|
}
|
|
177
207
|
|
|
@@ -183,6 +213,7 @@ async function select(user, branch) {
|
|
|
183
213
|
state.pendingPin = null;
|
|
184
214
|
state.focusedComment = null;
|
|
185
215
|
state.frameUrl = null;
|
|
216
|
+
state.bridge = false;
|
|
186
217
|
location.hash = `#/u/${encodeURIComponent(user)}/${encodeURIComponent(branch)}`;
|
|
187
218
|
el('frame').hidden = true;
|
|
188
219
|
el('frame').src = 'about:blank';
|
|
@@ -228,7 +259,9 @@ function renderStage() {
|
|
|
228
259
|
}
|
|
229
260
|
|
|
230
261
|
const share = b.shares.find((s) => s.user === sel.user && s.branch === sel.branch);
|
|
231
|
-
const authorName = share ? share.name
|
|
262
|
+
const authorName = share ? share.name
|
|
263
|
+
: sel.user === b.me.slug ? b.me.name
|
|
264
|
+
: (b.heads[sel.branch] && b.heads[sel.branch].author) || sel.user;
|
|
232
265
|
const head = b.heads[sel.branch];
|
|
233
266
|
el('stage-title').textContent = sel.branch;
|
|
234
267
|
el('stage-meta').textContent = `${authorName}${head ? ' · updated ' + timeAgo(head.time) : ''}${sel.user === b.me.slug && sel.branch === b.ownBranch ? ' · your working copy' : ''}`;
|
|
@@ -263,19 +296,78 @@ function renderStage() {
|
|
|
263
296
|
}
|
|
264
297
|
|
|
265
298
|
const cm = el('btn-comment-mode');
|
|
266
|
-
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click to
|
|
299
|
+
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
|
|
267
300
|
cm.classList.toggle('active-mode', state.commentMode);
|
|
268
|
-
|
|
301
|
+
// The in-page inspector handles hover + capture when present; the overlay
|
|
302
|
+
// only captures clicks as a fallback for previews the proxy could not reach.
|
|
303
|
+
el('pin-layer').classList.toggle('capture', state.commentMode && !state.bridge);
|
|
304
|
+
sendToFrame({ ds: 'mode', comment: state.commentMode });
|
|
269
305
|
|
|
270
306
|
renderPins();
|
|
271
307
|
}
|
|
272
308
|
|
|
309
|
+
function sendToFrame(msg) {
|
|
310
|
+
const frame = el('frame');
|
|
311
|
+
if (frame.contentWindow) {
|
|
312
|
+
try { frame.contentWindow.postMessage(msg, '*'); } catch { /* not ready */ }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function pushCommentsToFrame() {
|
|
317
|
+
const sel = state.selected;
|
|
318
|
+
if (!sel || !state.bridge) return;
|
|
319
|
+
const items = branchComments(sel.branch).map((c, i) => ({
|
|
320
|
+
id: c.id,
|
|
321
|
+
n: i + 1,
|
|
322
|
+
selector: c.selector || null,
|
|
323
|
+
relX: c.relX,
|
|
324
|
+
relY: c.relY,
|
|
325
|
+
xPct: c.xPct,
|
|
326
|
+
yPct: c.yPct,
|
|
327
|
+
resolved: !!c.resolvedAt,
|
|
328
|
+
}));
|
|
329
|
+
sendToFrame({ ds: 'comments', items, focusedId: state.focusedComment });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
window.addEventListener('message', (e) => {
|
|
333
|
+
const frame = el('frame');
|
|
334
|
+
if (e.source !== frame.contentWindow) return;
|
|
335
|
+
const d = e.data || {};
|
|
336
|
+
if (d.ds === 'ready') {
|
|
337
|
+
state.bridge = true;
|
|
338
|
+
state.route = d.route || '/';
|
|
339
|
+
sendToFrame({ ds: 'mode', comment: state.commentMode });
|
|
340
|
+
pushCommentsToFrame();
|
|
341
|
+
renderStage();
|
|
342
|
+
} else if (d.ds === 'pin') {
|
|
343
|
+
state.pendingPin = {
|
|
344
|
+
xPct: d.xPct, yPct: d.yPct,
|
|
345
|
+
selector: d.selector, elementLabel: d.elementLabel,
|
|
346
|
+
relX: d.relX, relY: d.relY, route: d.route,
|
|
347
|
+
};
|
|
348
|
+
const composer = el('composer');
|
|
349
|
+
composer.hidden = false;
|
|
350
|
+
const wrap = el('frame-wrap');
|
|
351
|
+
composer.style.left = Math.min(d.clientX, wrap.clientWidth - 260) + 'px';
|
|
352
|
+
composer.style.top = Math.min(d.clientY + 14, wrap.clientHeight - 120) + 'px';
|
|
353
|
+
el('composer-text').value = '';
|
|
354
|
+
el('composer-text').focus();
|
|
355
|
+
} else if (d.ds === 'pin-click') {
|
|
356
|
+
state.focusedComment = d.id;
|
|
357
|
+
renderComments();
|
|
358
|
+
pushCommentsToFrame();
|
|
359
|
+
const card = document.querySelector(`[data-comment="${d.id}"]`);
|
|
360
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
273
364
|
/* ---------- pins ---------- */
|
|
274
365
|
|
|
275
366
|
function renderPins() {
|
|
276
367
|
const layer = el('pin-layer');
|
|
277
368
|
layer.querySelectorAll('.pin').forEach((p) => p.remove());
|
|
278
369
|
const sel = state.selected;
|
|
370
|
+
if (state.bridge) { pushCommentsToFrame(); return; }
|
|
279
371
|
if (!sel || el('frame').hidden) return;
|
|
280
372
|
const comments = branchComments(sel.branch);
|
|
281
373
|
comments.forEach((c, i) => {
|
|
@@ -317,12 +409,17 @@ async function postComment() {
|
|
|
317
409
|
const sel = state.selected;
|
|
318
410
|
if (!text || !sel || !state.pendingPin) return;
|
|
319
411
|
const rect = el('pin-layer').getBoundingClientRect();
|
|
412
|
+
const pin = state.pendingPin;
|
|
320
413
|
await api('/api/comments', {
|
|
321
414
|
targetUser: sel.user,
|
|
322
415
|
branch: sel.branch,
|
|
323
|
-
route:
|
|
324
|
-
xPct:
|
|
325
|
-
yPct:
|
|
416
|
+
route: pin.route || state.route || '/',
|
|
417
|
+
xPct: pin.xPct,
|
|
418
|
+
yPct: pin.yPct,
|
|
419
|
+
selector: pin.selector || null,
|
|
420
|
+
elementLabel: pin.elementLabel || null,
|
|
421
|
+
relX: pin.relX,
|
|
422
|
+
relY: pin.relY,
|
|
326
423
|
viewportLabel: state.viewport,
|
|
327
424
|
viewportW: Math.round(rect.width),
|
|
328
425
|
viewportH: Math.round(rect.height),
|
|
@@ -400,6 +497,7 @@ function commentCard(c, num) {
|
|
|
400
497
|
<span class="comment-num">${num}</span>
|
|
401
498
|
</div>
|
|
402
499
|
<div class="comment-text">${escapeHtml(c.text)}</div>
|
|
500
|
+
${c.elementLabel ? `<div class="comment-anchor">${escapeHtml(c.elementLabel)}</div>` : ''}
|
|
403
501
|
<div class="comment-meta">
|
|
404
502
|
<span>${timeAgo(c.createdAt / 1000)}</span>
|
|
405
503
|
${c.viewportLabel ? `<span>· ${escapeHtml(c.viewportLabel)}</span>` : ''}
|
|
@@ -443,6 +541,7 @@ function commentCard(c, num) {
|
|
|
443
541
|
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
444
542
|
state.focusedComment = c.id;
|
|
445
543
|
renderPins();
|
|
544
|
+
pushCommentsToFrame();
|
|
446
545
|
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
447
546
|
card.classList.add('focused');
|
|
448
547
|
};
|
|
@@ -514,6 +613,16 @@ function parseHash() {
|
|
|
514
613
|
}
|
|
515
614
|
|
|
516
615
|
(async function boot() {
|
|
616
|
+
// Each iframe navigation starts a fresh document; assume no inspector until
|
|
617
|
+
// the injected script announces itself again.
|
|
618
|
+
el('frame').addEventListener('load', () => {
|
|
619
|
+
state.bridge = false;
|
|
620
|
+
setTimeout(() => renderStage(), 300);
|
|
621
|
+
});
|
|
622
|
+
el('branch-filter').addEventListener('input', (e) => {
|
|
623
|
+
state.railFilter = e.target.value;
|
|
624
|
+
renderRail();
|
|
625
|
+
});
|
|
517
626
|
renderViewportSeg();
|
|
518
627
|
await refresh();
|
|
519
628
|
const target = parseHash();
|
package/public/index.html
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<span class="logo-mark"></span>
|
|
14
14
|
<span id="repo-name" class="repo-name">…</span>
|
|
15
15
|
</div>
|
|
16
|
+
<div class="rail-filter">
|
|
17
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
18
|
+
<input id="branch-filter" placeholder="Filter people or branches" spellcheck="false" autocomplete="off">
|
|
19
|
+
</div>
|
|
16
20
|
<div id="rail-sections"></div>
|
|
17
21
|
<div class="rail-foot">
|
|
18
22
|
<div id="sync-status" class="sync-status"></div>
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/* design-share in-page inspector.
|
|
2
|
+
Injected into previews by the daemon proxy. Draws the hover highlight and
|
|
3
|
+
tooltip during comment mode, anchors pins to real elements, and talks to
|
|
4
|
+
the dashboard through postMessage. Inert when not framed by the dashboard. */
|
|
5
|
+
(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
if (window.top === window || window.__designShareInspector) return;
|
|
8
|
+
window.__designShareInspector = true;
|
|
9
|
+
|
|
10
|
+
var Z = 2147483000;
|
|
11
|
+
var commentMode = false;
|
|
12
|
+
var comments = [];
|
|
13
|
+
var focusedId = null;
|
|
14
|
+
|
|
15
|
+
/* ---------- chrome (highlight box, tooltip, pin layer) ---------- */
|
|
16
|
+
|
|
17
|
+
var css = [
|
|
18
|
+
'.__ds-box{position:fixed;pointer-events:none;z-index:' + Z + ';border:1.5px solid #2f7cf6;',
|
|
19
|
+
'border-radius:3px;background:rgba(47,124,246,0.06);display:none;box-sizing:border-box;}',
|
|
20
|
+
'.__ds-tip{position:fixed;pointer-events:none;z-index:' + (Z + 2) + ';background:#16161a;color:#fff;',
|
|
21
|
+
'font:12px/1.45 -apple-system,BlinkMacSystemFont,sans-serif;padding:6px 10px;border-radius:7px;',
|
|
22
|
+
'max-width:340px;box-shadow:0 4px 16px rgba(0,0,0,0.3);display:none;white-space:nowrap;',
|
|
23
|
+
'overflow:hidden;text-overflow:ellipsis;}',
|
|
24
|
+
'.__ds-tip b{font-weight:600;color:#9ec2ff;}',
|
|
25
|
+
'.__ds-pin{position:fixed;z-index:' + (Z + 1) + ';width:20px;height:20px;border-radius:50% 50% 50% 0;',
|
|
26
|
+
'transform:translate(-3px,-17px) rotate(-45deg);background:#2f7cf6;color:#fff;border:2px solid #fff;',
|
|
27
|
+
'box-shadow:0 1px 4px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;',
|
|
28
|
+
'font:600 10px -apple-system,sans-serif;cursor:pointer;}',
|
|
29
|
+
'.__ds-pin>span{transform:rotate(45deg);}',
|
|
30
|
+
'.__ds-pin.__ds-resolved{background:#2da562;opacity:0.55;}',
|
|
31
|
+
'.__ds-pin.__ds-focused{outline:2px solid #2f7cf6;outline-offset:2px;}',
|
|
32
|
+
'.__ds-comment-cursor,.__ds-comment-cursor *{cursor:crosshair !important;}',
|
|
33
|
+
].join('');
|
|
34
|
+
var style = document.createElement('style');
|
|
35
|
+
style.textContent = css;
|
|
36
|
+
document.documentElement.appendChild(style);
|
|
37
|
+
|
|
38
|
+
var box = document.createElement('div');
|
|
39
|
+
box.className = '__ds-box';
|
|
40
|
+
var tip = document.createElement('div');
|
|
41
|
+
tip.className = '__ds-tip';
|
|
42
|
+
document.documentElement.appendChild(box);
|
|
43
|
+
document.documentElement.appendChild(tip);
|
|
44
|
+
var pinLayer = document.createElement('div');
|
|
45
|
+
document.documentElement.appendChild(pinLayer);
|
|
46
|
+
|
|
47
|
+
/* ---------- element naming, selectors ---------- */
|
|
48
|
+
|
|
49
|
+
var FRIENDLY = {
|
|
50
|
+
p: 'paragraph', a: 'link', img: 'image', button: 'button', input: 'input',
|
|
51
|
+
textarea: 'input', select: 'input', ul: 'list', ol: 'list', li: 'list item',
|
|
52
|
+
nav: 'navigation', svg: 'icon', video: 'video', form: 'form', table: 'table',
|
|
53
|
+
label: 'label', header: 'header', footer: 'footer', section: 'section',
|
|
54
|
+
};
|
|
55
|
+
function friendlyName(el) {
|
|
56
|
+
var t = el.tagName.toLowerCase();
|
|
57
|
+
if (/^h[1-6]$/.test(t)) return 'heading';
|
|
58
|
+
return FRIENDLY[t] || t;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function labelFor(el) {
|
|
62
|
+
var name = friendlyName(el);
|
|
63
|
+
var text = (el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '')
|
|
64
|
+
.replace(/\s+/g, ' ').trim();
|
|
65
|
+
if (text.length > 42) text = text.slice(0, 42) + '…';
|
|
66
|
+
return { name: name, text: text };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cssPath(el) {
|
|
70
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
71
|
+
var parts = [];
|
|
72
|
+
var node = el;
|
|
73
|
+
while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
|
|
74
|
+
var tag = node.tagName.toLowerCase();
|
|
75
|
+
if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
|
|
76
|
+
var index = 1, sib = node;
|
|
77
|
+
while ((sib = sib.previousElementSibling)) {
|
|
78
|
+
if (sib.tagName === node.tagName) index++;
|
|
79
|
+
}
|
|
80
|
+
parts.unshift(tag + ':nth-of-type(' + index + ')');
|
|
81
|
+
node = node.parentElement;
|
|
82
|
+
}
|
|
83
|
+
return parts.join(' > ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isOurs(el) {
|
|
87
|
+
return el && el.className && typeof el.className === 'string' && el.className.indexOf('__ds-') !== -1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* ---------- hover highlight ---------- */
|
|
91
|
+
|
|
92
|
+
var hoverEl = null;
|
|
93
|
+
|
|
94
|
+
function showHighlight(el, x, y) {
|
|
95
|
+
var r = el.getBoundingClientRect();
|
|
96
|
+
box.style.display = 'block';
|
|
97
|
+
box.style.left = r.left + 'px';
|
|
98
|
+
box.style.top = r.top + 'px';
|
|
99
|
+
box.style.width = r.width + 'px';
|
|
100
|
+
box.style.height = r.height + 'px';
|
|
101
|
+
|
|
102
|
+
var info = labelFor(el);
|
|
103
|
+
tip.innerHTML = '<b>' + info.name + '</b>' + (info.text ? ': “' + escapeHtml(info.text) + '”' : '');
|
|
104
|
+
tip.style.display = 'block';
|
|
105
|
+
var tw = tip.offsetWidth, th = tip.offsetHeight;
|
|
106
|
+
var tx = Math.min(Math.max(8, x + 12), window.innerWidth - tw - 8);
|
|
107
|
+
var ty = y + 16 + th > window.innerHeight - 8 ? y - th - 10 : y + 16;
|
|
108
|
+
tip.style.left = tx + 'px';
|
|
109
|
+
tip.style.top = ty + 'px';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hideHighlight() {
|
|
113
|
+
box.style.display = 'none';
|
|
114
|
+
tip.style.display = 'none';
|
|
115
|
+
hoverEl = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function escapeHtml(s) {
|
|
119
|
+
return s.replace(/[&<>"]/g, function (c) {
|
|
120
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
document.addEventListener('mousemove', function (e) {
|
|
125
|
+
if (!commentMode) return;
|
|
126
|
+
var el = e.target;
|
|
127
|
+
if (isOurs(el) || el === document.body || el === document.documentElement) { hideHighlight(); return; }
|
|
128
|
+
hoverEl = el;
|
|
129
|
+
showHighlight(el, e.clientX, e.clientY);
|
|
130
|
+
}, true);
|
|
131
|
+
|
|
132
|
+
document.addEventListener('mouseleave', hideHighlight, true);
|
|
133
|
+
|
|
134
|
+
/* ---------- click to pin ---------- */
|
|
135
|
+
|
|
136
|
+
['click', 'mousedown', 'mouseup'].forEach(function (type) {
|
|
137
|
+
document.addEventListener(type, function (e) {
|
|
138
|
+
if (!commentMode) return;
|
|
139
|
+
if (isOurs(e.target)) return; // pins stay clickable
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.stopPropagation();
|
|
142
|
+
if (type !== 'click') return;
|
|
143
|
+
var el = hoverEl || e.target;
|
|
144
|
+
var r = el.getBoundingClientRect();
|
|
145
|
+
var info = labelFor(el);
|
|
146
|
+
parent.postMessage({
|
|
147
|
+
ds: 'pin',
|
|
148
|
+
selector: cssPath(el),
|
|
149
|
+
elementLabel: info.name + (info.text ? ': “' + info.text + '”' : ''),
|
|
150
|
+
relX: r.width ? (e.clientX - r.left) / r.width : 0.5,
|
|
151
|
+
relY: r.height ? (e.clientY - r.top) / r.height : 0.5,
|
|
152
|
+
xPct: (e.clientX / window.innerWidth) * 100,
|
|
153
|
+
yPct: (e.clientY / window.innerHeight) * 100,
|
|
154
|
+
clientX: e.clientX,
|
|
155
|
+
clientY: e.clientY,
|
|
156
|
+
route: location.pathname,
|
|
157
|
+
}, '*');
|
|
158
|
+
hideHighlight();
|
|
159
|
+
}, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/* ---------- pins anchored to elements ---------- */
|
|
163
|
+
|
|
164
|
+
function renderPins() {
|
|
165
|
+
pinLayer.innerHTML = '';
|
|
166
|
+
comments.forEach(function (c) {
|
|
167
|
+
if (c.resolved && c.id !== focusedId) return;
|
|
168
|
+
var x = null, y = null;
|
|
169
|
+
var el = null;
|
|
170
|
+
if (c.selector) {
|
|
171
|
+
try { el = document.querySelector(c.selector); } catch (err) { el = null; }
|
|
172
|
+
}
|
|
173
|
+
if (el) {
|
|
174
|
+
var r = el.getBoundingClientRect();
|
|
175
|
+
if (r.width || r.height) {
|
|
176
|
+
x = r.left + (c.relX == null ? 0.5 : c.relX) * r.width;
|
|
177
|
+
y = r.top + (c.relY == null ? 0.5 : c.relY) * r.height;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (x == null && c.xPct != null) {
|
|
181
|
+
x = (c.xPct / 100) * window.innerWidth;
|
|
182
|
+
y = (c.yPct / 100) * window.innerHeight;
|
|
183
|
+
}
|
|
184
|
+
if (x == null) return;
|
|
185
|
+
var pin = document.createElement('div');
|
|
186
|
+
pin.className = '__ds-pin' + (c.resolved ? ' __ds-resolved' : '') + (c.id === focusedId ? ' __ds-focused' : '');
|
|
187
|
+
pin.style.left = x + 'px';
|
|
188
|
+
pin.style.top = y + 'px';
|
|
189
|
+
pin.innerHTML = '<span>' + c.n + '</span>';
|
|
190
|
+
pin.addEventListener('click', function (e) {
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
parent.postMessage({ ds: 'pin-click', id: c.id }, '*');
|
|
193
|
+
});
|
|
194
|
+
pinLayer.appendChild(pin);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var repaintQueued = false;
|
|
199
|
+
function queueRepaint() {
|
|
200
|
+
if (repaintQueued) return;
|
|
201
|
+
repaintQueued = true;
|
|
202
|
+
requestAnimationFrame(function () { repaintQueued = false; renderPins(); });
|
|
203
|
+
}
|
|
204
|
+
window.addEventListener('scroll', queueRepaint, true);
|
|
205
|
+
window.addEventListener('resize', queueRepaint);
|
|
206
|
+
setInterval(queueRepaint, 900);
|
|
207
|
+
|
|
208
|
+
/* ---------- bridge ---------- */
|
|
209
|
+
|
|
210
|
+
window.addEventListener('message', function (e) {
|
|
211
|
+
var d = e.data || {};
|
|
212
|
+
if (d.ds === 'mode') {
|
|
213
|
+
commentMode = !!d.comment;
|
|
214
|
+
document.documentElement.classList.toggle('__ds-comment-cursor', commentMode);
|
|
215
|
+
if (!commentMode) hideHighlight();
|
|
216
|
+
} else if (d.ds === 'comments') {
|
|
217
|
+
comments = d.items || [];
|
|
218
|
+
focusedId = d.focusedId || null;
|
|
219
|
+
renderPins();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
function announce() {
|
|
224
|
+
parent.postMessage({ ds: 'ready', route: location.pathname }, '*');
|
|
225
|
+
}
|
|
226
|
+
announce();
|
|
227
|
+
// The parent resets its bridge flag on iframe load, which fires after the
|
|
228
|
+
// first announce. Announce again once the page is fully loaded.
|
|
229
|
+
window.addEventListener('load', announce);
|
|
230
|
+
window.addEventListener('popstate', announce);
|
|
231
|
+
var push = history.pushState;
|
|
232
|
+
history.pushState = function () { push.apply(this, arguments); announce(); };
|
|
233
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -67,6 +67,29 @@ button { font: inherit; cursor: pointer; }
|
|
|
67
67
|
|
|
68
68
|
.repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
69
69
|
|
|
70
|
+
.rail-filter {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 7px;
|
|
74
|
+
margin: 2px 12px 4px;
|
|
75
|
+
padding: 4px 9px;
|
|
76
|
+
border: 1px solid var(--border);
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
color: var(--text-3);
|
|
79
|
+
background: #fff;
|
|
80
|
+
}
|
|
81
|
+
.rail-filter:focus-within { border-color: #c9c9cf; color: var(--text-2); }
|
|
82
|
+
.rail-filter input {
|
|
83
|
+
border: 0;
|
|
84
|
+
outline: none;
|
|
85
|
+
background: transparent;
|
|
86
|
+
font: inherit;
|
|
87
|
+
font-size: 12px;
|
|
88
|
+
color: var(--text);
|
|
89
|
+
width: 100%;
|
|
90
|
+
}
|
|
91
|
+
.rail-filter input::placeholder { color: var(--text-3); }
|
|
92
|
+
|
|
70
93
|
#rail-sections { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
|
|
71
94
|
|
|
72
95
|
.rail-section-label {
|
|
@@ -100,6 +123,18 @@ button { font: inherit; cursor: pointer; }
|
|
|
100
123
|
.branch-row .meta { font-size: 11px; color: var(--text-3); flex: none; }
|
|
101
124
|
.branch-row.dim { color: var(--text-2); }
|
|
102
125
|
|
|
126
|
+
.pr-link {
|
|
127
|
+
font-size: 10.5px;
|
|
128
|
+
color: var(--text-3);
|
|
129
|
+
border: 1px solid var(--border);
|
|
130
|
+
border-radius: 99px;
|
|
131
|
+
padding: 0 6px;
|
|
132
|
+
line-height: 16px;
|
|
133
|
+
flex: none;
|
|
134
|
+
background: #fff;
|
|
135
|
+
}
|
|
136
|
+
.pr-link:hover { color: var(--accent); border-color: var(--accent); }
|
|
137
|
+
|
|
103
138
|
.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
104
139
|
.dot.live { background: var(--green); }
|
|
105
140
|
.dot.idle { background: #d4d4d8; }
|
|
@@ -370,6 +405,19 @@ button { font: inherit; cursor: pointer; }
|
|
|
370
405
|
.comment-text { font-size: 12.5px; color: var(--text); }
|
|
371
406
|
.comment-meta { font-size: 11px; color: var(--text-3); margin-top: 5px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
372
407
|
|
|
408
|
+
.comment-anchor {
|
|
409
|
+
margin-top: 5px;
|
|
410
|
+
font-size: 11px;
|
|
411
|
+
color: var(--text-2);
|
|
412
|
+
background: #f6f6f7;
|
|
413
|
+
border: 1px solid var(--border);
|
|
414
|
+
border-radius: 5px;
|
|
415
|
+
padding: 3px 7px;
|
|
416
|
+
overflow: hidden;
|
|
417
|
+
text-overflow: ellipsis;
|
|
418
|
+
white-space: nowrap;
|
|
419
|
+
}
|
|
420
|
+
|
|
373
421
|
.chip {
|
|
374
422
|
display: inline-flex;
|
|
375
423
|
align-items: center;
|
package/src/github.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// PR links come from the gh CLI so no tokens are stored or handled: gh uses
|
|
4
|
+
// whoever is already logged in. Machines without gh (or without auth) simply
|
|
5
|
+
// return an empty map and the dashboard shows no PR links.
|
|
6
|
+
export function listOpenPRs(repoRoot) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
execFile('gh', [
|
|
9
|
+
'pr', 'list', '--state', 'open', '--limit', '200',
|
|
10
|
+
'--json', 'number,url,headRefName,title',
|
|
11
|
+
], { cwd: repoRoot, timeout: 15_000 }, (err, stdout) => {
|
|
12
|
+
if (err) return resolve({});
|
|
13
|
+
try {
|
|
14
|
+
const byBranch = {};
|
|
15
|
+
for (const pr of JSON.parse(stdout)) {
|
|
16
|
+
byBranch[pr.headRefName] = { number: pr.number, url: pr.url, title: pr.title };
|
|
17
|
+
}
|
|
18
|
+
resolve(byBranch);
|
|
19
|
+
} catch {
|
|
20
|
+
resolve({});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/previews.js
CHANGED
|
@@ -4,6 +4,7 @@ import http from 'node:http';
|
|
|
4
4
|
import net from 'node:net';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { git, tryGit } from './git.js';
|
|
7
|
+
import { startInjectingProxy } from './proxy.js';
|
|
7
8
|
|
|
8
9
|
const URL_RE = /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+[^\s'"]*)/;
|
|
9
10
|
const START_TIMEOUT_MS = 90_000;
|
|
@@ -125,6 +126,25 @@ export class PreviewManager {
|
|
|
125
126
|
return this.start(key, { cwd: wt, commit, worktree: wt });
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Comments need to see inside the preview, so the dashboard never talks to
|
|
130
|
+
// the dev server directly. Each preview gets a proxy that injects the
|
|
131
|
+
// inspector script. If the proxy fails, fall back to the direct URL: the
|
|
132
|
+
// preview still works, only element pinning degrades.
|
|
133
|
+
attachProxy(rec, targetUrl) {
|
|
134
|
+
if (rec.targetUrl) return;
|
|
135
|
+
rec.targetUrl = targetUrl;
|
|
136
|
+
startInjectingProxy(targetUrl)
|
|
137
|
+
.then(({ server, url }) => {
|
|
138
|
+
rec.proxyServer = server;
|
|
139
|
+
rec.url = url;
|
|
140
|
+
rec.status = 'ready';
|
|
141
|
+
})
|
|
142
|
+
.catch(() => {
|
|
143
|
+
rec.url = targetUrl;
|
|
144
|
+
rec.status = 'ready';
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
128
148
|
record(key) {
|
|
129
149
|
let rec = this.previews.get(key);
|
|
130
150
|
if (!rec) {
|
|
@@ -138,6 +158,8 @@ export class PreviewManager {
|
|
|
138
158
|
const rec = this.record(key);
|
|
139
159
|
rec.status = 'starting';
|
|
140
160
|
rec.url = null;
|
|
161
|
+
rec.targetUrl = null;
|
|
162
|
+
if (rec.proxyServer) { try { rec.proxyServer.close(); } catch { /* gone */ } rec.proxyServer = null; }
|
|
141
163
|
rec.error = null;
|
|
142
164
|
rec.log = '';
|
|
143
165
|
rec.commit = commit;
|
|
@@ -156,8 +178,7 @@ export class PreviewManager {
|
|
|
156
178
|
const dir = path.resolve(cwd, cfg.dir || '.');
|
|
157
179
|
const server = http.createServer((req, res) => serveStatic(dir, req, res));
|
|
158
180
|
server.listen(0, '127.0.0.1', () => {
|
|
159
|
-
rec
|
|
160
|
-
rec.status = 'ready';
|
|
181
|
+
this.attachProxy(rec, `http://localhost:${server.address().port}/`);
|
|
161
182
|
});
|
|
162
183
|
rec.server = server;
|
|
163
184
|
return rec;
|
|
@@ -176,11 +197,11 @@ export class PreviewManager {
|
|
|
176
197
|
const onData = (buf) => {
|
|
177
198
|
const text = stripAnsi(buf.toString());
|
|
178
199
|
rec.log = (rec.log + text).slice(-8000);
|
|
179
|
-
if (!rec.
|
|
200
|
+
if (!rec.targetUrl) {
|
|
180
201
|
const m = text.match(URL_RE);
|
|
181
202
|
if (m) {
|
|
182
|
-
|
|
183
|
-
rec
|
|
203
|
+
const targetUrl = m[1].replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
|
|
204
|
+
this.attachProxy(rec, targetUrl);
|
|
184
205
|
}
|
|
185
206
|
}
|
|
186
207
|
};
|
|
@@ -214,6 +235,9 @@ export class PreviewManager {
|
|
|
214
235
|
if (rec.server) {
|
|
215
236
|
try { rec.server.close(); } catch { /* gone */ }
|
|
216
237
|
}
|
|
238
|
+
if (rec.proxyServer) {
|
|
239
|
+
try { rec.proxyServer.close(); } catch { /* gone */ }
|
|
240
|
+
}
|
|
217
241
|
}
|
|
218
242
|
}
|
|
219
243
|
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const INSPECT_PATH = '/__design-share__/inspect.js';
|
|
8
|
+
const INSPECT_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public', 'inspect.js');
|
|
9
|
+
const INJECT_TAG = `<script src="${INSPECT_PATH}"></script>`;
|
|
10
|
+
|
|
11
|
+
// Sits in front of a preview dev server on its own port. Mirrors every path
|
|
12
|
+
// 1:1 so absolute asset URLs keep working, injects the inspector script into
|
|
13
|
+
// HTML responses, and pipes websocket upgrades straight through so hot module
|
|
14
|
+
// reload keeps working.
|
|
15
|
+
export function startInjectingProxy(targetUrl) {
|
|
16
|
+
const target = new URL(targetUrl);
|
|
17
|
+
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
if (req.url === INSPECT_PATH) {
|
|
20
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
21
|
+
return res.end(fs.readFileSync(INSPECT_FILE));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const headers = { ...req.headers, host: target.host };
|
|
25
|
+
delete headers['accept-encoding']; // keep HTML plain so injection is trivial
|
|
26
|
+
|
|
27
|
+
const upstream = http.request({
|
|
28
|
+
hostname: target.hostname,
|
|
29
|
+
port: target.port,
|
|
30
|
+
path: req.url,
|
|
31
|
+
method: req.method,
|
|
32
|
+
headers,
|
|
33
|
+
}, (up) => {
|
|
34
|
+
const type = up.headers['content-type'] || '';
|
|
35
|
+
const outHeaders = { ...up.headers };
|
|
36
|
+
|
|
37
|
+
// Point same-host redirects back at the proxy
|
|
38
|
+
if (outHeaders.location) {
|
|
39
|
+
outHeaders.location = outHeaders.location.replace(target.host, `localhost:${server.address().port}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (type.includes('text/html')) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
up.on('data', (c) => chunks.push(c));
|
|
45
|
+
up.on('end', () => {
|
|
46
|
+
let body = Buffer.concat(chunks).toString('utf8');
|
|
47
|
+
if (body.includes('</body>')) {
|
|
48
|
+
body = body.replace('</body>', `${INJECT_TAG}</body>`);
|
|
49
|
+
} else if (body.includes('</html>')) {
|
|
50
|
+
body = body.replace('</html>', `${INJECT_TAG}</html>`);
|
|
51
|
+
} else {
|
|
52
|
+
body += INJECT_TAG;
|
|
53
|
+
}
|
|
54
|
+
delete outHeaders['content-length'];
|
|
55
|
+
delete outHeaders['content-encoding'];
|
|
56
|
+
res.writeHead(up.statusCode, outHeaders);
|
|
57
|
+
res.end(body);
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
res.writeHead(up.statusCode, outHeaders);
|
|
61
|
+
up.pipe(res);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
upstream.on('error', () => {
|
|
66
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
67
|
+
res.end('preview process is not responding');
|
|
68
|
+
});
|
|
69
|
+
req.pipe(upstream);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Raw tcp passthrough for websockets (vite/next HMR)
|
|
73
|
+
server.on('upgrade', (req, socket, head) => {
|
|
74
|
+
const conn = net.connect(Number(target.port), target.hostname, () => {
|
|
75
|
+
const headerLines = Object.entries(req.headers)
|
|
76
|
+
.map(([k, v]) => `${k}: ${k === 'host' ? target.host : v}`)
|
|
77
|
+
.join('\r\n');
|
|
78
|
+
conn.write(`${req.method} ${req.url} HTTP/1.1\r\n${headerLines}\r\n\r\n`);
|
|
79
|
+
if (head && head.length) conn.write(head);
|
|
80
|
+
socket.pipe(conn);
|
|
81
|
+
conn.pipe(socket);
|
|
82
|
+
});
|
|
83
|
+
const drop = () => { socket.destroy(); conn.destroy(); };
|
|
84
|
+
conn.on('error', drop);
|
|
85
|
+
socket.on('error', drop);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
server.once('error', reject);
|
|
90
|
+
server.listen(0, '127.0.0.1', () => {
|
|
91
|
+
resolve({ server, url: `http://localhost:${server.address().port}/` });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { tryGit } from './git.js';
|
|
6
6
|
import { currentBranch } from './detect.js';
|
|
7
7
|
import { serveStatic } from './previews.js';
|
|
8
|
+
import { listOpenPRs } from './github.js';
|
|
8
9
|
|
|
9
10
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
10
11
|
const SYNC_INTERVAL_MS = 15_000;
|
|
@@ -36,6 +37,11 @@ export class DesignShareServer {
|
|
|
36
37
|
this.port = port;
|
|
37
38
|
this.server = null;
|
|
38
39
|
this.timers = [];
|
|
40
|
+
this.prs = {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async refreshPRs() {
|
|
44
|
+
this.prs = await listOpenPRs(this.repo.root);
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
async branchHeads() {
|
|
@@ -84,6 +90,7 @@ export class DesignShareServer {
|
|
|
84
90
|
otherBranches: others,
|
|
85
91
|
comments: Object.values(state.comments || {}),
|
|
86
92
|
previews: this.previews.statuses(),
|
|
93
|
+
prs: this.prs,
|
|
87
94
|
sync: {
|
|
88
95
|
hasRemote: this.store.hasRemote,
|
|
89
96
|
lastSync: this.store.lastSync,
|
|
@@ -128,6 +135,10 @@ export class DesignShareServer {
|
|
|
128
135
|
route: body.route || '/',
|
|
129
136
|
xPct: body.xPct,
|
|
130
137
|
yPct: body.yPct,
|
|
138
|
+
selector: typeof body.selector === 'string' ? body.selector.slice(0, 500) : null,
|
|
139
|
+
elementLabel: typeof body.elementLabel === 'string' ? body.elementLabel.slice(0, 200) : null,
|
|
140
|
+
relX: typeof body.relX === 'number' ? body.relX : null,
|
|
141
|
+
relY: typeof body.relY === 'number' ? body.relY : null,
|
|
131
142
|
viewportLabel: body.viewportLabel || 'desktop',
|
|
132
143
|
viewportW: body.viewportW,
|
|
133
144
|
viewportH: body.viewportH,
|
|
@@ -198,6 +209,8 @@ export class DesignShareServer {
|
|
|
198
209
|
() => tryGit(this.repo.root, ['fetch', '--quiet', 'origin']),
|
|
199
210
|
FETCH_INTERVAL_MS,
|
|
200
211
|
));
|
|
212
|
+
this.refreshPRs();
|
|
213
|
+
this.timers.push(setInterval(() => this.refreshPRs(), 180_000));
|
|
201
214
|
resolve(port);
|
|
202
215
|
});
|
|
203
216
|
};
|