Skip to content
Open
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
- To run a subset of the tests:
`ember test --path dist --filter "some text that appears in module name or test name"`
Note that the filter is matched against the module name and test name, not the file name! Try to avoid using pipe characters in the filter, since they can confuse auto-approval tool use filters set up by the user.
- **Always capture test output to a file.** A host test run can produce hundreds of KB of output (browser logs, indexer warnings, per-test diagnostics). If you only pipe through `tail`/`grep`, you lose everything else and have to re-run — which is slow and the bug may not reproduce. Redirect the full run to a file, then grep that file for failures, browser logs around a specific test, etc.
```
pnpm exec ember test --path dist --filter "Foo" 2>&1 | tee /tmp/host-test-foo.log
grep -E "^(not )?ok |^# " /tmp/host-test-foo.log # summary + per-test status
grep -B2 -A40 "not ok 27" /tmp/host-test-foo.log # detail for a specific failure
```
- run `pnpm lint` in this directory to lint changes made to this package
- run `pnpm lint:fix` directly in this directory to apply fixes for lint failures made to this package that can be automatically fixed.
- the host tests report this error:
Expand Down
104 changes: 96 additions & 8 deletions packages/base/cards-grid.gts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { registerDestructor } from '@ember/destroyable';
import { on } from '@ember/modifier';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { cached, tracked } from '@glimmer/tracking';
import { restartableTask } from 'ember-concurrency';
import { modifier } from 'ember-modifier';
import { TrackedArray } from 'tracked-built-ins';
Expand All @@ -12,12 +12,15 @@ import { HighlightIcon } from '@cardstack/boxel-ui/icons';
import LayoutGridPlusIcon from '@cardstack/boxel-icons/layout-grid-plus';
import Captions from '@cardstack/boxel-icons/captions';
import AllCardsIcon from '@cardstack/boxel-icons/square-stack';
import AllFilesIcon from '@cardstack/boxel-icons/files';
import FileIcon from '@cardstack/boxel-icons/file';

