From d4be9f0373e314416f9124c7ffdae5f545f8226b Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 18 Feb 2026 15:25:21 +0100 Subject: [PATCH 1/9] Allow opening linked filedef in the stack --- packages/base/links-to-editor.gts | 9 +++- .../components/host-mode/breadcrumb-item.gts | 11 +++- .../host/app/components/host-mode/card.gts | 11 +++- .../host/app/components/host-mode/content.gts | 11 +++- .../app/components/host-mode/stack-item.gts | 11 +++- .../operator-mode/interact-submode.gts | 13 +++++ .../operator-mode/operator-mode-overlays.gts | 19 +++++++ .../components/operator-mode/stack-item.gts | 21 ++++++-- packages/host/app/lib/stack-item.ts | 22 ++++++++ packages/host/app/resources/card-resource.ts | 45 +++++++++++++--- .../services/operator-mode-state-service.ts | 14 ++++- packages/host/app/services/store.ts | 53 +++++++++++++++++-- .../components/operator-mode-ui-test.gts | 22 ++++++++ .../components/operator-mode/setup.gts | 45 ++++++++++++++++ packages/runtime-common/index.ts | 1 + 15 files changed, 288 insertions(+), 20 deletions(-) diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index dba3f3f7839..2da7207ef7e 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -87,7 +87,10 @@ export class LinksToEditor extends GlimmerComponent { /> {{/if}} @@ -151,6 +154,10 @@ export class LinksToEditor extends GlimmerComponent { return this.args.model.value == null; } + get isFileDefField() { + return isFileDef(this.args.field.card); + } + get linkedCard() { if (this.args.model.value == null) { throw new Error( diff --git a/packages/host/app/components/host-mode/breadcrumb-item.gts b/packages/host/app/components/host-mode/breadcrumb-item.gts index 95b31ba3db0..077b34c50a2 100644 --- a/packages/host/app/components/host-mode/breadcrumb-item.gts +++ b/packages/host/app/components/host-mode/breadcrumb-item.gts @@ -5,6 +5,8 @@ import Component from '@glimmer/component'; import { cached } from '@glimmer/tracking'; import { cardTypeIcon, isCardInstance } from '@cardstack/runtime-common'; +import type { StoreReadType } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import { getCard } from '@cardstack/host/resources/card-resource'; @@ -26,7 +28,14 @@ export default class HostModeBreadcrumbItem extends Component { return undefined; } - return getCard(this, () => this.args.cardId); + return getCard(this, () => this.args.cardId, { + type: this.readType, + }); + } + + private get readType(): StoreReadType { + let id = this.args.cardId; + return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; } @cached diff --git a/packages/host/app/components/host-mode/card.gts b/packages/host/app/components/host-mode/card.gts index 57af68290f0..c44c3a498c1 100644 --- a/packages/host/app/components/host-mode/card.gts +++ b/packages/host/app/components/host-mode/card.gts @@ -7,6 +7,8 @@ import { BoxelButton, CardContainer } from '@cardstack/boxel-ui/components'; import CardRenderer from '@cardstack/host/components/card-renderer'; import CardError from '@cardstack/host/components/operator-mode/card-error'; import { getCard } from '@cardstack/host/resources/card-resource'; +import type { StoreReadType } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; interface Signature { Element: HTMLElement; @@ -25,7 +27,14 @@ export default class HostModeCard extends Component { return undefined; } - return getCard(this, () => this.args.cardId!); + return getCard(this, () => this.args.cardId!, { + type: this.readType, + }); + } + + private get readType(): StoreReadType { + let id = this.args.cardId ?? ''; + return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; } get card() { diff --git a/packages/host/app/components/host-mode/content.gts b/packages/host/app/components/host-mode/content.gts index 56e8b987fea..f0105940362 100644 --- a/packages/host/app/components/host-mode/content.gts +++ b/packages/host/app/components/host-mode/content.gts @@ -11,6 +11,8 @@ import { isCardInstance, } from '@cardstack/runtime-common'; import { meta } from '@cardstack/runtime-common/constants'; +import type { StoreReadType } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import { getCard } from '@cardstack/host/resources/card-resource'; @@ -45,7 +47,14 @@ export default class HostModeContent extends Component { return undefined; } - return getCard(this, () => this.args.primaryCardId!); + return getCard(this, () => this.args.primaryCardId!, { + type: this.primaryReadType, + }); + } + + private get primaryReadType(): StoreReadType { + let id = this.args.primaryCardId ?? ''; + return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; } get cardIds() { diff --git a/packages/host/app/components/host-mode/stack-item.gts b/packages/host/app/components/host-mode/stack-item.gts index 663bee6f557..be05749a22d 100644 --- a/packages/host/app/components/host-mode/stack-item.gts +++ b/packages/host/app/components/host-mode/stack-item.gts @@ -12,6 +12,8 @@ import { ContextButton } from '@cardstack/boxel-ui/components'; import { and, bool } from '@cardstack/boxel-ui/helpers'; import { getCard } from '@cardstack/host/resources/card-resource'; +import type { StoreReadType } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import HostModeCard from './card'; @@ -37,7 +39,14 @@ export default class HostModeStackItem extends Component { if (!this.args.cardId) { return undefined; } - return getCard(this, () => this.args.cardId); + return getCard(this, () => this.args.cardId, { + type: this.readType, + }); + } + + private get readType(): StoreReadType { + let id = this.args.cardId; + return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; } @cached diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 536ab66e57f..9ea225b935b 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -36,6 +36,7 @@ import { codeRefWithAbsoluteURL, identifyCard, isCardInstance, + isFileDefInstance, isResolvedCodeRef, CardError, loadCardDef, @@ -50,6 +51,7 @@ import { type ResolvedCodeRef, type Filter, } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import CopyCardToStackCommand from '@cardstack/host/commands/copy-card-to-stack'; @@ -212,6 +214,9 @@ export default class InteractSubmode extends Component { : cardOrURL instanceof URL ? cardOrURL.href : cardOrURL.id; + if (!cardId) { + return; + } if (opts?.openCardInRightMostStack) { stackIndex = this.stacks.length; } else if (typeof opts?.stackIndex === 'number') { @@ -229,10 +234,18 @@ export default class InteractSubmode extends Component { } stackIndex = opts.stackIndex; } + let isFileMeta = + (typeof cardOrURL === 'string' || cardOrURL instanceof URL) && + hasExtension(cardId) && + !cardId.endsWith('.json'); + if (cardOrURL && typeof cardOrURL === 'object' && !isFileMeta) { + isFileMeta = isFileDefInstance(cardOrURL as CardDef); + } let newItem = new StackItem({ id: cardId, format, stackIndex, + type: isFileMeta ? 'file-meta' : 'card', relationshipContext: opts?.fieldName ? { fieldName: opts.fieldName, diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index 509209a488f..194b29d060e 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -28,6 +28,9 @@ import { copyCardURLToClipboard } from '@cardstack/host/utils/clipboard'; import type { Format } from 'https://cardstack.com/base/card-api'; +import { isFileDefInstance } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; + import { removeFileExtension } from '../search-sheet/utils'; import Overlays from './overlays'; @@ -350,6 +353,9 @@ export default class OperatorModeOverlays extends Overlays { case 'select': return !this.isField(renderedCard) && !!this.args.toggleSelect; case 'edit': + if (this.isFileMetaTarget(renderedCard)) { + return false; + } return this.realm.canWrite(this.getCardId(renderedCard.cardDefOrId)); case 'more-options': return ( @@ -381,6 +387,16 @@ export default class OperatorModeOverlays extends Overlays { } } + private isFileMetaTarget( + renderedCard: StackItemRenderedCardForOverlayActions, + ): boolean { + let cardDefOrId = renderedCard.cardDefOrId; + if (typeof cardDefOrId === 'string') { + return hasExtension(cardDefOrId) && !cardDefOrId.endsWith('.json'); + } + return isFileDefInstance(cardDefOrId); + } + @action private registerDropdownAPI( renderedCard: StackItemRenderedCardForOverlayActions, @@ -426,6 +442,9 @@ export default class OperatorModeOverlays extends Overlays { protected override getFormatForCard( renderedCard: StackItemRenderedCardForOverlayActions, ): Format { + if (this.isFileMetaTarget(renderedCard)) { + return 'isolated'; + } return renderedCard.stackItem.format as Format; } diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index e6b783c7865..8759cd76419 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -43,6 +43,7 @@ import { type getCard, type getCards, type getCardCollection, + isFileDefInstance, cardTypeDisplayName, PermissionsContextName, RealmURLContextName, @@ -178,7 +179,9 @@ export default class OperatorModeStackItem extends Component { } private makeCardResource = () => { - this.cardResource = this.getCard(this, () => this.args.item.id); + this.cardResource = this.getCard(this, () => this.args.item.id, { + type: this.args.item.type, + }); }; private get url() { @@ -654,12 +657,24 @@ export default class OperatorModeStackItem extends Component { this.card[realmURL] && !this.isBuried && !this.isEditing && + !this.isFileCard && this.realm.canWrite(this.card[realmURL].href) ); } private get isEditing() { - return !this.isBuried && this.args.item.format === 'edit'; + return !this.isBuried && !this.isFileCard && this.args.item.format === 'edit'; + } + + private get isFileCard() { + return ( + this.args.item.type === 'file-meta' || + (this.card ? isFileDefInstance(this.card) : false) + ); + } + + private get cardFormat() { + return this.isFileCard ? 'isolated' : this.args.item.format; } private get showError() { @@ -831,7 +846,7 @@ export default class OperatorModeStackItem extends Component { ; stackIndex: number; id: string; + type?: StackItemType; closeAfterSaving?: boolean; relationshipContext?: { fieldName?: string; @@ -14,11 +15,28 @@ interface Args { }; } +export type StackItemType = 'card' | 'file-meta'; + +function inferStackItemType(id: string, type?: StackItemType): StackItemType { + if (type) { + return type; + } + let withoutJson = id.replace(/\.json$/, ''); + let path = withoutJson.split(/[?#]/)[0]; + let lastSegment = path.split('/').pop() ?? ''; + let lastDotIndex = lastSegment.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < lastSegment.length - 1) { + return 'file-meta'; + } + return 'card'; +} + export class StackItem { format: Format; request?: Deferred; stackIndex: number; closeAfterSaving?: boolean; + type: StackItemType; #id: string; relationshipContext?: | { @@ -33,6 +51,7 @@ export class StackItem { request, stackIndex, id, + type, closeAfterSaving, relationshipContext, } = args; @@ -41,6 +60,7 @@ export class StackItem { this.format = format; this.request = request; this.stackIndex = stackIndex; + this.type = inferStackItemType(this.#id, type); this.closeAfterSaving = closeAfterSaving; this.relationshipContext = relationshipContext; } @@ -57,12 +77,14 @@ export class StackItem { closeAfterSaving, stackIndex, relationshipContext, + type, } = this; return new StackItem({ format, request, closeAfterSaving, id, + type, stackIndex, relationshipContext, ...args, diff --git a/packages/host/app/resources/card-resource.ts b/packages/host/app/resources/card-resource.ts index 814673c4047..fdc632b3fd7 100644 --- a/packages/host/app/resources/card-resource.ts +++ b/packages/host/app/resources/card-resource.ts @@ -12,26 +12,30 @@ import { isCardInstance, isFileDefInstance } from '@cardstack/runtime-common'; import type { BaseDef } from 'https://cardstack.com/base/card-api'; import type StoreService from '../services/store'; +import type { StoreReadType } from '@cardstack/runtime-common'; interface Args { named: { id: string | undefined; + type?: StoreReadType; }; } export class CardResource extends Resource { #id: string | undefined; + #type: StoreReadType | undefined; #hasRegisteredDestructor = false; #hasReference = false; @service declare private store: StoreService; modify(_positional: never[], named: Args['named']) { - let { id } = named; - if (id !== this.#id) { + let { id, type } = named; + if (id !== this.#id || type !== this.#type) { this.dropReferenceIfHeld(); this.#id = id; + this.#type = type; if (this.#id) { - this.store.addReference(this.#id); + this.store.addReference(this.#id, { type: this.#type }); this.#hasReference = true; } } @@ -50,6 +54,10 @@ export class CardResource extends Resource { } } + private get readType(): StoreReadType { + return this.#type ?? 'card'; + } + // Note that this will return a stale instance when the server state for this // id becomes an error. use this.cardError to see the live server state for // this instance. @@ -57,7 +65,12 @@ export class CardResource extends Resource { if (!this.#id) { return undefined; } - let maybeCard = this.store.peek(this.#id) as unknown; + let maybeCard = + this.readType === 'file-meta' + ? ((this.store.peek(this.#id, { type: 'file-meta' }) as unknown) ?? + (this.store.peek(this.#id) as unknown)) + : ((this.store.peek(this.#id) as unknown) ?? + (this.store.peek(this.#id, { type: 'file-meta' }) as unknown)); return isCardInstance(maybeCard) || isFileDefInstance(maybeCard) ? (maybeCard as BaseDef) : undefined; @@ -67,7 +80,18 @@ export class CardResource extends Resource { if (!this.#id) { return undefined; } - let maybeError = this.store.peekError(this.#id); + if ( + this.readType === 'file-meta' && + this.store.peek(this.#id, { type: 'file-meta' }) + ) { + return undefined; + } + let maybeError = + this.readType === 'file-meta' + ? this.store.peekError(this.#id, { type: 'file-meta' }) ?? + this.store.peekError(this.#id) + : this.store.peekError(this.#id) ?? + this.store.peekError(this.#id, { type: 'file-meta' }); return maybeError && !isCardInstance(maybeError) ? maybeError : undefined; } @@ -79,7 +103,9 @@ export class CardResource extends Resource { if (!this.#id) { return false; } - return Boolean(this.store.peek(this.#id)); + return this.readType === 'file-meta' + ? Boolean(this.store.peek(this.#id, { type: 'file-meta' })) + : Boolean(this.store.peek(this.#id)); } get autoSaveState() { @@ -100,10 +126,15 @@ export class CardResource extends Resource { // ``` // If you need to use `getCard()` in something that is not a Component, then // let's talk. -export function getCard(parent: object, id: () => string | undefined) { +export function getCard( + parent: object, + id: () => string | undefined, + opts?: { type?: StoreReadType }, +) { return CardResource.from(parent, () => ({ named: { id: id(), + type: opts?.type, }, })); } diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index 357d7f6db3a..ac1fb9b3ac6 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -27,7 +27,7 @@ import { import type { Submode } from '@cardstack/host/components/submode-switcher'; import { Submodes } from '@cardstack/host/components/submode-switcher'; -import { StackItem } from '@cardstack/host/lib/stack-item'; +import { StackItem, type StackItemType } from '@cardstack/host/lib/stack-item'; import { file, @@ -92,6 +92,7 @@ export interface OperatorModeState { interface CardItem { id: string; format: 'isolated' | 'edit' | 'head'; + type?: StackItemType; } export type FileView = 'inspector' | 'browser'; @@ -413,6 +414,9 @@ export default class OperatorModeStateService extends Service { editCardOnStack(stackIndex: number, card: CardDef): void { let item = this.findCardInStack(card, stackIndex); + if (item.type === 'file-meta') { + return; + } this.replaceItemInStack( item, item.clone({ @@ -875,11 +879,14 @@ export default class OperatorModeStateService extends Service { throw new Error(`Unknown format for card on stack ${item.format}`); } if (item.id) { - let instance = this.store.peek(item.id); + let instance = + this.store.peek(item.id) ?? + this.store.peek(item.id, { type: 'file-meta' }); if (!isLocalId(item.id) || instance?.id) { serializedStack.push({ id: instance?.id ?? item.id, format: item.format, + type: item.type === 'card' ? undefined : item.type, }); } } @@ -903,12 +910,14 @@ export default class OperatorModeStateService extends Service { fieldName?: string; fieldType?: 'linksTo' | 'linksToMany'; }, + type?: StackItemType, ) { let stackItem = new StackItem({ id, stackIndex, format, relationshipContext, + type, }); return stackItem; } @@ -961,6 +970,7 @@ export default class OperatorModeStateService extends Service { id: item.id, format, stackIndex, + type: item.type, }), ); } diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 161bae71bd4..b26790e672e 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -18,6 +18,7 @@ import { TrackedObject, TrackedMap } from 'tracked-built-ins'; import { hasExecutableExtension, isCardError, + isCardErrorJSONAPI, isCardInstance, isFileDefInstance, isFileMetaResource, @@ -58,6 +59,7 @@ import { type CardResource, type Saved, } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import type { CardDef, BaseDef } from 'https://cardstack.com/base/card-api'; import type * as CardAPI from 'https://cardstack.com/base/card-api'; @@ -232,10 +234,13 @@ export default class StoreService extends Service implements StoreInterface { } } - addReference(id: string | undefined) { + addReference(id: string | undefined, opts?: { type?: StoreReadType }) { if (!id) { return; } + let readType: StoreReadType = + opts?.type ?? + (hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'); // synchronously update the reference count so we don't run into race // conditions requiring a mutex let currentReferenceCount = this.referenceCount.get(id) ?? 0; @@ -259,7 +264,7 @@ export default class StoreService extends Service implements StoreInterface { this.subscribeToRealm(new URL(id)); // intentionally not awaiting this. we keep track of the promise in // this.newReferencePromises - this.wireUpNewReference(id); + this.wireUpNewReference(id, readType); } } @@ -859,12 +864,26 @@ export default class StoreService extends Service implements StoreInterface { deferred.fulfill(); } - private async wireUpNewReference(url: string) { + private async wireUpNewReference( + url: string, + readType: StoreReadType = 'card', + ) { let deferred = new Deferred(); await this.withTestWaiters(async () => { this.newReferencePromises.push(deferred.promise); try { await this.ready; + if (readType === 'file-meta') { + let instanceOrError = await this.getFileMetaInstance({ + idOrDoc: url, + }); + this.setIdentityContext( + instanceOrError as FileDef | CardErrorJSONAPI, + 'file-meta', + ); + deferred.fulfill(); + return; + } // Check file-meta map as well as card map — file-meta instances // are loaded into their own map by store.get(id, { type: 'file-meta' }) let fileMetaInstance = @@ -876,10 +895,38 @@ export default class StoreService extends Service implements StoreInterface { return; } let instanceOrError = this.peekError(url) ?? this.peek(url); + if ( + isCardErrorJSONAPI(instanceOrError) && + instanceOrError.status === 415 + ) { + let fileInstanceOrError = await this.getFileMetaInstance({ + idOrDoc: url, + }); + this.setIdentityContext( + fileInstanceOrError as FileDef | CardErrorJSONAPI, + 'file-meta', + ); + deferred.fulfill(); + return; + } if (!instanceOrError) { instanceOrError = await this.getCardInstance({ idOrDoc: url, }); + if ( + isCardErrorJSONAPI(instanceOrError) && + instanceOrError.status === 415 + ) { + let fileInstanceOrError = await this.getFileMetaInstance({ + idOrDoc: url, + }); + this.setIdentityContext( + fileInstanceOrError as FileDef | CardErrorJSONAPI, + 'file-meta', + ); + deferred.fulfill(); + return; + } this.setIdentityContext(instanceOrError); } await this.startAutoSaving(instanceOrError); diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 732f4abe4f5..d2c5105ae68 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -86,6 +86,28 @@ module('Integration | operator-mode | ui', function (hooks) { .includesText('Author'); }); + test(`click on "links to" the embedded file will open it on the stack`, async function (assert) { + ctx.setCardInOperatorModeState(`${testRealmURL}FileLinkCard/with-file`); + await renderComponent( + class TestDriver extends GlimmerComponent { + + }, + ); + + await waitFor('[data-test-file-link-attachment] [data-test-card]'); + await click('[data-test-file-link-attachment] [data-test-card]'); + await waitFor('[data-test-stack-card-index="1"]'); + assert.dom('[data-test-stack-card-index]').exists({ count: 2 }); + assert + .dom('[data-test-stack-card-index="1"]') + .includesText('notes.txt'); + assert + .dom( + '[data-test-stack-card-index="1"] [data-test-filedef-edit-unavailable]', + ) + .doesNotExist(); + }); + test(`toggles mode switcher`, async function (assert) { ctx.setCardInOperatorModeState(`${testRealmURL}BlogPost/1`); await renderComponent( diff --git a/packages/host/tests/integration/components/operator-mode/setup.gts b/packages/host/tests/integration/components/operator-mode/setup.gts index 26bed7aed1a..5c0bae487e5 100644 --- a/packages/host/tests/integration/components/operator-mode/setup.gts +++ b/packages/host/tests/integration/components/operator-mode/setup.gts @@ -73,12 +73,14 @@ export function setupOperatorModeTests( let textArea: typeof import('https://cardstack.com/base/text-area'); let cardsGrid: typeof import('https://cardstack.com/base/cards-grid'); let spec: typeof import('https://cardstack.com/base/spec'); + let fileApi: typeof import('https://cardstack.com/base/file-api'); cardApi = await loader.import(`${baseRealm.url}card-api`); string = await loader.import(`${baseRealm.url}string`); textArea = await loader.import(`${baseRealm.url}text-area`); cardsGrid = await loader.import(`${baseRealm.url}cards-grid`); spec = await loader.import(`${baseRealm.url}spec`); + fileApi = await loader.import(`${baseRealm.url}file-api`); let { field, @@ -95,6 +97,7 @@ export function setupOperatorModeTests( let { default: TextAreaField } = textArea; let { CardsGrid } = cardsGrid; let { Spec } = spec; + let { FileDef } = fileApi; // use string source so we can get the transpiled scoped CSS let friendWithCSSSource = ` @@ -370,6 +373,21 @@ export function setupOperatorModeTests( }; } + class FileLinkCard extends CardDef { + static displayName = 'File Link Card'; + @field title = contains(StringField); + @field attachment = linksTo(FileDef as unknown as typeof CardDef); + + static isolated = class Isolated extends Component { + + }; + } + class ExplodingCard extends CardDef { static displayName = 'Exploding Card'; @field name = contains(StringField); @@ -449,6 +467,7 @@ export function setupOperatorModeTests( 'boom-field.gts': { BoomField }, 'boom-pet.gts': { BoomPet }, 'blog-post.gts': { BlogPost }, + 'file-link-card.gts': { FileLinkCard }, 'exploding-card.gts': { ExplodingCard }, 'car.gts': { Car }, 'author.gts': { Author }, @@ -609,6 +628,32 @@ export function setupOperatorModeTests( firstName: 'Mark', lastName: 'Jackson', }), + 'FileLinkCard/notes.txt': 'Hello from a file link', + 'FileLinkCard/with-file.json': { + data: { + type: 'card', + attributes: { + title: 'Linked file example', + }, + relationships: { + attachment: { + links: { + self: './notes.txt', + }, + data: { + type: 'file-meta', + id: './notes.txt', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../file-link-card', + name: 'FileLinkCard', + }, + }, + }, + }, 'BlogPost/1.json': blogPost, 'BlogPost/2.json': new BlogPost({ cardTitle: 'Beginnings' }), 'ExplodingCard/1.json': explodingCard, diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index ccf9faa6ee0..da3485a6a34 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -440,6 +440,7 @@ export type AutoSaveState = { export type getCard = ( parent: object, id: () => string | undefined, + opts?: { type?: StoreReadType }, ) => // This is a duck type of the CardResource { id: string | undefined; From dc120373df0a950f00bce035d22dce707d38c5c7 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 19 Feb 2026 10:04:12 +0100 Subject: [PATCH 2/9] Fix tests --- packages/base/links-to-editor.gts | 6 +- .../tests/acceptance/file-chooser-test.gts | 1 + .../components/operator-mode-ui-test.gts | 60 +++++++++++++++++-- .../components/operator-mode/setup.gts | 45 -------------- 4 files changed, 57 insertions(+), 55 deletions(-) diff --git a/packages/base/links-to-editor.gts b/packages/base/links-to-editor.gts index 2da7207ef7e..a982e3ad038 100644 --- a/packages/base/links-to-editor.gts +++ b/packages/base/links-to-editor.gts @@ -88,7 +88,7 @@ export class LinksToEditor extends GlimmerComponent { {{/if}} @@ -154,10 +154,6 @@ export class LinksToEditor extends GlimmerComponent { return this.args.model.value == null; } - get isFileDefField() { - return isFileDef(this.args.field.card); - } - get linkedCard() { if (this.args.model.value == null) { throw new Error( diff --git a/packages/host/tests/acceptance/file-chooser-test.gts b/packages/host/tests/acceptance/file-chooser-test.gts index 683b79489c0..2e66a6d4b79 100644 --- a/packages/host/tests/acceptance/file-chooser-test.gts +++ b/packages/host/tests/acceptance/file-chooser-test.gts @@ -44,6 +44,7 @@ module('Acceptance | file chooser tests', function (hooks) { { id: fileId, format: 'isolated', + type: 'file-meta', }, ], ], diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index d2c5105ae68..ca5b3b7c8e4 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -87,6 +87,59 @@ module('Integration | operator-mode | ui', function (hooks) { }); test(`click on "links to" the embedded file will open it on the stack`, async function (assert) { + await ctx.testRealm.write( + 'file-link-card.gts', + ` + import { CardDef, Component, field, contains, linksTo, StringField } from 'https://cardstack.com/base/card-api'; + import { FileDef } from 'https://cardstack.com/base/file-api'; + + export class FileLinkCard extends CardDef { + static displayName = 'File Link Card'; + @field title = contains(StringField); + @field attachment = linksTo(FileDef); + + static isolated = class Isolated extends Component { + + }; + } + `, + ); + + await ctx.testRealm.write( + 'FileLinkCard/notes.txt', + 'Hello from a file link', + ); + await ctx.testRealm.write('FileLinkCard/with-file.json', { + data: { + type: 'card', + attributes: { + title: 'Linked file example', + }, + relationships: { + attachment: { + links: { + self: './notes.txt', + }, + data: { + type: 'file-meta', + id: './notes.txt', + }, + }, + }, + meta: { + adoptsFrom: { + module: '../file-link-card', + name: 'FileLinkCard', + }, + }, + }, + }); + ctx.setCardInOperatorModeState(`${testRealmURL}FileLinkCard/with-file`); await renderComponent( class TestDriver extends GlimmerComponent { @@ -98,14 +151,11 @@ module('Integration | operator-mode | ui', function (hooks) { await click('[data-test-file-link-attachment] [data-test-card]'); await waitFor('[data-test-stack-card-index="1"]'); assert.dom('[data-test-stack-card-index]').exists({ count: 2 }); - assert - .dom('[data-test-stack-card-index="1"]') - .includesText('notes.txt'); assert .dom( - '[data-test-stack-card-index="1"] [data-test-filedef-edit-unavailable]', + '[data-test-stack-card-index="1"] [data-test-boxel-card-header-title]', ) - .doesNotExist(); + .includesText('notes.txt'); }); test(`toggles mode switcher`, async function (assert) { diff --git a/packages/host/tests/integration/components/operator-mode/setup.gts b/packages/host/tests/integration/components/operator-mode/setup.gts index 5c0bae487e5..26bed7aed1a 100644 --- a/packages/host/tests/integration/components/operator-mode/setup.gts +++ b/packages/host/tests/integration/components/operator-mode/setup.gts @@ -73,14 +73,12 @@ export function setupOperatorModeTests( let textArea: typeof import('https://cardstack.com/base/text-area'); let cardsGrid: typeof import('https://cardstack.com/base/cards-grid'); let spec: typeof import('https://cardstack.com/base/spec'); - let fileApi: typeof import('https://cardstack.com/base/file-api'); cardApi = await loader.import(`${baseRealm.url}card-api`); string = await loader.import(`${baseRealm.url}string`); textArea = await loader.import(`${baseRealm.url}text-area`); cardsGrid = await loader.import(`${baseRealm.url}cards-grid`); spec = await loader.import(`${baseRealm.url}spec`); - fileApi = await loader.import(`${baseRealm.url}file-api`); let { field, @@ -97,7 +95,6 @@ export function setupOperatorModeTests( let { default: TextAreaField } = textArea; let { CardsGrid } = cardsGrid; let { Spec } = spec; - let { FileDef } = fileApi; // use string source so we can get the transpiled scoped CSS let friendWithCSSSource = ` @@ -373,21 +370,6 @@ export function setupOperatorModeTests( }; } - class FileLinkCard extends CardDef { - static displayName = 'File Link Card'; - @field title = contains(StringField); - @field attachment = linksTo(FileDef as unknown as typeof CardDef); - - static isolated = class Isolated extends Component { - - }; - } - class ExplodingCard extends CardDef { static displayName = 'Exploding Card'; @field name = contains(StringField); @@ -467,7 +449,6 @@ export function setupOperatorModeTests( 'boom-field.gts': { BoomField }, 'boom-pet.gts': { BoomPet }, 'blog-post.gts': { BlogPost }, - 'file-link-card.gts': { FileLinkCard }, 'exploding-card.gts': { ExplodingCard }, 'car.gts': { Car }, 'author.gts': { Author }, @@ -628,32 +609,6 @@ export function setupOperatorModeTests( firstName: 'Mark', lastName: 'Jackson', }), - 'FileLinkCard/notes.txt': 'Hello from a file link', - 'FileLinkCard/with-file.json': { - data: { - type: 'card', - attributes: { - title: 'Linked file example', - }, - relationships: { - attachment: { - links: { - self: './notes.txt', - }, - data: { - type: 'file-meta', - id: './notes.txt', - }, - }, - }, - meta: { - adoptsFrom: { - module: '../file-link-card', - name: 'FileLinkCard', - }, - }, - }, - }, 'BlogPost/1.json': blogPost, 'BlogPost/2.json': new BlogPost({ cardTitle: 'Beginnings' }), 'ExplodingCard/1.json': explodingCard, From c88c5b3362ace111eff8be8944115148ff6cf3a4 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 19 Feb 2026 12:15:06 +0100 Subject: [PATCH 3/9] Fix tests --- .../components/operator-mode-ui-test.gts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index ca5b3b7c8e4..75f2ec94bd3 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -87,6 +87,8 @@ module('Integration | operator-mode | ui', function (hooks) { }); test(`click on "links to" the embedded file will open it on the stack`, async function (assert) { + let linkedFileId = `${testRealmURL}FileLinkCard/notes.txt`; + await ctx.testRealm.write( 'file-link-card.gts', ` @@ -152,10 +154,18 @@ module('Integration | operator-mode | ui', function (hooks) { await waitFor('[data-test-stack-card-index="1"]'); assert.dom('[data-test-stack-card-index]').exists({ count: 2 }); assert - .dom( - '[data-test-stack-card-index="1"] [data-test-boxel-card-header-title]', - ) - .includesText('notes.txt'); + .dom(`[data-test-stack-card="${linkedFileId}"]`) + .exists('linked file opens as a second stack card'); + assert.strictEqual( + ctx.operatorModeStateService.state?.stacks?.[0]?.[1]?.id, + linkedFileId, + 'operator mode state targets the linked file', + ); + assert.strictEqual( + ctx.operatorModeStateService.state?.stacks?.[0]?.[1]?.type, + 'file-meta', + 'stack item type is file-meta', + ); }); test(`toggles mode switcher`, async function (assert) { From 2e51a0979ceaaba57a981bf14202d7e7b9fb9382 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 19 Feb 2026 13:24:24 +0100 Subject: [PATCH 4/9] Simplify figuring out whether it's a card or file --- .../operator-mode/interact-submode.gts | 33 ++++++++++++------ .../app/components/operator-mode/overlays.gts | 4 ++- .../operator-mode/preview-panel/index.gts | 7 +++- packages/host/app/lib/stack-item.ts | 7 ---- .../services/operator-mode-state-service.ts | 9 ++++- packages/host/app/services/store.ts | 34 +------------------ 6 files changed, 41 insertions(+), 53 deletions(-) diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 9ea225b935b..78dc0c1b2f3 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -51,11 +51,10 @@ import { type ResolvedCodeRef, type Filter, } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; import CopyCardToStackCommand from '@cardstack/host/commands/copy-card-to-stack'; -import { StackItem } from '@cardstack/host/lib/stack-item'; +import { StackItem, type StackItemType } from '@cardstack/host/lib/stack-item'; import { stackBackgroundsResource } from '@cardstack/host/resources/stack-backgrounds'; @@ -188,6 +187,7 @@ export default class InteractSubmode extends Component { request: new Deferred(), closeAfterSaving: opts?.closeAfterCreating, stackIndex, + type: 'card', }); this.addToStack(newItem); return localId; @@ -234,18 +234,12 @@ export default class InteractSubmode extends Component { } stackIndex = opts.stackIndex; } - let isFileMeta = - (typeof cardOrURL === 'string' || cardOrURL instanceof URL) && - hasExtension(cardId) && - !cardId.endsWith('.json'); - if (cardOrURL && typeof cardOrURL === 'object' && !isFileMeta) { - isFileMeta = isFileDefInstance(cardOrURL as CardDef); - } + let stackItemType = this.getStackItemType(cardOrURL, cardId); let newItem = new StackItem({ id: cardId, format, stackIndex, - type: isFileMeta ? 'file-meta' : 'card', + type: stackItemType, relationshipContext: opts?.fieldName ? { fieldName: opts.fieldName, @@ -264,6 +258,23 @@ export default class InteractSubmode extends Component { this.operatorModeStateService.editCardOnStack(stackIndex, card); }; + private getStackItemType( + cardOrURL: CardDef | URL | string, + cardId: string, + ): StackItemType { + if ( + cardOrURL && + typeof cardOrURL === 'object' && + !(cardOrURL instanceof URL) + ) { + return isFileDefInstance(cardOrURL as CardDef) ? 'file-meta' : 'card'; + } + let fileMetaInstanceOrError = + this.store.peek(cardId, { type: 'file-meta' }) ?? + this.store.peekError(cardId, { type: 'file-meta' }); + return fileMetaInstanceOrError ? 'file-meta' : 'card'; + } + private saveCard = (id: string): void => { this.store.save(id); }; @@ -513,6 +524,7 @@ export default class InteractSubmode extends Component { id: url.href, format: 'isolated', stackIndex: 0, + type: this.getStackItemType(url, url.href), }); // it's important that we await the stack item readiness _before_ // we mutate the stack, otherwise there are very odd visual artifacts @@ -555,6 +567,7 @@ export default class InteractSubmode extends Component { id: url.href, format: 'isolated', stackIndex, + type: this.getStackItemType(url, url.href), }); // await stackItem.ready(); this.operatorModeStateService.clearStackAndAdd( diff --git a/packages/host/app/components/operator-mode/overlays.gts b/packages/host/app/components/operator-mode/overlays.gts index b0871721e1d..19290fbcc7a 100644 --- a/packages/host/app/components/operator-mode/overlays.gts +++ b/packages/host/app/components/operator-mode/overlays.gts @@ -230,7 +230,9 @@ export default class Overlays extends Component { let canWrite = this.realm.canWrite(cardId); format = canWrite ? format : 'isolated'; if (this.args.viewCard) { - await this.args.viewCard(new URL(cardId), format, { + let target = + typeof cardDefOrId === 'string' ? new URL(cardId) : cardDefOrId; + await this.args.viewCard(target, format, { fieldType, fieldName, }); diff --git a/packages/host/app/components/operator-mode/preview-panel/index.gts b/packages/host/app/components/operator-mode/preview-panel/index.gts index 2f2ad9a7d6d..1ea80d29dd5 100644 --- a/packages/host/app/components/operator-mode/preview-panel/index.gts +++ b/packages/host/app/components/operator-mode/preview-panel/index.gts @@ -24,6 +24,7 @@ import { getMenuItems, identifyCard, isCardInstance, + isFileDefInstance, isResolvedCodeRef, } from '@cardstack/runtime-common'; @@ -94,7 +95,11 @@ export default class PreviewPanel extends Component { private openInInteractMode = () => { if (this.cardId) { - this.operatorModeStateService.openCardInInteractMode(this.cardId); + this.operatorModeStateService.openCardInInteractMode( + this.cardId, + 'isolated', + isFileDefInstance(this.args.card) ? 'file-meta' : 'card', + ); } }; diff --git a/packages/host/app/lib/stack-item.ts b/packages/host/app/lib/stack-item.ts index ed1cb284d2c..079bbed5095 100644 --- a/packages/host/app/lib/stack-item.ts +++ b/packages/host/app/lib/stack-item.ts @@ -21,13 +21,6 @@ function inferStackItemType(id: string, type?: StackItemType): StackItemType { if (type) { return type; } - let withoutJson = id.replace(/\.json$/, ''); - let path = withoutJson.split(/[?#]/)[0]; - let lastSegment = path.split('/').pop() ?? ''; - let lastDotIndex = lastSegment.lastIndexOf('.'); - if (lastDotIndex > 0 && lastDotIndex < lastSegment.length - 1) { - return 'file-meta'; - } return 'card'; } diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index 6346c422b7f..25f26fbdddd 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -1139,7 +1139,11 @@ export default class OperatorModeStateService extends Service { })); }); - openCardInInteractMode(id: string, format: Format = 'isolated') { + openCardInInteractMode( + id: string, + format: Format = 'isolated', + type: StackItemType = 'card', + ) { this.clearStacks(); // Determine realm URL. If id is a localId, look up the instance in the store to read its realm. let realmHref: string | undefined; @@ -1162,11 +1166,13 @@ export default class OperatorModeStateService extends Service { id: `${realmHref}index`, stackIndex: 0, format: 'isolated', + type: 'card', }); let newItem = new StackItem({ id, // keep provided id (may be localId) so later replacement on save works stackIndex: 0, format, + type, }); this.addItemToStack(indexItem); this.addItemToStack(newItem); @@ -1193,6 +1199,7 @@ export default class OperatorModeStateService extends Service { id, format: 'isolated', stackIndex: 0, + type: 'card', }); this.clearStacks(); this.addItemToStack(stackItem); diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index b26790e672e..7f03b0594c1 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -18,7 +18,6 @@ import { TrackedObject, TrackedMap } from 'tracked-built-ins'; import { hasExecutableExtension, isCardError, - isCardErrorJSONAPI, isCardInstance, isFileDefInstance, isFileMetaResource, @@ -59,7 +58,6 @@ import { type CardResource, type Saved, } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; import type { CardDef, BaseDef } from 'https://cardstack.com/base/card-api'; import type * as CardAPI from 'https://cardstack.com/base/card-api'; @@ -238,9 +236,7 @@ export default class StoreService extends Service implements StoreInterface { if (!id) { return; } - let readType: StoreReadType = - opts?.type ?? - (hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'); + let readType: StoreReadType = opts?.type ?? 'card'; // synchronously update the reference count so we don't run into race // conditions requiring a mutex let currentReferenceCount = this.referenceCount.get(id) ?? 0; @@ -895,38 +891,10 @@ export default class StoreService extends Service implements StoreInterface { return; } let instanceOrError = this.peekError(url) ?? this.peek(url); - if ( - isCardErrorJSONAPI(instanceOrError) && - instanceOrError.status === 415 - ) { - let fileInstanceOrError = await this.getFileMetaInstance({ - idOrDoc: url, - }); - this.setIdentityContext( - fileInstanceOrError as FileDef | CardErrorJSONAPI, - 'file-meta', - ); - deferred.fulfill(); - return; - } if (!instanceOrError) { instanceOrError = await this.getCardInstance({ idOrDoc: url, }); - if ( - isCardErrorJSONAPI(instanceOrError) && - instanceOrError.status === 415 - ) { - let fileInstanceOrError = await this.getFileMetaInstance({ - idOrDoc: url, - }); - this.setIdentityContext( - fileInstanceOrError as FileDef | CardErrorJSONAPI, - 'file-meta', - ); - deferred.fulfill(); - return; - } this.setIdentityContext(instanceOrError); } await this.startAutoSaving(instanceOrError); From 918c240666d58852fea4135a52705b8f092fe451 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Thu, 19 Feb 2026 13:54:02 +0100 Subject: [PATCH 5/9] Simplify --- .../host/app/components/host-mode/card.gts | 6 ++- .../host/app/components/host-mode/content.gts | 2 +- .../app/components/host-mode/stack-item.gts | 3 +- .../operator-mode/operator-mode-overlays.gts | 6 ++- .../components/operator-mode/stack-item.gts | 4 +- packages/host/app/lib/stack-item.ts | 4 +- packages/host/app/resources/card-resource.ts | 11 ++--- .../components/operator-mode-ui-test.gts | 45 ++++++++++--------- 8 files changed, 46 insertions(+), 35 deletions(-) diff --git a/packages/host/app/components/host-mode/card.gts b/packages/host/app/components/host-mode/card.gts index c44c3a498c1..9699568c462 100644 --- a/packages/host/app/components/host-mode/card.gts +++ b/packages/host/app/components/host-mode/card.gts @@ -4,11 +4,13 @@ import { cached } from '@glimmer/tracking'; import { BoxelButton, CardContainer } from '@cardstack/boxel-ui/components'; +import type { StoreReadType } from '@cardstack/runtime-common'; + +import { hasExtension } from '@cardstack/runtime-common/url'; + import CardRenderer from '@cardstack/host/components/card-renderer'; import CardError from '@cardstack/host/components/operator-mode/card-error'; import { getCard } from '@cardstack/host/resources/card-resource'; -import type { StoreReadType } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; interface Signature { Element: HTMLElement; diff --git a/packages/host/app/components/host-mode/content.gts b/packages/host/app/components/host-mode/content.gts index f0105940362..0923e8170db 100644 --- a/packages/host/app/components/host-mode/content.gts +++ b/packages/host/app/components/host-mode/content.gts @@ -10,8 +10,8 @@ import { CardCrudFunctionsContextName, isCardInstance, } from '@cardstack/runtime-common'; -import { meta } from '@cardstack/runtime-common/constants'; import type { StoreReadType } from '@cardstack/runtime-common'; +import { meta } from '@cardstack/runtime-common/constants'; import { hasExtension } from '@cardstack/runtime-common/url'; import { getCard } from '@cardstack/host/resources/card-resource'; diff --git a/packages/host/app/components/host-mode/stack-item.gts b/packages/host/app/components/host-mode/stack-item.gts index be05749a22d..8341b1a70ee 100644 --- a/packages/host/app/components/host-mode/stack-item.gts +++ b/packages/host/app/components/host-mode/stack-item.gts @@ -11,10 +11,11 @@ import { cached, tracked } from '@glimmer/tracking'; import { ContextButton } from '@cardstack/boxel-ui/components'; import { and, bool } from '@cardstack/boxel-ui/helpers'; -import { getCard } from '@cardstack/host/resources/card-resource'; import type { StoreReadType } from '@cardstack/runtime-common'; import { hasExtension } from '@cardstack/runtime-common/url'; +import { getCard } from '@cardstack/host/resources/card-resource'; + import HostModeCard from './card'; interface Signature { diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index e87ec67af08..0bc35ffed0a 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -24,12 +24,14 @@ import { ThreeDotsHorizontal, } from '@cardstack/boxel-ui/icons'; +import { isFileDefInstance } from '@cardstack/runtime-common'; + +import { hasExtension } from '@cardstack/runtime-common/url'; + import { copyCardURLToClipboard } from '@cardstack/host/utils/clipboard'; import type { Format } from 'https://cardstack.com/base/card-api'; -import { isFileDefInstance } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; import { removeFileExtension } from '../card-search/utils'; import Overlays from './overlays'; diff --git a/packages/host/app/components/operator-mode/stack-item.gts b/packages/host/app/components/operator-mode/stack-item.gts index 8759cd76419..faa4f1970d3 100644 --- a/packages/host/app/components/operator-mode/stack-item.gts +++ b/packages/host/app/components/operator-mode/stack-item.gts @@ -663,7 +663,9 @@ export default class OperatorModeStackItem extends Component { } private get isEditing() { - return !this.isBuried && !this.isFileCard && this.args.item.format === 'edit'; + return ( + !this.isBuried && !this.isFileCard && this.args.item.format === 'edit' + ); } private get isFileCard() { diff --git a/packages/host/app/lib/stack-item.ts b/packages/host/app/lib/stack-item.ts index 079bbed5095..446a740f91d 100644 --- a/packages/host/app/lib/stack-item.ts +++ b/packages/host/app/lib/stack-item.ts @@ -17,7 +17,7 @@ interface Args { export type StackItemType = 'card' | 'file-meta'; -function inferStackItemType(id: string, type?: StackItemType): StackItemType { +function inferStackItemType(type?: StackItemType): StackItemType { if (type) { return type; } @@ -53,7 +53,7 @@ export class StackItem { this.format = format; this.request = request; this.stackIndex = stackIndex; - this.type = inferStackItemType(this.#id, type); + this.type = inferStackItemType(type); this.closeAfterSaving = closeAfterSaving; this.relationshipContext = relationshipContext; } diff --git a/packages/host/app/resources/card-resource.ts b/packages/host/app/resources/card-resource.ts index fdc632b3fd7..a8486dad420 100644 --- a/packages/host/app/resources/card-resource.ts +++ b/packages/host/app/resources/card-resource.ts @@ -9,10 +9,11 @@ import { Resource } from 'ember-modify-based-class-resource'; import { isCardInstance, isFileDefInstance } from '@cardstack/runtime-common'; +import type { StoreReadType } from '@cardstack/runtime-common'; + import type { BaseDef } from 'https://cardstack.com/base/card-api'; import type StoreService from '../services/store'; -import type { StoreReadType } from '@cardstack/runtime-common'; interface Args { named: { @@ -88,10 +89,10 @@ export class CardResource extends Resource { } let maybeError = this.readType === 'file-meta' - ? this.store.peekError(this.#id, { type: 'file-meta' }) ?? - this.store.peekError(this.#id) - : this.store.peekError(this.#id) ?? - this.store.peekError(this.#id, { type: 'file-meta' }); + ? (this.store.peekError(this.#id, { type: 'file-meta' }) ?? + this.store.peekError(this.#id)) + : (this.store.peekError(this.#id) ?? + this.store.peekError(this.#id, { type: 'file-meta' })); return maybeError && !isCardInstance(maybeError) ? maybeError : undefined; } diff --git a/packages/host/tests/integration/components/operator-mode-ui-test.gts b/packages/host/tests/integration/components/operator-mode-ui-test.gts index 75f2ec94bd3..d67dfb7bf40 100644 --- a/packages/host/tests/integration/components/operator-mode-ui-test.gts +++ b/packages/host/tests/integration/components/operator-mode-ui-test.gts @@ -116,31 +116,34 @@ module('Integration | operator-mode | ui', function (hooks) { 'FileLinkCard/notes.txt', 'Hello from a file link', ); - await ctx.testRealm.write('FileLinkCard/with-file.json', { - data: { - type: 'card', - attributes: { - title: 'Linked file example', - }, - relationships: { - attachment: { - links: { - self: './notes.txt', - }, - data: { - type: 'file-meta', - id: './notes.txt', + await ctx.testRealm.write( + 'FileLinkCard/with-file.json', + JSON.stringify({ + data: { + type: 'card', + attributes: { + title: 'Linked file example', + }, + relationships: { + attachment: { + links: { + self: './notes.txt', + }, + data: { + type: 'file-meta', + id: './notes.txt', + }, }, }, - }, - meta: { - adoptsFrom: { - module: '../file-link-card', - name: 'FileLinkCard', + meta: { + adoptsFrom: { + module: '../file-link-card', + name: 'FileLinkCard', + }, }, }, - }, - }); + }), + ); ctx.setCardInOperatorModeState(`${testRealmURL}FileLinkCard/with-file`); await renderComponent( From c0f0c60172cb4c118a058f66d4be60cc48f5c56a Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 20 Feb 2026 09:45:00 +0100 Subject: [PATCH 6/9] A common function for inferring read type --- .../host/app/components/host-mode/breadcrumb-item.gts | 8 +++----- packages/host/app/components/host-mode/card.gts | 10 +++------- packages/host/app/components/host-mode/content.gts | 8 +++----- packages/host/app/components/host-mode/stack-item.gts | 9 +++------ packages/host/app/lib/read-type.ts | 11 +++++++++++ 5 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 packages/host/app/lib/read-type.ts diff --git a/packages/host/app/components/host-mode/breadcrumb-item.gts b/packages/host/app/components/host-mode/breadcrumb-item.gts index 077b34c50a2..3c6fba1d5d3 100644 --- a/packages/host/app/components/host-mode/breadcrumb-item.gts +++ b/packages/host/app/components/host-mode/breadcrumb-item.gts @@ -5,9 +5,8 @@ import Component from '@glimmer/component'; import { cached } from '@glimmer/tracking'; import { cardTypeIcon, isCardInstance } from '@cardstack/runtime-common'; -import type { StoreReadType } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; +import { inferStoreReadType } from '@cardstack/host/lib/read-type'; import { getCard } from '@cardstack/host/resources/card-resource'; import type { ComponentLike } from '@glint/template'; @@ -33,9 +32,8 @@ export default class HostModeBreadcrumbItem extends Component { }); } - private get readType(): StoreReadType { - let id = this.args.cardId; - return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; + private get readType() { + return inferStoreReadType(this.args.cardId); } @cached diff --git a/packages/host/app/components/host-mode/card.gts b/packages/host/app/components/host-mode/card.gts index 9699568c462..1868bb6e3bc 100644 --- a/packages/host/app/components/host-mode/card.gts +++ b/packages/host/app/components/host-mode/card.gts @@ -4,12 +4,9 @@ import { cached } from '@glimmer/tracking'; import { BoxelButton, CardContainer } from '@cardstack/boxel-ui/components'; -import type { StoreReadType } from '@cardstack/runtime-common'; - -import { hasExtension } from '@cardstack/runtime-common/url'; - import CardRenderer from '@cardstack/host/components/card-renderer'; import CardError from '@cardstack/host/components/operator-mode/card-error'; +import { inferStoreReadType } from '@cardstack/host/lib/read-type'; import { getCard } from '@cardstack/host/resources/card-resource'; interface Signature { @@ -34,9 +31,8 @@ export default class HostModeCard extends Component { }); } - private get readType(): StoreReadType { - let id = this.args.cardId ?? ''; - return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; + private get readType() { + return inferStoreReadType(this.args.cardId); } get card() { diff --git a/packages/host/app/components/host-mode/content.gts b/packages/host/app/components/host-mode/content.gts index 0923e8170db..fa2f0404626 100644 --- a/packages/host/app/components/host-mode/content.gts +++ b/packages/host/app/components/host-mode/content.gts @@ -10,10 +10,9 @@ import { CardCrudFunctionsContextName, isCardInstance, } from '@cardstack/runtime-common'; -import type { StoreReadType } from '@cardstack/runtime-common'; import { meta } from '@cardstack/runtime-common/constants'; -import { hasExtension } from '@cardstack/runtime-common/url'; +import { inferStoreReadType } from '@cardstack/host/lib/read-type'; import { getCard } from '@cardstack/host/resources/card-resource'; import type { @@ -52,9 +51,8 @@ export default class HostModeContent extends Component { }); } - private get primaryReadType(): StoreReadType { - let id = this.args.primaryCardId ?? ''; - return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; + private get primaryReadType() { + return inferStoreReadType(this.args.primaryCardId); } get cardIds() { diff --git a/packages/host/app/components/host-mode/stack-item.gts b/packages/host/app/components/host-mode/stack-item.gts index 8341b1a70ee..8850add513a 100644 --- a/packages/host/app/components/host-mode/stack-item.gts +++ b/packages/host/app/components/host-mode/stack-item.gts @@ -11,9 +11,7 @@ import { cached, tracked } from '@glimmer/tracking'; import { ContextButton } from '@cardstack/boxel-ui/components'; import { and, bool } from '@cardstack/boxel-ui/helpers'; -import type { StoreReadType } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; - +import { inferStoreReadType } from '@cardstack/host/lib/read-type'; import { getCard } from '@cardstack/host/resources/card-resource'; import HostModeCard from './card'; @@ -45,9 +43,8 @@ export default class HostModeStackItem extends Component { }); } - private get readType(): StoreReadType { - let id = this.args.cardId; - return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; + private get readType() { + return inferStoreReadType(this.args.cardId); } @cached diff --git a/packages/host/app/lib/read-type.ts b/packages/host/app/lib/read-type.ts new file mode 100644 index 00000000000..4a3fec96898 --- /dev/null +++ b/packages/host/app/lib/read-type.ts @@ -0,0 +1,11 @@ +import type { StoreReadType } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; + +export function inferStoreReadType( + id: string | null | undefined, +): StoreReadType { + if (!id) { + return 'card'; + } + return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; +} From 3c9dcbd0e8a241a6095f39419f88eb623f4f6cad Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 20 Feb 2026 09:54:20 +0100 Subject: [PATCH 7/9] Simplify --- packages/host/app/resources/card-resource.ts | 41 +++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/host/app/resources/card-resource.ts b/packages/host/app/resources/card-resource.ts index a8486dad420..29827de09d1 100644 --- a/packages/host/app/resources/card-resource.ts +++ b/packages/host/app/resources/card-resource.ts @@ -59,6 +59,28 @@ export class CardResource extends Resource { return this.#type ?? 'card'; } + private get fallbackReadType(): StoreReadType { + return this.readType === 'file-meta' ? 'card' : 'file-meta'; + } + + private peekForType(type: StoreReadType): unknown { + if (!this.#id) { + return undefined; + } + return type === 'file-meta' + ? (this.store.peek(this.#id, { type: 'file-meta' }) as unknown) + : (this.store.peek(this.#id) as unknown); + } + + private peekErrorForType(type: StoreReadType) { + if (!this.#id) { + return undefined; + } + return type === 'file-meta' + ? this.store.peekError(this.#id, { type: 'file-meta' }) + : this.store.peekError(this.#id); + } + // Note that this will return a stale instance when the server state for this // id becomes an error. use this.cardError to see the live server state for // this instance. @@ -67,11 +89,8 @@ export class CardResource extends Resource { return undefined; } let maybeCard = - this.readType === 'file-meta' - ? ((this.store.peek(this.#id, { type: 'file-meta' }) as unknown) ?? - (this.store.peek(this.#id) as unknown)) - : ((this.store.peek(this.#id) as unknown) ?? - (this.store.peek(this.#id, { type: 'file-meta' }) as unknown)); + this.peekForType(this.readType) ?? + this.peekForType(this.fallbackReadType); return isCardInstance(maybeCard) || isFileDefInstance(maybeCard) ? (maybeCard as BaseDef) : undefined; @@ -81,18 +100,12 @@ export class CardResource extends Resource { if (!this.#id) { return undefined; } - if ( - this.readType === 'file-meta' && - this.store.peek(this.#id, { type: 'file-meta' }) - ) { + if (this.readType === 'file-meta' && this.peekForType('file-meta')) { return undefined; } let maybeError = - this.readType === 'file-meta' - ? (this.store.peekError(this.#id, { type: 'file-meta' }) ?? - this.store.peekError(this.#id)) - : (this.store.peekError(this.#id) ?? - this.store.peekError(this.#id, { type: 'file-meta' })); + this.peekErrorForType(this.readType) ?? + this.peekErrorForType(this.fallbackReadType); return maybeError && !isCardInstance(maybeError) ? maybeError : undefined; } From 9651f9d0490753016b6bcec922644c2cfd66b2c5 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 20 Feb 2026 10:25:58 +0100 Subject: [PATCH 8/9] Fix import order --- .../app/components/operator-mode/operator-mode-overlays.gts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index 6246b82759c..cdc70cf098a 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -31,16 +31,15 @@ import { ThreeDotsHorizontal, } from '@cardstack/boxel-ui/icons'; +import type { CommandContext } from '@cardstack/runtime-common'; import { isFileDefInstance } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; - -import type { CommandContext } from '@cardstack/runtime-common'; import { CardCrudFunctionsContextName, CommandContextName, getMenuItems, } from '@cardstack/runtime-common'; +import { hasExtension } from '@cardstack/runtime-common/url'; import type { CardCrudFunctions, From cb32ae9e82c108b129f9a40b8b897cd7cd9c9a64 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Fri, 20 Feb 2026 11:06:26 +0100 Subject: [PATCH 9/9] Extract common logic --- .../operator-mode/operator-mode-overlays.gts | 5 +++-- packages/host/app/lib/read-type.ts | 12 ++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/host/app/components/operator-mode/operator-mode-overlays.gts b/packages/host/app/components/operator-mode/operator-mode-overlays.gts index cdc70cf098a..c9cff84704f 100644 --- a/packages/host/app/components/operator-mode/operator-mode-overlays.gts +++ b/packages/host/app/components/operator-mode/operator-mode-overlays.gts @@ -39,7 +39,8 @@ import { CommandContextName, getMenuItems, } from '@cardstack/runtime-common'; -import { hasExtension } from '@cardstack/runtime-common/url'; + +import { isFileMetaId } from '@cardstack/host/lib/read-type'; import type { CardCrudFunctions, @@ -364,7 +365,7 @@ export default class OperatorModeOverlays extends Overlays { ): boolean { let cardDefOrId = renderedCard.cardDefOrId; if (typeof cardDefOrId === 'string') { - return hasExtension(cardDefOrId) && !cardDefOrId.endsWith('.json'); + return isFileMetaId(cardDefOrId); } return isFileDefInstance(cardDefOrId); } diff --git a/packages/host/app/lib/read-type.ts b/packages/host/app/lib/read-type.ts index 4a3fec96898..acbc2f413c8 100644 --- a/packages/host/app/lib/read-type.ts +++ b/packages/host/app/lib/read-type.ts @@ -1,11 +1,15 @@ import type { StoreReadType } from '@cardstack/runtime-common'; import { hasExtension } from '@cardstack/runtime-common/url'; +export function isFileMetaId(id: string | null | undefined): boolean { + if (!id) { + return false; + } + return hasExtension(id) && !id.endsWith('.json'); +} + export function inferStoreReadType( id: string | null | undefined, ): StoreReadType { - if (!id) { - return 'card'; - } - return hasExtension(id) && !id.endsWith('.json') ? 'file-meta' : 'card'; + return isFileMetaId(id) ? 'file-meta' : 'card'; }