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
14 changes: 12 additions & 2 deletions docs/component-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,18 @@ arrays, tuples, records, callbacks, and common built-in types. The braces button
switches to a plain input for custom types such as `typeof theme` or imported
aliases. Switching modes preserves the current type and union options; hovering
a menu choice never changes the prop. Unsupported types open in input mode.
Imported or composite `Props` contracts remain read-only until their editable
fields are declared locally; clicking a prop never opens the full source editor.
Common fields in composite `Props` contracts stay editable when their declaration
is local and shared by the public and runtime types. Variant-dependent fields show
their conditions and explain why source editing is needed. Imported, generic, and
ambiguous declarations remain restricted. Add and reorder operations on composite
contracts still require source editing. Declaration locations appear in an information
tooltip, not as runtime binding chips. Clicking a prop never opens the full source editor.
Renames also update indexed and picked references to local contract types.

Property rows show names and type icons. Type and default choices use the shared
custom dropdown, with matching expression toggles. Long option lists scroll after
six rows, with Add item outside the scrolling region. Property settings close on
outside clicks, including clicks in the canvas, and remain dismissible when read-only.

The panel listens for external changes to the open component and automatically
loads the latest properties. Stacki's own writes are filtered by the project
Expand Down
9 changes: 9 additions & 0 deletions electron/componentPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,21 @@ if (selected) {
return isFinite(l) ? { l: l, t: t, w: r - l, h: b - t } : null;
}
var scheduled = false;
var lastStatus = '';
function reportStatus(status) {
if (status === lastStatus) return;
lastStatus = status;
window.parent.postMessage({ type: 'avb:component-preview', status: status }, '*');
}
function fit() {
scheduled = false;
stage.style.transform = 'none';
unfix();
var box = bbox(stage);
if (!box || box.w < 1 || box.h < 1) {
document.body.classList.add('avb-is-empty');
stage.style.visibility = 'hidden';
reportStatus('empty');
return;
}
document.body.classList.remove('avb-is-empty');
Expand All @@ -216,6 +224,7 @@ if (selected) {
stage.style.transform =
'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')';
stage.style.visibility = 'visible';
reportStatus('ready');
}
function refit() {
if (scheduled) return;
Expand Down
116 changes: 96 additions & 20 deletions electron/componentProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import fs from 'node:fs';
import path from 'node:path';
import { assert } from '../shared/assert';
import { PROPERTY_LIMITS } from '../shared/component-properties';
import type { ComponentProperties, PropertyChange } from '../shared/component-properties';
import type {
ComponentProperties,
PropertyChange,
PropertyOptionRename,
} from '../shared/component-properties';
import { err, ok, type Result } from '../shared/result';
import { literalOptions } from '../shared/property-options';
import { sameFilesystemPath } from './platform';
import { editPropertyDefinition, readComponentProperties } from './propertyDefinitions';
import { renameComponentReferences } from './propertyRename';
import { renameComponentOptionValues, renameComponentReferences } from './propertyRename';

interface PropertyLocation {
readonly projectPath: string;
Expand Down Expand Up @@ -100,37 +105,43 @@ function planPropertyChanges(
if (change.kind === 'remove') {
return planPropertyRemoval(request, first, change.name);
}
if (
change.kind !== 'save' ||
!change.originalName ||
change.originalName === change.property.name
) {
const propertyRename = propertyRenameForChange(change);
const optionRenames = change.kind === 'save' ? change.optionRenames ?? [] : [];
if (!propertyRename && optionRenames.length === 0) {
return ok([first]);
}
if (change.kind !== 'save' || !change.originalName) {
return err({
code: 'option',
message: 'Only an existing property can rename options.',
});
}
const valid = validateOptionRenames(request.source, source, change, optionRenames);
if (!valid.ok) {
return valid;
}
const consumers = readPropertyConsumers(request);
if (!consumers.ok) {
return consumers;
}
const rename = { from: change.originalName, to: change.property.name };
const changes: FileChange[] = [];
for (const consumer of consumers.value) {
const { file, names } = consumer;
const own = sameFilesystemPath(file, request.file);
const original = own ? source : consumer.source;
const result = renameComponentReferences(
original,
names,
rename,
own ? 'definition' : 'consumer'
);
if (!result.ok) {
const changed = planConsumerChange(original, names, change, propertyRename, optionRenames, own);
if (!changed.ok) {
return err({
code: result.error.code,
message: `${path.relative(request.projectPath, file)}: ${result.error.message}`,
code: changed.error.code,
message: `${path.relative(request.projectPath, file)}: ${changed.error.message}`,
});
}
if (own || result.value !== original) {
changes.push({ file, before: own ? request.source : original, after: result.value });
if (own || changed.value !== original) {
changes.push({
file,
before: own ? request.source : original,
after: changed.value,
});
}
}
assert(
Expand All @@ -140,6 +151,68 @@ function planPropertyChanges(
return ok(changes);
}

function propertyRenameForChange(
change: PropertyChange
): { readonly from: string; readonly to: string } | undefined {
if (change.kind !== 'save' || !change.originalName) {
return undefined;
}
return change.originalName === change.property.name
? undefined
: { from: change.originalName, to: change.property.name };
}

function planConsumerChange(
source: string,
names: ReadonlySet<string>,
change: Extract<PropertyChange, { readonly kind: 'save' }>,
propertyRename: { readonly from: string; readonly to: string } | undefined,
optionRenames: readonly PropertyOptionRename[],
own: boolean
): Result<string> {
const renamed = propertyRename
? renameComponentReferences(source, names, propertyRename, own ? 'definition' : 'consumer')
: ok(source);
if (!renamed.ok || optionRenames.length === 0) {
return renamed;
}
return renameComponentOptionValues(renamed.value, names, change.property.name, optionRenames);
}

function validateOptionRenames(
beforeSource: string,
afterSource: string,
change: Extract<PropertyChange, { readonly kind: 'save' }>,
renames: readonly PropertyOptionRename[]
): Result<void> {
if (renames.length === 0) {
return ok(undefined);
}
const before = readComponentProperties(beforeSource).properties.find(
(property) => property.name === change.originalName
);
const after = readComponentProperties(afterSource).properties.find(
(property) => property.name === change.property.name
);
const beforeOptions = before ? literalOptions(before.type) : undefined;
const afterOptions = after ? literalOptions(after.type) : undefined;
if (!beforeOptions || !afterOptions) {
return err({
code: 'option',
message: 'Option renames require an editable literal union.',
});
}
for (const rename of renames) {
if (!beforeOptions.includes(rename.from) || !afterOptions.includes(rename.to)) {
return err({
code: 'option',
message: 'An option rename does not match the saved union.',
});
}
}
return ok(undefined);
}

function commitPropertyChanges(
changes: readonly FileChange[],
noteWrite: (file: string, source: string) => void
Expand Down Expand Up @@ -196,7 +269,10 @@ function rollbackPropertyChanges(
message: `Save failed and recovery failed for: ${failed.join(', ')}. ${String(reason)}`,
});
}
return err({ code: 'filesystem', message: `Save failed; changes restored. ${String(reason)}` });
return err({
code: 'filesystem',
message: `Save failed; changes restored. ${String(reason)}`,
});
}

function planPropertyRemoval(
Expand Down
36 changes: 22 additions & 14 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3000,19 +3000,25 @@ function writeChunks(model: ParserPageModel) {
// Page IPC
// ---------------------------------------------------------------------------

function parsePageSource(pagePath: string, source: string): IpcResults['page:read'] {
if (isMarkdownPage(pagePath)) {
return { ...parseMarkdownPage(source, { mdx: isMdx(pagePath) }), source };
}
const parsed = parsePage(source, { locs: true });
if (parsed.editable) {resolveChunks(parsed.model, pagePath, { locs: true });}
return { ...parsed, source };
}

ipcMain.handle('page:read', async (_e, pagePath) => {
const source = readSource(pagePath);
// Markdown builds the same tree from a different syntax, so everything
// downstream — navigator, props, text editing, undo — is unchanged. Only
// the writer has to know which one it is; model.format carries that.
if (isMarkdownPage(pagePath)) {
return { ...parseMarkdownPage(source, { mdx: isMdx(pagePath) }), source };
}
const parsed = parsePage(source);
if (parsed.editable) {
resolveChunks(parsed.model, pagePath);
}
return { ...parsed, source };
return parsePageSource(pagePath, source);
});

ipcMain.handle('page:parse', async (_e, { pagePath, source }) => {
return parsePageSource(pagePath, source);
});

// Astro's dev server serves a page's <style> block ONE EDIT BEHIND: after the file
Expand Down Expand Up @@ -3059,18 +3065,20 @@ function writePageText(pagePath: string, text: string) {
}

ipcMain.handle('page:write', async (_e, { pagePath, model }) => {
let source: string;
if (isMarkdownPage(pagePath)) {
writePageText(pagePath, serializeMarkdownPage(parseMarkdownModel(model)));
return { ok: true as const };
source = serializeMarkdownPage(parseMarkdownModel(model));
} else {
source = serializePage(model);
writeChunks(parseSerializePage(model));
}
writePageText(pagePath, serializePage(model));
writeChunks(parseSerializePage(model));
return { ok: true as const };
writePageText(pagePath, source);
return { ok: true as const, ...parsePageSource(pagePath, source) };
});

ipcMain.handle('page:writeRaw', async (_e, { pagePath, source }) => {
writePageText(pagePath, source);
return { ok: true as const };
return { ok: true as const, ...parsePageSource(pagePath, source) };
});

ipcMain.handle('page:create', async (_e, { projectPath, name, layout }) => {
Expand Down
10 changes: 9 additions & 1 deletion electron/morphClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,14 @@ function patchAttrs(live: Element, prev: Element, next: Element): void {
}

function patchNode(live: Node, prev: Node, next: Node): void {
// An unchanged server subtree has no patch to contribute. Client code is
// free to reorder, remove, or clone anything inside it, so descending into
// that live subtree is both wasted work and actively unsafe: a slider or nav
// can no longer resemble its server rendering even though the edit happened
// somewhere else on the page.
if (prev.isEqualNode(next)) {
return;
}
if (live.nodeType === 3 || live.nodeType === 8) {
// Only when the server changed it, and only if the live copy still says
// what the server last said — client code that rewrote this text keeps it.
Expand Down Expand Up @@ -786,4 +794,4 @@ window.addEventListener('message', (e: MessageEvent) => {
// update is the module's entry; scriptSignature is not referenced internally
// but is part of the tested surface — test/morph.js slices it out of this
// source, so deleting it would delete coverage of the script-diff decision.
export { update, scriptSignature };
export { update, scriptSignature };
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,7 @@ contextBridge.exposeInMainWorld('avb', {

// Pages
readPage: invoke('page:read'),
parsePageSource: invoke('page:parse'),
writePage: invoke('page:write'),
writePageRaw: invoke('page:writeRaw'),
createPage: invoke('page:create'),
Expand Down
Loading
Loading