import {
cardIdToURL,
chooseCard,
specRef,
baseRealm,
baseFileRef,
isCardInstance,
SupportedMimeType,
subscribeToRealm,
Expand Down Expand Up @@ -101,21 +104,26 @@ class Isolated extends Component<typeof CardsGrid> {
</template>

private cardTypeFilters: FilterOption[] = new TrackedArray();
private fileTypeFilters: FilterOption[] = new TrackedArray();
private highlightsCards: BoxComponent[] = new TrackedArray();
private filterOptions: FilterOption[] = [];
private viewOptions: ViewOption[] = new TrackedArray([StripView, GridView]);
private sortOptions: SortOption[] = new TrackedArray(SORT_OPTIONS);

@tracked private activeViewId: ViewOption['id'] = this.viewOptions[1].id;
@tracked private activeFilter!: FilterOption;
@tracked private activeSort: SortOption = this.sortOptions[0];
// Tracked separately from `fileTypeFilters.length` so the All Files
// group still appears when the only file summaries the realm has are
// bare `FileDef` rows (we exclude those from the leaf list to avoid
// duplicating the group's own root). Without this, a realm that only
// contains binary/unmapped files would silently hide the entire group.
@tracked private hasAnyFileSummary = false;

#unsubscribeFromRealm: (() => void) | undefined;
#subscribedRealm: string | undefined;

constructor(owner: any, args: any) {
super(owner, args);
this.setupFilterOptions();
this.activeFilter = this.filterOptions[0];
this.loadHighlightsCards.perform();
registerDestructor(this, () => this.teardownRealmSubscription());
Expand Down Expand Up @@ -148,6 +156,12 @@ class Isolated extends Component<typeof CardsGrid> {
return realmHref?.includes('/personal/') ?? false;
}

// The per-group filter wrappers are `@cached` so their object identity
// stays stable across re-computations of `filterOptions`. `FilterList`'s
// `isSelected` is an identity comparison (`@filter === @activeFilter`), so
// returning a fresh object on every getter access would break the active
// highlight after the first render.
@cached
private get highlightFilter(): FilterOption {
return {
displayName: 'Highlights',
Expand All @@ -156,6 +170,7 @@ class Isolated extends Component<typeof CardsGrid> {
};
}

@cached
private get allCardsFilter(): FilterOption {
return {
displayName: 'All Cards',
Expand All @@ -174,12 +189,45 @@ class Isolated extends Component<typeof CardsGrid> {
};
}

private setupFilterOptions() {
this.filterOptions.splice(0, this.filterOptions.length);
@cached
private get allFilesFilter(): FilterOption {
return {
displayName: 'All Files',
icon: AllFilesIcon,
query: {
filter: {
type: baseFileRef,
},
},
filters: this.fileTypeFilters,
isExpanded: false,
};
}

// Derived from tracked state — `isPersonalRealm` and `hasAnyFileSummary`
// are the only deps that change the visible group set. We avoid the
// previous `splice + push` approach because mutating a `TrackedArray`
// during the component constructor (which runs mid-render) fires Glimmer's
// "you attempted to update X but it was already used in this computation"
// assertion. A `@cached` getter only re-runs when its tracked deps change
// and stays stable otherwise, so identity-based comparisons keep working.
@cached
private get filterOptions(): FilterOption[] {
let options: FilterOption[] = [];
if (this.isPersonalRealm) {
this.filterOptions.push(this.highlightFilter);
options.push(this.highlightFilter);
}
options.push(this.allCardsFilter);
// Hide the All Files group only when the realm has no file rows at all
// — matches the "empty groups: hide" decision in the Linear plan. We
// can't use `fileTypeFilters.length > 0` here because bare `FileDef`
// rows are intentionally excluded from the leaf list (they would be a
// duplicate of the group itself), so a realm with only bare FileDef
// files would otherwise lose the entire group.
if (this.hasAnyFileSummary) {
options.push(this.allFilesFilter);
}
this.filterOptions.push(this.allCardsFilter);
return options;
}

private teardownRealmSubscription() {
Expand Down Expand Up @@ -293,23 +341,56 @@ class Isolated extends Component<typeof CardsGrid> {
displayName: string;
total: number;
iconHTML: string | null;
// Older realm-server builds may not stamp `kind` yet — treat missing
// as 'instance' so this client stays compatible during a rolling
// deploy. New servers always set the discriminator.
kind?: 'instance' | 'file';
};
}[];
let excludedCardTypeIds = [
`${baseRealm.url}card-api/CardDef`,
`${baseRealm.url}cards-grid/CardsGrid`,
];
// The "All Files" group already represents the bare FileDef root — listing
// it again as a leaf would just be a duplicate row.
let excludedFileTypeIds = [`${baseRealm.url}card-api/FileDef`];

this.cardTypeFilters.splice(0, this.cardTypeFilters.length);
this.fileTypeFilters.splice(0, this.fileTypeFilters.length);
let sawFileSummary = false;

cardTypeSummaries.forEach((summary) => {
if (!summary.id || excludedCardTypeIds.includes(summary.id)) {
if (!summary.id) {
return;
}
let codeRef = codeRefFromInternalKey(summary.id);
if (!codeRef) {
return;
}
let kind = summary.attributes.kind ?? 'instance';
if (kind === 'file') {
// Even when the only file summary is the bare-FileDef root (which
// we exclude from the leaf list), we still want the All Files
// group to appear in the sidebar — otherwise the user has no way
// to reach those files. Flip this flag before the leaf-list gate.
sawFileSummary = true;
if (excludedFileTypeIds.includes(summary.id)) {
return;
}
this.fileTypeFilters.push({
displayName: summary.attributes.displayName ?? codeRef.name,
icon: summary.attributes.iconHTML ?? FileIcon,
query: {
filter: {
type: codeRef,
},
},
});
return;
}
if (excludedCardTypeIds.includes(summary.id)) {
return;
}
this.cardTypeFilters.push({
displayName: summary.attributes.displayName ?? codeRef.name,
icon: summary.attributes.iconHTML ?? Captions,
Expand All @@ -321,6 +402,13 @@ class Isolated extends Component<typeof CardsGrid> {
});
});

this.hasAnyFileSummary = sawFileSummary;

// `filterOptions` is a @cached getter that derives the group list from
// tracked state — `fileTypeFilters.length` changing here causes it to
// re-compute on the next read, which adds/removes the All Files group
// without any imperative array mutation.

let flattenedFilters: FilterOption[] = [];
this.filterOptions.map((f) =>
f.filters?.length
Expand Down
91 changes: 90 additions & 1 deletion packages/base/components/card-list.gts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { consume } from 'ember-provide-consume-context';

import { LoadingIndicator } from '@cardstack/boxel-ui/components';

import FileIcon from '@cardstack/boxel-icons/file';

import { cn, eq } from '@cardstack/boxel-ui/helpers';

import {
Expand Down Expand Up @@ -48,6 +50,33 @@ export default class CardList extends Component<Signature> {
}
}

// Last URL segment, used as a visible label when the prerender pipeline
// didn't produce HTML for a file row (CS-11171 — `.gts`/`.ts` FileDef rows
// currently skip the FileRender pass, so their `fitted_html` is null and
// `<card.component />` renders nothing).
fileNameFromUrl(url: string): string {
try {
let pathname = new URL(url).pathname;
let segment = pathname.split('/').filter(Boolean).pop();
return segment ?? url;
} catch {
let segments = url.split('/').filter(Boolean);
return segments[segments.length - 1] ?? url;
}
}

// Render the filename fallback only when the prerender pipeline produced
// no HTML AND the row is not an error. Error rows have their own
// dedicated error component built by PrerenderedCard's constructor, which
// also has empty `html`; treating them like file fallbacks would swap a
// helpful "rendering error" affordance for a bare filename.
shouldRenderFallback(card: {
hasHtml?: boolean;
isError?: boolean;
}): boolean {
return card.hasHtml === false && !card.isError;
}

<template>
<ul
class={{cn
Expand Down Expand Up @@ -77,6 +106,7 @@ export default class CardList extends Component<Signature> {
'boxel-card-list-item'
instance-error=card.isError
clickable=(if this.cardCrudFunctions.viewCard true false)
fallback=(this.shouldRenderFallback card)
}}
data-test-instance-error={{card.isError}}
data-test-cards-grid-item={{removeFileExtension card.url}}
Expand All @@ -86,7 +116,23 @@ export default class CardList extends Component<Signature> {
tabindex={{if this.cardCrudFunctions.viewCard '0'}}
{{on 'click' (fn this.handleCardClick card.url)}}
>
<card.component />
{{#if (this.shouldRenderFallback card)}}
{{! CS-11171: file rows whose prerender produced no HTML
(currently `.gts`/`.ts` FileDef rows) — render a name
so the row is at least visible and the click handler on
this `<li>` can still route the user into interact-mode
(and from there into Code Mode via the kebab menu).
Error rows are excluded so PrerenderedCard's dedicated
error component still gets rendered for them. }}
<div class='card-fallback' data-test-card-fallback>
<FileIcon class='card-fallback__icon' role='presentation' />
<div class='card-fallback__name'>
{{this.fileNameFromUrl card.url}}
</div>
</div>
{{else}}
<card.component />
{{/if}}
</li>
{{else}}
<p>No results were found</p>
Expand Down Expand Up @@ -147,6 +193,49 @@ export default class CardList extends Component<Signature> {
max-width: var(--embedded-card-max-width);
min-height: var(--embedded-card-min-height);
}
.boxel-card-list-item.fallback {
background-color: var(--boxel-100);
border: 1px solid var(--boxel-200);
border-radius: var(--boxel-border-radius);
padding: var(--boxel-sp-xs);
align-items: center;
justify-content: flex-start;
}
.card-fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--boxel-sp-xs);
width: 100%;
height: 100%;
overflow: hidden;
text-align: center;
}
.card-fallback__icon {
width: 2rem;
height: 2rem;
color: var(--boxel-500);
flex-shrink: 0;
}
.card-fallback__name {
font: 500 var(--boxel-font-sm);
color: var(--boxel-dark);
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.strip-view .card-fallback {
flex-direction: row;
justify-content: flex-start;
text-align: left;
}
.strip-view .card-fallback__icon {
width: 1.5rem;
height: 1.5rem;
}
.instance-error {
position: relative;
}
Expand Down
46 changes: 44 additions & 2 deletions packages/base/file-menu-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {
} from '@cardstack/boxel-ui/helpers';

import ArrowLeft from '@cardstack/boxel-icons/arrow-left';
import ClipboardCopy from '@cardstack/boxel-icons/clipboard-copy';
import CodeIcon from '@cardstack/boxel-icons/code';
import Eye from '@cardstack/boxel-icons/eye';
import LinkIcon from '@cardstack/boxel-icons/link';
import Trash2Icon from '@cardstack/boxel-icons/trash-2';

import CopyCardAsMarkdownCommand from '@cardstack/boxel-host/commands/copy-card-as-markdown';
import CopyFileToRealmCommand from '@cardstack/boxel-host/commands/copy-file-to-realm';
import OpenInInteractModeCommand from '@cardstack/boxel-host/commands/open-in-interact-mode';
import ShowFileCommand from '@cardstack/boxel-host/commands/show-file';
Expand Down Expand Up @@ -35,8 +38,47 @@ export function getDefaultFileMenuItems(
});
}
if (params.menuContext === 'interact') {
if (fileDefInstanceId && params.canEdit) {
// TODO: add menu item to delete the file
if (fileDefInstanceId) {
// Mirror the CardDef menu so users get a consistent set of actions on
// file rows. `CopyCardAsMarkdownCommand` is generic — it fetches the
// URL with `Accept: text/markdown` — so a file URL works the same way
// a card URL does, returning whatever the file row's `markdown`
// column holds (populated by the FileRender pass; null today for
// `.gts`/`.ts` rows pending CS-11171).
menuItems.push({
label: 'Copy as Markdown',
action: () =>
new CopyCardAsMarkdownCommand(params.commandContext).execute({
cardId: fileDefInstanceId,
}),
icon: ClipboardCopy,
});
// Files are read-only in interact-mode (no edit format). The "Open in
// Code Mode" entry is the canonical way for a user who opened a file
// via CardsGrid's All Files group to jump to the editing surface.
menuItems.push({
label: 'Open in Code Mode',
action: async () => {
await new SwitchSubmodeCommand(params.commandContext).execute({
submode: 'code',
codePath: fileDefInstanceId,
});
},
icon: CodeIcon,
});
if (params.canEdit) {
// `deleteCard` ultimately routes through `store.delete(id)`, which
// calls the realm's source-delete endpoint — the endpoint doesn't
// care whether the URL points at a card JSON or some other file
// kind, so reusing it for FileDef rows is the right plumbing.
menuItems.push({
label: 'Delete',
action: () =>
params.cardCrudFunctions.deleteCard?.(fileDefInstanceId),
icon: Trash2Icon,
dangerous: true,
});
}
}
}
if (
Expand Down
Loading
Loading