Skip to content

Commit 82efaed

Browse files
committed
refactor(ide): the IDE is always its own window
The IDE was reachable six ways. Three of them swapped the shell inside the running process, so the Fiddle you were working in silently became a different product — and that is not only jarring, it breaks the assumption the one deliberate path was built around. openShowcaseInIdeWindow spawns a child process for a documented reason: the Scintilla registry, its keyWindow attach, and the config-store writer lease all assume ONE WINDOW PER PROCESS. One entry point honoured that; the rest went around it. One rule now: opening a workspace spawns a window, and every command says where it lands. - File ▸ Open Folder in IDE… and the palette's Open Folder in IDE (⇧⌘O) spawn - Open Showcase from URL in IDE — it was "Open Showcase (URL)", differing from "Open Showcase" only in which product it dropped you into, with nothing in either name to say so — spawns too - File ▸ Open Fiddle Folder… (⌘O) still loads into this Fiddle, and says so The folder reaches the child by env, not by deep link. The deep-link scheme is a public contract with a parser and tests; "open this local directory" is a private handoff between a parent window and the child it just spawned. Also drops a duplicate delivery. main.ts answered the openFolder bridge call twice — reply callback plus a `folderOpened` broadcast, a fallback from when the reply was unreliable on some runtimes. Two deliveries were harmless while both did the same thing; with the callback now spawning, honouring the broadcast as well would have opened the new window AND converted this one. The helper sits above openFolderDialog rather than beside its siblings further down: Lynx evaluates a useCallback's dependency array at declaration, so a later declaration referenced from an earlier one is a load-time TDZ crash.
1 parent 00320ce commit 82efaed

5 files changed

Lines changed: 137 additions & 29 deletions

File tree

