Skip to content
1 change: 1 addition & 0 deletions packages/boxel-ui/addon/src/components/menu/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default class Menu extends Component<Signature> {
type='button'
class='boxel-menu__item__content'
role='menuitem'
data-menu-item-id={{menuItem.id}}
data-test-boxel-menu-item-text={{menuItem.label}}
{{on 'click' (fn this.invokeMenuItemAction menuItem.action)}}
disabled={{menuItem.disabled}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,10 @@ export default class SubmodeLayout extends Component<Signature> {
}

.submode-layout-top-bar-center {
flex: 1;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
min-width: 0;
Expand Down Expand Up @@ -722,6 +725,10 @@ export default class SubmodeLayout extends Component<Signature> {
.profile-icon-button {
--boxel-icon-button-width: var(--container-button-size);
--boxel-icon-button-height: var(--container-button-size);
/* Match the outline treatment used by the search and AI-assistant
icon buttons (see .ai-assistant-button/search-sheet), instead of
the Avatar component's default 2px solid white border. */
--profile-avatar-icon-border: var(--boxel-border-flexible);

background: none;

Expand Down
195 changes: 176 additions & 19 deletions packages/host/app/components/operator-mode/workspace-chooser/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ArchiveIcon from '@cardstack/boxel-icons/archive';
import Home from '@cardstack/boxel-icons/home';
import Shapes from '@cardstack/boxel-icons/shapes';
import { dropTask } from 'ember-concurrency';
import { modifier } from 'ember-modifier';

import { BoxelSelect } from '@cardstack/boxel-ui/components';
import { add, eq } from '@cardstack/boxel-ui/helpers';
Expand Down Expand Up @@ -156,6 +157,25 @@ export default class WorkspaceChooser extends Component<Signature> {
return urls;
}

// Newest first. Realms without a createdAt (e.g. a broken/orphaned realm
// reference) sort to the end rather than clumping at the front.
private sortByCreatedAtDesc = <T extends string>(urls: T[]): T[] => {
return [...urls].sort((a, b) => {
let aCreatedAt = this.realm.info(a).createdAt;
let bCreatedAt = this.realm.info(b).createdAt;
if (!aCreatedAt && !bCreatedAt) {
return 0;
}
if (!aCreatedAt) {
return 1;
}
if (!bCreatedAt) {
return -1;
}
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
});
};

private get filteredUserRealmIdentifiers() {
// Render in list order: `_realm-auth` enumerates realms
// newest-created-first and realms created mid-session are prepended, so
Expand All @@ -165,7 +185,9 @@ export default class WorkspaceChooser extends Component<Signature> {
}

private get filteredCatalogRealmIdentifiers() {
return this.filterByHosted(this.communityRealmIdentifiers);
return this.filterByHosted(
this.sortByCreatedAtDesc(this.communityRealmIdentifiers),
);
}

private get favoriteRealmIdentifiers() {
Expand All @@ -178,6 +200,29 @@ export default class WorkspaceChooser extends Component<Signature> {
return this.filterByHosted(filtered);
}

// Only favorited tiles render a Cards / Files / Definitions row, and the
// counts behind it are an aggregate over each realm's whole index — so they
// are requested here, for that set alone, rather than arriving with the realm
// info the chooser loads for every workspace. Installed as a modifier on the
// Favorites list so it runs after render and re-runs when the set of
// favorites changes; `loadIndexCounts` is fire-and-forget and skips realms
// already loaded or in flight, so the dashboard never waits on it.
// `revision` is passed but unread: ember-modifier re-runs a modifier when any
// argument changes, so taking it makes a re-index that marked counts stale
// re-trigger the load. Without it this would only re-run when *which* realms
// are favorited changes — never when the answer for those realms does.
private trackFavoriteCounts = modifier(
(_el: HTMLElement, [urls, _revision]: [string, number]) => {
if (urls) {
this.realm.loadIndexCounts(urls.split(' '));
}
},
);

private get favoriteCountsKey() {
return this.favoriteRealmIdentifiers.join(' ');
}

private get userWorkspacesEmptyMessage(): string | null {
if (
this.sortOrder === 'hosted-only' &&
Expand Down Expand Up @@ -208,10 +253,87 @@ export default class WorkspaceChooser extends Component<Signature> {
return null;
}

// Measured live from the Your Workspaces tile grid so favorited tiles can
// mirror however many tiles actually fit per row at the current viewport
// width (see measureWorkspaceGrid below).
@tracked private workspaceGridWidth = 0;
@tracked private workspaceTileWidth = 0;
@tracked private workspaceTileGap = 0;

private get tilesPerRow(): number {
if (!this.workspaceTileWidth) {
return 3;
}
let perRow = Math.floor(
(this.workspaceGridWidth + this.workspaceTileGap) /
(this.workspaceTileWidth + this.workspaceTileGap),
);
return Math.max(1, perRow);
}

// Two tiles' worth of Favorites-section width for every 3-across, rounded
// up — a row of 3 or 4 gives favorited tiles pairs, a row of 5 gives
// groups of 3, etc.
private get favoritesSlotCount(): number {
return Math.ceil(this.tilesPerRow / 2);
}

// The width a single favorited tile should be so that `favoritesSlotCount`
// of them, plus the gaps between them, exactly span the measured Your
// Workspaces row width. Null before the grid has been measured.
private get favoritesSlotWidth(): number | null {
if (!this.workspaceTileWidth) {
return null;
}
let n = this.favoritesSlotCount;
let rowWidth =
this.tilesPerRow * this.workspaceTileWidth +
(this.tilesPerRow - 1) * this.workspaceTileGap;
return (rowWidth - (n - 1) * this.workspaceTileGap) / n;
}

// Applied to real favorited tiles (via cssVar, see workspace.gts) so they
// take up this responsive width. Undefined before the grid has been
// measured, so the tile falls back to its static CSS width.
private get favoritesTileWidthPx(): string | undefined {
let width = this.favoritesSlotWidth;
return width === null ? undefined : `${width}px`;
}

// Watches the Your Workspaces tile grid so we always know how many tiles
// currently fit per row (and their exact width/gap), regardless of how
// many workspaces the user actually has.
private measureWorkspaceGrid = modifier((el: HTMLElement) => {
let measure = () => {
this.workspaceGridWidth = el.clientWidth;
let tile = el.querySelector('.workspace-card button.workspace');
if (tile) {
this.workspaceTileWidth = tile.getBoundingClientRect().width;
}
let gap = parseFloat(getComputedStyle(el).columnGap);
if (!Number.isNaN(gap)) {
this.workspaceTileGap = gap;
}
};
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(measure);
ro.observe(el);
}
window.addEventListener('resize', measure);
measure();
return () => {
ro?.disconnect();
window.removeEventListener('resize', measure);
};
});

// The keyboard-selected tile, identified by its position in the flat,
// DOM-ordered sequence of selectable tiles. The sequence spans, in render
// order: Favorites, Your Workspaces, the "New Workspace" tile, then Catalogs.
@tracked private selectedIndex = 0;
// order: Favorites, the "New Workspace" tile, Your Workspaces, then Catalogs.
// `null` until the user moves the selection, at which point
// `defaultSelectedIndex` no longer applies.
@tracked private selectedIndex: number | null = null;

private get favoritesCount() {
return this.favoriteRealmIdentifiers.length;
Expand All @@ -235,23 +357,38 @@ export default class WorkspaceChooser extends Component<Signature> {
}

// navIndex of the first tile in each section. The "New Workspace" tile sits
// between Your Workspaces and Catalogs.
private get userWorkspacesNavBase() {
// first in Your Workspaces, ahead of the actual workspace tiles.
private get addWorkspaceNavIndex() {
return this.favoritesCount;
}

private get addWorkspaceNavIndex() {
return this.favoritesCount + this.userWorkspacesCount;
private get userWorkspacesNavBase() {
return this.favoritesCount + (this.isAddWorkspaceShown ? 1 : 0);
}

private get catalogNavBase() {
return this.addWorkspaceNavIndex + (this.isAddWorkspaceShown ? 1 : 0);
return this.userWorkspacesNavBase + this.userWorkspacesCount;
}

private get selectableCount() {
return this.catalogNavBase + this.renderedCatalogCount;
}

// Where the selection sits before the user has moved it. The "New Workspace"
// tile renders first within Your Workspaces, but it must not be what opening
// the chooser lands on: the selected tile takes focus, so starting there
// would make the first Enter create a workspace instead of opening one. Skip
// past it to the first real workspace whenever there is one.
private get defaultSelectedIndex() {
if (this.favoritesCount > 0) {
return 0;
}
if (this.userWorkspacesCount > 0) {
return this.userWorkspacesNavBase;
}
return 0;
}

// `selectedIndex` can fall out of range when the selectable set shrinks
// without a keypress (e.g. switching to the Hosted Only filter hides the
// user section and "New Workspace" tile). Clamping on read keeps a tile
Expand All @@ -262,7 +399,8 @@ export default class WorkspaceChooser extends Component<Signature> {
if (count === 0) {
return 0;
}
return Math.min(Math.max(this.selectedIndex, 0), count - 1);
let index = this.selectedIndex ?? this.defaultSelectedIndex;
return Math.min(Math.max(index, 0), count - 1);
}

// Keep the selection in sync with focus, so tabbing onto a tile selects it.
Expand All @@ -272,7 +410,13 @@ export default class WorkspaceChooser extends Component<Signature> {
return;
}
let index = Number((tile as HTMLElement).dataset.navIndex);
if (!Number.isNaN(index) && index !== this.selectedIndex) {
// Compare against the *effective* selection, not the raw backing field.
// The selected tile is focused by a modifier (`focusWhenSelected`), whose
// synchronous `focus()` re-enters here during the same render pass that
// just read `currentIndex`. Writing `selectedIndex` there trips Ember's
// backtracking-rerender assertion, so the already-selected case has to be
// a genuine no-op — which it isn't if we compare to a still-unset field.
if (!Number.isNaN(index) && index !== this.currentIndex) {
this.selectedIndex = index;
}
}
Expand Down Expand Up @@ -428,12 +572,21 @@ export default class WorkspaceChooser extends Component<Signature> {
data-test-favorites-empty
>{{this.favoritesEmptyMessage}}</span>
{{else}}
<div class='workspace-list' data-test-favorites-list>
<div
class='workspace-list'
data-test-favorites-list
{{this.trackFavoriteCounts
this.favoriteCountsKey
this.realm.indexCountsRevision
}}
>
{{#each this.favoriteRealmIdentifiers as |realmIdentifier i|}}
<Workspace
@realmIdentifier={{realmIdentifier}}
@navIndex={{i}}
@isSelected={{eq this.currentIndex i}}
@isFavoritesSection={{true}}
@enlargedWidth={{this.favoritesTileWidthPx}}
/>
{{/each}}
</div>
Expand All @@ -450,7 +603,18 @@ export default class WorkspaceChooser extends Component<Signature> {
data-test-workspaces-empty
>{{this.userWorkspacesEmptyMessage}}</span>
{{else}}
<div class='workspace-list' data-test-workspace-list>
<div
class='workspace-list'
data-test-workspace-list
{{this.measureWorkspaceGrid}}
>
<AddWorkspace
@navIndex={{this.addWorkspaceNavIndex}}
@isSelected={{eq this.currentIndex this.addWorkspaceNavIndex}}
/>
{{#if this.matrixService.isInitializingNewUser}}
<WorkspaceLoadingIndicator />
{{/if}}
{{#each
this.filteredUserRealmIdentifiers
as |realmIdentifier i|
Expand All @@ -464,13 +628,6 @@ export default class WorkspaceChooser extends Component<Signature> {
/>
{{/let}}
{{/each}}
{{#if this.matrixService.isInitializingNewUser}}
<WorkspaceLoadingIndicator />
{{/if}}
<AddWorkspace
@navIndex={{this.addWorkspaceNavIndex}}
@isSelected={{eq this.currentIndex this.addWorkspaceNavIndex}}
/>
</div>
{{/if}}
</div>
Expand Down
Loading
Loading