Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/trace-generated-text-definitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@open-codesign/desktop": patch
"@open-codesign/runtime": patch
"@open-codesign/shared": patch
"@open-codesign/i18n": patch
---

Improve deterministic text editing for generated JSX/TSX: edit independent mixed-child segments, shared local component literals, and lexically traced const/object/static-array string definitions. Display exact source origins and shared-definition scope, bind individual preview fields, and refuse unresolved instance data rather than guessing. Preserve hash conflicts, path safety, and atomic span writes without a model call.

Unify editing into preview selection and one field panel, removing the separate source picker. Support static segments beside dynamic content, disabled control selection, explicit containing-element navigation, and fresh field validation before saving. Retired source-mode IPC requests are explicitly rejected rather than acting as a bypass. Localize editability reasons, source-definition explanations and inspect/save/refresh messages for Chinese and English interfaces, keeping backend reason codes in collapsed diagnostics.

Bind resolved HTML-to-JSX previews to the requested file/design/workspace and refresh sources outside the expanded file tree. Add data-driven browser persistence, shared-reference, mutation/escape, mixed-text, ambiguous-mapping and source-conflict regressions.
137 changes: 77 additions & 60 deletions SOURCE_EDITING.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/desktop/src/main/onboarding-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ describe('registerOnboardingIpc — channel versioning', () => {
} finally {
probe.mockRestore();
}
});
}, 15_000);
it('registers settings:v1:list-providers without the unversioned settings:list-providers shim', async () => {
await registerIpcForTest();

Expand Down
77 changes: 54 additions & 23 deletions apps/desktop/src/main/source-edit-engine.source-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const source = `function App() {
const dialog = React.useRef(null);
const label = React.useMemo(() => String(count), [count]);
React.useEffect(() => { document.title = label; }, [label]);
return <main><h1 title="Original">Welcome</h1>
return <main><h1 title="Original" style={{gap:2}}>Welcome</h1>
<button onClick={() => setCount(count + 1)}>{label}</button>
{[1, 2].map(n => <p>Repeated</p>)}
<section ref={dialog}><span>Dialog</span></section>
Expand All @@ -15,7 +15,7 @@ const source = `function App() {
ReactDOM.createRoot(document.getElementById('root')).render(<App />);`;

function inspect() {
const result = analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'source' });
const result = analyzeSourceEdit({ path: 'App.jsx', source });
if (result.status !== 'ready') throw new Error(result.message);
return result;
}
Expand All @@ -26,40 +26,76 @@ function request(tag: string) {
return {
path: 'App.jsx',
source,
selectionMode: 'source' as const,
expectedSourceHash: result.sourceHash,
targetId: target.id,
scope: 'source-definition' as const,
operation: { kind: 'set-text' as const, value: 'Edited' },
};
}

describe('explicit source selection beside opaque execution', () => {
it('keeps preview inspection and preview apply closed', () => {
expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({
describe('one preview-only source edit path', () => {
it('rejects legacy source mode for both inspection and patch planning', () => {
expect(analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'source' })).toMatchObject({
status: 'rejected',
reason: 'unsafe-source',
reason: 'source-mode-removed',
});
expect(planSourceEdit({ ...request('h1'), selectionMode: 'preview' })).toMatchObject({
expect(planSourceEdit({ ...request('h1'), selectionMode: 'source' })).toMatchObject({
status: 'rejected',
reason: 'unsafe-source',
reason: 'source-mode-removed',
});
expect(
planSourceEdit({ ...request('h1'), source: `${source}\n// stale`, selectionMode: 'source' }),
).toMatchObject({ status: 'rejected', reason: 'source-mode-removed' });
});
it('treats omitted and explicit preview modes identically', () => {
expect(analyzeSourceEdit({ path: 'App.jsx', source, selectionMode: 'preview' })).toEqual(
inspect(),
);
expect(planSourceEdit({ ...request('h1'), selectionMode: 'preview' })).toEqual(
planSourceEdit(request('h1')),
);
});
it('keeps existing text, attribute and style candidates beside effects and explicit mounting', () => {
expect(inspect().targets.find((t) => t.tagName === 'h1')?.editableFields).toEqual([
{ kind: 'set-text', value: 'Welcome' },
{ kind: 'set-attribute', name: 'title', value: 'Original' },
{ kind: 'set-style', property: 'gap', value: '2' },
]);
expect(
planSourceEdit({
...request('h1'),
operation: { kind: 'set-attribute', name: 'title', value: 'Changed' },
}),
).toMatchObject({
status: 'applied',
content: source.replace('title="Original"', 'title={"Changed"}'),
});
expect(
planSourceEdit({
...request('h1'),
operation: { kind: 'set-style', property: 'gap', value: '8' },
}),
).toMatchObject({ status: 'applied', content: source.replace('gap:2', 'gap:8') });
});
it('replaces only the explicitly selected static literal without executing effects', () => {
it('replaces only the selected static literal without executing effects', () => {
const result = planSourceEdit(request('h1'));
expect(result.status).toBe('applied');
if (result.status !== 'applied') throw new Error(result.message);
expect(result.content).toBe(source.replace('>Welcome<', '>{"Edited"}<'));
expect(result.content).not.toContain('data-codesign-source-id');
});
it('keeps dynamic and repeated targets unsupported', () => {
for (const tag of ['button', 'p'])
expect(planSourceEdit(request(tag))).toMatchObject({
status: 'rejected',
reason: 'unsupported-field',
});
it('refuses dynamic data but edits repeated literal definitions with shared scope', () => {
expect(planSourceEdit(request('button'))).toMatchObject({
status: 'rejected',
reason: 'unsupported-field',
});
expect(planSourceEdit(request('p'))).toMatchObject({
status: 'applied',
scope: 'source-definition',
content: source.replace('>Repeated<', '>{"Edited"}<'),
});
});
it('does not overwrite an external edit or accept a forged target', () => {
it('does not overwrite external edits or accept forged targets', () => {
expect(planSourceEdit({ ...request('h1'), source: `${source}\n// external` })).toMatchObject({
status: 'rejected',
reason: 'stale-source',
Expand All @@ -77,17 +113,12 @@ describe('explicit source selection beside opaque execution', () => {
}),
).toMatchObject({ status: 'rejected', reason: 'unsupported-field' });
expect(
analyzeSourceEdit({
path: 'App.jsx',
source: `import x from "x";${source}`,
selectionMode: 'source',
}),
analyzeSourceEdit({ path: 'App.jsx', source: `import x from "x";${source}` }),
).toMatchObject({ status: 'rejected', reason: 'unsupported-module' });
expect(
analyzeSourceEdit({
path: 'App.jsx',
source: 'function App(){return flag ? <h1>A</h1> : <h1>B</h1>}',
selectionMode: 'source',
}),
).toMatchObject({ status: 'rejected', reason: 'unsupported-entry' });
});
Expand Down
96 changes: 62 additions & 34 deletions apps/desktop/src/main/source-edit-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,35 @@ describe('source-definition span editing support matrix', () => {
});
});

function expectLiteralCandidates(source: string, path = 'App.jsx') {
const withLiterals = source.replace('<header', '<header title="Original" style={{gap:2}}');
const result = inspect(withLiterals, path);
for (const target of result.targets) {
if (target.tagName === 'header' && target.editableFields.length) {
expect(target.editableFields).toContainEqual({
kind: 'set-attribute',
name: 'title',
value: 'Original',
});
expect(target.editableFields).toContainEqual({
kind: 'set-style',
property: 'gap',
value: '2',
});
}
for (const operation of target.editableFields)
expect(
planSourceEdit({
path,
source: withLiterals,
expectedSourceHash: result.sourceHash,
targetId: target.id,
scope: 'source-definition',
operation,
}),
).toMatchObject({ status: 'applied' });
}
}
function expectRejectedByInspectAndPlan(source: string, reason: string, path = 'App.jsx') {
expect(analyzeSourceEdit({ path, source })).toMatchObject({ status: 'rejected', reason });
const result = planSourceEdit({
Expand Down Expand Up @@ -283,9 +312,9 @@ describe('public review regressions: script entries and effects', () => {
])('rejects TSX module shapes too: %s', (source) => {
expectRejectedByInspectAndPlan(source, 'unsupported-module', 'App.tsx');
});
it('rejects an effect mutating through self without relying on an append blacklist', () => {
it('keeps direct literal candidates beside effects for independent preview verification', () => {
const source = `function App(){React.useEffect(()=>{self['document'].getElementsByTagName('header')[0].append(' injected')},[]);return <header>Hello</header>}`;
expectRejectedByInspectAndPlan(source, 'unsafe-source');
expectLiteralCandidates(source);
});
it.each([
'React.useEffect(()=>{},[])',
Expand All @@ -298,11 +327,8 @@ describe('public review regressions: script entries and effects', () => {
"const R=React; R['use'+'Effect'](()=>{},[])",
`React['useEffect'](()=>{},[])`,
`React['use'+'Effect'](()=>{},[])`,
])('fails closed on non-state or reflected hook execution: %s', (effect) => {
expectRejectedByInspectAndPlan(
`function App(){${effect};return <header>Hello</header>}`,
'unsafe-source',
);
])('keeps direct literal candidates beside non-state or reflected hooks: %s', (effect) => {
expectLiteralCandidates(`function App(){${effect};return <header>Hello</header>}`);
});
it.each([
'self',
Expand All @@ -311,24 +337,20 @@ describe('public review regressions: script entries and effects', () => {
'top',
'parent',
'this',
])('rejects the %s global/reflection entry independently of hooks', (entry) => {
expectRejectedByInspectAndPlan(
])('keeps direct literal candidates beside the %s global/reflection entry', (entry) => {
expectLiteralCandidates(
`function App(){${entry}['document'].getElementsByTagName('header')[0].append(' injected');return <header>Hello</header>}`,
'unsafe-source',
);
});
});

describe('public review regressions: scheduling is outside the source boundary', () => {
it('rejects string-evaluating timers without executing the candidate', () => {
describe('preview candidates do not execute or globally reject scheduling code', () => {
it('exposes existing literal fields without executing string timers', () => {
const source = `function App(){setTimeout("document.querySelector('header').textContent='injected'",0);return <header>Hello</header>}`;
expectRejectedByInspectAndPlan(source, 'unsafe-source');
expectLiteralCandidates(source);
});
it('also rejects callback timers instead of inspecting only string arguments', () => {
expectRejectedByInspectAndPlan(
'function App(){setTimeout(()=>{},0);return <header>Hello</header>}',
'unsafe-source',
);
it('keeps direct literal candidates for callback timers as well', () => {
expectLiteralCandidates('function App(){setTimeout(()=>{},0);return <header>Hello</header>}');
});
it.each([
'setTimeout',
Expand All @@ -346,10 +368,9 @@ describe('public review regressions: scheduling is outside the source boundary',
'postMessage',
'MessageChannel',
'MessagePort',
])('rejects scheduling global %s even through a captured alias', (reference) => {
expectRejectedByInspectAndPlan(
])('keeps direct literal candidates for scheduling global %s through aliases', (reference) => {
expectLiteralCandidates(
`function App(){const schedule=${reference};return <header>Hello</header>}`,
'unsafe-source',
);
});
});
Expand Down Expand Up @@ -394,21 +415,21 @@ describe('conservative rejection and independent revalidation', () => {
'function App(){return <header>A</header>} ReactDOM.createRoot(root).render(<App/>);',
'unsafe-source',
],
])('rejects unsupported ownership: %s', (source, reason) => {
expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({
status: 'rejected',
reason,
});
])('separates structural refusal from globally opaque execution: %s', (source, reason) => {
if (reason === 'unsafe-source' || reason === 'reused-entry') {
expectLiteralCandidates(source);
} else
expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({
status: 'rejected',
reason,
});
});
it.each([
'function useState(){return [0,evil]} function App(){const [count,setCount]=useState(0);return <header onClick={()=>setCount(0)}>Static</header>}',
'function App(){const [count,setCount]=React.useState(0);return <header>{items.map(setCount=><button onClick={()=>setCount(0)}>Click</button>)}</header>}',
'function App(){const [count,setCount]=React.useState(0);return <header onClick={count=>setCount(count)}>Static</header>}',
])('rejects shadowed hook/setter/value bindings: %s', (source) => {
expect(analyzeSourceEdit({ path: 'App.jsx', source })).toMatchObject({
status: 'rejected',
reason: 'unsafe-source',
});
])('does not let opaque hook/setter/value bindings hide direct literal candidates: %s', (source) => {
expectLiteralCandidates(source);
});
it('rejects stale hashes before locating targets', () => {
const inspected = inspect(simple);
Expand Down Expand Up @@ -465,11 +486,18 @@ describe('conservative rejection and independent revalidation', () => {
expect(targets.map((target) => target.tagName)).toEqual(['my-widget', 'script', 'style']);
expect(targets.every((target) => target.editableFields.length === 0)).toBe(true);
});
it('rejects shared definitions even if currently rendered once', () => {
it('edits a shared text definition without asserting instance uniqueness', () => {
const source =
'function Card(){return <header title="one">Shared</header>} function App(){return <Card/>}';
expect(inspect(source).targets[0]?.editableFields).toEqual([]);
expect(inspect(source).targets[0]?.editableFields).toEqual([
{ kind: 'set-text', value: 'Shared' },
]);
expect(apply(source, { kind: 'set-text', value: 'x' })).toMatchObject({
status: 'applied',
scope: 'source-definition',
content: source.replace('>Shared<', '>{"x"}<'),
});
expect(apply(source, { kind: 'set-attribute', name: 'title', value: 'x' })).toMatchObject({
status: 'rejected',
reason: 'unsupported-field',
});
Expand All @@ -479,7 +507,7 @@ describe('conservative rejection and independent revalidation', () => {
'<header>Hello <b>child</b></header>',
'<header>{`template`}</header>',
'<header>{"A"}{"B"}</header>',
])('rejects non-sole-static text %s', (jsx) => {
])('rejects unresolved text or a missing mixed-segment ID: %s', (jsx) => {
expect(apply(`function App(){return ${jsx}}`, { kind: 'set-text', value: 'x' })).toMatchObject({
status: 'rejected',
reason: 'unsupported-field',
Expand Down
Loading
Loading