From 644324651563e2fbca5be793ecf5b45cf05b581f Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 7 Apr 2026 16:53:00 +0800 Subject: [PATCH 1/4] Fix cyclic deps: consolidate NumberField, FileDef, ImageDef into card-api; add cardThumbnail to CardInfoField NumberField, FileDef, and ImageDef were in separate modules that all imported from card-api. Adding `cardThumbnail = linksTo(() => ImageDef)` to CardInfoField would have created a circular import chain. Moving all three into card-api breaks the cycle; the old files become re-export shims for backwards compatibility. --- packages/base/card-api.gts | 511 +++++++++++++++++- packages/base/default-templates/card-info.gts | 8 + packages/base/file-api.gts | 265 +-------- packages/base/image-file-def.gts | 206 +------ packages/base/number.gts | 64 +-- 5 files changed, 530 insertions(+), 524 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 2fab222c7ab..3a4ee811ec2 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1,8 +1,10 @@ import Modifier from 'ember-modifier'; import GlimmerComponent from '@glimmer/component'; +import { concat } from '@ember/helper'; import { isEqual } from 'lodash'; import { WatchedArray } from './watched-array'; import { BoxelInput, CopyButton } from '@cardstack/boxel-ui/components'; +import { copyCardURLToClipboard } from '@cardstack/boxel-ui/helpers'; import { type MenuItemOptions, not } from '@cardstack/boxel-ui/helpers'; import { getBoxComponent, @@ -16,6 +18,7 @@ import { getLinksToManyComponent } from './links-to-many-component'; import { assertIsSerializerName, baseRef, + byteStreamToUint8Array, CardContextName, CardError, CodeRef, @@ -31,6 +34,7 @@ import { getSerializer, humanReadable, identifyCard, + inferContentType, isBaseInstance, isCardError, isCardInstance as _isCardInstance, @@ -104,6 +108,7 @@ import FieldDefEditTemplate from './default-templates/field-edit'; import MarkdownTemplate from './default-templates/markdown'; import CaptionsIcon from '@cardstack/boxel-icons/captions'; import LetterCaseIcon from '@cardstack/boxel-icons/letter-case'; +import HashIcon from '@cardstack/boxel-icons/hash'; import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle'; import RectangleEllipsisIcon from '@cardstack/boxel-icons/rectangle-ellipsis'; import TextAreaIcon from '@cardstack/boxel-icons/align-left'; @@ -111,9 +116,19 @@ import ThemeIcon from '@cardstack/boxel-icons/palette'; import ImportIcon from '@cardstack/boxel-icons/import'; import FilePencilIcon from '@cardstack/boxel-icons/file-pencil'; import WandIcon from '@cardstack/boxel-icons/wand'; +import FileIcon from '@cardstack/boxel-icons/file'; +import ArrowLeft from '@cardstack/boxel-icons/arrow-left'; +import LinkIcon from '@cardstack/boxel-icons/link'; +import Eye from '@cardstack/boxel-icons/eye'; +import CodeIcon from '@cardstack/boxel-icons/code'; // normalizeEnumOptions used by enum moved to packages/base/enum.gts import PatchThemeCommand from '@cardstack/boxel-host/commands/patch-theme'; import CopyAndEditCommand from '@cardstack/boxel-host/commands/copy-and-edit'; +import CopyFileToRealmCommand from '@cardstack/boxel-host/commands/copy-file-to-realm'; +import OpenInInteractModeCommand from '@cardstack/boxel-host/commands/open-in-interact-mode'; +import ShowFileCommand from '@cardstack/boxel-host/commands/show-file'; +import SwitchSubmodeCommand from '@cardstack/boxel-host/commands/switch-submode'; +import { md5 } from 'super-fast-md5'; import { callSerializeHook, @@ -154,12 +169,13 @@ import { type NotLoadedValue, } from './field-support'; import { type GetMenuItemParams, getDefaultCardMenuItems } from './menu-items'; +import { TextInputValidator } from './text-input-validator'; import { LinkableDocument, SingleFileMetaDocument, } from '@cardstack/runtime-common/document-types'; import type { FileMetaResource } from '@cardstack/runtime-common'; -import type { FileDef } from './file-api'; +import { NumberSerializer } from '@cardstack/runtime-common'; export const BULK_GENERATED_ITEM_COUNT = 3; @@ -2163,7 +2179,10 @@ export class BaseDef { if (isNotLoadedValue(rawValue)) { let normalizedId = rawValue.reference; if (value[relativeTo]) { - normalizedId = resolveCardReference(normalizedId, value[relativeTo]); + normalizedId = resolveCardReference( + normalizedId, + value[relativeTo], + ); } return [fieldName, { id: makeAbsoluteURL(rawValue.reference) }]; } @@ -2333,6 +2352,285 @@ export class StringField extends FieldDef { }; } +export function deserializeForUI(value: string | number | null): number | null { + const validationError = NumberSerializer.validate(value); + if (validationError) { + return null; + } + + return NumberSerializer.deserializeSync(value); +} + +export function serializeForUI(val: number | null): string | undefined { + let serialized = NumberSerializer.serialize(val); + if (serialized != null) { + return String(serialized); + } + return undefined; +} + +class NumberFieldView extends Component { + +} + +export class NumberField extends FieldDef { + static displayName = 'Number'; + static icon = HashIcon; + static [primitive]: number; + static [fieldSerializer] = 'number'; + static [useIndexBasedKey]: never; + static embedded = NumberFieldView; + static atom = NumberFieldView; + + static edit = class Edit extends Component { + + + textInputValidator: TextInputValidator = new TextInputValidator( + () => this.args.model, + (inputVal) => this.args.set(inputVal), + deserializeForUI, + serializeForUI, + NumberSerializer.validate, + ); + }; +} + +class FileView extends Component { + +} + +class FileEdit extends Component { + +} + +export type SerializedFile = { + sourceUrl: string; + url: string; + name: string; + contentType: string; + contentHash?: string; + contentSize?: number; +} & Extra; + +export type ByteStream = ReadableStream | Uint8Array; + +export class FileContentMismatchError extends Error { + name = 'FileContentMismatchError'; +} + +export interface SerializedFileDef { + url?: string; + sourceUrl: string; + name?: string; + contentHash?: string; + contentSize?: number; + contentType?: string; + content?: string; + error?: string; +} + +export class FileDef extends BaseDef { + static displayName = 'File'; + static isFileDef = true; + static icon = FileIcon; + [isSavedInstance] = true; + + static assignInitialFieldValue( + instance: BaseDef, + fieldName: string, + value: any, + ) { + if (fieldName === 'id') { + let deserialized = getDataBucket(instance); + deserialized.set('id', value); + } else { + super.assignInitialFieldValue(instance, fieldName, value); + } + } + + @field id = contains(ReadOnlyField); + @field sourceUrl = contains(StringField); + @field url = contains(StringField); + @field name = contains(StringField); + @field contentType = contains(StringField); + @field contentHash = contains(StringField); + @field contentSize = contains(NumberField); + + static embedded: BaseDefComponent = FileView; + static fitted: BaseDefComponent = FileView; + static isolated: BaseDefComponent = FileView; + static atom: BaseDefComponent = FileView; + static edit: BaseDefComponent = FileEdit; + + static async extractAttributes( + url: string, + getStream: () => Promise, + options: { contentHash?: string; contentSize?: number } = {}, + ): Promise { + let parsed = new URL(url); + let name = decodeURIComponent( + parsed.pathname.split('/').pop() ?? parsed.pathname, + ); + let contentType = inferContentType(name); + let contentHash: string | undefined = options.contentHash; + let contentSize: number | undefined = options.contentSize; + if (!contentHash || contentSize === undefined) { + let bytes = await byteStreamToUint8Array(await getStream()); + if (!contentHash) { + try { + contentHash = md5(bytes); + } catch { + contentHash = md5(new TextDecoder().decode(bytes)); + } + } + if (contentSize === undefined) { + contentSize = bytes.byteLength; + } + } + + return { + sourceUrl: url, + url, + name, + contentType, + contentHash, + contentSize, + }; + } + + serialize() { + return { + sourceUrl: this.sourceUrl, + url: this.url, + name: this.name, + contentType: this.contentType, + contentHash: this.contentHash, + contentSize: this.contentSize, + }; + } + + [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { + return getDefaultFileMenuItems(this, params); + } +} + +export function createFileDef({ + url, + sourceUrl, + name, + contentType, + contentHash, + contentSize, +}: SerializedFileDef) { + return new FileDef({ + url, + sourceUrl, + name, + contentType, + contentHash, + contentSize, + }); +} + +export function getDefaultFileMenuItems( + fileDefInstance: FileDef, + params: GetMenuItemParams, +): MenuItemOptions[] { + let fileDefInstanceId = fileDefInstance.id as unknown as string; + let menuItems: MenuItemOptions[] = []; + if ( + ['interact', 'code-mode-preview', 'code-mode-playground'].includes( + params.menuContext, + ) + ) { + menuItems.push({ + label: 'Copy File URL', + action: () => copyCardURLToClipboard(fileDefInstanceId), + icon: LinkIcon, + disabled: !fileDefInstanceId, + }); + } + if (params.menuContext === 'interact') { + if (fileDefInstanceId && params.canEdit) { + // TODO: add menu item to delete the file + } + } + if ( + params.menuContext === 'ai-assistant' && + params.menuContextParams.canEditActiveRealm + ) { + menuItems.push({ + label: 'Copy to Workspace', + action: async () => { + const { newFileUrl } = await new CopyFileToRealmCommand( + params.commandContext, + ).execute({ + sourceFileUrl: fileDefInstance.sourceUrl, + targetRealm: params.menuContextParams.activeRealmURL, + }); + + await new ShowFileCommand(params.commandContext).execute({ + fileUrl: newFileUrl, + }); + }, + icon: ArrowLeft, + }); + } + if ( + ['code-mode-preview', 'code-mode-playground'].includes(params.menuContext) + ) { + menuItems.push({ + label: 'Open in Interact Mode', + action: () => { + new OpenInInteractModeCommand(params.commandContext).execute({ + cardId: fileDefInstanceId, + format: params.format === 'edit' ? 'edit' : 'isolated', + }); + }, + icon: Eye, + }); + } + if (params.menuContext === 'code-mode-playground') { + menuItems.push({ + label: 'Open in Code Mode', + action: async () => { + await new SwitchSubmodeCommand(params.commandContext).execute({ + submode: 'code', + codePath: fileDefInstanceId + ? new URL(fileDefInstanceId).href + : undefined, + }); + }, + icon: CodeIcon, + }); + } + return menuItems; +} + // TODO: This is a simple workaround until the thumbnailURL is converted into an actual image field export class MaybeBase64Field extends StringField { static embedded = class Embedded extends Component { @@ -2350,6 +2648,209 @@ export class MaybeBase64Field extends StringField { static atom = MaybeBase64Field.embedded; } +// ImageDef +class Isolated extends Component { + +} + +class Atom extends Component { + +} + +class Embedded extends Component { + +} + +class Fitted extends Component { + get backgroundImageStyle() { + if (this.args.model.url) { + return `background-image: url(${this.args.model.url});`; + } + return undefined; + } + + +} + +export class ImageDef extends FileDef { + static displayName = 'Image'; + static acceptTypes = 'image/*'; + + @field width = contains(NumberField); + @field height = contains(NumberField); + + static isolated: BaseDefComponent = Isolated; + static embedded: BaseDefComponent = Embedded; + static atom: BaseDefComponent = Atom; + static fitted: BaseDefComponent = Fitted; +} + export class TextAreaField extends StringField { static displayName = 'TextArea'; static icon = TextAreaIcon; @@ -2452,6 +2953,7 @@ export class CardInfoField extends FieldDef { static displayName = 'Card Info'; @field name = contains(StringField); @field summary = contains(StringField); + @field cardThumbnail = linksTo(() => ImageDef); @field cardThumbnailURL = contains(MaybeBase64Field); @field theme = linksTo(() => Theme); @field notes = contains(MarkdownField); @@ -2790,7 +3292,10 @@ function lazilyLoadLink( inflightLoads = new Map(); inflightLinkLoads.set(instance, inflightLoads); } - let reference = resolveCardReference(link, instance.id ?? instance[relativeTo]); + let reference = resolveCardReference( + link, + instance.id ?? instance[relativeTo], + ); let key = `${field.name}/${reference}`; let promise = inflightLoads.get(key); let store = getStore(instance); diff --git a/packages/base/default-templates/card-info.gts b/packages/base/default-templates/card-info.gts index 108797f08fb..50f1f19e59e 100644 --- a/packages/base/default-templates/card-info.gts +++ b/packages/base/default-templates/card-info.gts @@ -217,6 +217,14 @@ class CardInfoEditor extends GlimmerComponent { {{#if this.isThumbnailEditorVisible}}
+ + <@fields.cardInfo.cardThumbnail /> + { - -} - -class Edit extends Component { - -} - -export type SerializedFile = { - sourceUrl: string; - url: string; - name: string; - contentType: string; - contentHash?: string; - contentSize?: number; -} & Extra; - -export type ByteStream = ReadableStream | Uint8Array; - -// Throw this error from extractAttributes when the file content doesn't match this FileDef's -// expectations so the extractor can fall back to a superclass/base FileDef. -export class FileContentMismatchError extends Error { - name = 'FileContentMismatchError'; -} - -export class FileDef extends BaseDef { - static displayName = 'File'; - static isFileDef = true; - static icon = FileIcon; - [isSavedInstance] = true; - - static assignInitialFieldValue( - instance: BaseDef, - fieldName: string, - value: any, - ) { - if (fieldName === 'id') { - // Similar to CardDef, set 'id' directly in the deserialized cache - // to avoid triggering recomputes during instantiation - let deserialized = getDataBucket(instance); - deserialized.set('id', value); - } else { - super.assignInitialFieldValue(instance, fieldName, value); - } - } - - @field id = contains(ReadOnlyField); - @field sourceUrl = contains(StringField); - @field url = contains(StringField); - @field name = contains(StringField); - @field contentType = contains(StringField); - @field contentHash = contains(StringField); - @field contentSize = contains(NumberField); - - static embedded: BaseDefComponent = View; - static fitted: BaseDefComponent = View; - static isolated: BaseDefComponent = View; - static atom: BaseDefComponent = View; - static edit: BaseDefComponent = Edit; - - static async extractAttributes( - url: string, - getStream: () => Promise, - options: { contentHash?: string; contentSize?: number } = {}, - ): Promise { - let parsed = new URL(url); - let name = decodeURIComponent( - parsed.pathname.split('/').pop() ?? parsed.pathname, - ); - let contentType = inferContentType(name); - let contentHash: string | undefined = options.contentHash; - let contentSize: number | undefined = options.contentSize; - if (!contentHash || contentSize === undefined) { - let bytes = await byteStreamToUint8Array(await getStream()); - if (!contentHash) { - try { - contentHash = md5(bytes); - } catch { - contentHash = md5(new TextDecoder().decode(bytes)); - } - } - if (contentSize === undefined) { - contentSize = bytes.byteLength; - } - } - - return { - sourceUrl: url, - url, - name, - contentType, - contentHash, - contentSize, - }; - } - - serialize() { - return { - sourceUrl: this.sourceUrl, - url: this.url, - name: this.name, - contentType: this.contentType, - contentHash: this.contentHash, - contentSize: this.contentSize, - }; - } - - [getMenuItems](params: GetMenuItemParams): MenuItemOptions[] { - return getDefaultFileMenuItems(this, params); - } -} - -export interface SerializedFileDef { - url?: string; - sourceUrl: string; - name?: string; - contentHash?: string; - contentSize?: number; - contentType?: string; - content?: string; - error?: string; -} - -export function createFileDef({ - url, - sourceUrl, - name, - contentType, - contentHash, - contentSize, -}: SerializedFileDef) { - return new FileDef({ url, sourceUrl, name, contentType, contentHash, contentSize }); -} - -export function getDefaultFileMenuItems( - fileDefInstance: FileDef, - params: GetMenuItemParams, -): MenuItemOptions[] { - let fileDefInstanceId = fileDefInstance.id as unknown as string; - let menuItems: MenuItemOptions[] = []; - if ( - ['interact', 'code-mode-preview', 'code-mode-playground'].includes( - params.menuContext, - ) - ) { - menuItems.push({ - label: 'Copy File URL', - action: () => copyCardURLToClipboard(fileDefInstanceId), - icon: LinkIcon, - disabled: !fileDefInstanceId, - }); - } - if (params.menuContext === 'interact') { - if (fileDefInstanceId && params.canEdit) { - // TODO: add menu item to delete the file - } - } - if ( - params.menuContext === 'ai-assistant' && - params.menuContextParams.canEditActiveRealm - ) { - menuItems.push({ - label: 'Copy to Workspace', - action: async () => { - const { newFileUrl } = await new CopyFileToRealmCommand( - params.commandContext, - ).execute({ - sourceFileUrl: fileDefInstance.sourceUrl, - targetRealm: params.menuContextParams.activeRealmURL, - }); - - await new ShowFileCommand(params.commandContext).execute({ - fileUrl: newFileUrl, - }); - }, - icon: ArrowLeft, - }); - } - if ( - ['code-mode-preview', 'code-mode-playground'].includes(params.menuContext) - ) { - menuItems.push({ - label: 'Open in Interact Mode', - action: () => { - new OpenInInteractModeCommand(params.commandContext).execute({ - cardId: fileDefInstanceId, - format: params.format === 'edit' ? 'edit' : 'isolated', - }); - }, - icon: Eye, - }); - } - if (params.menuContext === 'code-mode-playground') { - menuItems.push({ - label: 'Open in Code Mode', - action: async () => { - await new SwitchSubmodeCommand(params.commandContext).execute({ - submode: 'code', - codePath: fileDefInstanceId - ? new URL(fileDefInstanceId).href - : undefined, - }); - }, - icon: CodeIcon, - }); - } - return menuItems; -} diff --git a/packages/base/image-file-def.gts b/packages/base/image-file-def.gts index ea00c671d07..62de126d588 100644 --- a/packages/base/image-file-def.gts +++ b/packages/base/image-file-def.gts @@ -1,205 +1 @@ -import NumberField from './number'; -import { BaseDefComponent, Component, contains, field } from './card-api'; -import { FileDef } from './file-api'; - -class Isolated extends Component { - -} - -class Atom extends Component { - -} - -class Embedded extends Component { - -} - -class Fitted extends Component { - get backgroundImageStyle() { - if (this.args.model.url) { - return `background-image: url(${this.args.model.url});`; - } - return undefined; - } - - -} - -export class ImageDef extends FileDef { - static displayName = 'Image'; - static acceptTypes = 'image/*'; - - @field width = contains(NumberField); - @field height = contains(NumberField); - - static isolated: BaseDefComponent = Isolated; - static embedded: BaseDefComponent = Embedded; - static atom: BaseDefComponent = Atom; - static fitted: BaseDefComponent = Fitted; -} +export { ImageDef } from './card-api'; diff --git a/packages/base/number.gts b/packages/base/number.gts index 33e2e1606d9..9c3913e527b 100644 --- a/packages/base/number.gts +++ b/packages/base/number.gts @@ -1,59 +1,5 @@ -import { primitive, Component, useIndexBasedKey, FieldDef } from './card-api'; -import { BoxelInput } from '@cardstack/boxel-ui/components'; -import { TextInputValidator } from './text-input-validator'; -import { not } from '@cardstack/boxel-ui/helpers'; -import HashIcon from '@cardstack/boxel-icons/hash'; -import { fieldSerializer, NumberSerializer } from '@cardstack/runtime-common'; - -export function deserializeForUI(value: string | number | null): number | null { - const validationError = NumberSerializer.validate(value); - if (validationError) { - return null; - } - - return NumberSerializer.deserializeSync(value); -} - -export function serializeForUI(val: number | null): string | undefined { - let serialized = NumberSerializer.serialize(val); - if (serialized != null) { - return String(serialized); - } - return undefined; -} - -class View extends Component { - -} - -export default class NumberField extends FieldDef { - static displayName = 'Number'; - static icon = HashIcon; - static [primitive]: number; - static [fieldSerializer] = 'number'; - static [useIndexBasedKey]: never; - static embedded = View; - static atom = View; - - static edit = class Edit extends Component { - - - textInputValidator: TextInputValidator = new TextInputValidator( - () => this.args.model, - (inputVal) => this.args.set(inputVal), - deserializeForUI, - serializeForUI, - NumberSerializer.validate, - ); - }; -} +export { + NumberField as default, + deserializeForUI, + serializeForUI, +} from './card-api'; From 5d71e37e281e72a14d43548c18c32f25e5549a9d Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 7 Apr 2026 16:53:08 +0800 Subject: [PATCH 2/4] Remove MaybeBase64Field, use StringField for cardThumbnailURL MaybeBase64Field was a StringField subclass whose only purpose was to display "(Base64 encoded value)" instead of raw data. Now that cardThumbnail (ImageDef) exists as the proper image field, the workaround is no longer needed. Replace all usages with plain StringField and update test snapshots accordingly. --- docs/spec.md | 1 - packages/base/card-api.gts | 24 ++----------------- packages/experiments-realm/puppy-card.gts | 3 +-- packages/host/tests/cards/puppy-card.gts | 3 +-- packages/host/tests/helpers/base-realm.ts | 3 --- .../components/card-basics-test.gts | 5 ++-- packages/realm-server/tests/helpers/index.ts | 20 ++++++++-------- 7 files changed, 16 insertions(+), 43 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 8ed143d713c..b6fb02db660 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -64,7 +64,6 @@ Each spec type has specific characteristics and use cases: **Example Use Cases**: - `SocialMediaLink` - Composite field for social platform data -- `MaybeBase64Field` - String field with base64 encoding capabilities - `TextAreaField` - Multi-line text input field - `GeoPointField` - Coordinate field for maps diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 3a4ee811ec2..8de7404ab5f 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2631,23 +2631,6 @@ export function getDefaultFileMenuItems( return menuItems; } -// TODO: This is a simple workaround until the thumbnailURL is converted into an actual image field -export class MaybeBase64Field extends StringField { - static embedded = class Embedded extends Component { - get isBase64() { - return this.args.model?.startsWith('data:'); - } - - }; - static atom = MaybeBase64Field.embedded; -} - // ImageDef class Isolated extends Component {