tweaklocal 0.3.1 → 0.3.3
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 +211 -21
- package/package.json +1 -1
- package/src/resolver.js +152 -27
- package/src/server.js +8 -2
package/overlay/overlay.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
#twk-root{position:fixed;inset:0;pointer-events:none;z-index:2147483000;font-family:ui-sans-serif,system-ui,sans-serif}
|
|
17
17
|
.twk-outline{position:fixed;border:1.5px solid #6366f1;border-radius:3px;background:rgba(99,102,241,.08);pointer-events:none;transition:all .04s linear}
|
|
18
18
|
.twk-outline.twk-selected{border-color:#10b981;background:rgba(16,185,129,.06)}
|
|
19
|
+
.twk-multi{position:fixed;border:1.5px solid #f59e0b;border-radius:3px;background:rgba(245,158,11,.10);pointer-events:none}
|
|
20
|
+
.twk-multibar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);background:#111827;color:#f9fafb;border:1.5px solid #f59e0b;border-radius:10px;box-shadow:0 8px 30px rgba(0,0,0,.4);padding:8px;pointer-events:auto;display:flex;gap:6px;align-items:center;font-size:12px;z-index:2147483001}
|
|
21
|
+
.twk-multibar input{background:#1f2937;border:1px solid #374151;border-radius:6px;color:#f9fafb;padding:5px 8px;font-size:12px;outline:none;width:220px}
|
|
22
|
+
.twk-multibar button{background:#374151;color:#f9fafb;border:none;border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
|
|
23
|
+
.twk-multibar button.twk-danger{background:#dc2626}
|
|
24
|
+
.twk-multibar .twk-count{color:#fbbf24;font-weight:700}
|
|
19
25
|
.twk-badge{position:fixed;background:#312e81;color:#e0e7ff;font-size:11px;padding:2px 7px;border-radius:4px;pointer-events:none;white-space:nowrap;transform:translateY(-100%)}
|
|
20
26
|
.twk-pop-label{position:fixed;background:#10b981;color:#052e1b;font-size:13.8px;font-weight:700;padding:3px 10px;border-radius:8px 8px 0 0;pointer-events:none;white-space:nowrap;transform:translate(-50%,-100%);text-align:center}
|
|
21
27
|
.twk-delete-btn{position:fixed;width:22px;height:22px;border-radius:50%;background:#dc2626;color:#fff;border:none;cursor:pointer;pointer-events:auto;display:flex;align-items:center;justify-content:center;padding:0;box-shadow:0 2px 6px rgba(0,0,0,.4)}
|
|
@@ -61,8 +67,13 @@
|
|
|
61
67
|
selected: null, // { el, loc }
|
|
62
68
|
editing: null, // { el, original }
|
|
63
69
|
tailwind: true, // daemon reports whether the app uses Tailwind
|
|
70
|
+
model: 'auto', // NL model override; 'auto' = router picks
|
|
71
|
+
multi: [], // [{ el, loc }] shift-click multi-selection
|
|
64
72
|
};
|
|
65
73
|
|
|
74
|
+
// Stack of undoable tweak ids (LIFO) for ⌘Z / Ctrl-Z global undo.
|
|
75
|
+
const undoStack = [];
|
|
76
|
+
|
|
66
77
|
// ---------- helpers ----------
|
|
67
78
|
const el = (tag, cls, text) => {
|
|
68
79
|
const n = document.createElement(tag);
|
|
@@ -192,30 +203,39 @@
|
|
|
192
203
|
deleteBtn.style.display = 'none';
|
|
193
204
|
deleteBtn.title = 'Delete element';
|
|
194
205
|
root.appendChild(deleteBtn);
|
|
206
|
+
// All instances of a mapped template share one source stamp; the DOM position
|
|
207
|
+
// of a given instance tells the daemon WHICH data item it is (list items
|
|
208
|
+
// render in array order).
|
|
209
|
+
function instanceIndex(elm, loc) {
|
|
210
|
+
const instances = [...document.querySelectorAll(`[data-twk="${CSS.escape(loc)}"]`)];
|
|
211
|
+
const i = instances.indexOf(elm);
|
|
212
|
+
return i >= 0 ? i : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function deleteOne({ el: elm, loc }) {
|
|
216
|
+
const payload = { loc, index: instanceIndex(elm, loc) };
|
|
217
|
+
// Dry-run first so a refusal never flickers the element.
|
|
218
|
+
await api('delete', { ...payload, dryRun: true });
|
|
219
|
+
if (document.contains(elm)) elm.style.display = 'none'; // optimistic; HMR makes it real
|
|
220
|
+
await api('delete', payload);
|
|
221
|
+
}
|
|
222
|
+
|
|
195
223
|
deleteBtn.onclick = async (e) => {
|
|
196
224
|
e.preventDefault();
|
|
197
225
|
e.stopPropagation();
|
|
198
226
|
const sel = state.selected;
|
|
199
227
|
if (!sel || !document.contains(sel.el)) return;
|
|
200
|
-
// Check first so a refusal never flickers the element or loses the
|
|
201
|
-
// selection — only hide optimistically once we know it'll succeed.
|
|
202
228
|
deleteBtn.disabled = true;
|
|
203
229
|
try {
|
|
204
|
-
await
|
|
230
|
+
await deleteOne(sel);
|
|
205
231
|
} catch (err) {
|
|
232
|
+
if (document.contains(sel.el)) sel.el.style.display = '';
|
|
206
233
|
deleteBtn.disabled = false;
|
|
207
|
-
addTweak({ id: 'x' + Date.now(), status: 'error', label: err.message.slice(0, 140) });
|
|
234
|
+
addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message.slice(0, 140)}` });
|
|
208
235
|
return;
|
|
209
236
|
}
|
|
210
237
|
deleteBtn.disabled = false;
|
|
211
|
-
sel.el.style.display = 'none'; // optimistic; HMR makes it real
|
|
212
238
|
deselect();
|
|
213
|
-
try {
|
|
214
|
-
await api('delete', { loc: sel.loc });
|
|
215
|
-
} catch (err) {
|
|
216
|
-
sel.el.style.display = '';
|
|
217
|
-
addTweak({ id: 'x' + Date.now(), status: 'error', label: `delete: ${err.message}` });
|
|
218
|
-
}
|
|
219
239
|
};
|
|
220
240
|
|
|
221
241
|
function select(target) {
|
|
@@ -252,6 +272,97 @@
|
|
|
252
272
|
deleteBtn.style.display = 'none';
|
|
253
273
|
}
|
|
254
274
|
|
|
275
|
+
// ---------- multi-select (shift-click) ----------
|
|
276
|
+
const multiBoxes = []; // pooled outline divs
|
|
277
|
+
const multiBar = el('div', 'twk-multibar');
|
|
278
|
+
multiBar.style.display = 'none';
|
|
279
|
+
root.appendChild(multiBar);
|
|
280
|
+
|
|
281
|
+
function toggleMulti(target) {
|
|
282
|
+
const loc = target.getAttribute('data-twk');
|
|
283
|
+
const i = state.multi.findIndex((m) => m.el === target);
|
|
284
|
+
if (i >= 0) state.multi.splice(i, 1);
|
|
285
|
+
else {
|
|
286
|
+
// leaving single-selection mode: fold the current single selection in too
|
|
287
|
+
if (state.selected && !state.multi.some((m) => m.el === state.selected.el)) {
|
|
288
|
+
state.multi.push({ el: state.selected.el, loc: state.selected.loc });
|
|
289
|
+
}
|
|
290
|
+
if (!state.multi.some((m) => m.el === target)) state.multi.push({ el: target, loc });
|
|
291
|
+
deselect();
|
|
292
|
+
}
|
|
293
|
+
renderMulti();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function clearMulti() {
|
|
297
|
+
state.multi = [];
|
|
298
|
+
renderMulti();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function positionMulti() {
|
|
302
|
+
state.multi.forEach((m, i) => {
|
|
303
|
+
if (!multiBoxes[i]) { multiBoxes[i] = el('div', 'twk-multi'); root.appendChild(multiBoxes[i]); }
|
|
304
|
+
const box = multiBoxes[i];
|
|
305
|
+
if (!document.contains(m.el)) { box.style.display = 'none'; return; }
|
|
306
|
+
box.style.display = 'block';
|
|
307
|
+
positionBox(box, m.el);
|
|
308
|
+
});
|
|
309
|
+
for (let i = state.multi.length; i < multiBoxes.length; i++) multiBoxes[i].style.display = 'none';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let multiInput;
|
|
313
|
+
function renderMulti() {
|
|
314
|
+
positionMulti();
|
|
315
|
+
if (!state.multi.length) { multiBar.style.display = 'none'; return; }
|
|
316
|
+
multiBar.style.display = 'flex';
|
|
317
|
+
multiBar.textContent = '';
|
|
318
|
+
multiBar.append(el('span', 'twk-count', String(state.multi.length)));
|
|
319
|
+
multiBar.append(el('span', null, 'selected'));
|
|
320
|
+
multiInput = el('input');
|
|
321
|
+
multiInput.placeholder = 'Describe a change for all…';
|
|
322
|
+
multiInput.value = multiBar._draft || '';
|
|
323
|
+
multiInput.oninput = () => { multiBar._draft = multiInput.value; };
|
|
324
|
+
multiInput.onkeydown = (e) => { if (e.key === 'Enter') applyMultiNL(); e.stopPropagation(); };
|
|
325
|
+
const apply = el('button', 'twk-primary', 'Apply');
|
|
326
|
+
apply.onclick = applyMultiNL;
|
|
327
|
+
const del = el('button', 'twk-danger', 'Delete all');
|
|
328
|
+
del.onclick = deleteMulti;
|
|
329
|
+
const clear = el('button', null, 'Clear');
|
|
330
|
+
clear.onclick = clearMulti;
|
|
331
|
+
multiBar.append(multiInput, apply, del, clear);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function applyMultiNL() {
|
|
335
|
+
const instruction = (multiInput?.value || '').trim();
|
|
336
|
+
if (!instruction) return;
|
|
337
|
+
const targets = state.multi.slice();
|
|
338
|
+
multiBar._draft = '';
|
|
339
|
+
clearMulti();
|
|
340
|
+
for (const t of targets) {
|
|
341
|
+
try {
|
|
342
|
+
const r = await api('nl', { loc: t.loc, instruction, model: state.model });
|
|
343
|
+
addTweak({ id: r.id, status: 'queued', model: r.model, label: `${shortLoc(t.loc)}: ${instruction.slice(0, 40)}` });
|
|
344
|
+
} catch (e) {
|
|
345
|
+
addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `${shortLoc(t.loc)}: ${e.message}` });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function deleteMulti() {
|
|
351
|
+
const targets = state.multi.slice();
|
|
352
|
+
clearMulti();
|
|
353
|
+
// Delete bottom-up so a mapped-list removal doesn't shift the index of a
|
|
354
|
+
// not-yet-deleted sibling in the same list.
|
|
355
|
+
targets.sort((a, b) => (instanceIndex(b.el, b.loc) ?? 0) - (instanceIndex(a.el, a.loc) ?? 0));
|
|
356
|
+
for (const t of targets) {
|
|
357
|
+
try {
|
|
358
|
+
await deleteOne(t);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
if (document.contains(t.el)) t.el.style.display = '';
|
|
361
|
+
addTweak({ id: 'x' + Date.now() + t.loc, status: 'error', label: `delete ${shortLoc(t.loc)}: ${e.message}` });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
function reposition() {
|
|
256
367
|
const s = state.selected;
|
|
257
368
|
if (!s) return;
|
|
@@ -322,12 +433,25 @@
|
|
|
322
433
|
function fontStep(target, dir) {
|
|
323
434
|
const scale = readTheme().textSizes;
|
|
324
435
|
const classes = classList(target);
|
|
436
|
+
// An arbitrary size like `text-[34px]` also sets font-size and would
|
|
437
|
+
// override the scale class we add — remove it too, and seed the step from
|
|
438
|
+
// the element's rendered px so the first bump goes the right direction.
|
|
439
|
+
const arbitrary = classes.filter((c) => /^text-\[[^\]]+\]$/.test(c));
|
|
325
440
|
const cur = classes.find((c) => scale.includes(c));
|
|
326
|
-
let idx
|
|
441
|
+
let idx;
|
|
442
|
+
if (cur) idx = scale.indexOf(cur);
|
|
443
|
+
else {
|
|
444
|
+
const px = parseFloat(getComputedStyle(target).fontSize) || 16;
|
|
445
|
+
// nearest scale entry by rendered size, else middle
|
|
446
|
+
idx = scale.indexOf('text-base');
|
|
447
|
+
if (idx < 0) idx = Math.floor(scale.length / 2);
|
|
448
|
+
}
|
|
327
449
|
if (idx < 0) idx = Math.floor(scale.length / 2);
|
|
328
450
|
const next = Math.min(Math.max(idx + dir, 0), scale.length - 1);
|
|
329
|
-
|
|
330
|
-
|
|
451
|
+
const add = scale[next];
|
|
452
|
+
const remove = [...(cur ? [cur] : []), ...arbitrary];
|
|
453
|
+
if (add === cur && !arbitrary.length) return null;
|
|
454
|
+
return { remove, add: [add] };
|
|
331
455
|
}
|
|
332
456
|
|
|
333
457
|
// Property editor: which classes to strip when setting each property.
|
|
@@ -608,14 +732,14 @@
|
|
|
608
732
|
|
|
609
733
|
const nlRow = el('div', 'twk-row');
|
|
610
734
|
const input = el('input');
|
|
611
|
-
input.placeholder = 'Describe a change…
|
|
735
|
+
input.placeholder = 'Describe a change…';
|
|
612
736
|
const go = el('button', 'twk-primary', 'Go');
|
|
613
737
|
const send = async () => {
|
|
614
738
|
const instruction = input.value.trim();
|
|
615
739
|
if (!instruction) return;
|
|
616
740
|
input.value = '';
|
|
617
741
|
try {
|
|
618
|
-
const r = await api('nl', { loc: s.loc, instruction });
|
|
742
|
+
const r = await api('nl', { loc: s.loc, instruction, model: state.model });
|
|
619
743
|
addTweak({ id: r.id, status: 'queued', model: r.model, label: instruction.slice(0, 60) });
|
|
620
744
|
} catch (e) {
|
|
621
745
|
addTweak({ id: 'x' + Date.now(), status: 'error', label: e.message });
|
|
@@ -625,6 +749,20 @@
|
|
|
625
749
|
input.onkeydown = (e) => { if (e.key === 'Enter') send(); e.stopPropagation(); };
|
|
626
750
|
nlRow.append(input, go);
|
|
627
751
|
pop.appendChild(nlRow);
|
|
752
|
+
|
|
753
|
+
// Model picker: Auto (router) or a forced tier.
|
|
754
|
+
const modelRow = el('div', 'twk-row');
|
|
755
|
+
modelRow.append(el('span', 'twk-label', 'Model'));
|
|
756
|
+
const modelSel = el('select');
|
|
757
|
+
for (const [label, value] of [['Auto (routed)', 'auto'], ['Sonnet', 'claude-sonnet-5'], ['Opus', 'claude-opus-4-8'], ['Fable', 'claude-fable-5']]) {
|
|
758
|
+
const o = el('option', null, label);
|
|
759
|
+
o.value = value;
|
|
760
|
+
if (value === state.model) o.selected = true;
|
|
761
|
+
modelSel.appendChild(o);
|
|
762
|
+
}
|
|
763
|
+
modelSel.onchange = () => { state.model = modelSel.value; };
|
|
764
|
+
modelRow.append(modelSel);
|
|
765
|
+
pop.appendChild(modelRow);
|
|
628
766
|
// delete lives as a floating icon on the element itself (deleteBtn)
|
|
629
767
|
}
|
|
630
768
|
|
|
@@ -733,7 +871,14 @@
|
|
|
733
871
|
row._dot.className = 'twk-dot ' + t.status;
|
|
734
872
|
const inFlight = t.status === 'queued' || t.status === 'running';
|
|
735
873
|
row._cancel.style.display = inFlight ? '' : 'none';
|
|
736
|
-
|
|
874
|
+
const undoable = t.status === 'done' && !String(t.id).startsWith('x');
|
|
875
|
+
row._undo.style.display = undoable ? '' : 'none';
|
|
876
|
+
const key = String(t.id);
|
|
877
|
+
if (undoable && !undoStack.includes(key)) undoStack.push(key);
|
|
878
|
+
if (t.status === 'reverted') {
|
|
879
|
+
const i = undoStack.indexOf(key);
|
|
880
|
+
if (i >= 0) undoStack.splice(i, 1);
|
|
881
|
+
}
|
|
737
882
|
}
|
|
738
883
|
const bits = [];
|
|
739
884
|
if (t.model) bits.push(t.model.replace(/^claude-/, '') + (t.effort ? ` @ ${t.effort}` : ''));
|
|
@@ -768,22 +913,64 @@
|
|
|
768
913
|
|
|
769
914
|
function setMode(on) {
|
|
770
915
|
state.selectMode = on;
|
|
771
|
-
hint.textContent = on ? 'select mode — click
|
|
772
|
-
if (!on) { setHover(null); deselect(); }
|
|
916
|
+
hint.textContent = on ? 'select mode — click · shift-click multi · Tab next · ⌘Z undo · Esc exit' : '⌘. select mode';
|
|
917
|
+
if (!on) { setHover(null); deselect(); clearMulti(); }
|
|
773
918
|
}
|
|
774
919
|
|
|
920
|
+
// Visible, stamped elements in document order — the Tab cycle.
|
|
921
|
+
function stampedEls() {
|
|
922
|
+
return [...document.querySelectorAll('[data-twk]')].filter((n) => n.getClientRects().length);
|
|
923
|
+
}
|
|
924
|
+
function selectNext(dir) {
|
|
925
|
+
const els = stampedEls();
|
|
926
|
+
if (!els.length) return;
|
|
927
|
+
const cur = state.selected?.el;
|
|
928
|
+
let idx = cur ? els.indexOf(cur) : -1;
|
|
929
|
+
idx = (idx + dir + els.length) % els.length;
|
|
930
|
+
const next = els[idx];
|
|
931
|
+
select(next);
|
|
932
|
+
next.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function undoLast() {
|
|
936
|
+
const id = undoStack.pop();
|
|
937
|
+
if (id == null) return;
|
|
938
|
+
try {
|
|
939
|
+
await api('undo', { id });
|
|
940
|
+
setTimeout(reposition, 350);
|
|
941
|
+
} catch { /* already reverted / unknown — drop it */ }
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ⌘Z / Ctrl-Z should still do native undo inside inputs and while editing copy.
|
|
945
|
+
const inTextField = (t) =>
|
|
946
|
+
t instanceof Element && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
947
|
+
|
|
775
948
|
addEventListener('keydown', (e) => {
|
|
776
949
|
if ((e.metaKey || e.ctrlKey) && e.key === '.') {
|
|
777
950
|
e.preventDefault();
|
|
778
951
|
setMode(!state.selectMode);
|
|
779
952
|
return;
|
|
780
953
|
}
|
|
954
|
+
// Global undo (works whenever the overlay is active, not just in select
|
|
955
|
+
// mode) — but never hijack undo inside a text field or mid copy-edit.
|
|
956
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z') && !e.shiftKey) {
|
|
957
|
+
if (state.editing || inTextField(e.target)) return;
|
|
958
|
+
e.preventDefault();
|
|
959
|
+
undoLast();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
781
962
|
if (!state.selectMode) return;
|
|
782
963
|
if (e.key === 'Escape') {
|
|
783
964
|
if (state.editing) return finishTextEdit(false);
|
|
965
|
+
if (state.multi.length) return clearMulti();
|
|
784
966
|
if (state.selected) return deselect();
|
|
785
967
|
return setMode(false);
|
|
786
968
|
}
|
|
969
|
+
if (e.key === 'Tab' && !state.editing) {
|
|
970
|
+
e.preventDefault();
|
|
971
|
+
selectNext(e.shiftKey ? -1 : 1);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
787
974
|
if (e.key === 'Enter' && state.editing) {
|
|
788
975
|
e.preventDefault();
|
|
789
976
|
finishTextEdit(true);
|
|
@@ -812,10 +999,13 @@
|
|
|
812
999
|
e.preventDefault();
|
|
813
1000
|
e.stopPropagation();
|
|
814
1001
|
const target = e.target instanceof Element ? e.target.closest('[data-twk]') : null;
|
|
1002
|
+
// Shift/⌘-click toggles an element in the multi-selection set.
|
|
1003
|
+
if (target && (e.shiftKey || e.metaKey || e.ctrlKey)) return toggleMulti(target);
|
|
1004
|
+
if (state.multi.length) clearMulti();
|
|
815
1005
|
if (target) select(target);
|
|
816
1006
|
else deselect();
|
|
817
1007
|
}, true);
|
|
818
1008
|
|
|
819
|
-
addEventListener('scroll', () => { reposition(); setHover(state.hoverEl); }, true);
|
|
820
|
-
addEventListener('resize', () => reposition());
|
|
1009
|
+
addEventListener('scroll', () => { reposition(); positionMulti(); setHover(state.hoverEl); }, true);
|
|
1010
|
+
addEventListener('resize', () => { reposition(); positionMulti(); });
|
|
821
1011
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tweaklocal",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -94,27 +94,72 @@ export function applyClassEdit(root, loc, removes = [], adds = []) {
|
|
|
94
94
|
const { abs, content, element } = loadTarget(root, loc);
|
|
95
95
|
const opening = element.openingElement;
|
|
96
96
|
const attr = opening.attributes.find(
|
|
97
|
-
(a) =>
|
|
98
|
-
a.type === 'JSXAttribute' &&
|
|
99
|
-
a.name.name === 'className' &&
|
|
100
|
-
a.value &&
|
|
101
|
-
a.value.type === 'StringLiteral'
|
|
97
|
+
(a) => a.type === 'JSXAttribute' && a.name.name === 'className'
|
|
102
98
|
);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let classes =
|
|
106
|
-
classes = classes.filter((c) => !removes.includes(c));
|
|
99
|
+
|
|
100
|
+
const editList = (list) => {
|
|
101
|
+
let classes = list.split(/\s+/).filter(Boolean).filter((c) => !removes.includes(c));
|
|
107
102
|
for (const a of adds) if (!classes.includes(a)) classes.push(a);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
return classes.join(' ');
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// No className yet — add a fresh string attribute.
|
|
107
|
+
if (!attr) {
|
|
113
108
|
const pos = opening.name.end;
|
|
114
|
-
|
|
115
|
-
content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos);
|
|
109
|
+
return writeChecked(abs, content, content.slice(0, pos) + ` className="${adds.join(' ')}"` + content.slice(pos));
|
|
116
110
|
}
|
|
117
|
-
|
|
111
|
+
|
|
112
|
+
// className="..." (plain string attribute)
|
|
113
|
+
if (attr.value && attr.value.type === 'StringLiteral') {
|
|
114
|
+
const next = content.slice(0, attr.value.start + 1) + editList(attr.value.value) + content.slice(attr.value.end - 1);
|
|
115
|
+
return writeChecked(abs, content, next);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const expr = attr.value && attr.value.type === 'JSXExpressionContainer' ? attr.value.expression : null;
|
|
119
|
+
|
|
120
|
+
// className={"..."} / className={'...'} (string inside an expression)
|
|
121
|
+
if (expr && expr.type === 'StringLiteral') {
|
|
122
|
+
const next = content.slice(0, expr.start) + JSON.stringify(editList(expr.value)) + content.slice(expr.end);
|
|
123
|
+
return writeChecked(abs, content, next);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// className={`... ${dyn} ...`} — edit only the STATIC class tokens; keep the
|
|
127
|
+
// dynamic `${...}` expressions verbatim, and stay a single className. (The
|
|
128
|
+
// old code appended a second className attribute here, which React silently
|
|
129
|
+
// ignored — the reported "font change didn't persist" bug.)
|
|
130
|
+
if (expr && expr.type === 'TemplateLiteral') {
|
|
131
|
+
const SENT = '\u0000'; // placeholder marking where each `${expr}` sits
|
|
132
|
+
const parts = [];
|
|
133
|
+
expr.quasis.forEach((q, i) => {
|
|
134
|
+
parts.push(q.value.raw);
|
|
135
|
+
if (i < expr.expressions.length) parts.push(SENT);
|
|
136
|
+
});
|
|
137
|
+
let tokens = parts
|
|
138
|
+
.join('')
|
|
139
|
+
.split(/\s+/)
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.flatMap((t) => {
|
|
142
|
+
if (!t.includes(SENT)) return [t];
|
|
143
|
+
const out = [];
|
|
144
|
+
t.split(SENT).forEach((seg, i, a) => { if (seg) out.push(seg); if (i < a.length - 1) out.push(SENT); });
|
|
145
|
+
return out;
|
|
146
|
+
});
|
|
147
|
+
tokens = tokens.filter((t) => t === SENT || !removes.includes(t));
|
|
148
|
+
for (const a of adds) if (!tokens.includes(a)) tokens.push(a);
|
|
149
|
+
let ei = 0;
|
|
150
|
+
const rebuilt = tokens
|
|
151
|
+
.map((t) => (t === SENT ? '${' + content.slice(expr.expressions[ei].start, expr.expressions[ei++].end) + '}' : t))
|
|
152
|
+
.join(' ');
|
|
153
|
+
const next = content.slice(0, expr.start) + '`' + rebuilt + '`' + content.slice(expr.end);
|
|
154
|
+
return writeChecked(abs, content, next);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Computed className (cn(...), a variable, &&/ternary at the top level) —
|
|
158
|
+
// refuse rather than write a duplicate/broken attribute. The overlay surfaces
|
|
159
|
+
// this; size/color still work via the inline-style lane.
|
|
160
|
+
throw new Error(
|
|
161
|
+
"className here is computed, so I can't edit its classes deterministically. Use the size/color controls (inline style) or describe the change to route it through the model."
|
|
162
|
+
);
|
|
118
163
|
}
|
|
119
164
|
|
|
120
165
|
/**
|
|
@@ -255,16 +300,17 @@ function nameOfFunction(ast, fn) {
|
|
|
255
300
|
|
|
256
301
|
// Walk up from the element looking for a `{...}` JSX expression whose whole
|
|
257
302
|
// removal parses — covers `{cond && <el/>}` (conditional) and `{arr.map(...)}`
|
|
258
|
-
// (list template
|
|
259
|
-
// function
|
|
260
|
-
//
|
|
303
|
+
// (list template). Never crosses a function boundary except an inline callback
|
|
304
|
+
// (a function that is itself a call argument, e.g. the .map render callback).
|
|
305
|
+
// For lists, also returns the map CallExpression so the caller can target the
|
|
306
|
+
// underlying data array instead of the whole list.
|
|
261
307
|
function removableExpressionContainer(chain) {
|
|
262
308
|
for (let i = 1; i < chain.length; i++) {
|
|
263
309
|
const node = chain[i];
|
|
264
310
|
if (node.type === 'JSXExpressionContainer') {
|
|
265
311
|
const inner = chain[i - 1];
|
|
266
312
|
const isList = FN_TYPES.has(inner?.type) || inner?.type === 'CallExpression';
|
|
267
|
-
return { container: node, kind: isList ? 'list' : 'conditional block' };
|
|
313
|
+
return { container: node, kind: isList ? 'list' : 'conditional block', mapCall: isList ? inner : null };
|
|
268
314
|
}
|
|
269
315
|
if (FN_TYPES.has(node.type)) {
|
|
270
316
|
// crossing a function is only allowed for inline callbacks (map etc.)
|
|
@@ -275,6 +321,68 @@ function removableExpressionContainer(chain) {
|
|
|
275
321
|
return null;
|
|
276
322
|
}
|
|
277
323
|
|
|
324
|
+
// Unwrap TS wrappers around an initializer (as const / satisfies / parens).
|
|
325
|
+
function unwrapExpr(node) {
|
|
326
|
+
let n = node;
|
|
327
|
+
while (n && (n.type === 'TSAsExpression' || n.type === 'TSSatisfiesExpression' || n.type === 'TSNonNullExpression' || n.type === 'ParenthesizedExpression'))
|
|
328
|
+
n = n.expression;
|
|
329
|
+
return n;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// For `{items.map(cb)}`: resolve `items` to a local `const items = [...]`
|
|
333
|
+
// ArrayExpression in the same file. Returns the array node or null.
|
|
334
|
+
function resolveMappedArray(ast, mapCall) {
|
|
335
|
+
let call = mapCall;
|
|
336
|
+
// chain may have handed us the arrow (expression-body case) — find the call
|
|
337
|
+
if (call && FN_TYPES.has(call.type)) return null; // caller passes the real call below
|
|
338
|
+
if (!call || call.type !== 'CallExpression') return null;
|
|
339
|
+
const callee = call.callee;
|
|
340
|
+
if (
|
|
341
|
+
callee?.type !== 'MemberExpression' ||
|
|
342
|
+
callee.property?.type !== 'Identifier' ||
|
|
343
|
+
!['map', 'flatMap'].includes(callee.property.name) ||
|
|
344
|
+
callee.object?.type !== 'Identifier'
|
|
345
|
+
) return null;
|
|
346
|
+
const arrName = callee.object.name;
|
|
347
|
+
let arr = null;
|
|
348
|
+
walk(ast, (n) => {
|
|
349
|
+
if (arr) return;
|
|
350
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.id.name === arrName) {
|
|
351
|
+
const init = unwrapExpr(n.init);
|
|
352
|
+
if (init?.type === 'ArrayExpression') arr = init;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
return arr;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Remove the k-th element of an ArrayExpression, handling commas and whole
|
|
359
|
+
// lines. Returns new content, or null if the result doesn't parse.
|
|
360
|
+
function removeArrayElement(content, arrayNode, k, file) {
|
|
361
|
+
const el = arrayNode.elements[k];
|
|
362
|
+
if (!el) return null;
|
|
363
|
+
let start = el.start;
|
|
364
|
+
let end = el.end;
|
|
365
|
+
const after = content.slice(end).match(/^\s*,/);
|
|
366
|
+
if (after) end += after[0].length;
|
|
367
|
+
else {
|
|
368
|
+
const before = content.slice(0, start).match(/,\s*$/);
|
|
369
|
+
if (before) start -= before[0].length;
|
|
370
|
+
}
|
|
371
|
+
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
|
|
372
|
+
const trailing = content.slice(end).match(/^[ \t]*\n/);
|
|
373
|
+
if (/^[ \t]*$/.test(content.slice(lineStart, start)) && trailing) {
|
|
374
|
+
start = lineStart;
|
|
375
|
+
end += trailing[0].length;
|
|
376
|
+
}
|
|
377
|
+
const next = content.slice(0, start) + content.slice(end);
|
|
378
|
+
try {
|
|
379
|
+
parseSource(next, file);
|
|
380
|
+
return next;
|
|
381
|
+
} catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
278
386
|
// Walk the project for JSX usages of <Name ...>. Returns [{file, element}].
|
|
279
387
|
function findUsages(root, name) {
|
|
280
388
|
const usages = [];
|
|
@@ -309,15 +417,16 @@ function findUsages(root, name) {
|
|
|
309
417
|
/**
|
|
310
418
|
* Delete behavior, in order:
|
|
311
419
|
* 1. Normal element (has siblings): remove it in place.
|
|
312
|
-
* 2.
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* 3.
|
|
420
|
+
* 2. List template (`{arr.map(...)}`): the clicked DOM instance's `index`
|
|
421
|
+
* identifies the data item — remove that ONE entry from the local array
|
|
422
|
+
* literal. Deleting one card removes one card, not the list. (To remove a
|
|
423
|
+
* whole list, select its surrounding wrapper element instead.)
|
|
424
|
+
* 3. Conditional (`{cond && <el/>}`): remove the whole conditional block.
|
|
425
|
+
* 4. Sole return of a NAMED component used in exactly one place: remove that
|
|
317
426
|
* usage at its call site.
|
|
318
427
|
* Otherwise refuse with an actionable message (ambiguous → model lane).
|
|
319
428
|
*/
|
|
320
|
-
export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
429
|
+
export function applyDeleteElement(root, loc, { dryRun = false, index } = {}) {
|
|
321
430
|
// reuse loadTarget's AST — element identity must hold for the ancestor walk
|
|
322
431
|
const { abs, content, element, file, ast } = loadTarget(root, loc);
|
|
323
432
|
|
|
@@ -331,6 +440,22 @@ export function applyDeleteElement(root, loc, { dryRun = false } = {}) {
|
|
|
331
440
|
const chain = ancestorChain(ast, element);
|
|
332
441
|
|
|
333
442
|
const wrapper = removableExpressionContainer(chain);
|
|
443
|
+
if (wrapper && wrapper.kind === 'list') {
|
|
444
|
+
const arr = resolveMappedArray(ast, wrapper.mapCall);
|
|
445
|
+
if (arr && Number.isInteger(index) && index >= 0 && index < arr.elements.length) {
|
|
446
|
+
const next = removeArrayElement(content, arr, index, file);
|
|
447
|
+
if (next !== null) {
|
|
448
|
+
const detail = `item ${index + 1} of ${arr.elements.length}`;
|
|
449
|
+
if (dryRun) return { abs, before: content, after: next, wouldWrite: false, removedBlock: detail };
|
|
450
|
+
return { ...writeChecked(abs, content, next), removedBlock: detail };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// data not editable deterministically (imported/computed array, or the
|
|
454
|
+
// instance index is unknown) — never silently delete the whole list
|
|
455
|
+
throw new Error(
|
|
456
|
+
"this is one item in a rendered list whose data I can't safely edit here. Describe the change (e.g. \"remove the second stat\") and it'll route through the model — or select the list's surrounding container to delete the whole list."
|
|
457
|
+
);
|
|
458
|
+
}
|
|
334
459
|
if (wrapper) {
|
|
335
460
|
const next = removeElementFromContent(content, wrapper.container, file);
|
|
336
461
|
if (next !== null) {
|
package/src/server.js
CHANGED
|
@@ -128,12 +128,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
128
128
|
|
|
129
129
|
if (url.pathname === '/api/delete') {
|
|
130
130
|
if (body.dryRun) {
|
|
131
|
-
applyDeleteElement(root, body.loc, { dryRun: true }); // throws if unsafe, writes nothing
|
|
131
|
+
applyDeleteElement(root, body.loc, { dryRun: true, index: body.index }); // throws if unsafe, writes nothing
|
|
132
132
|
return json(res, { ok: true, dryRun: true });
|
|
133
133
|
}
|
|
134
134
|
const id = nextId++;
|
|
135
135
|
const target = describeTarget(root, body.loc);
|
|
136
|
-
const write = applyDeleteElement(root, body.loc);
|
|
136
|
+
const write = applyDeleteElement(root, body.loc, { index: body.index });
|
|
137
137
|
remember(id, write);
|
|
138
138
|
telemetry.record('delete');
|
|
139
139
|
const label = write.removedUsage
|
|
@@ -173,6 +173,12 @@ export function startServer({ root, port = 4100 }) {
|
|
|
173
173
|
const { file } = parseLoc(body.loc);
|
|
174
174
|
const target = describeTarget(root, body.loc);
|
|
175
175
|
const route = await classify(body.instruction, { cwd: root });
|
|
176
|
+
// Manual model override from the picker: keep the router's effort/tier
|
|
177
|
+
// (still sized to the request) but force the model the user chose.
|
|
178
|
+
if (body.model && body.model !== 'auto') {
|
|
179
|
+
route.model = body.model;
|
|
180
|
+
route.tier = 'manual';
|
|
181
|
+
}
|
|
176
182
|
const abs = path.resolve(root, file);
|
|
177
183
|
remember(id, { abs, before: fs.readFileSync(abs, 'utf8') });
|
|
178
184
|
telemetry.record('nl');
|