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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <hello@timothyricks.com>",
Expand Down Expand Up @@ -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",
Expand Down
76 changes: 47 additions & 29 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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') {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
19 changes: 19 additions & 0 deletions src/appTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
}
Expand Down
38 changes: 38 additions & 0 deletions src/codeWindowTarget.ts
Original file line number Diff line number Diff line change
@@ -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 `<STYLE>` arrives here as "STYLE".
const language = RAW_CODE_LANGUAGES[subject.name.toLowerCase()];
assert(language !== undefined, `raw node <${subject.name}> has no code language`);
return { targetId: subject.id, title: `<${subject.name}>`, language };
}
return undefined;
}

// The parser keeps exactly these elements raw (`RAW_ELEMENTS` in
// electron/astroParser.ts), so they are the only ones with a code language.
const RAW_CODE_LANGUAGES: Readonly<Record<string, string>> = {
script: 'javascript',
style: 'css',
};
Loading
Loading