diff --git a/package-lock.json b/package-lock.json index a48b7d92..1de861ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacki", - "version": "0.1.33", + "version": "0.1.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacki", - "version": "0.1.33", + "version": "0.1.34", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 555fa95a..de3a650f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stacki", "productName": "Stacki", - "version": "0.1.33", + "version": "0.1.34", "description": "Stacki — a visual builder for Astro projects — pages, layouts, components, props, live preview, and GitHub publishing.", "main": "dist/electron/main.js", "author": "Timothy Ricks ", @@ -32,6 +32,7 @@ "test:bridge": "node test/bridge.js", "test:outlines": "node test/outlines.js", "test:navigator": "node test/navigator.js", + "test:navigatoropencode": "node test/navigator-open-code.js", "test:classattr": "node test/class-attr.js", "test:gridtracks": "node test/grid-tracks.js", "test:windowbounds": "node test/window-bounds.js", diff --git a/src/App.tsx b/src/App.tsx index 07c1a1b3..b1816b07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -100,12 +100,19 @@ import { toRecord } from '../shared/record'; import { projectRelativePath } from './projectPath.js'; import { currentDesktopPlatform, shortcutLabel } from './shortcutLabel.js'; import { sourceNodeAtOffset } from './codePanelModel.js' +import { + codeWindowFor, + FRONTMATTER_SUBJECT, + type CodeSubject, + type FrontmatterSubject, +} from './codeWindowTarget'; import { cloneEditorModel, findEditorNodeById as findNodeById, findEditorParentList as findParentList, nodeId, toEditorPageState, + adoptParsedModel, type AppHistory, type AssetPick, type CodeWindowState, @@ -935,7 +942,11 @@ export default function App() { write: async (pagePath, state) => { if (state.editable) { const written = await writeProjectPage(pagePath, state.model); - return written ? toEditorPageState(written) : undefined; + // The write returns the file re-parsed (current structure and source + // ranges for the code panel), with node ids the parser regenerated. + // Re-key it onto the session ids, or every editor keyed by node id + // remounts after each save and drops focus mid-typing (issue #29). + return written ? toEditorPageState(adoptParsedModel(state, written)) : undefined; } const written = await writeProjectPageRaw(pagePath, state.source); return written ? toEditorPageState(written) : undefined; @@ -1547,23 +1558,29 @@ export default function App() { const version = codeEditVersionRef.current + 1; codeEditVersionRef.current = version; try { - const parsed = toEditorPageState(await parseSourcePage(open.path, source)); + const parsed = await parseSourcePage(open.path, source); if (version !== codeEditVersionRef.current) { return; } if (pageStateRef.current.currentPage?.path !== open.path) { return; } + // The parse reflects the typed source (fresh structure and offsets); + // re-key it onto the session ids so the editors it feeds don't remount + // per keystroke (issue #29). + const result = toEditorPageState( + adoptParsedModel(pageStateRef.current.pageState, parsed), + ); pushHistory('code-source'); - if (parsed.editable) { + if (result.editable) { const inFrontmatter = - parsed.model.bodyStart !== undefined && position < parsed.model.bodyStart; - const selected = sourceNodeAtOffset(parsed.model.nodes, position); + result.model.bodyStart !== undefined && position < result.model.bodyStart; + const selected = sourceNodeAtOffset(result.model.nodes, position); setSelectedId(inFrontmatter ? 'frontmatter' : selected?.id ?? null); } else { setSelectedId(null); } - setPageState({ ...parsed, dirty: true }); + setPageState({ ...result, dirty: true }); scheduleSave('live'); } catch (error: unknown) { if (version === codeEditVersionRef.current) { @@ -1621,7 +1638,12 @@ export default function App() { let result: EditorPageState; try { - result = toEditorPageState(await readPage(page.path)); + const parsed = await readPage(page.path); + // The disk snapshot arrives with regenerated parser ids; re-key it + // onto the session ids so aligned nodes keep their identity and the + // trail remap below only has to cover genuinely changed regions + // (issue #29). + result = toEditorPageState(adoptParsedModel(state, parsed)); } catch { return; } @@ -1633,7 +1655,9 @@ export default function App() { pendingFiles.clear(); if (latest.currentPage?.path !== page.path || latest.pageState !== state) {return;} - // Re-select the node at the same tree position (ids regenerate). + // Re-select the node at the same tree position — the fallback for + // regions whose ids genuinely regenerated (nodes added or removed + // externally); aligned regions kept their session ids above. const selId = selectedIdRef.current; let nextSelected = selId; if (selId && selId !== 'layout' && selId !== 'frontmatter') { @@ -3592,7 +3616,7 @@ export default function App() { }; const selectedNode: - | EditorNode | { readonly id: 'frontmatter'; readonly kind: 'frontmatter'; readonly value: string } | null = + | EditorNode | (FrontmatterSubject & { readonly value: string }) | null = model && selectedId ? selectedId === 'frontmatter' ? { id: 'frontmatter', kind: 'frontmatter', value: frontmatterCode } @@ -3944,29 +3968,22 @@ export default function App() { ? codeWinNode.inner : null; - // Returns whether the selection actually has a code editor, so the Enter + // Returns whether the subject actually has a code editor, so the Enter // shortcut below knows whether it handled the key. - const openCodeWindow = () => { - if (!selectedNode) { + const openCodeWindowFor = (subject: CodeSubject | null): boolean => { + const codeWindow = subject === null ? undefined : codeWindowFor(subject); + if (codeWindow === undefined) { return false; } - if (selectedNode.kind === 'frontmatter') { - setCodeWin({ - targetId: 'frontmatter', - title: 'Frontmatter', - language: 'javascript', - }); - return true; - } - if (selectedNode.kind === 'raw') { - setCodeWin({ - targetId: selectedNode.id, - title: `<${selectedNode.name}>`, - language: selectedNode.name === 'style' ? 'css' : 'javascript', - }); - return true; - } - return false; + setCodeWin(codeWindow); + return true; + }; + const openCodeWindow = (): boolean => openCodeWindowFor(selectedNode); + // A navigator double-click names its row instead of reading `selectedNode` + // (see `openCode` in StructureTree). An id the tree no longer holds, a row + // removed between the click and the render, opens nothing. + const openCodeWindowById = (id: string): void => { + openCodeWindowFor(id === 'frontmatter' ? FRONTMATTER_SUBJECT : tree.node(id)); }; // Read by the keydown effect, which is set up long before this exists. openCodeWindowRef.current = openCodeWindow; @@ -4619,6 +4636,7 @@ export default function App() { onSelect={setSelectedId} onHoverNode={setHoverNodeId} onOpenComponent={(name, id) => openComponent(name, pathFor(id))} + onOpenCode={openCodeWindowById} onChangeLayout={changeLayout} onDropComponent={addComponent} onMoveNode={moveNode} diff --git a/src/appTypes.ts b/src/appTypes.ts index 6b1deebb..3b5bc41a 100644 --- a/src/appTypes.ts +++ b/src/appTypes.ts @@ -4,6 +4,7 @@ import type { IpcResults, WireGitInfo, WireInjectedRoute } from '../shared/ipc-r import type { ParsePageResult } from '../shared/page-node'; import type { ScanResult } from '../shared/scan'; import type { AssetRequest } from './assetPick'; +import { adoptNodeIds } from './modelAdoption'; import type { VariableSelection } from './variablesBridge'; import { cloneEditorModel, @@ -213,6 +214,24 @@ export function toEditorPageState( return { ...input, model: cloneEditorModel(input.model), dirty: false }; } +/** Re-key a freshly parsed page onto the session's node ids (see + * modelAdoption.ts). The parser regenerates every id on each parse, and the UI + * keys editors by node id, so installing a fresh parse wholesale re-keyed + * every editor and dropped field focus mid-typing (issue #29). Returns + * `parsed` unchanged when either side lacks an editable page model. */ +export function adoptParsedModel( + local: EditorPageState | null, + parsed: ParsePageResult & { readonly source: string }, +): ParsePageResult & { readonly source: string } { + if (!isEditableState(local) || !parsed.editable) { + return parsed; + } + return { + ...parsed, + model: { ...parsed.model, nodes: adoptNodeIds(local.model.nodes, parsed.model.nodes) }, + }; +} + export function isOpenFile(page: CurrentPage | null): page is OpenFile { return page?.kind === 'page' || page?.kind === 'component'; } diff --git a/src/codeWindowTarget.ts b/src/codeWindowTarget.ts new file mode 100644 index 00000000..5317c476 --- /dev/null +++ b/src/codeWindowTarget.ts @@ -0,0 +1,38 @@ +import { assert } from '../shared/assert'; +import type { CodeWindowState, EditorNode } from './appTypes'; + +/** The page frontmatter: selectable like a node, but it is not one. */ +export interface FrontmatterSubject { + readonly id: 'frontmatter'; + readonly kind: 'frontmatter'; +} + +export type CodeSubject = EditorNode | FrontmatterSubject; + +export const FRONTMATTER_SUBJECT: FrontmatterSubject = { id: 'frontmatter', kind: 'frontmatter' }; + +// What the floating code editor opens for a subject. Enter, the "Edit code" +// button and a navigator double-click all ask this one rule, so they cannot +// disagree on the title or the language. Markup has no editor of its own: the +// answer is `undefined`, and Enter is left unhandled. +export function codeWindowFor(subject: CodeSubject): CodeWindowState | undefined { + if (subject.kind === 'frontmatter') { + return { targetId: 'frontmatter', title: 'Frontmatter', language: 'javascript' }; + } + if (subject.kind === 'raw') { + assert(subject.id.length > 0, 'raw node has an id'); + // The parser matches the tag case-insensitively but keeps it as written, + // so `