diff --git a/packages/host/app/components/operator-mode/code-mode.gts b/packages/host/app/components/operator-mode/code-mode.gts index 0ab1f4f1042..508f283f501 100644 --- a/packages/host/app/components/operator-mode/code-mode.gts +++ b/packages/host/app/components/operator-mode/code-mode.gts @@ -48,7 +48,10 @@ import config from '@cardstack/host/config/environment'; import monacoModifier from '@cardstack/host/modifiers/monaco'; -import { adoptionChainResource } from '@cardstack/host/resources/adoption-chain'; +import { + getCardType, + type CardType, +} from '@cardstack/host/resources/card-type'; import { file, isReady, @@ -78,6 +81,8 @@ import RecentFilesService from '@cardstack/host/services/recent-files-service'; import { CardDef } from 'https://cardstack.com/base/card-api'; +import { type BaseDef } from 'https://cardstack.com/base/card-api'; + import FileTree from '../editor/file-tree'; import BinaryFileInfo from './binary-file-info'; @@ -111,6 +116,16 @@ const defaultPanelWidths: PanelWidths = { emptyCodeModePanel: '80%', }; +interface ExportedCard { + cardType: CardType; + card: typeof BaseDef; +} + +// Element +// - exported / unexported card or field +// - exported class or function +export type ElementInFile = ExportedCard; // can add more types here + export default class CodeMode extends Component { @service declare monacoService: MonacoService; @service declare cardService: CardService; @@ -122,6 +137,7 @@ export default class CodeMode extends Component { @tracked private loadFileError: string | null = null; @tracked private maybeMonacoSDK: MonacoSDK | undefined; @tracked private card: CardDef | undefined; + @tracked private selectedElement: ElementInFile | undefined; @tracked cardError: Error | undefined; private hasUnsavedSourceChanges = false; private hasUnsavedCardChanges = false; @@ -298,6 +314,56 @@ export default class CodeMode extends Component { return state; }); + @use private elements = resource(() => { + if (!this.importedModule) { + return new TrackedObject({ + error: null, + isLoading: false, + value: [], + load: () => Promise, + }); + } + + const state: { + isLoading: boolean; + value: ElementInFile[] | null; + error: Error | undefined; + load: () => Promise; + } = new TrackedObject({ + isLoading: true, + value: [], + error: undefined, + load: async () => { + state.isLoading = true; + if (this.importedModule === undefined) { + state.value = []; + return; + } + try { + await this.importedModule.loaded; + let module = this.importedModule?.module; + if (module) { + let cards = cardsOrFieldsFromModule(module); + let elements: ElementInFile[] = cards.map((card) => { + return { + cardType: getCardType(this, () => card), + card: card, + }; + }); + state.value = elements; + } + } catch (error: any) { + state.error = error; + } finally { + state.isLoading = false; + } + }, + }); + + state.load(); + return state; + }); + private openFile = maybe(this, (context) => { if (!this.codePath) { this.setFileView('browser'); @@ -353,15 +419,6 @@ export default class CodeMode extends Component { } } }); - - @use private adoptionChain = resource(() => { - if (this.importedModule) { - return adoptionChainResource(this, this.importedModule); - } else { - return undefined; - } - }); - // We are actually loading cards using a side-effect of this cached getter // instead of a resource because with a resource it becomes impossible // to ignore our own auto-save echoes, since the act of auto-saving triggers @@ -420,6 +477,31 @@ export default class CodeMode extends Component { return this.card; } + private get selectedElementInFile() { + if (this.selectedElement) { + return this.selectedElement; + } else { + if (this.elementsInFile === null) { + return; + } + return this.elementsInFile.length > 0 + ? this.elementsInFile[0] + : undefined; + } + } + + @action + private selectElementInFile(el: ElementInFile) { + this.selectedElement = el; + } + + get elementsInFile() { + if (this.elements.value === null) { + return []; + } + return this.elements.value; + } + private loadIfDifferent = restartableTask( async (url: URL, incomingDoc?: SingleCardDocument) => { await this.withTestWaiters(async () => { @@ -691,8 +773,9 @@ export default class CodeMode extends Component { @cardInstance={{this.card}} @readyFile={{this.readyFile}} @realmInfo={{this.realmInfo}} - @realmIconURL={{this.realmIconURL}} - @adoptionChain={{this.adoptionChain}} + @selectedElement={{this.selectedElementInFile}} + @elements={{this.elementsInFile}} + @selectElement={{this.selectElementInFile}} @delete={{this.delete}} data-test-card-inheritance-panel /> @@ -1025,3 +1108,14 @@ function comparableSerialization(doc: LooseSingleCardDocument) { } return doc; } + +function isCardOrField(cardOrField: any): cardOrField is typeof BaseDef { + return typeof cardOrField === 'function' && 'baseDef' in cardOrField; +} + +function cardsOrFieldsFromModule( + module: Record, + _never?: never, // glint insists that w/o this last param that there are actually no params +): (typeof BaseDef)[] { + return Object.values(module).filter(isCardOrField); +} diff --git a/packages/host/app/components/operator-mode/definition-container/base.gts b/packages/host/app/components/operator-mode/definition-container/base.gts index 61a60e2dcb9..39f0faa3269 100644 --- a/packages/host/app/components/operator-mode/definition-container/base.gts +++ b/packages/host/app/components/operator-mode/definition-container/base.gts @@ -16,9 +16,8 @@ interface Action { export interface BaseArgs { title: string | undefined; name: string | undefined; - fileExtension: string; - realmInfo: RealmInfo | null; - realmIconURL: string | null | undefined; + fileExtension: string | undefined; + realmInfo: RealmInfo | undefined | null; isActive: boolean; } @@ -35,6 +34,10 @@ export class BaseDefinitionContainer extends Component { return this.args.realmInfo?.name; } + get realmIcon(): string | undefined | null { + return this.args.realmInfo?.iconURL; + } + + + getCardTypeDisplayName(t: typeof BaseDef) { + let card = new t(); + return cardTypeDisplayName(card); + } } diff --git a/packages/host/app/resources/adoption-chain.ts b/packages/host/app/resources/adoption-chain.ts deleted file mode 100644 index 8cca36c7589..00000000000 --- a/packages/host/app/resources/adoption-chain.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getOwner } from '@ember/application'; - -import { service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; - -import { Resource } from 'ember-resources'; - -import { Loader } from '@cardstack/runtime-common/loader'; - -import { - getCardType, - type CardType, -} from '@cardstack/host/resources/card-type'; - -import { type BaseDef } from 'https://cardstack.com/base/card-api'; - -import { ImportResource } from './import'; - -import type LoaderService from '../services/loader-service'; - -interface AdoptionChainResourceArgs { - named: { module: ImportResource | undefined; loader: Loader }; -} - -export class AdoptionChainResource extends Resource { - @service declare loaderService: LoaderService; - @tracked cards: (typeof BaseDef)[] = []; - @tracked _cardTypes: CardType[] = []; - - modify(_positional: never[], named: AdoptionChainResourceArgs['named']) { - let importResource = named['module']; - if (importResource && importResource.module) { - let module = importResource.module; - this.cards = cardsOrFieldsFromModule(module); - this._cardTypes = this.cards.map((c) => getCardType(this, () => c)); - } - } - - get types() { - return this._cardTypes.map((t) => t.type); - } -} - -function cardsOrFieldsFromModule( - module: Record, - _never?: never, // glint insists that w/o this last param that there are actually no params -): (typeof BaseDef)[] { - return Object.values(module).filter(isCardOrField); -} - -export function isCardOrField(cardOrField: any): cardOrField is typeof BaseDef { - return typeof cardOrField === 'function' && 'baseDef' in cardOrField; -} - -export function adoptionChainResource( - parent: object, - module: ImportResource | undefined, -) { - return AdoptionChainResource.from(parent, () => ({ - named: { - module, - loader: ( - (getOwner(parent) as any).lookup( - 'service:loader-service', - ) as LoaderService - ).loader, - }, - })) as AdoptionChainResource; -} diff --git a/packages/host/app/resources/card-type.ts b/packages/host/app/resources/card-type.ts index ca57e5c8de9..dc6cffb50e1 100644 --- a/packages/host/app/resources/card-type.ts +++ b/packages/host/app/resources/card-type.ts @@ -1,4 +1,5 @@ import { getOwner } from '@ember/application'; +import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { restartableTask } from 'ember-concurrency'; @@ -10,10 +11,14 @@ import { baseRealm, moduleFrom, getAncestor, + RealmInfo, } from '@cardstack/runtime-common'; +import { SupportedMimeType } from '@cardstack/runtime-common'; import { isCodeRef, type CodeRef } from '@cardstack/runtime-common/code-ref'; import { Loader } from '@cardstack/runtime-common/loader'; +import type CardService from '@cardstack/host/services/card-service'; + import type { BaseDef, Field, @@ -36,21 +41,32 @@ export interface Type { displayName: string; super: Type | undefined; fields: { name: string; card: Type | CodeRef; type: FieldType }[]; + moduleMeta: { + extension: string; + realmInfo: RealmInfo; + }; codeRef: CodeRef; } export class CardType extends Resource { @tracked type: Type | undefined; + @service declare cardService: CardService; declare loader: Loader; typeCache: Map = new Map(); + moduleMetaCache: Map = + new Map(); ready: Promise | undefined; + modify(_positional: never[], named: Args['named']) { let { definition, loader } = named; this.loader = loader; - this.assembleType.perform(definition); this.ready = this.assembleType.perform(definition); } + get isLoading() { + return this.assembleType.isRunning; + } + private assembleType = restartableTask(async (card: typeof BaseDef) => { let maybeType = await this.toType(card); if (isCodeRef(maybeType)) { @@ -105,17 +121,49 @@ export class CardType extends Resource { }), ), ); + + let moduleIdentifier = moduleFrom(ref); + let moduleMeta = + this.moduleMetaCache.get(moduleIdentifier) ?? + (await this.moduleMeta(new URL(moduleIdentifier))); + this.moduleMetaCache.set(moduleIdentifier, moduleMeta); let type: Type = { id, - module: moduleFrom(ref), + module: moduleIdentifier, super: superType, displayName: card.prototype.constructor.displayName || 'Card', fields: fieldTypes, + moduleMeta, codeRef: ref, }; this.typeCache.set(id, type); return type; } + + private moduleMeta = async (url: URL) => { + let response = await this.loader.fetch(url, { + headers: { Accept: SupportedMimeType.CardSource }, + }); + + if (!response.ok) { + throw new Error( + `Could not get file ${url.href}, status ${response.status}: ${ + response.statusText + } - ${await response.text()}`, + ); + } + let realmURL = response.headers.get('x-boxel-realm-url'); + if (realmURL === null) { + throw new Error(`Could not get realm url for ${url.href}`); + } + let realmInfo = await this.cardService.getRealmInfoByRealmURL( + new URL(realmURL), + ); + return { + realmInfo, + extension: '.' + new URL(response.url).pathname.split('.').pop() || '', + }; + }; } export function getCardType(parent: object, card: () => typeof BaseDef) { diff --git a/packages/host/app/resources/file.ts b/packages/host/app/resources/file.ts index 5f8548080f5..ebc933f2d7d 100644 --- a/packages/host/app/resources/file.ts +++ b/packages/host/app/resources/file.ts @@ -9,7 +9,6 @@ import { Resource } from 'ember-resources'; import { SupportedMimeType, logger } from '@cardstack/runtime-common'; -import config from '@cardstack/host/config/environment'; import type CardService from '@cardstack/host/services/card-service'; import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; @@ -172,25 +171,15 @@ class _FileResource extends Resource { let content = utf8.decode(buffer); let self = this; - // Inside test, The loader occasionally doesn't do a network request and creates Response object manually - // This means that reading response.url will give url = '' and we cannot manually alter the url in Response - // The below condition is a workaround - // TODO: CS-5982 - let url: string; - if (config.environment === 'test') { - url = response.url === '' ? this._url : response.url; - } else { - url = response.url; - } this.updateState({ state: 'ready', lastModified, realmURL, content, - name: url.split('/').pop()!, + name: response.url.split('/').pop()!, size, - url: url, + url: response.url, write(content: string, flushLoader?: true) { self.writing = self.writeTask.perform(this, content, flushLoader); }, diff --git a/packages/host/app/services/realm-info-service.ts b/packages/host/app/services/realm-info-service.ts index f36b1b31bd2..afa65ebe4a9 100644 --- a/packages/host/app/services/realm-info-service.ts +++ b/packages/host/app/services/realm-info-service.ts @@ -14,7 +14,9 @@ export default class RecentFilesService extends Service { return this.cachedRealmURLsForFileURL.get(fileURL)!; } - let response = await this.loaderService.loader.fetch(fileURL); + let response = await this.loaderService.loader.fetch(fileURL, { + headers: { Accept: SupportedMimeType.CardSource }, + }); let realmURL = response.headers.get('x-boxel-realm-url'); if (!realmURL) { diff --git a/packages/host/tests/acceptance/code-mode-test.ts b/packages/host/tests/acceptance/code-mode-test.ts index ae2c52989d1..54336a64c10 100644 --- a/packages/host/tests/acceptance/code-mode-test.ts +++ b/packages/host/tests/acceptance/code-mode-test.ts @@ -27,6 +27,8 @@ import { setupLocalIndexing, setupMockMessageService, testRealmURL, + sourceFetchRedirectHandle, + sourceFetchReturnUrlHandle, } from '../helpers'; const indexCardSource = ` @@ -176,6 +178,14 @@ module('Acceptance | code mode tests', function (hooks) { realm = await TestRealm.createWithAdapter(adapter, loader, this.owner, { isAcceptanceTest: true, + overridingHandlers: [ + async (req: Request) => { + return sourceFetchRedirectHandle(req, adapter, testRealmURL); + }, + async (req: Request) => { + return sourceFetchReturnUrlHandle(req, realm.maybeHandle.bind(realm)); + }, + ], }); await realm.ready; }); @@ -593,12 +603,11 @@ module('Acceptance | code mode tests', function (hooks) { await waitFor('[data-test-card-instance-definition]'); assert.dom('[data-test-card-module-definition]').includesText('Card'); - //TODO: CS-5957 deriving extension - // assert - // .dom( - // '[data-test-card-module-definition] [data-test-definition-file-extension]', - // ) - // .includesText('.gts'); + assert + .dom( + '[data-test-card-module-definition] [data-test-definition-file-extension]', + ) + .includesText('.gts'); assert .dom( '[data-test-card-module-definition] [data-test-definition-realm-name]', diff --git a/packages/host/tests/acceptance/operator-mode-test.ts b/packages/host/tests/acceptance/operator-mode-test.ts index 7d8a1d127bf..d7120972bf5 100644 --- a/packages/host/tests/acceptance/operator-mode-test.ts +++ b/packages/host/tests/acceptance/operator-mode-test.ts @@ -36,6 +36,8 @@ import { waitForSyntaxHighlighting, type TestContextWithSSE, type TestContextWithSave, + sourceFetchRedirectHandle, + sourceFetchReturnUrlHandle, } from '../helpers'; module('Acceptance | operator mode tests', function (hooks) { @@ -265,6 +267,14 @@ module('Acceptance | operator mode tests', function (hooks) { realm = await TestRealm.createWithAdapter(adapter, loader, this.owner, { isAcceptanceTest: true, + overridingHandlers: [ + async (req: Request) => { + return sourceFetchRedirectHandle(req, adapter, testRealmURL); + }, + async (req: Request) => { + return sourceFetchReturnUrlHandle(req, realm.maybeHandle.bind(realm)); + }, + ], }); await realm.ready; }); diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index eca523d0c42..3d5d6408342 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -5,7 +5,7 @@ import { findAll, waitUntil } from '@ember/test-helpers'; import { buildWaiter } from '@ember/test-waiters'; import GlimmerComponent from '@glimmer/component'; -import { parse } from 'date-fns'; +import { formatRFC7231, parse } from 'date-fns'; import { Kind, @@ -16,7 +16,10 @@ import { createResponse, RealmInfo, Deferred, + executableExtensions, + SupportedMimeType, } from '@cardstack/runtime-common'; +import { type RequestHandler } from '@cardstack/runtime-common/loader'; import { Loader } from '@cardstack/runtime-common/loader'; import { LocalPath, RealmPaths } from '@cardstack/runtime-common/paths'; @@ -30,6 +33,7 @@ import { type EntrySetter, type SearchEntryWithErrors, } from '@cardstack/runtime-common/search-index'; +import { getFileWithFallbacks } from '@cardstack/runtime-common/stream'; import CardPrerender from '@cardstack/host/components/card-prerender'; @@ -135,6 +139,7 @@ interface Options { realmURL?: string; isAcceptanceTest?: true; onFetch?: (req: Request) => Promise; + overridingHandlers?: RequestHandler[]; } // We use a rendered component to facilitate our indexing (this emulates @@ -172,7 +177,14 @@ export const TestRealm = { } else { await makeRenderer(); } - return makeRealm(adapter, loader, owner, opts?.realmURL, opts?.onFetch); + return makeRealm( + adapter, + loader, + owner, + opts?.realmURL, + opts?.onFetch, + opts?.overridingHandlers, + ); }, }; @@ -370,6 +382,7 @@ function makeRealm( owner: Owner, realmURL = testRealmURL, onFetch?: (req: Request) => Promise, + overridingHandlers?: RequestHandler[], ) { let localIndexer = owner.lookup( 'service:local-indexer', @@ -388,6 +401,9 @@ function makeRealm( return realm.maybeHandle(req); }); } + if (overridingHandlers && overridingHandlers.length > 0) { + loader.prependURLHandlers(overridingHandlers); + } realm = new Realm( realmURL, adapter, @@ -676,7 +692,67 @@ export function diff( }; } -export class MockResponse extends Response { +function isCardSourceFetch(request: Request) { + return ( + request.method === 'GET' && + request.headers.get('Accept') === SupportedMimeType.CardSource && + request.url.includes(testRealmURL) + ); +} + +export async function sourceFetchReturnUrlHandle( + request: Request, + defaultHandle: (req: Request) => Promise, +) { + if (isCardSourceFetch(request)) { + let r = await defaultHandle(request); + if (r) { + return new MockRedirectedResponse(r.body, r, request.url) as Response; + } + } + return null; +} + +export async function sourceFetchRedirectHandle( + request: Request, + adapter: RealmAdapter, + realmURL: string, +) { + let urlParts = new URL(request.url).pathname.split('.'); + if ( + isCardSourceFetch(request) && + urlParts.length === 1 //has no extension + ) { + const realmPaths = new RealmPaths(realmURL); + const localPath = realmPaths.local(request.url); + const ref = await getFileWithFallbacks( + localPath, + adapter.openFile.bind(adapter), + executableExtensions, + ); + let maybeExtension = ref?.path.split('.').pop(); + let responseUrl = maybeExtension + ? `${request.url}.${maybeExtension}` + : request.url; + + if ( + ref && + (ref.content instanceof ReadableStream || + ref.content instanceof Uint8Array || + typeof ref.content === 'string') + ) { + let r = createResponse(realmURL, ref.content, { + headers: { + 'last-modified': formatRFC7231(ref.lastModified), + }, + }); + return new MockRedirectedResponse(r.body, r, responseUrl) as Response; + } + } + return null; +} + +export class MockRedirectedResponse extends Response { private _mockUrl: string; constructor( @@ -688,6 +764,10 @@ export class MockResponse extends Response { this._mockUrl = url || ''; } + get redirected() { + return true; + } + get url() { return this._mockUrl; } diff --git a/packages/host/tests/integration/components/operator-mode-test.gts b/packages/host/tests/integration/components/operator-mode-test.gts index e8e055a629a..7e316a7cb6d 100644 --- a/packages/host/tests/integration/components/operator-mode-test.gts +++ b/packages/host/tests/integration/components/operator-mode-test.gts @@ -39,6 +39,8 @@ import { TestRealmAdapter, TestRealm, type TestContextWithSave, + sourceFetchRedirectHandle, + sourceFetchReturnUrlHandle, } from '../../helpers'; import { MockMatrixService } from '../../helpers/mock-matrix-service'; import { renderComponent } from '../../helpers/render-component'; @@ -630,7 +632,16 @@ module('Integration | operator-mode', function (hooks) { '.realm.json': `{ "name": "${realmName}", "iconURL": "https://example-icon.test" }`, ...Object.fromEntries(personCards), }); - realm = await TestRealm.createWithAdapter(adapter, loader, this.owner); + realm = await TestRealm.createWithAdapter(adapter, loader, this.owner, { + overridingHandlers: [ + async (req: Request) => { + return sourceFetchRedirectHandle(req, adapter, testRealmURL); + }, + async (req: Request) => { + return sourceFetchReturnUrlHandle(req, realm.maybeHandle.bind(realm)); + }, + ], + }); await realm.ready; setCardInOperatorModeState = async (cardURL: string) => { diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 9d94572e9aa..de44b6d4b59 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -152,8 +152,8 @@ export class Loader { this.urlHandlers.push(handler); } - setURLHandlers(handlers: RequestHandler[]) { - this.urlHandlers = handlers; + prependURLHandlers(handlers: RequestHandler[]) { + this.urlHandlers = [...handlers, ...this.urlHandlers]; } shimModule(moduleIdentifier: string, module: Record) { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 001a3ec0a48..0d386e2cd70 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -438,29 +438,33 @@ export class Realm { if (this.#router.handles(request)) { return this.#router.handle(request); } else { - let url = new URL(request.url); - let localPath = this.paths.local(url); - let maybeHandle = await this.getFileWithFallbacks( - localPath, - executableExtensions, - ); + return this.fallbackHandle(request); + } + } - if (!maybeHandle) { - return notFound(this.url, request, `${request.url} not found`); - } + async fallbackHandle(request: Request) { + let url = new URL(request.url); + let localPath = this.paths.local(url); + let maybeHandle = await this.getFileWithFallbacks( + localPath, + executableExtensions, + ); - let handle = maybeHandle; - - if ( - executableExtensions.some((extension) => - handle.path.endsWith(extension), - ) && - !localPath.startsWith(assetsDir) - ) { - return this.makeJS(await fileContentToText(handle), handle.path); - } else { - return await this.serveLocalFile(handle); - } + if (!maybeHandle) { + return notFound(this.url, request, `${request.url} not found`); + } + + let handle = maybeHandle; + + if ( + executableExtensions.some((extension) => + handle.path.endsWith(extension), + ) && + !localPath.startsWith(assetsDir) + ) { + return this.makeJS(await fileContentToText(handle), handle.path); + } else { + return await this.serveLocalFile(handle); } }