Skip to content
Draft
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
66 changes: 28 additions & 38 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { getLinksToManyComponent } from './links-to-many-component';
import {
assertIsSerializerName,
baseRef,
baseRealm,
CardContextName,
CardError,
CodeRef,
Expand All @@ -32,12 +31,12 @@ import {
getSerializer,
humanReadable,
identifyCard,
normalizeCodeRef,
isBaseInstance,
isCardError,
isCardInstance as _isCardInstance,
isCardResource,
isFileMetaResource,
isFileDef,
isField,
isFieldInstance,
isRelationship,
Expand Down Expand Up @@ -170,6 +169,7 @@ export {
getFields,
isCard,
isField,
isFileDef,
localId,
meta,
primitive,
Expand Down Expand Up @@ -336,28 +336,6 @@ export function instanceOf(instance: BaseDef, clazz: typeof BaseDef): boolean {
return false;
}

export function isFileDefConstructor(card: typeof BaseDef): boolean {
let baseFileDefRef = {
module: `${baseRealm.url}file-api`,
name: 'FileDef',
};
let current: typeof BaseDef | undefined = card;
while (current) {
let ref = identifyCard(current);
if (ref) {
let normalized = normalizeCodeRef(ref);
if (
normalized.module === baseFileDefRef.module &&
normalized.name === baseFileDefRef.name
) {
return true;
}
}
current = getAncestor(current) as typeof BaseDef | undefined;
}
return false;
}

class Logger {
private promises: Promise<any>[] = [];

Expand Down Expand Up @@ -1090,7 +1068,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
visited: Set<string>,
opts?: SerializeOpts,
) {
let relationshipType = isFileDefConstructor(this.card as typeof BaseDef)
let relationshipType = isFileDef(this.card)
? FileMetaResourceType
: CardResourceType;
if (isNotLoadedValue(value)) {
Expand All @@ -1100,6 +1078,10 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
links: {
self: makeRelativeURL(value.reference, opts),
},
data: {
type: relationshipType,
id: makeRelativeURL(value.reference, opts),
},
},
},
};
Expand All @@ -1113,7 +1095,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
},
};
}
if (isFileDefConstructor(this.card as typeof BaseDef) && !value.id) {
if (isFileDef(this.card) && !value.id) {
throw new Error(
`linksTo field '${this.name}' cannot serialize a FileDef without an id`,
);
Expand Down Expand Up @@ -1269,7 +1251,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
if (isNotLoadedValue(value)) {
return value;
}
if (isFileDefConstructor(this.card as typeof BaseDef) && !value.id) {
if (isFileDef(this.card) && !value.id) {
throw new Error(
`field validation error: the linksTo field '${this.name}' cannot reference a FileDef without an id`,
);
Expand Down Expand Up @@ -1313,14 +1295,13 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
let innerModel = model.field(fieldName);
return innerModel as unknown as Box<CardDef | null>;
};
let isFileDef = isFileDefConstructor(linksToField.card as typeof BaseDef);
let isFileDefField = isFileDef(linksToField.card);
function shouldRenderEditor(
format: Format | undefined,
defaultFormat: Format,
isComputed: boolean,
isFileDef: boolean,
) {
return (format ?? defaultFormat) === 'edit' && !isComputed && !isFileDef;
return (format ?? defaultFormat) === 'edit' && !isComputed;
}
function getChildFormat(
format: Format | undefined,
Expand Down Expand Up @@ -1354,9 +1335,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
<CardCrudFunctionsConsumer as |cardCrudFunctions|>
<DefaultFormatsConsumer as |defaultFormats|>
{{#if
(shouldRenderEditor
@format defaultFormats.cardDef isComputed isFileDef
)
(shouldRenderEditor @format defaultFormats.cardDef isComputed)
}}
<LinksToEditor
@model={{(getInnerModel)}}
Expand All @@ -1372,7 +1351,7 @@ class LinksTo<CardT extends LinkableDefConstructor> implements Field<CardT> {
@format
defaultFormats.cardDef
model
isFileDef
isFileDefField
}}
@displayContainer={{@displayContainer}}
...attributes
Expand Down Expand Up @@ -1583,7 +1562,7 @@ class LinksToMany<FieldT extends LinkableDefConstructor>
throw new Error(`Expected array for field value ${this.name}`);
}

let relationshipType = isFileDefConstructor(this.card as typeof BaseDef)
let relationshipType = isFileDef(this.card)
? FileMetaResourceType
: CardResourceType;
let relationships: Record<string, Relationship> = {};
Expand All @@ -1606,7 +1585,7 @@ class LinksToMany<FieldT extends LinkableDefConstructor>
};
return;
}
if (isFileDefConstructor(this.card as typeof BaseDef) && !value.id) {
if (isFileDef(this.card) && !value.id) {
throw new Error(
`linksToMany field '${this.name}' cannot serialize a FileDef without an id`,
);
Expand Down Expand Up @@ -1818,7 +1797,7 @@ class LinksToMany<FieldT extends LinkableDefConstructor>
if (
!isNotLoadedValue(value) &&
value != null &&
isFileDefConstructor(expectedCard as typeof BaseDef) &&
isFileDef(expectedCard) &&
!value.id
) {
throw new Error(
Expand Down Expand Up @@ -2773,7 +2752,7 @@ function lazilyLoadLink(
),
);
(async () => {
let isFileLink = isFileDefConstructor(field.card as typeof BaseDef);
let isFileLink = isFileDef(field.card);
try {
let fieldValue: CardDef | FileDef;
if (isFileLink) {
Expand Down Expand Up @@ -2840,6 +2819,11 @@ function lazilyLoadLink(
isFileLink || reference.endsWith('.json')
? reference
: `${reference}.json`;
if (isMissingFile) {
console.warn(
`[linksTo missing file] field=${field.name} reference=${reference} referenceForMissingFile=${referenceForMissingFile} isFileLink=${isFileLink}`,
);
}
let payloadError: {
title: string;
status: number;
Expand All @@ -2859,6 +2843,12 @@ function lazilyLoadLink(
if (isCardError(error) && error.deps?.length) {
payloadError.deps = [...new Set(error.deps)];
}
if (isMissingFile) {
let missingDep = isFileLink ? reference : referenceForMissingFile;
payloadError.deps = [
...new Set([...(payloadError.deps ?? []), missingDep]),
];
}
let payload = JSON.stringify({
type: 'error',
error: payloadError,
Expand Down
13 changes: 11 additions & 2 deletions packages/base/links-to-editor.gts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ import {
getBoxComponent,
} from './field-component';
import {
type CardDef,
type BaseDef,
type Box,
type Field,
type CardContext,
type LinkableDefConstructor,
CreateCardFn,
isFileDef,
} from './card-api';
import {
chooseCard,
chooseFile,
baseCardRef,
identifyCard,
CardContextName,
Expand All @@ -37,7 +38,7 @@ import { hash } from '@ember/helper';
interface Signature {
Element: HTMLElement;
Args: {
model: Box<CardDef | null>;
model: Box<BaseDef | null>;
field: Field<LinkableDefConstructor>;
typeConstraint?: ResolvedCodeRef;
createCard?: CreateCardFn;
Expand Down Expand Up @@ -166,6 +167,14 @@ export class LinksToEditor extends GlimmerComponent<Signature> {
}

private chooseCard = restartableTask(async () => {
if (isFileDef(this.args.field.card)) {
let file = await chooseFile();
if (file) {
this.args.model.value = file;
}
return;
}

let type = identifyCard(this.args.field.card) ?? baseCardRef;
if (this.args.typeConstraint) {
type = await getNarrowestType(this.args.typeConstraint, type, myLoader());
Expand Down
15 changes: 5 additions & 10 deletions packages/base/links-to-many-component.gts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
type LinkableDefConstructor,
CreateCardFn,
CardCrudFunctions,
isFileDefConstructor,
isFileDef,
} from './card-api';
import {
BoxComponentSignature,
Expand Down Expand Up @@ -482,9 +482,8 @@ function shouldRenderEditor(
format: Format | undefined,
defaultFormat: Format,
isComputed: boolean,
isFileDef: boolean,
) {
return (format ?? defaultFormat) === 'edit' && !isComputed && !isFileDef;
return (format ?? defaultFormat) === 'edit' && !isComputed;
}
const componentCache = initSharedState(
'linksToManyComponentCache',
Expand Down Expand Up @@ -515,15 +514,11 @@ export function getLinksToManyComponent({
getBoxComponent(cardTypeFor(field, child), child, field),
); // Wrap the the components in a function so that the template is reactive to changes in the model (this is essentially a helper)
let isComputed = !!field.computeVia || !!field.queryDefinition;
let isFileDef = isFileDefConstructor(field.card as typeof BaseDef);
let isFileDefField = isFileDef(field.card);
let linksToManyComponent = class LinksToManyComponent extends GlimmerComponent<BoxComponentSignature> {
<template>
<DefaultFormatsConsumer as |defaultFormats|>
{{#if
(shouldRenderEditor
@format defaultFormats.cardDef isComputed isFileDef
)
}}
{{#if (shouldRenderEditor @format defaultFormats.cardDef isComputed)}}
<LinksToManyEditor
@model={{model}}
@arrayField={{arrayField}}
Expand Down Expand Up @@ -559,7 +554,7 @@ export function getLinksToManyComponent({
@format={{getPluralChildFormat
effectiveFormat
model
isFileDef
isFileDefField
}}
@displayContainer={{@displayContainer}}
class='linksToMany-item'
Expand Down
2 changes: 2 additions & 0 deletions packages/host/app/components/card-prerender.gts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ export default class CardPrerender extends Component {
renderOptions?: RenderRouteOptions;
}): Promise<FileExtractResponse> => {
this.#nonce++;
this.localIndexer.renderError = undefined;
this.#renderErrorPayload = undefined;
let shouldClearCache = this.#consumeClearCacheForRender(
Boolean(renderOptions?.clearCache),
);
Expand Down
18 changes: 10 additions & 8 deletions packages/host/app/components/operator-mode/choose-file-modal.gts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import {
Deferred,
RealmPaths,
type LocalPath,
isCardErrorJSONAPI,
} from '@cardstack/runtime-common';

import ModalContainer from '@cardstack/host/components/modal-container';

import type MatrixService from '@cardstack/host/services/matrix-service';
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';

import type RealmService from '@cardstack/host/services/realm';
import type StoreService from '@cardstack/host/services/store';

import type { FileDef } from 'https://cardstack.com/base/file-api';

Expand All @@ -44,7 +44,7 @@ export default class ChooseFileModal extends Component<Signature> {

@service private declare operatorModeStateService: OperatorModeStateService;
@service private declare realm: RealmService;
@service private declare matrixService: MatrixService;
@service private declare store: StoreService;

constructor(owner: Owner, args: Signature['Args']) {
super(owner, args);
Expand Down Expand Up @@ -72,13 +72,15 @@ export default class ChooseFileModal extends Component<Signature> {
}

@action
private pick(path: LocalPath | undefined) {
private async pick(path: LocalPath | undefined) {
if (this.deferred && this.selectedRealm && path) {
let fileURL = new RealmPaths(this.selectedRealm.url).fileURL(path);
let file = this.matrixService.fileAPI.createFileDef({
sourceUrl: fileURL.toString(),
name: fileURL.toString().split('/').pop()!,
});
let file = await this.store.getFileMeta<FileDef>(fileURL.href);
if (isCardErrorJSONAPI(file)) {
throw new Error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
}
this.deferred.fulfill(file);
Comment on lines +80 to 84
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error thrown here will result in an uncaught promise rejection if the user clicks the Add button while a file is selected but the file metadata fails to load. Consider handling this error more gracefully, such as displaying an error message to the user rather than throwing an uncaught exception.

Suggested change
throw new Error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
}
this.deferred.fulfill(file);
console.error(
`choose-file-modal: failed to load file meta for ${fileURL.href}`,
);
// Treat this as if no file was chosen so callers receive `undefined`.
this.deferred.fulfill(undefined as unknown as FileDef);
} else {
this.deferred.fulfill(file);
}

Copilot uses AI. Check for mistakes.
}

Expand Down
26 changes: 26 additions & 0 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {

import type { CardDef, BaseDef } from 'https://cardstack.com/base/card-api';
import type * as CardAPI from 'https://cardstack.com/base/card-api';
import type { FileDef } from 'https://cardstack.com/base/file-api';

import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event';

Expand Down Expand Up @@ -377,6 +378,31 @@ export default class StoreService extends Service implements StoreInterface {
return await this.getInstance<T>({ idOrDoc: id });
}

async getFileMeta<T extends FileDef>(
url: string,
): Promise<T | CardErrorJSONAPI> {
return await this.withTestWaiters(async () => {
try {
let fileMetaDoc = await this.store.loadFileMetaDocument(url);
if (isCardError(fileMetaDoc)) {
throw fileMetaDoc;
}
let api = await this.cardService.getAPI();
let fileInstance = await api.createFromSerialized(
fileMetaDoc.data,
fileMetaDoc,
fileMetaDoc.data.id ? new URL(fileMetaDoc.data.id) : new URL(url),
{ store: this.store },
);
this.setIdentityContext(fileInstance as unknown as CardDef);
return fileInstance as unknown as T;
} catch (error: any) {
let errorResponse = processCardError(url, error);
return errorResponse.errors[0];
}
});
}

// Bypass cached state and fetch from source of truth
async getWithoutCache<T extends CardDef>(
id: string,
Expand Down
6 changes: 1 addition & 5 deletions packages/host/app/utils/render-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,7 @@ function applyAuthMessageOverrides(renderError: RenderError): RenderError {
function extractMissingRefFromMessage(message: string): string | undefined {
let match = /^missing file (.+?)(?: not found)?$/i.exec(message.trim());
if (match?.[1]) {
let ref = match[1].trim();
if (!ref.endsWith('.json')) {
ref = `${ref}.json`;
}
return ref;
return match[1].trim();
}
return undefined;
}
Expand Down
Loading
Loading