.changeset/gallery-and-new-fiddle.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,33 @@ third label competing with the two real controls.
3737
Also removes a stale light-theme override that was repainting the cards' `Open`
3838
Blueprint blue — the brand token already adapts per theme, so the override was
3939
undoing the accent in exactly one of them.
40+
41+
**One rule for the IDE: it is always its own window.**
42+
43+
The IDE was reachable six ways and three of them swapped the shell inside the
44+
running process — the Fiddle you were working in silently became a different
45+
product. That also broke the assumption the one deliberate path was built
46+
around: the Scintilla registry, its keyWindow attach, and the config-store
47+
writer lease all assume one window per process, which is exactly why the
48+
gallery's `IDE` action spawns a child. One entry point honoured that; the rest
49+
went around it.
50+
51+
Now every "open a workspace" act spawns, and every command says where it goes:
52+
53+
- `File ▸ Open Folder in IDE…` and the palette's `Open Folder in IDE` (⇧⌘O)
54+
spawn a window instead of converting this one
55+
- the palette's `Open Showcase from URL in IDE` — previously `Open Showcase
56+
(URL)`, which differed from `Open Showcase` only in landing you in a
57+
different product — does the same
58+
- `File ▸ Open Fiddle Folder…` (⌘O) keeps loading a folder into this Fiddle,
59+
and now says so
60+
61+
The spawned window receives its folder by env rather than by deep link: the
62+
deep-link scheme is a public contract with a parser and tests, and this is a
63+
private handoff between a parent and the child it just spawned.
64+
65+
Also removes a duplicate delivery: `main.ts` answered the openFolder bridge call
66+
both through its reply callback and as a `folderOpened` broadcast, a fallback
67+
from when the reply was unreliable. Harmless while both did the same thing —
68+
but with the callback now spawning, honouring the broadcast too would have
69+
opened a new window *and* converted the old one.

lynxtron-go/src/app/App.tsx

Lines changed: 86 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -708,25 +708,13 @@ export function App(props: { onRender?: () => void } = {}) {
708708
}
709709
}, [applyWorkspaceSession, createFolderWorkspaceSession, createShowcaseWorkspaceSession, log, rememberWorkspaceSession]);
710710

711-
// Listen for global event from main.ts after showOpenDialog resolves
712-
useEffect(() => {
713-
const handler = (data: any) => {
714-
try {
715-
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
716-
if (parsed?.path) openFolder(parsed.path);
717-
} catch (_) { /* ignore */ }
718-
};
719-
try {
720-
// @ts-ignore
721-
lynx.getJSModule('GlobalEventEmitter').addListener('folderOpened', handler);
722-
} catch (_) { /* ignore */ }
723-
return () => {
724-
try {
725-
// @ts-ignore
726-
lynx.getJSModule('GlobalEventEmitter').removeListener('folderOpened', handler);
727-
} catch (_) {}
728-
};
729-
}, [openFolder]);
711+
// The `folderOpened` broadcast is NOT handled here. main.ts answers the
712+
// openFolder bridge call twice — once through the reply callback and once as
713+
// a global event, added long ago as a fallback for runtimes where the reply
714+
// was unreliable. Two deliveries of one dialog result were harmless while
715+
// both did the same thing; now that picking a folder spawns an IDE WINDOW,
716+
// honouring the broadcast as well would open the new window AND convert this
717+
// one. The callback in openFolderDialog is the single handler.
730718

731719

732720
// Auto-restore last workspace on startup
@@ -1247,6 +1235,74 @@ export function App(props: { onRender?: () => void } = {}) {
12471235
}, [log]);
12481236

12491237
// ── Open folder dialog ────────────────────────────────────────────────────
1238+
/**
1239+
* Open an arbitrary folder as an IDE workspace — in a NEW WINDOW, never in
1240+
* this one.
1241+
*
1242+
* The IDE used to be reachable four ways, and three of them swapped the
1243+
* shell inside the running process: the Fiddle you were working in silently
1244+
* became a different product. That is not just jarring, it breaks the
1245+
* assumption the deliberate path was built around — see
1246+
* openShowcaseInIdeWindow above: the Scintilla registry, its keyWindow
1247+
* attach, and the config-store writer lease all assume ONE WINDOW PER
1248+
* PROCESS. One entry point honoured that; the rest went around it.
1249+
*
1250+
* So there is one rule now: the IDE is always its own window. `Open` always
1251+
* means "in this Fiddle", and going to the IDE is always a differently-named
1252+
* act.
1253+
*
1254+
* The folder travels by env rather than by deep link. The deep-link scheme
1255+
* is a public contract with a parser and tests; "open this local directory"
1256+
* is a private handoff from a parent window to the child it just spawned.
1257+
*/
1258+
const openFolderInIdeWindow = useCallback((folderPath: string) => {
1259+
const rt = (foundationApi() as any)?.runtime;
1260+
const exec = (foundationApi() as any)?.exec;
1261+
if (!rt?.execPath || !rt?.appDir || !exec?.runAsync) {
1262+
showOutput('warn', '[IDE] spawn bridge unavailable — opening in this window instead');
1263+
openFolder(folderPath);
1264+
return;
1265+
}
1266+
const handle = exec.runAsync(rt.execPath, [rt.appDir], {
1267+
env: {
1268+
LYNXTRON_ALLOW_MULTI: '1',
1269+
LYNXTRON_WINDOW_CASCADE: '1',
1270+
LYNXTRON_BOOT_TARGET: 'ide',
1271+
LYNXTRON_BOOT_FOLDER: folderPath,
1272+
// Children must NOT inherit the dev automation channels — two pollers
1273+
// on the same /tmp command files steal each other's commands.
1274+
LYNXTRON_FIDDLE_DEV: '0',
1275+
},
1276+
onExit: (code: number | null) => {
1277+
showOutput('info', `[IDE] window for "${folderPath}" exited (code=${code})`);
1278+
},
1279+
});
1280+
if (handle?.pid) {
1281+
setGalleryOpen(false);
1282+
showOutput('info', `[IDE] opened "${folderPath}" in a new window (pid=${handle.pid})`);
1283+
setStatus(`Opened ${folderPath} in new IDE window`);
1284+
} else {
1285+
showOutput('warn', '[IDE] spawn failed — opening in this window instead');
1286+
openFolder(folderPath);
1287+
}
1288+
}, [openFolder, showOutput]);
1289+
1290+
/**
1291+
* A window spawned as a dedicated IDE opens the folder it was spawned for,
1292+
* once. The parent hands it over by env (see preload-bridge) because this is
1293+
* an internal handoff, not a public deep link.
1294+
*/
1295+
const bootFolderOpenedRef = useRef(false);
1296+
useEffect(() => {
1297+
if (bootFolderOpenedRef.current) return;
1298+
let folder: string | null = null;
1299+
try { folder = (getExposed() as any)?.bootFolder ?? null; } catch (_) { folder = null; }
1300+
if (!folder) return;
1301+
bootFolderOpenedRef.current = true;
1302+
openFolder(folder);
1303+
// eslint-disable-next-line react-hooks/exhaustive-deps
1304+
}, []);
1305+
12501306
const openFolderDialog = useCallback(() => {
12511307
console.log('[IDE] openFolderDialog tapped');
12521308
log('[IDE] openFolderDialog tapped');
@@ -1255,13 +1311,13 @@ export function App(props: { onRender?: () => void } = {}) {
12551311
NativeModules.bridge.call('openFolder', {}, (result: any) => {
12561312
console.log('[IDE] openFolder bridge callback:', JSON.stringify(result));
12571313
log(`[IDE] openFolder callback: ${JSON.stringify(result)}`);
1258-
if (result?.path) openFolder(result.path);
1314+
if (result?.path) openFolderInIdeWindow(result.path);
12591315
});
12601316
} catch (e) {
12611317
console.error('[IDE] openFolderDialog error:', e);
12621318
log(`openFolderDialog error: ${e}`);
12631319
}
1264-
}, [log, openFolder]);
1320+
}, [log, openFolderInIdeWindow]);
12651321

12661322
const startShowcaseList = useCallback(() => {
12671323
setPickerQuery('');
@@ -1695,9 +1751,14 @@ export function App(props: { onRender?: () => void } = {}) {
16951751
// eslint-disable-next-line react-hooks/exhaustive-deps
16961752
}, []);
16971753

1754+
/**
1755+
* Fetch a showcase by URL and open it. Its only caller is the palette's
1756+
* "Open Showcase from URL in IDE", and the destination is in that name now:
1757+
* a fetched workspace is the same kind of thing as a folder you picked, so
1758+
* it opens the same way — a new IDE window, never this one.
1759+
*/
16981760
const fetchShowcaseByUrl = useCallback(async (
16991761
url: string,
1700-
source: 'folder' | 'showcase' = 'folder',
17011762
options?: { showLoading?: boolean },
17021763
): Promise<string | null> => {
17031764
const shouldShowLoading = options?.showLoading !== false;
@@ -1727,7 +1788,7 @@ export function App(props: { onRender?: () => void } = {}) {
17271788
console.log('[IDE] fetchShowcaseByUrl success', { url, showcasePath });
17281789
log(`[IDE] fetchShowcaseByUrl success url=${url} path=${showcasePath}`);
17291790
showOutput('info', `Showcase fetched to: ${showcasePath}`);
1730-
openFolder(showcasePath, source);
1791+
openFolderInIdeWindow(showcasePath);
17311792
setStatus(`Opened showcase: ${showcasePath.split('/').pop()}`);
17321793
return showcasePath;
17331794
}
@@ -1750,7 +1811,7 @@ export function App(props: { onRender?: () => void } = {}) {
17501811
}
17511812
}
17521813
return null;
1753-
}, [clearShowcaseLoading, log, openFolder, showOutput, startShowcaseLoading]);
1814+
}, [clearShowcaseLoading, log, openFolderInIdeWindow, showOutput, startShowcaseLoading]);
17541815

17551816
const resolveShowcaseEntryWorkspacePath = useCallback(async (entry: ShowcaseEntry): Promise<string | null> => {
17561817
// Same policy the Fiddle uses — local source tree, then an already
@@ -2301,7 +2362,7 @@ export function App(props: { onRender?: () => void } = {}) {
23012362
if (pickerMode === 'url') {
23022363
setPickerOpen(false);
23032364
setPickerMode(undefined);
2304-
void fetchShowcaseByUrl(value, 'showcase');
2365+
void fetchShowcaseByUrl(value);
23052366
return;
23062367
}
23072368
if (pickerMode === 'bundleUrl') {

lynxtron-go/src/app/commands/showcase-commands.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ export function registerShowcaseCommands(deps: {
3232
},
3333
});
3434

35+
// Named for its destination. "Open Showcase" loads a showcase into THIS
36+
// Fiddle; this fetches one and opens it as a workspace in a new IDE window.
37+
// They were "Open Showcase" and "Open Showcase (URL)" — one verb, one noun,
38+
// two different products, and nothing in either name said which.
3539
registerCommand({
3640
id: 'showcase.openUrl',
37-
label: 'Open Showcase (URL)',
41+
label: 'Open Showcase from URL in IDE',
3842
execute: () => {
3943
deps.startUrlFetch();
4044
},
@@ -144,7 +148,7 @@ export function registerShowcaseCommands(deps: {
144148

145149
registerCommand({
146150
id: 'folder.open',
147-
label: 'Open Folder',
151+
label: 'Open Folder in IDE',
148152
keybinding: 'Cmd+Shift+O',
149153
execute: () => { deps.setPickerOpen(false); deps.openFolderDialog(); },
150154
});

lynxtron-go/src/main/desktop/main.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,9 @@ function buildAppMenu(w: LynxWindowInstance, surface: MenuSurface) {
558558
? [
559559
{
560560
id: 'openFolder',
561-
label: 'Open Folder…',
561+
// Says where it goes. The IDE is always its own window now, so this
562+
// no longer replaces whatever you were working in.
563+
label: 'Open Folder in IDE…',
562564
accelerator: 'CmdOrCtrl+O',
563565
registerAccelerator: true,
564566
// App.tsx runs the native dialog itself through the openFolder
@@ -596,7 +598,11 @@ function buildAppMenu(w: LynxWindowInstance, surface: MenuSurface) {
596598
{ type: 'separator' },
597599
{
598600
id: 'open',
599-
label: 'Open...',
601+
// Loads a folder's fiddle files into THIS Fiddle (fiddle:openFolder
602+
// → loadLocalFiddle). Distinct from "Open Folder in IDE…" below,
603+
// which opens a workspace in its own window — the two used to be
604+
// "Open..." and "Open Folder…", which said nothing about either.
605+
label: 'Open Fiddle Folder…',
600606
accelerator: 'CmdOrCtrl+O',
601607
registerAccelerator: true,
602608
click: async () => {

lynxtron-go/src/main/desktop/preload-bridge.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export function createPreloadBridge() {
2929
// (Gallery IDE action) — the UI hides Fiddle-centric chrome like the
3030
// route-back chevrons in that mode.
3131
bootTarget: process.env.LYNXTRON_BOOT_TARGET ?? null,
32+
/**
33+
* An absolute folder for an IDE window to open at startup. Passed by env
34+
* rather than by deep link on purpose: the deep-link scheme is a public,
35+
* user-facing contract, and "open this arbitrary local directory" is an
36+
* internal handoff between a parent window and the child it spawned.
37+
*/
38+
bootFolder: process.env.LYNXTRON_BOOT_FOLDER ?? null,
3239
...createFoundationBridge(dbg),
3340
ls: extensionHost.bridge,
3441
pty: pty.bridge,

0 commit comments

Comments
 (0)