cmdzero 0.8.5 → 0.9.1
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/overlay/overlay.js +133 -25
- package/package.json +1 -1
- package/src/resolver.js +208 -2
- package/src/server.js +13 -2
package/overlay/overlay.js
CHANGED
|
@@ -97,11 +97,31 @@
|
|
|
97
97
|
.cz-dot.done{background:#10b981}.cz-dot.queued,.cz-dot.running{background:#f59e0b;animation:cz-pulse 1s infinite}.cz-dot.error{background:#ef4444}.cz-dot.reverted,.cz-dot.cancelled{background:#6b7280}
|
|
98
98
|
.cz-tweak button{background:none;border:none;color:#818cf8;cursor:pointer;font-size:12.5px;padding:0}
|
|
99
99
|
.cz-meta{color:#9ca3af}
|
|
100
|
-
|
|
100
|
+
/* The hint is visible in BOTH states (out of select mode it's the ⌘0
|
|
101
|
+
affordance) — .cz-in only rides the swap between the two texts. */
|
|
102
|
+
.cz-hint{position:fixed;left:14px;bottom:14px;background:#111827;color:#9ca3af;font-size:12.5px;padding:6px 11px;border-radius:6px;pointer-events:none;opacity:0;transform:translateY(6px);transition:opacity .2s ease,transform .2s ease}
|
|
103
|
+
.cz-hint.cz-in{opacity:1;transform:translateY(0)}
|
|
101
104
|
.cz-reload-toggle{position:fixed;left:14px;bottom:46px;background:#111827;color:#6ee7b7;border:1px solid #10b981;font-size:11.5px;padding:4px 9px;border-radius:6px;cursor:pointer;pointer-events:auto}
|
|
102
105
|
.cz-reload-toggle.off{color:#9ca3af;border-color:#374151}
|
|
103
106
|
[contenteditable="plaintext-only"],[contenteditable="true"]{outline:2px dashed #10b981;outline-offset:2px}
|
|
104
|
-
@keyframes cz-pulse{50%{opacity:.4}}
|
|
107
|
+
@keyframes cz-pulse{50%{opacity:.4}}
|
|
108
|
+
/* Arming select mode is otherwise a silent text swap. A one-shot sweep says
|
|
109
|
+
"we woke up"; the ring stays lit to say "we're still listening". Both are
|
|
110
|
+
opacity/transform only so they cost nothing next to watchLayout()'s polling.
|
|
111
|
+
The ring starts below the 30px banner rather than cutting across it. */
|
|
112
|
+
.cz-ring{position:fixed;top:30px;left:0;right:0;bottom:0;pointer-events:none;opacity:0;box-shadow:inset 0 0 0 2px rgba(16,185,129,.55),inset 0 0 22px rgba(16,185,129,.14);transition:opacity .18s ease}
|
|
113
|
+
.cz-ring.cz-on{opacity:1;animation:cz-breathe 3s ease-in-out infinite}
|
|
114
|
+
.cz-shine{position:fixed;inset:0;pointer-events:none;opacity:0;background:linear-gradient(105deg,transparent 40%,rgba(110,231,183,.18) 50%,transparent 60%)}
|
|
115
|
+
/* Near-linear on purpose: a fast-out curve spends most of the run parked off
|
|
116
|
+
the right edge, so the band only actually crosses the screen for a blink. */
|
|
117
|
+
.cz-shine.cz-sweep{animation:cz-sweep .55s cubic-bezier(.45,.05,.55,.95)}
|
|
118
|
+
@keyframes cz-breathe{50%{opacity:.55}}
|
|
119
|
+
@keyframes cz-sweep{0%{opacity:0;transform:translateX(-120%)}15%{opacity:1}85%{opacity:1}100%{opacity:0;transform:translateX(120%)}}
|
|
120
|
+
@media (prefers-reduced-motion:reduce){
|
|
121
|
+
.cz-ring.cz-on{animation:none}
|
|
122
|
+
.cz-shine.cz-sweep{animation:none}
|
|
123
|
+
.cz-hint{transition:none}
|
|
124
|
+
}`;
|
|
105
125
|
|
|
106
126
|
const style = document.createElement('style');
|
|
107
127
|
style.textContent = css;
|
|
@@ -502,7 +522,7 @@
|
|
|
502
522
|
const s = state.selected;
|
|
503
523
|
if (!s) return;
|
|
504
524
|
try {
|
|
505
|
-
const meta = await api('resolve', { loc: s.loc });
|
|
525
|
+
const meta = await api('resolve', { loc: s.loc, ancestors: ancestorStamps(s.el) });
|
|
506
526
|
if (state.selected !== s) return; // selection changed meanwhile
|
|
507
527
|
s.meta = meta;
|
|
508
528
|
renderPopover();
|
|
@@ -1067,33 +1087,75 @@
|
|
|
1067
1087
|
// literal we can edit in place (headings, buttons, links, paragraphs — not
|
|
1068
1088
|
// containers, and not text shared across multiple instances).
|
|
1069
1089
|
function canEditWholeText(s) {
|
|
1070
|
-
|
|
1090
|
+
if (!s || !document.contains(s.el)) return false;
|
|
1091
|
+
// Text traced to a call site's prop: the ancestors already picked out which
|
|
1092
|
+
// usage this is, so several instances of the shared component are fine —
|
|
1093
|
+
// the edit lands on this one's data, not on the component they share.
|
|
1094
|
+
const src = s.meta?.textSource;
|
|
1095
|
+
if (src) return !!textHost(s.el, src);
|
|
1096
|
+
const lits = s.meta?.texts;
|
|
1071
1097
|
if (!lits || lits.length !== 1 || s.instances > 1) return false;
|
|
1072
|
-
return
|
|
1098
|
+
return normText(lits[0].value) === normText(s.el.textContent);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Rendered text and source text never match byte for byte: HTML collapses
|
|
1102
|
+
// runs of whitespace, JSX literals wrap across lines, and a word-splitting
|
|
1103
|
+
// animation may join with non-breaking spaces the source spells as plain ones.
|
|
1104
|
+
// Compare what a reader sees, not what the file holds.
|
|
1105
|
+
const normText = (s) => (s || '').replace(/\s+/g, ' ').trim();
|
|
1106
|
+
|
|
1107
|
+
// The element that displays the whole traced string. A reveal animation
|
|
1108
|
+
// renders a span per word, so the span you clicked holds "something." while
|
|
1109
|
+
// the sentence lives a level or two up — that ancestor is what gets edited.
|
|
1110
|
+
function textHost(el, src) {
|
|
1111
|
+
const want = normText(src.value);
|
|
1112
|
+
if (!src.wholeText) return normText(el.textContent) === want ? el : null;
|
|
1113
|
+
for (let p = el; p; p = p.parentElement) {
|
|
1114
|
+
if (normText(p.textContent) === want) return p;
|
|
1115
|
+
}
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// The stamps above this element, nearest first. The daemon needs them to tell
|
|
1120
|
+
// which call site rendered a shared component's markup.
|
|
1121
|
+
function ancestorStamps(el) {
|
|
1122
|
+
const out = [];
|
|
1123
|
+
for (let p = el?.parentElement; p; p = p.parentElement) {
|
|
1124
|
+
const cz = p.getAttribute('data-cz');
|
|
1125
|
+
if (cz) out.push(cz);
|
|
1126
|
+
}
|
|
1127
|
+
return out;
|
|
1073
1128
|
}
|
|
1074
1129
|
|
|
1075
1130
|
function startTextEdit(selectAll = true) {
|
|
1076
1131
|
const s = state.selected;
|
|
1077
1132
|
// the popover can outlive its selection (HMR, reloads) — never throw
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
if (
|
|
1133
|
+
const source = s?.meta?.textSource || null;
|
|
1134
|
+
const literal = source ? source.value : s?.meta?.texts?.[0]?.value;
|
|
1135
|
+
if (literal == null || !s || !document.contains(s.el)) return;
|
|
1136
|
+
// For traced text the element holding the sentence may be an ancestor of the
|
|
1137
|
+
// one clicked — edit that, or a reveal's per-word spans would each try to
|
|
1138
|
+
// hold the whole heading.
|
|
1139
|
+
const host = source ? textHost(s.el, source) : s.el;
|
|
1140
|
+
if (!host) return;
|
|
1141
|
+
if (state.editing && state.editing.el === host) return; // already editing this one
|
|
1081
1142
|
const frag = document.createDocumentFragment();
|
|
1082
|
-
while (
|
|
1143
|
+
while (host.firstChild) frag.appendChild(host.firstChild);
|
|
1083
1144
|
state.editing = {
|
|
1084
|
-
el:
|
|
1145
|
+
el: host,
|
|
1085
1146
|
loc: s.loc,
|
|
1147
|
+
source, // set when the text lives in a call site's prop rather than here
|
|
1086
1148
|
literal,
|
|
1087
1149
|
frag,
|
|
1088
|
-
prevUserSelect:
|
|
1150
|
+
prevUserSelect: host.style.userSelect,
|
|
1089
1151
|
};
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
try {
|
|
1093
|
-
|
|
1152
|
+
host.textContent = literal;
|
|
1153
|
+
host.style.userSelect = 'text'; // buttons often have user-select: none
|
|
1154
|
+
try { host.contentEditable = 'plaintext-only'; } catch { host.contentEditable = 'true'; }
|
|
1155
|
+
host.focus({ preventScroll: true });
|
|
1094
1156
|
const sel = document.getSelection();
|
|
1095
|
-
if (selectAll) sel?.selectAllChildren(
|
|
1096
|
-
else { const rng = document.createRange(); rng.selectNodeContents(
|
|
1157
|
+
if (selectAll) sel?.selectAllChildren(host);
|
|
1158
|
+
else { const rng = document.createRange(); rng.selectNodeContents(host); rng.collapse(false); sel?.removeAllRanges(); sel?.addRange(rng); }
|
|
1097
1159
|
hint.textContent = 'editing text — type to change · ⌘0 or click another element saves · Esc cancels';
|
|
1098
1160
|
}
|
|
1099
1161
|
|
|
@@ -1114,7 +1176,18 @@
|
|
|
1114
1176
|
return;
|
|
1115
1177
|
}
|
|
1116
1178
|
try {
|
|
1117
|
-
|
|
1179
|
+
if (ed.source) {
|
|
1180
|
+
// The string belongs to this call site, not to the component whose
|
|
1181
|
+
// markup we clicked — writing there would retitle every usage at once.
|
|
1182
|
+
await api('edit-prop', {
|
|
1183
|
+
loc: ed.source.loc,
|
|
1184
|
+
prop: ed.source.prop,
|
|
1185
|
+
oldText: ed.literal,
|
|
1186
|
+
newText,
|
|
1187
|
+
});
|
|
1188
|
+
} else {
|
|
1189
|
+
await api('edit-text', { loc: ed.loc, oldText: ed.literal, newText });
|
|
1190
|
+
}
|
|
1118
1191
|
// keep the new text; HMR re-renders the component (animations included)
|
|
1119
1192
|
} catch (e) {
|
|
1120
1193
|
restore();
|
|
@@ -1417,18 +1490,18 @@
|
|
|
1417
1490
|
if (tries-- > 0 && Math.abs(scrollY - y) > 2) setTimeout(pin, 30);
|
|
1418
1491
|
else { try { history.scrollRestoration = 'auto'; } catch { /* ignore */ } }
|
|
1419
1492
|
})();
|
|
1420
|
-
if (saved.selectMode) setMode(true);
|
|
1493
|
+
if (saved.selectMode) setMode(true, false);
|
|
1421
1494
|
if (saved.loc) {
|
|
1422
1495
|
const again = document.querySelector(`[data-cz="${CSS.escape(saved.loc)}"]`);
|
|
1423
1496
|
if (again) select(again);
|
|
1424
1497
|
}
|
|
1425
1498
|
}
|
|
1426
|
-
if (document.readyState === 'complete') restoreAfterReload();
|
|
1427
|
-
else addEventListener('load', restoreAfterReload);
|
|
1428
1499
|
|
|
1429
1500
|
// ---------- mode + events ----------
|
|
1430
|
-
const hint = el('div', 'cz-hint', '⌘0 select mode');
|
|
1431
|
-
|
|
1501
|
+
const hint = el('div', 'cz-hint cz-in', '⌘0 select mode');
|
|
1502
|
+
const ring = el('div', 'cz-ring');
|
|
1503
|
+
const shine = el('div', 'cz-shine');
|
|
1504
|
+
root.append(hint, ring, shine);
|
|
1432
1505
|
|
|
1433
1506
|
const reloadToggle = el('button', 'cz-reload-toggle');
|
|
1434
1507
|
const syncReloadToggle = () => {
|
|
@@ -1444,13 +1517,48 @@
|
|
|
1444
1517
|
syncReloadToggle();
|
|
1445
1518
|
root.appendChild(reloadToggle);
|
|
1446
1519
|
|
|
1447
|
-
|
|
1520
|
+
// Restart the sweep even if it's mid-flight: drop the class, force a reflow,
|
|
1521
|
+
// re-add it. Without the reflow the browser coalesces the two states and the
|
|
1522
|
+
// animation never replays on a fast ⌘0 ⌘0 ⌘0.
|
|
1523
|
+
function sweep() {
|
|
1524
|
+
shine.classList.remove('cz-sweep');
|
|
1525
|
+
void shine.offsetWidth;
|
|
1526
|
+
shine.classList.add('cz-sweep');
|
|
1527
|
+
}
|
|
1528
|
+
shine.addEventListener('animationend', () => shine.classList.remove('cz-sweep'));
|
|
1529
|
+
|
|
1530
|
+
// Let the pill drop out before the words change, so the swap reads as one
|
|
1531
|
+
// motion rather than a jump cut. Compare against the text we're heading for,
|
|
1532
|
+
// not the one on screen: mid-swap those differ, and a fast ⌘0 ⌘0 would
|
|
1533
|
+
// otherwise take the early return and leave the pill showing the wrong mode.
|
|
1534
|
+
let hintTimer = null;
|
|
1535
|
+
let hintText = hint.textContent;
|
|
1536
|
+
function setHintText(text) {
|
|
1537
|
+
if (hintText === text) return;
|
|
1538
|
+
hintText = text;
|
|
1539
|
+
clearTimeout(hintTimer);
|
|
1540
|
+
hint.classList.remove('cz-in');
|
|
1541
|
+
hintTimer = setTimeout(() => { hint.textContent = hintText; hint.classList.add('cz-in'); }, 140);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// `announce` plays the activation sweep. It's on for a real ⌘0 press and off
|
|
1545
|
+
// when we're only restoring the mode after an auto-reload — the sweep should
|
|
1546
|
+
// read as "you just armed this", not fire again on every write.
|
|
1547
|
+
function setMode(on, announce = true) {
|
|
1448
1548
|
state.selectMode = on;
|
|
1449
|
-
|
|
1549
|
+
setHintText(on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘0 select mode');
|
|
1550
|
+
ring.classList.toggle('cz-on', on);
|
|
1551
|
+
if (on && announce) sweep();
|
|
1450
1552
|
if (on) watchLayout(); // keep the boxes glued while anything on the page moves
|
|
1451
1553
|
else { setHover(null); deselect(); clearMulti(); }
|
|
1452
1554
|
}
|
|
1453
1555
|
|
|
1556
|
+
// Run the restore only now: it calls setMode(), which touches the hint/ring/
|
|
1557
|
+
// shine consts declared above. On a document that's already `complete` when
|
|
1558
|
+
// the overlay is injected it would otherwise run during the temporal dead zone.
|
|
1559
|
+
if (document.readyState === 'complete') restoreAfterReload();
|
|
1560
|
+
else addEventListener('load', restoreAfterReload);
|
|
1561
|
+
|
|
1454
1562
|
// Visible, stamped elements in document order — the Tab cycle.
|
|
1455
1563
|
function stampedEls() {
|
|
1456
1564
|
return [...document.querySelectorAll('[data-cz]')].filter((n) => n.getClientRects().length);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmdzero",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Tweak your UI live in the browser and write the changes straight to source. Copy and Tailwind edits cost zero tokens; everything else routes to a right-sized model via headless claude.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/resolver.js
CHANGED
|
@@ -64,12 +64,192 @@ export function loadTarget(root, loc) {
|
|
|
64
64
|
return { file, abs, content, element, ast };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
/** The nearest enclosing function whose name looks like a component. */
|
|
68
|
+
function enclosingComponent(ast, chain) {
|
|
69
|
+
for (const node of chain) {
|
|
70
|
+
if (!FN_TYPES.has(node.type)) continue;
|
|
71
|
+
const name = nameOfFunction(ast, node);
|
|
72
|
+
if (name && /^[A-Z]/.test(name)) return { fn: node, name };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Is `name` destructured out of the component's props? ({ text, as }) => ... */
|
|
78
|
+
function isDestructuredProp(fn, name) {
|
|
79
|
+
const param = fn?.params?.[0];
|
|
80
|
+
if (param?.type !== 'ObjectPattern') return false;
|
|
81
|
+
return param.properties.some(
|
|
82
|
+
(p) => p.type === 'ObjectProperty' && p.key?.type === 'Identifier' && p.key.name === name
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The `{ident}` an element renders as its only child, if that's its shape. */
|
|
87
|
+
function soleExpressionIdentifier(element) {
|
|
88
|
+
const meaningful = element.children.filter(
|
|
89
|
+
(c) => !(c.type === 'JSXText' && !c.value.trim())
|
|
90
|
+
);
|
|
91
|
+
if (meaningful.length !== 1) return null;
|
|
92
|
+
const only = meaningful[0];
|
|
93
|
+
if (only.type !== 'JSXExpressionContainer') return null;
|
|
94
|
+
const expr = unwrapExpr(only.expression);
|
|
95
|
+
return expr?.type === 'Identifier' ? expr.name : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function usageLoc(usage) {
|
|
99
|
+
const s = usage.element.openingElement.loc.start;
|
|
100
|
+
return `${usage.file}:${s.line}:${s.column}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `words.map(cb)` → "words" */
|
|
104
|
+
function mapCallArrayName(node) {
|
|
105
|
+
const call = unwrapExpr(node);
|
|
106
|
+
if (call?.type !== 'CallExpression') return null;
|
|
107
|
+
const callee = call.callee;
|
|
108
|
+
if (
|
|
109
|
+
callee?.type !== 'MemberExpression' ||
|
|
110
|
+
callee.property?.type !== 'Identifier' ||
|
|
111
|
+
!['map', 'flatMap'].includes(callee.property.name) ||
|
|
112
|
+
callee.object?.type !== 'Identifier'
|
|
113
|
+
) return null;
|
|
114
|
+
return callee.object.name;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `const words = text.split(" ")` → "text" */
|
|
118
|
+
function splitSourceOf(ast, arrName) {
|
|
119
|
+
let prop = null;
|
|
120
|
+
walk(ast, (n) => {
|
|
121
|
+
if (prop) return;
|
|
122
|
+
if (n.type !== 'VariableDeclarator' || n.id?.type !== 'Identifier' || n.id.name !== arrName) return;
|
|
123
|
+
const init = unwrapExpr(n.init);
|
|
124
|
+
if (
|
|
125
|
+
init?.type === 'CallExpression' &&
|
|
126
|
+
init.callee?.type === 'MemberExpression' &&
|
|
127
|
+
init.callee.property?.type === 'Identifier' &&
|
|
128
|
+
init.callee.property.name === 'split' &&
|
|
129
|
+
init.callee.object?.type === 'Identifier'
|
|
130
|
+
) prop = init.callee.object.name;
|
|
131
|
+
});
|
|
132
|
+
return prop;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Text reached one hop indirectly: a reveal animation splits a prop into a span
|
|
137
|
+
* per word, so the span you can actually click renders {word}, not {text}. The
|
|
138
|
+
* word isn't addressable in the source — the sentence is — so the editable unit
|
|
139
|
+
* is the whole prop either way.
|
|
140
|
+
*
|
|
141
|
+
* Covers the wrapper that renders {words.map(...)} and any element sitting
|
|
142
|
+
* inside the map callback.
|
|
143
|
+
*/
|
|
144
|
+
function splitMappedProp(ast, chain, element) {
|
|
145
|
+
const meaningful = element.children.filter((c) => !(c.type === 'JSXText' && !c.value.trim()));
|
|
146
|
+
if (meaningful.length === 1 && meaningful[0].type === 'JSXExpressionContainer') {
|
|
147
|
+
const arrName = mapCallArrayName(meaningful[0].expression);
|
|
148
|
+
if (arrName) {
|
|
149
|
+
const prop = splitSourceOf(ast, arrName);
|
|
150
|
+
if (prop) return prop;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const node of chain) {
|
|
154
|
+
const arrName = mapCallArrayName(node);
|
|
155
|
+
if (!arrName) continue;
|
|
156
|
+
const prop = splitSourceOf(ast, arrName);
|
|
157
|
+
if (prop) return prop;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Text is data: `<RevealWords text="..."/>` renders {text} inside a span that
|
|
164
|
+
* lives in Reveal.tsx, so every heading stamps as that same span. Editing there
|
|
165
|
+
* rewrites the shared component and changes the copy under every call site at
|
|
166
|
+
* once — the string belongs to the call site's prop.
|
|
167
|
+
*
|
|
168
|
+
* The stamp can't say which call site rendered this element, so the overlay
|
|
169
|
+
* sends the clicked node's ancestor stamps. The nearest one from another file is
|
|
170
|
+
* the call site; look the component up there. Two usages in that file and we
|
|
171
|
+
* genuinely cannot tell which heading was clicked, so say so rather than rewrite
|
|
172
|
+
* the wrong one.
|
|
173
|
+
*/
|
|
174
|
+
function traceTextProp(root, { file, ast, chain, element }, ancestors = []) {
|
|
175
|
+
const component = enclosingComponent(ast, chain);
|
|
176
|
+
if (!component) return { source: null, note: null };
|
|
177
|
+
|
|
178
|
+
// {text} straight from the prop, or {word} one hop through split/map — the
|
|
179
|
+
// latter can only be rewritten a sentence at a time.
|
|
180
|
+
let propName = null;
|
|
181
|
+
let wholeText = false;
|
|
182
|
+
const direct = soleExpressionIdentifier(element);
|
|
183
|
+
if (direct && isDestructuredProp(component.fn, direct)) {
|
|
184
|
+
propName = direct;
|
|
185
|
+
} else {
|
|
186
|
+
const mapped = splitMappedProp(ast, chain, element);
|
|
187
|
+
if (mapped && isDestructuredProp(component.fn, mapped)) {
|
|
188
|
+
propName = mapped;
|
|
189
|
+
wholeText = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!propName) return { source: null, note: null };
|
|
193
|
+
|
|
194
|
+
// Nearest ancestor first; the element's own file is the component, not a call site.
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
for (const stamp of ancestors) {
|
|
197
|
+
let candidate;
|
|
198
|
+
try { candidate = parseLoc(stamp).file; } catch { continue; }
|
|
199
|
+
candidate = path.isAbsolute(candidate) ? path.relative(root, candidate) : candidate;
|
|
200
|
+
if (candidate === file || seen.has(candidate)) continue;
|
|
201
|
+
seen.add(candidate);
|
|
202
|
+
|
|
203
|
+
const here = findUsages(root, component.name).filter((u) => u.file === candidate);
|
|
204
|
+
if (here.length === 0) continue;
|
|
205
|
+
if (here.length > 1) {
|
|
206
|
+
return {
|
|
207
|
+
source: null,
|
|
208
|
+
note: `this text comes from <${component.name}>'s ${propName} prop, but ${candidate} has ${here.length} usages — I can't tell which one you clicked. Select the heading itself, or describe the change.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const attr = here[0].element.openingElement.attributes.find(
|
|
213
|
+
(a) =>
|
|
214
|
+
a.type === 'JSXAttribute' &&
|
|
215
|
+
a.name.name === propName &&
|
|
216
|
+
a.value?.type === 'StringLiteral'
|
|
217
|
+
);
|
|
218
|
+
if (!attr) {
|
|
219
|
+
return {
|
|
220
|
+
source: null,
|
|
221
|
+
note: `<${component.name}> in ${candidate} passes ${propName} as an expression, not a plain string, so there's no literal to edit here.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
source: {
|
|
226
|
+
file: candidate,
|
|
227
|
+
loc: usageLoc(here[0]),
|
|
228
|
+
component: component.name,
|
|
229
|
+
prop: propName,
|
|
230
|
+
value: attr.value.value,
|
|
231
|
+
// The clicked element shows a slice of this string (one word of a
|
|
232
|
+
// reveal), so the edit replaces the whole sentence. The overlay uses
|
|
233
|
+
// this to edit the element that actually holds all of it.
|
|
234
|
+
wholeText,
|
|
235
|
+
},
|
|
236
|
+
note: null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { source: null, note: null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function describeTarget(root, loc, { ancestors = [] } = {}) {
|
|
243
|
+
const { file, content, element, ast } = loadTarget(root, loc);
|
|
69
244
|
const opening = element.openingElement;
|
|
70
245
|
const texts = element.children
|
|
71
246
|
.filter((c) => c.type === 'JSXText' && c.value.trim())
|
|
72
247
|
.map((c) => ({ value: c.value.trim(), start: c.start, end: c.end }));
|
|
248
|
+
|
|
249
|
+
// Only trace when there's no literal of our own to edit.
|
|
250
|
+
const traced = texts.length === 0
|
|
251
|
+
? traceTextProp(root, { file, ast, chain: ancestorChain(ast, element), element }, ancestors)
|
|
252
|
+
: { source: null, note: null };
|
|
73
253
|
const classAttr = opening.attributes.find(
|
|
74
254
|
(a) =>
|
|
75
255
|
a.type === 'JSXAttribute' &&
|
|
@@ -84,10 +264,36 @@ export function describeTarget(root, loc) {
|
|
|
84
264
|
lines: { start: element.loc.start.line, end: element.loc.end.line },
|
|
85
265
|
snippet: content.slice(element.start, element.end),
|
|
86
266
|
texts,
|
|
267
|
+
// Where this element's text really lives, when it renders a prop rather than
|
|
268
|
+
// a literal of its own. Null when it has its own text, or when the call site
|
|
269
|
+
// can't be pinned down (textSourceNote says why).
|
|
270
|
+
textSource: traced.source,
|
|
271
|
+
textSourceNote: traced.note,
|
|
87
272
|
className: classAttr ? classAttr.value.value : null,
|
|
88
273
|
};
|
|
89
274
|
}
|
|
90
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Rewrite a string prop at a call site: `<RevealWords text="old"/>` → "new".
|
|
278
|
+
* This is the write half of traceTextProp — the edit lands on the call site's
|
|
279
|
+
* data, so sibling usages of the same component keep their own copy.
|
|
280
|
+
*/
|
|
281
|
+
export function applyPropTextEdit(root, loc, prop, oldText, newText) {
|
|
282
|
+
const { abs, content, element } = loadTarget(root, loc);
|
|
283
|
+
const attr = element.openingElement.attributes.find(
|
|
284
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === prop && a.value?.type === 'StringLiteral'
|
|
285
|
+
);
|
|
286
|
+
if (!attr) throw new Error(`no string ${prop} prop to edit at ${loc}`);
|
|
287
|
+
if (oldText != null && attr.value.value !== oldText) {
|
|
288
|
+
throw new Error(`${prop} has changed since it was read — reload and try again`);
|
|
289
|
+
}
|
|
290
|
+
// A JSX attribute string is not a JS string: backslash escapes don't apply,
|
|
291
|
+
// entities do. Escape the two characters that would end or reopen it.
|
|
292
|
+
const escaped = newText.replace(/&/g, '&').replace(/"/g, '"');
|
|
293
|
+
const next = content.slice(0, attr.value.start) + `"${escaped}"` + content.slice(attr.value.end);
|
|
294
|
+
return writeChecked(abs, content, next);
|
|
295
|
+
}
|
|
296
|
+
|
|
91
297
|
export function applyTextEdit(root, loc, oldText, newText) {
|
|
92
298
|
const { abs, content, element } = loadTarget(root, loc);
|
|
93
299
|
const node = element.children.find(
|
package/src/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { describeTarget, applyTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
|
|
5
|
+
import { describeTarget, applyTextEdit, applyPropTextEdit, applyClassEdit, applyStyleEdit, applyDeleteElement, applyMove, parseLoc, checkSyntax } from './resolver.js';
|
|
6
6
|
import { classify, buildPrompt, runClaude, runDeploy } from './router.js';
|
|
7
7
|
import { initTelemetry, DISCLOSURE } from './telemetry.js';
|
|
8
8
|
|
|
@@ -126,7 +126,18 @@ export function startServer({ root, port = 4100, heartbeatMs = 15000 }) {
|
|
|
126
126
|
const body = await readJson(req);
|
|
127
127
|
|
|
128
128
|
if (url.pathname === '/api/resolve') {
|
|
129
|
-
|
|
129
|
+
// ancestors let the daemon tell two call sites of the same component
|
|
130
|
+
// apart — without them, every <RevealWords> heading looks identical.
|
|
131
|
+
return json(res, describeTarget(root, body.loc, { ancestors: body.ancestors || [] }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (url.pathname === '/api/edit-prop') {
|
|
135
|
+
const id = nextId++;
|
|
136
|
+
const write = applyPropTextEdit(root, body.loc, body.prop, body.oldText, body.newText);
|
|
137
|
+
remember(id, write);
|
|
138
|
+
telemetry.record('copy');
|
|
139
|
+
broadcast({ type: 'tweak', id, kind: 'copy', status: 'done', tokens: 0, label: `copy: "${body.newText.slice(0, 40)}"`, ...recordSavings(0, 50) });
|
|
140
|
+
return json(res, { ok: true, id });
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
if (url.pathname === '/api/edit-text') {
|