From 55984a38df8b3723df2502f2f7642130217cb77c Mon Sep 17 00:00:00 2001 From: Timothy Ricks Date: Fri, 18 Sep 2026 20:12:49 -0500 Subject: [PATCH 1/3] feat: release v0.1.31 with clearer component property editing Edit common local props without flattening variant contracts, and show declaration provenance as metadata. Unify property rows and dropdown controls, keep option add actions outside scrolling lists, and make settings dismissible. --- docs/component-properties.md | 14 +- electron/propertyContracts.ts | 300 ++++++++++++++++++++++++ electron/propertyDefinitions.ts | 125 +++++++++- electron/propertyOrigins.ts | 162 +++++++++++++ electron/propertyRename.ts | 28 ++- package-lock.json | 4 +- package.json | 2 +- shared/component-properties.ts | 52 +++- src/panels/ComponentPropertiesPanel.tsx | 110 ++++----- src/panels/ListField.tsx | 105 ++++----- src/panels/PropertyDeclarationInfo.tsx | 43 ++++ src/panels/PropertyDefault.tsx | 49 ++-- src/panels/PropertyEditor.tsx | 115 ++++++--- src/panels/PropertyReadOnlyFields.tsx | 46 ++++ src/panels/PropertyReorder.tsx | 2 +- src/panels/PropertyType.tsx | 97 ++++++-- src/panels/componentProperties.css | 153 +++--------- src/styles.css | 21 +- src/ui/ListFieldRow.tsx | 54 +++++ test/component-properties-panel.test.js | 105 ++++++++- test/component-properties.test.js | 266 +++++++++++++++++++++ 21 files changed, 1518 insertions(+), 335 deletions(-) create mode 100644 electron/propertyContracts.ts create mode 100644 electron/propertyOrigins.ts create mode 100644 src/panels/PropertyDeclarationInfo.tsx create mode 100644 src/panels/PropertyReadOnlyFields.tsx create mode 100644 src/ui/ListFieldRow.tsx diff --git a/docs/component-properties.md b/docs/component-properties.md index 49127ffd..24e5a22f 100644 --- a/docs/component-properties.md +++ b/docs/component-properties.md @@ -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 diff --git a/electron/propertyContracts.ts b/electron/propertyContracts.ts new file mode 100644 index 00000000..cbd47b44 --- /dev/null +++ b/electron/propertyContracts.ts @@ -0,0 +1,300 @@ +import ts from 'typescript'; +import { assert } from '../shared/assert'; +import { PROPERTY_LIMITS } from '../shared/component-properties'; +import type { PropertyEditing } from '../shared/component-properties'; +import { isAstroProps, propertyKey, syntaxNodes } from './propertySyntax'; +import type { PropertySyntax } from './propertySyntax'; + +type Declaration = ts.TypeAliasDeclaration | ts.InterfaceDeclaration; +interface Usage { + readonly member: ts.PropertySignature; + readonly conditional: boolean; +} +interface Work { + readonly node: ts.Node; + readonly conditional: boolean; + readonly path: readonly string[]; +} +interface Branch { + readonly condition: string; + readonly fields: ReadonlyMap; +} +interface Contract { + readonly members: ReadonlyMap; + readonly names: ReadonlySet; + readonly branches: readonly (readonly Branch[])[]; + readonly complete: boolean; +} +export interface PropertyContracts { + readonly names: ReadonlySet; + readonly editable: ReadonlyMap; + readonly editing: (name: string) => PropertyEditing; + readonly conditions: (name: string) => readonly string[]; +} + +// A common member must be the same declaration in both the public contract and +// the runtime assertion. Editing it then preserves every variant and runtime type. +export function readPropertyContracts(document: PropertySyntax): PropertyContracts { + const declarations = new Map(); + for (const statement of document.syntax.statements) { + if (ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement)) { + if (declarations.has(statement.name.text)) { + return unavailableContracts('Duplicate type declarations require editing in source.'); + } + declarations.set(statement.name.text, statement); + } + } + const assertions = syntaxNodes(document.syntax) + .filter(ts.isAsExpression) + .filter((node) => isAstroProps(node.expression)); + const attributes = htmlAttributeImports(document.syntax); + const publicContract = readContract(declarations.get('Props'), declarations, attributes); + const runtimeContracts = assertions.map((node) => + readContract(node.type, declarations, attributes) + ); + const editable = new Map(); + const reasons = new Map(); + for (const [name, usages] of publicContract.members) { + const member = usages[0]?.member; + assert(member !== undefined, 'Contract field has a declaration'); + const reason = commonMemberRestriction(name, publicContract, runtimeContracts); + if (reason) { + reasons.set(name, reason); + } else { + editable.set(name, member); + } + } + assert(editable.size <= PROPERTY_LIMITS.fieldsMax, 'Editable contract fields are bounded'); + return { + names: new Set( + [publicContract, ...runtimeContracts].flatMap((contract) => [...contract.names]) + ), + editable, + editing: (name) => + editable.has(name) + ? { kind: 'editable' } + : { + kind: 'restricted', + reason: + reasons.get(name) ?? + 'This prop is inherited or imported. Edit its declaration in source.', + }, + conditions: (name) => contractConditions(publicContract, name), + }; +} + +function unavailableContracts(reason: string): PropertyContracts { + return { + names: new Set(), + editable: new Map(), + editing: () => ({ kind: 'restricted', reason }), + conditions: () => [], + }; +} + +function commonMemberRestriction( + name: string, + contract: Contract, + runtimeContracts: readonly Contract[] +): string | undefined { + const usages = contract.members.get(name) ?? []; + if (usages.some((usage) => usage.conditional)) { + return 'This prop has variant rules. Edit its declaration in source to preserve those rules.'; + } + if (new Set(usages.map((usage) => usage.member)).size !== 1) { + return 'This prop combines multiple declarations. Edit those declarations in source.'; + } + if (!contract.complete) { + return 'This prop depends on a shared or computed type that must be edited in source.'; + } + const member = usages[0]?.member; + for (const runtime of runtimeContracts) { + const matches = runtime.members.get(name) ?? []; + if ( + !runtime.complete || + matches.length === 0 || + matches.some((match) => match.member !== member || match.conditional) + ) { + return 'This prop has a separate runtime type. Edit both declarations together in source.'; + } + } + return undefined; +} + +function readContract( + root: ts.Node | undefined, + declarations: ReadonlyMap, + attributes: ReadonlySet +): Contract { + const work: Work[] = root ? [{ node: root, conditional: false, path: [] }] : []; + const members = new Map(); + const names = new Set(); + const branches: (readonly Branch[])[] = []; + let complete = root !== undefined; + for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length > 0; count++) { + const item = work.pop(); + assert(item !== undefined, 'Contract traversal has a work item'); + if (ts.isPropertySignature(item.node)) { + const name = propertyKey(item.node.name); + if (name) { + const usages = members.get(name) ?? []; + assert(usages.length < PROPERTY_LIMITS.fieldsMax, 'Contract field usages are bounded'); + members.set(name, [...usages, { member: item.node, conditional: item.conditional }]); + } + } else { + if (ts.isTypeAliasDeclaration(item.node) || ts.isInterfaceDeclaration(item.node)) { + names.add(item.node.name.text); + } + if (ts.isUnionTypeNode(item.node)) { + branches.push(unionBranches(item.node)); + } + const children = contractChildren(item, declarations, attributes); + if (children === undefined) { + complete = false; + } else { + work.push(...children); + } + } + assert(work.length <= PROPERTY_LIMITS.nodesMax, 'Contract traversal queue is bounded'); + } + assert(work.length === 0, 'Contract traversal completes within its budget'); + assert(members.size <= PROPERTY_LIMITS.fieldsMax, 'Contract field count is bounded'); + return { members, names, branches, complete }; +} + +function contractChildren( + item: Work, + declarations: ReadonlyMap, + attributes: ReadonlySet +): readonly Work[] | undefined { + const { node } = item; + if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) { + if (node.typeParameters || item.path.includes(node.name.text) || item.path.length >= 64) { + return undefined; + } + if (node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) { + return undefined; + } + const next = { ...item, path: [...item.path, node.name.text] }; + if (ts.isTypeAliasDeclaration(node)) { + return [{ ...next, node: node.type }]; + } + return [ + ...node.members.map((member) => ({ ...next, node: member })), + ...(node.heritageClauses ?? []) + .flatMap((clause) => clause.types) + .map((type) => ({ ...next, node: type })), + ]; + } + if (ts.isTypeLiteralNode(node)) { + return node.members.map((member) => ({ ...item, node: member })); + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + return node.types.map((type) => ({ + ...item, + node: type, + conditional: item.conditional || ts.isUnionTypeNode(node), + })); + } + if (ts.isParenthesizedTypeNode(node)) { + return [{ ...item, node: node.type }]; + } + if (ts.isTypeReferenceNode(node) || ts.isExpressionWithTypeArguments(node)) { + const name = ts.isTypeReferenceNode(node) ? node.typeName.getText() : node.expression.getText(); + // DOM attributes are inherited, but locally authored members remain editable. + if (attributes.has(name) && !declarations.has(name)) { + return []; + } + const declaration = declarations.get(name); + return declaration && !node.typeArguments ? [{ ...item, node: declaration }] : undefined; + } + return undefined; +} + +function htmlAttributeImports(syntax: ts.SourceFile): ReadonlySet { + const names = new Set(); + for (const statement of syntax.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + const bindings = statement.importClause?.namedBindings; + if ( + statement.moduleSpecifier.text === 'astro/types' && + bindings && + ts.isNamedImports(bindings) + ) { + for (const specifier of bindings.elements) { + if ((specifier.propertyName ?? specifier.name).text === 'HTMLAttributes') { + names.add(specifier.name.text); + } + } + } + } + } + return names; +} + +function unionBranches(node: ts.UnionTypeNode): readonly Branch[] { + const fields = node.types.map(branchFields); + const discriminator = [...(fields[0]?.keys() ?? [])].find((name) => + fields.every((branch) => literalBranchValue(branch.get(name)?.type) !== undefined) + ); + return fields.map((branch, index) => ({ + fields: branch, + condition: discriminator + ? `${discriminator} = ${literalBranchValue(branch.get(discriminator)?.type)}` + : `Variant branch ${index + 1}`, + })); +} + +function branchFields(root: ts.TypeNode): ReadonlyMap { + const work = [root]; + const fields = new Map(); + for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length; count++) { + const node = work.pop(); + assert(node !== undefined, 'Variant branch work item exists'); + if (ts.isTypeLiteralNode(node)) { + for (const member of node.members) { + if (ts.isPropertySignature(member)) { + const name = propertyKey(member.name); + if (name) { + fields.set(name, member); + } + } + } + } else if (ts.isIntersectionTypeNode(node)) { + work.push(...node.types); + } else if (ts.isParenthesizedTypeNode(node)) { + work.push(node.type); + } + } + assert(work.length === 0, 'Variant branch traversal is bounded'); + return fields; +} + +function literalBranchValue(type: ts.TypeNode | undefined): string | undefined { + if (!type) { + return undefined; + } + const types = ts.isUnionTypeNode(type) ? type.types : [type]; + return types.every((value) => ts.isLiteralTypeNode(value)) ? type.getText() : undefined; +} + +function contractConditions(contract: Contract, name: string): readonly string[] { + const common = contract.members.get(name)?.find((usage) => !usage.conditional)?.member; + const conditions = contract.branches + .filter((branches) => branches.some((branch) => branch.fields.has(name))) + .flatMap((branches) => + branches.map((branch) => { + const member = branch.fields.get(name) ?? common; + const state = member + ? member.type?.kind === ts.SyntaxKind.NeverKeyword + ? 'not allowed' + : member.questionToken + ? 'optional' + : 'required' + : 'not declared'; + return `${branch.condition}: ${state}`; + }) + ); + assert(conditions.length <= PROPERTY_LIMITS.fieldsMax, 'Property variant conditions are bounded'); + return conditions; +} diff --git a/electron/propertyDefinitions.ts b/electron/propertyDefinitions.ts index 3e8428f4..9c898551 100644 --- a/electron/propertyDefinitions.ts +++ b/electron/propertyDefinitions.ts @@ -1,3 +1,8 @@ +import { readPropertyOrigins } from './propertyOrigins'; +import { readPropertyContracts } from './propertyContracts'; +import type { PropertyContracts } from './propertyContracts'; +import type { PropertyOrigins } from './propertyOrigins'; +import type { SchemaField } from './astroParser.types'; import { createPropertyTypeReader } from './propertyTypes'; import { bindComponentDefault } from './propertyRename'; import ts from 'typescript'; @@ -6,6 +11,7 @@ import type { ComponentProperties, ComponentProperty, PropertyChange, + PropertyEditing, } from '../shared/component-properties'; import { PROPERTY_LIMITS } from '../shared/component-properties'; import { err, ok, type Result } from '../shared/result'; @@ -35,18 +41,32 @@ interface Definitions { export function readComponentProperties(source: string): ComponentProperties { const definitions = readDefinitions(source); + const origins = readPropertyOrigins(definitions.document); + const fields = readDefinitionFields(definitions); + const contracts = readPropertyContracts(definitions.document); + return { + source, + properties: [...fields.values()].map((property) => + sourcedProperty(property, definitions, origins, contracts) + ), + frontmatter: definitions.document.frontmatter, + advanced: definitions.advanced, + }; +} + +function readDefinitionFields(definitions: Definitions): ReadonlyMap { const fields = new Map(); const names = new Set([ ...definitions.members.map((member) => propertyKey(member.name)), ...definitions.bindings.map((binding) => propertyKey(binding.propertyName ?? binding.name)), ]); - for (const field of parsePropSchema(source)) { + for (const field of parsePropSchema(definitions.document.source)) { if (!definitions.advanced && !names.has(field.name)) { continue; } fields.set(field.name, { name: field.name, - type: field.type || 'unknown', + type: schemaPropertyType(field), required: !field.optional, readonly: false, defaultValue: '', @@ -86,14 +106,74 @@ export function readComponentProperties(source: string): ComponentProperties { } assert(fields.size <= PROPERTY_LIMITS.fieldsMax, 'Component field count is bounded'); assert(new Set(fields.keys()).size === fields.size, 'Component property names are unique'); + return fields; +} + +function schemaPropertyType(field: SchemaField): string { + if (field.type === 'enum' && field.options?.length) { + return field.options + .map((value) => (field.numeric ? value : JSON.stringify(value))) + .join(' | '); + } + return ['enum', 'other', 'code', 'attrs', 'slot', 'style'].includes(field.type) + ? 'unknown' + : field.type || 'unknown'; +} + +function sourcedProperty( + property: ComponentProperty, + definitions: Definitions, + origins: PropertyOrigins, + contracts: PropertyContracts +): ComponentProperty { + const binding = definitions.bindings.find( + (item) => propertyKey(item.propertyName ?? item.name) === property.name + ); + const types = (origins.members.get(property.name) ?? []) + .flatMap((member) => (member.type ? [definitions.readType(member.type)] : [])) + .filter((type) => type !== 'never'); + const member = contracts.editable.get(property.name); return { - source, - properties: [...fields.values()], - frontmatter: definitions.document.frontmatter, - advanced: definitions.advanced, + ...property, + ...(member + ? { + required: member.questionToken === undefined, + readonly: + member.modifiers?.some((item) => item.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false, + description: readDescription(member, definitions.document.frontmatter), + } + : {}), + type: definitions.advanced && types.length ? [...new Set(types)].join(' | ') : property.type, + origin: origins.origin(property.name, binding), + editing: definitions.advanced + ? contractEditing(definitions, contracts, property.name) + : { + kind: 'editable', + }, + conditions: contracts.conditions(property.name), }; } +function contractEditing( + definitions: Definitions, + contracts: PropertyContracts, + name: string +): PropertyEditing { + if ( + definitions.patterns.length > 1 || + definitions.bindings.some((binding) => !ts.isIdentifier(binding.name)) + ) { + return { + kind: 'restricted', + reason: 'This prop uses complex destructuring. Edit it in source.', + }; + } + if (!/^[A-Za-z_$][\w$]*$/.test(name)) { + return { kind: 'restricted', reason: 'This prop uses a quoted name. Edit it in source.' }; + } + return contracts.editing(name); +} + export function editPropertyDefinition(source: string, change: PropertyChange): Result { const definitions = readDefinitions(source); if (change.kind === 'source') { @@ -104,12 +184,7 @@ export function editPropertyDefinition(source: string, change: PropertyChange): return ok(replaceFrontmatter(definitions.document, change.frontmatter.replace(/\s*$/, '\n'))); } if (definitions.advanced) { - return err({ - code: 'advanced', - message: - 'This contract uses imported, generic, or composite props. ' + - 'Declare editable fields in Props.', - }); + return editCommonDefinition(definitions, change); } if (change.kind === 'order') { return reorderDefinitions(definitions, change.names); @@ -120,6 +195,32 @@ export function editPropertyDefinition(source: string, change: PropertyChange): return saveDefinition(definitions, change.originalName, change.property); } +function editCommonDefinition( + definitions: Definitions, + change: Exclude +): Result { + if (change.kind === 'order' || (change.kind === 'save' && !change.originalName)) { + return err({ + code: 'advanced', + message: 'Add or reorder declarations in this combined type in source.', + }); + } + const name = change.kind === 'remove' ? change.name : change.originalName; + // Renderer permissions are descriptive only; every write rechecks the current source. + const contracts = readPropertyContracts(definitions.document); + const access = contractEditing(definitions, contracts, name); + if (access.kind === 'restricted') { + return err({ code: 'restricted', message: access.reason }); + } + const member = contracts.editable.get(name); + assert(member !== undefined, 'An editable common property has a declaration'); + assert(propertyKey(member.name) === name, 'The edit targets the requested declaration'); + const common = { ...definitions, members: [member] }; + return change.kind === 'remove' + ? removeDefinition(common, name) + : saveDefinition(common, name, change.property); +} + function readDefinitions(source: string): Definitions { const document = readPropertySyntax(source); const declarations = document.syntax.statements.filter((statement) => { diff --git a/electron/propertyOrigins.ts b/electron/propertyOrigins.ts new file mode 100644 index 00000000..af22b90d --- /dev/null +++ b/electron/propertyOrigins.ts @@ -0,0 +1,162 @@ +import ts from 'typescript'; +import { assert } from '../shared/assert'; +import { PROPERTY_LIMITS } from '../shared/component-properties'; +import type { PropertyOrigin, PropertySource } from '../shared/component-properties'; +import { propertyKey, syntaxNodes } from './propertySyntax'; +import type { PropertySyntax } from './propertySyntax'; + +type Declaration = ts.InterfaceDeclaration | ts.TypeAliasDeclaration; +interface Work { + readonly node: ts.Node; + readonly owner: string; +} +export interface PropertyOrigins { + readonly members: ReadonlyMap; + readonly origin: (name: string, binding: ts.BindingElement | undefined) => PropertyOrigin; +} + +// Trace only the prop contract graph: similarly named fields in unrelated objects are not sources. +// Props is authoritative; a widened Astro.props assertion supplies missing fields only. +export function readPropertyOrigins(document: PropertySyntax): PropertyOrigins { + const declarations = new Map(); + for (const statement of document.syntax.statements) { + if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) { + declarations.set(statement.name.text, statement); + } + } + const roots = [ + 'Props', + ...syntaxNodes(document.syntax) + .filter(ts.isAsExpression) + .filter((node) => node.expression.getText() === 'Astro.props') + .map((node) => node.type.getText()), + ]; + const members = new Map(); + const sources = new Map(); + assert(roots.length <= PROPERTY_LIMITS.nodesMax, 'Property origin roots are bounded'); + for (const root of new Set(roots)) { + const found = readOriginMembers(declarations.get(root), declarations); + for (const [name, entries] of found) { + if (!members.has(name)) { + members.set( + name, + entries.map((entry) => entry.member) + ); + sources.set( + name, + entries.map((entry) => + originSource( + document, + entry.member, + `${entry.owner}.${name}`, + entry.member.type?.getText() ?? 'unknown' + ) + ) + ); + } + } + } + assert(members.size <= PROPERTY_LIMITS.fieldsMax, 'Property origin fields are bounded'); + return { + members, + origin: (name, binding) => ({ + declarations: sources.get(name) ?? [], + ...(binding?.initializer + ? { + defaultValue: originSource( + document, + binding, + `Astro.props.${name}`, + binding.initializer.getText() + ), + } + : {}), + }), + }; +} + +interface OriginMember { + readonly member: ts.PropertySignature; + readonly owner: string; +} +function readOriginMembers( + root: Declaration | undefined, + declarations: ReadonlyMap +): ReadonlyMap { + const work: Work[] = root ? [{ node: root, owner: root.name.text }] : []; + const seen = new Set(); + const members = new Map(); + for (let count = 0; count < PROPERTY_LIMITS.nodesMax && work.length > 0; count++) { + const item = work.pop(); + assert(item !== undefined, 'A property origin work item exists'); + if (seen.has(item.node)) { + continue; + } + seen.add(item.node); + if (ts.isPropertySignature(item.node)) { + const name = propertyKey(item.node.name); + if (name) { + const previous = members.get(name) ?? []; + assert(previous.length < PROPERTY_LIMITS.fieldsMax, 'Property source count is bounded'); + members.set(name, [...previous, { member: item.node, owner: item.owner }]); + } + } else { + work.push(...originChildren(item, declarations).reverse()); + assert(work.length <= PROPERTY_LIMITS.nodesMax, 'Property origin queue is bounded'); + } + } + assert(work.length === 0, 'Property origin traversal completes within bounds'); + return members; +} + +function originChildren(item: Work, declarations: ReadonlyMap): Work[] { + const { node, owner } = item; + if (ts.isTypeAliasDeclaration(node)) { + return [{ node: node.type, owner: node.name.text }]; + } + if (ts.isInterfaceDeclaration(node)) { + return [ + ...node.members.map((member) => ({ node: member, owner: node.name.text })), + ...(node.heritageClauses ?? []) + .flatMap((clause) => clause.types) + .flatMap((type) => originReference(type.expression.getText(), declarations)), + ]; + } + if (ts.isTypeLiteralNode(node)) { + return node.members.map((member) => ({ node: member, owner })); + } + if (ts.isUnionTypeNode(node) || ts.isIntersectionTypeNode(node)) { + return node.types.map((type) => ({ node: type, owner })); + } + if (ts.isParenthesizedTypeNode(node)) { + return [{ node: node.type, owner }]; + } + if (ts.isTypeReferenceNode(node)) { + // Generic transformations can rename or omit fields; do not invent per-field provenance. + return node.typeArguments ? [] : originReference(node.typeName.getText(), declarations); + } + return []; +} + +function originReference(name: string, declarations: ReadonlyMap): Work[] { + const declaration = declarations.get(name); + return declaration ? [{ node: declaration, owner: name }] : []; +} + +function originSource( + document: PropertySyntax, + node: ts.Node, + label: string, + expression: string +): PropertySource { + const offset = node.getStart(document.syntax); + assert(offset >= 0, 'Property source offset is nonnegative'); + assert(offset <= document.frontmatter.length, 'Property source offset is inside frontmatter'); + return { + label, + expression, + line: + document.syntax.getLineAndCharacterOfPosition(offset).line + + document.source.slice(0, document.start).split('\n').length, + }; +} diff --git a/electron/propertyRename.ts b/electron/propertyRename.ts index 490ba910..aecf3f9e 100644 --- a/electron/propertyRename.ts +++ b/electron/propertyRename.ts @@ -3,6 +3,7 @@ import { parse } from '@astrojs/compiler/sync'; import type { Node, AttributeNode } from '@astrojs/compiler/types'; import ts from 'typescript'; +import { readPropertyContracts } from './propertyContracts'; import { assert } from '../shared/assert'; import { PROPERTY_LIMITS } from '../shared/component-properties'; import { err, ok, type Result } from '../shared/result'; @@ -21,7 +22,12 @@ export interface PropertyRename { } type PropertyReferenceEdit = - | { readonly kind: 'rename'; readonly from: string; readonly to: string } + | { + readonly kind: 'rename'; + readonly from: string; + readonly to: string; + readonly typeNames: ReadonlySet; + } | { readonly kind: 'default'; readonly from: string; @@ -36,7 +42,16 @@ export function renameComponentReferences( rename: PropertyRename, owner: 'definition' | 'consumer' ): Result { - return editComponentReferences(source, names, { kind: 'rename', ...rename }, owner); + const typeNames = + owner === 'definition' + ? readPropertyContracts(readPropertySyntax(source)).names + : new Set(); + return editComponentReferences( + source, + names, + { kind: 'rename', ...rename, typeNames: new Set(['Props', ...typeNames]) }, + owner + ); } export function bindComponentDefault( @@ -309,7 +324,7 @@ function renameAstroAccess( const edits: SourceEdit[] = []; for (const node of syntaxNodes(syntax)) { if (rename.kind === 'rename') { - const typeEdit = renameIndexedPropType(node, start, rename); + const typeEdit = renameIndexedPropType(node, start, rename, rename.typeNames); if (typeEdit) { edits.push(typeEdit); } @@ -382,7 +397,8 @@ function defaultBindingName(source: string): string { function renameIndexedPropType( node: ts.Node, start: number, - rename: PropertyRename + rename: PropertyRename, + typeNames: ReadonlySet ): SourceEdit | undefined { if ( !ts.isLiteralTypeNode(node) || @@ -398,11 +414,11 @@ function renameIndexedPropType( const indexed = ts.isIndexedAccessTypeNode(parent) && ts.isTypeReferenceNode(parent.objectType) && - parent.objectType.typeName.getText() === 'Props'; + typeNames.has(parent.objectType.typeName.getText()); const picked = ts.isTypeReferenceNode(parent) && ['Pick', 'Omit'].includes(parent.typeName.getText()) && - parent.typeArguments?.[0]?.getText() === 'Props'; + typeNames.has(parent.typeArguments?.[0]?.getText() ?? ''); if (!indexed && !picked) { return undefined; } diff --git a/package-lock.json b/package-lock.json index fece4b5a..dbd3b1cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "stacki", - "version": "0.1.30", + "version": "0.1.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stacki", - "version": "0.1.30", + "version": "0.1.31", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8e8b1146..8f84c5c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stacki", "productName": "Stacki", - "version": "0.1.30", + "version": "0.1.31", "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 ", diff --git a/shared/component-properties.ts b/shared/component-properties.ts index 31e2b775..86e8cf9a 100644 --- a/shared/component-properties.ts +++ b/shared/component-properties.ts @@ -1,5 +1,5 @@ // Source text is the revision token: edits cannot overwrite a newer disk revision. -import { boolean, list, object, text } from './boundary'; +import { boolean, count, list, object, optional, text } from './boundary'; import { LIMITS } from './limits'; import { toRecord } from './record'; import type { Result } from './result'; @@ -21,6 +21,21 @@ export interface ComponentProperty { readonly readonly: boolean; readonly defaultValue: string; readonly description: string; + readonly origin?: PropertyOrigin; + readonly editing?: PropertyEditing; + readonly conditions?: readonly string[]; +} +export type PropertyEditing = + | { readonly kind: 'editable' } + | { readonly kind: 'restricted'; readonly reason: string }; +export interface PropertySource { + readonly label: string; + readonly expression: string; + readonly line: number; +} +export interface PropertyOrigin { + readonly declarations: readonly PropertySource[]; + readonly defaultValue?: PropertySource; } export interface ComponentProperties { readonly source: string; @@ -66,8 +81,43 @@ export function parseComponentProperty(input: unknown): ComponentProperty { readonly: boolean, defaultValue: propertyText, description: propertyText, + origin: optional(parsePropertyOrigin), + editing: optional(parsePropertyEditing), + conditions: optional((value) => propertyList(value, propertyText)), })(input); } + +function parsePropertyEditing(input: unknown): PropertyEditing { + const value = toRecord(input); + if (value?.['kind'] === 'editable') { + return { kind: 'editable' }; + } + if (value?.['kind'] === 'restricted') { + const reason = propertyText(value['reason']); + if (reason.trim()) { + return { kind: 'restricted', reason }; + } + } + throw new Error('Invalid property editing permission'); +} + +function parsePropertyOrigin(input: unknown): PropertyOrigin { + return object({ + declarations: (value) => propertyList(value, parsePropertySource), + defaultValue: optional(parsePropertySource), + })(input); +} + +function parsePropertySource(input: unknown): PropertySource { + const source = object({ label: propertyText, expression: propertyText, line: count })(input); + if (!source.label.trim()) { + throw new Error('Property source label must be nonempty'); + } + if (source.line < 1 || source.line > PROPERTY_LIMITS.sourceCharsMax) { + throw new Error('Property source line is out of bounds'); + } + return source; +} export function propertyList(input: unknown, parse: (input: unknown) => T): readonly T[] { const values = list(parse)(input); if (values.length > PROPERTY_LIMITS.fieldsMax) { diff --git a/src/panels/ComponentPropertiesPanel.tsx b/src/panels/ComponentPropertiesPanel.tsx index 813132ca..d0687627 100644 --- a/src/panels/ComponentPropertiesPanel.tsx +++ b/src/panels/ComponentPropertiesPanel.tsx @@ -7,7 +7,9 @@ import type { PropertyChange, } from '../../shared/component-properties'; import useListReorder from '../ui/useListReorder'; -import { TrashIcon } from '../ui/Icons'; +import { literalOptions } from '../propertyOptions'; +import ListFieldRow from '../ui/ListFieldRow'; +import { ComponentPropertiesIcon, FieldNumberIcon, FieldSwitchIcon } from '../ui/Icons'; import { PropertyGrip, movePropertyItem } from './PropertyReorder'; import { PropertiesIcon } from '../ui/PropertiesIcon'; import { PropertyEditor } from './PropertyEditor'; @@ -69,7 +71,9 @@ function PropertyPanelContent({ data, busy, save }: PropertyContentProps) { - - + )} + ))} ); } + +function PropertyTypeIcon({ type }: { readonly type: string }) { + if (literalOptions(type)) { + return ; + } + switch (type) { + case 'boolean': + return ; + case 'number': + return ; + case 'string': + return 'T'; + default: + return '{}'; + } +} + +function propertyIsEditable(property: ComponentProperty, data: ComponentProperties): boolean { + return property.editing ? property.editing.kind === 'editable' : !data.advanced; +} diff --git a/src/panels/ListField.tsx b/src/panels/ListField.tsx index 8f142326..e943ec71 100644 --- a/src/panels/ListField.tsx +++ b/src/panels/ListField.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react'; import type { Item } from '../arrayValue'; import { assert } from '../../shared/assert'; import { LIMITS } from '../../shared/limits'; -import { DragIcon, PlusIcon, TrashIcon, CloseIcon } from '../ui/Icons.jsx'; +import { PlusIcon, CloseIcon } from '../ui/Icons.jsx'; +import ListFieldRow from '../ui/ListFieldRow'; import { arrayItems, arrayText, blankLike, itemLabel, moveItem } from '../arrayValue.js'; // A prop that takes a list, edited as a list. @@ -176,9 +177,13 @@ export default function ListField(props: ListFieldProps) { return (
event.preventDefault()} onDrop={state.drop}> {state.items.length === 0 && note ?
{note}
: null} - {state.items.map((item, index) => ( - - ))} + {state.items.length > 0 && ( +
closeListRowEditor(state)}> + {state.items.map((item, index) => ( + + ))} +
+ )} - -
+ {itemLabel(item) || Empty} + ); } @@ -438,13 +430,7 @@ function ListEditor({ state }: { readonly state: ListState }) { } ) } - onClose={() => { - write(items, { - immediate: true, - change: { kind: 'edit', index: editor.index }, - }); - setEditor({ kind: 'closed' }); - }} + onClose={() => closeListRowEditor(state)} /> ); } @@ -466,3 +452,14 @@ function ListEditor({ state }: { readonly state: ListState }) { } } } + +function closeListRowEditor(state: ListState): void { + if (state.editor.kind === 'existing') { + // Commit before dismissing so scrolling cannot leave a popup detached from its row. + state.write(state.items, { + immediate: true, + change: { kind: 'edit', index: state.editor.index }, + }); + state.setEditor({ kind: 'closed' }); + } +} diff --git a/src/panels/PropertyDeclarationInfo.tsx b/src/panels/PropertyDeclarationInfo.tsx new file mode 100644 index 00000000..438feffd --- /dev/null +++ b/src/panels/PropertyDeclarationInfo.tsx @@ -0,0 +1,43 @@ +import type { ComponentProperty } from '../../shared/component-properties'; +import { HelpCircleIcon } from '../ui/Icons'; + +// Declaration locations are metadata, not runtime value bindings. +export function PropertyDeclarationInfo({ property }: { readonly property: ComponentProperty }) { + const origin = property.origin; + const sources = origin?.declarations ?? []; + const details = sources.map( + (source) => `${source.label} (line ${source.line}): ${source.expression}` + ); + if (origin?.defaultValue) { + details.push(`Default (line ${origin.defaultValue.line}): ${origin.defaultValue.expression}`); + } + if (details.length === 0) { + return null; + } + return ( + + ); +} + +export function PropertyConditions({ property }: { readonly property: ComponentProperty }) { + if (!property.conditions?.length) { + return null; + } + return ( +
+ Variant rules +
    + {property.conditions.map((condition, index) => ( +
  • {condition}
  • + ))} +
+
+ ); +} diff --git a/src/panels/PropertyDefault.tsx b/src/panels/PropertyDefault.tsx index d4707040..b2713511 100644 --- a/src/panels/PropertyDefault.tsx +++ b/src/panels/PropertyDefault.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import type { ComponentProperty } from '../../shared/component-properties'; import { PROPERTY_LIMITS } from '../../shared/component-properties'; import { literalOptions } from '../propertyOptions'; +import Dropdown from '../ui/Dropdown'; +import { BracesIcon } from '../ui/Icons'; interface DefaultProps { readonly property: ComponentProperty; @@ -14,17 +16,22 @@ export function PropertyDefault({ property, onChange }: DefaultProps) { const [mode, setMode] = useState<'value' | 'expression'>( simple && isLiteralDefault(property) ? 'value' : 'expression' ); + const expression = mode === 'expression'; + const action = expression ? 'Use the default value control' : 'Write a default expression'; return ( -
+
Default value {simple && ( )}
@@ -42,16 +49,6 @@ export function PropertyDefault({ property, onChange }: DefaultProps) { onChange={(event) => onChange(event.target.value)} /> )} -
- - {property.defaultValue - ? 'Used when a value is omitted or undefined.' - : 'No default value.'} - - -
); } @@ -60,18 +57,20 @@ function DefaultControl({ property, onChange }: DefaultProps) { if (options || property.type === 'boolean') { const choices = [...new Set(options ?? ['true', 'false'])]; return ( - + options={[ + { value: '', label: 'No default' }, + ...choices.map((choice) => ({ + value: choice, + label: propertyDefaultText(choice) ?? choice, + })), + ]} + onChange={onChange} + livePreview={false} + searchable + searchPlaceholder="Search defaults…" + /> ); } if (property.type === 'number') { diff --git a/src/panels/PropertyEditor.tsx b/src/panels/PropertyEditor.tsx index aacdbba7..8e158d83 100644 --- a/src/panels/PropertyEditor.tsx +++ b/src/panels/PropertyEditor.tsx @@ -1,21 +1,26 @@ +import PropertyReadOnlyFields from './PropertyReadOnlyFields'; +import { PropertyConditions, PropertyDeclarationInfo } from './PropertyDeclarationInfo'; import { TrashIcon } from '../ui/Icons'; import { PropertyOptions } from './PropertyOptions'; import type { OptionRename } from './PropertyOptions'; import { PropertyType } from './PropertyType'; import { PropertyDefault, propertyDefaultText } from './PropertyDefault'; export { literalOptions } from '../propertyOptions'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; +import useDismiss from '../ui/useDismiss'; import type { ComponentProperty, PropertyChange } from '../../shared/component-properties'; import { PROPERTY_LIMITS } from '../../shared/component-properties'; interface PropertyEditorProps { readonly property: ComponentProperty; readonly originalName: string; - readonly disabled: boolean; + readonly access: 'editable' | 'readonly' | 'saving'; readonly onClose: () => void; readonly onSave: (change: PropertyChange) => Promise; } export function PropertyEditor(props: PropertyEditorProps) { + const editorRef = useRef(null); + useDismiss(editorRef, props.access !== 'saving', props.onClose); const [property, setProperty] = useState(props.property); const [error, setError] = useState(''); const update = (key: Key, value: ComponentProperty[Key]) => { @@ -30,54 +35,94 @@ export function PropertyEditor(props: PropertyEditorProps) { void props.onSave({ kind: 'save', originalName: props.originalName, property }); }; return ( -
+
- {props.originalName ? 'Property settings' : 'New property'} +
+ {props.originalName ? 'Property settings' : 'New property'} + +
-
- - update('type', type)} /> - + + {props.access === 'readonly' ? ( + + ) : ( + setProperty((previous) => changePropertyOptions(previous, type, rename)) } /> - - update('defaultValue', value)} - /> -