Skip to content

Commit 868be56

Browse files
authored
Playground instance previewed in edit view should save changes (#2165)
1 parent 65ebc95 commit 868be56

File tree

10 files changed

+258
-209
lines changed

10 files changed

+258
-209
lines changed

packages/host/app/components/operator-mode/code-submode.gts

+6-57
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { isTesting } from '@embroider/macros';
1010
import Component from '@glimmer/component';
1111
import { tracked, cached } from '@glimmer/tracking';
1212

13-
import { dropTask, restartableTask, timeout, all } from 'ember-concurrency';
13+
import { dropTask, restartableTask, timeout } from 'ember-concurrency';
1414

1515
import perform from 'ember-concurrency/helpers/perform';
1616

@@ -158,12 +158,10 @@ export default class CodeSubmode extends Component<Signature> {
158158
@tracked private isCreateModalOpen = false;
159159
@tracked private itemToDelete: CardDef | URL | null | undefined;
160160

161-
private hasUnsavedCardChanges = false;
162161
private defaultPanelWidths: PanelWidths;
163162
private defaultPanelHeights: PanelHeights;
164163
private updateCursorByName: ((name: string) => void) | undefined;
165164
private panelSelections: Record<string, SelectedAccordionItem>;
166-
#currentCard: CardDef | undefined;
167165

168166
private createFileModal: CreateFileModal | undefined;
169167
private cardResource = getCard(
@@ -178,7 +176,7 @@ export default class CodeSubmode extends Component<Signature> {
178176
return url;
179177
},
180178
{
181-
onCardInstanceChange: () => this.onCardLoaded,
179+
isAutoSave: () => true,
182180
},
183181
);
184182
private moduleContentsResource = moduleContentsResource(
@@ -236,18 +234,6 @@ export default class CodeSubmode extends Component<Signature> {
236234
: defaultPanelHeights;
237235

238236
registerDestructor(this, () => {
239-
// destructor functons are called synchronously. in order to save,
240-
// which is async, we leverage an EC task that is running in a
241-
// parent component (EC task lifetimes are bound to their context)
242-
// that is not being destroyed.
243-
if (this.hasUnsavedCardChanges && this.#currentCard) {
244-
// we use this.#currentCard here instead of this.card because in
245-
// the destructor we no longer have access to resources bound to
246-
// this component since they are destroyed first, so this.#currentCard
247-
// is something we copy from the card resource when it changes so that
248-
// we have access to it in the destructor
249-
this.args.saveCardOnClose(this.#currentCard);
250-
}
251237
this.operatorModeStateService.unsubscribeFromOpenFileStateChanges(this);
252238
});
253239
}
@@ -450,26 +436,6 @@ export default class CodeSubmode extends Component<Signature> {
450436
}
451437
}
452438

453-
private onCardLoaded = (
454-
oldCard: CardDef | undefined,
455-
newCard: CardDef | undefined,
456-
) => {
457-
// this handles the scenario for the initial load, as well as unloading a
458-
// card that went into an error state or was deleted
459-
if (oldCard !== newCard) {
460-
if (oldCard) {
461-
this.cardResource.api.unsubscribeFromChanges(
462-
oldCard,
463-
this.onCardChange,
464-
);
465-
}
466-
if (newCard) {
467-
this.cardResource.api.subscribeToChanges(newCard, this.onCardChange);
468-
}
469-
}
470-
this.#currentCard = newCard;
471-
};
472-
473439
private get declarations() {
474440
return this.moduleContentsResource?.declarations;
475441
}
@@ -541,25 +507,6 @@ export default class CodeSubmode extends Component<Signature> {
541507
);
542508
}
543509

544-
private onCardChange = () => {
545-
this.initiateAutoSaveTask.perform();
546-
};
547-
548-
private initiateAutoSaveTask = restartableTask(async () => {
549-
if (this.card) {
550-
this.hasUnsavedCardChanges = true;
551-
await timeout(this.environmentService.autoSaveDelayMs);
552-
await this.saveCard.perform(this.card);
553-
this.hasUnsavedCardChanges = false;
554-
}
555-
});
556-
557-
private saveCard = restartableTask(async (card: CardDef) => {
558-
// these saves can happen so fast that we'll make sure to wait at
559-
// least 500ms for human consumption
560-
await all([this.cardService.saveModel(card), timeout(500)]);
561-
});
562-
563510
private loadScopedCSS = restartableTask(async () => {
564511
if (this.cardError?.meta.scopedCssUrls) {
565512
await Promise.all(
@@ -571,7 +518,9 @@ export default class CodeSubmode extends Component<Signature> {
571518
});
572519

573520
private get isSaving() {
574-
return this.sourceFileIsSaving || this.saveCard.isRunning;
521+
return (
522+
this.sourceFileIsSaving || !!this.cardResource.autoSaveState?.isSaving
523+
);
575524
}
576525

577526
@action
@@ -818,7 +767,7 @@ export default class CodeSubmode extends Component<Signature> {
818767
data-test-code-mode
819768
data-test-save-idle={{and
820769
(not this.sourceFileIsSaving)
821-
this.initiateAutoSaveTask.isIdle
770+
(not this.cardResource.autoSaveState.isSaving)
822771
}}
823772
>
824773
<div class='code-mode-top-bar'>

packages/host/app/components/operator-mode/code-submode/playground-panel.gts

+18
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> {
214214
@cardTypeDisplayName={{cardTypeDisplayName this.card}}
215215
@cardTypeIcon={{cardTypeIcon this.card}}
216216
@realmInfo={{realmInfo}}
217+
@onEdit={{if this.canEdit this.setEditFormat}}
217218
@isTopCard={{true}}
218219
@moreOptionsMenuItems={{this.contextMenuItems}}
219220
/>
@@ -411,6 +412,7 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> {
411412
private cardResource = getCard(
412413
this,
413414
() => this.playgroundSelections[this.args.moduleId]?.cardId,
415+
{ isAutoSave: () => true },
414416
);
415417

416418
private get card(): CardDef | undefined {
@@ -485,6 +487,22 @@ class PlaygroundPanelContent extends Component<PlaygroundContentSignature> {
485487
this.persistSelections(chosenCard.id);
486488
}
487489
});
490+
491+
private get canEdit() {
492+
return (
493+
this.format !== 'edit' &&
494+
this.card?.id &&
495+
this.realm.canWrite(this.card.id)
496+
);
497+
}
498+
499+
@action
500+
private setEditFormat() {
501+
if (!this.card?.id) {
502+
return;
503+
}
504+
this.persistSelections(this.card.id, 'edit');
505+
}
488506
}
489507

490508
interface Signature {

packages/host/app/components/operator-mode/code-submode/spec-preview.gts

+2-33
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import DotIcon from '@cardstack/boxel-icons/dot';
1212

1313
import LayoutList from '@cardstack/boxel-icons/layout-list';
1414
import StackIcon from '@cardstack/boxel-icons/stack';
15-
import { task, restartableTask, timeout, all } from 'ember-concurrency';
15+
import { task } from 'ember-concurrency';
1616

1717
import {
1818
BoxelButton,
@@ -54,8 +54,6 @@ import OperatorModeStateService from '@cardstack/host/services/operator-mode-sta
5454
import RealmService from '@cardstack/host/services/realm';
5555
import type RealmServerService from '@cardstack/host/services/realm-server';
5656

57-
import type { CardDef } from 'https://cardstack.com/base/card-api';
58-
5957
import { Spec, type SpecType } from 'https://cardstack.com/base/spec';
6058

6159
import type { WithBoundArgs } from '@glint/template';
@@ -482,7 +480,7 @@ export default class SpecPreview extends GlimmerComponent<Signature> {
482480
}
483481

484482
private cardResource = getCard(this, () => this.selectedId, {
485-
onCardInstanceChange: () => this.onCardLoaded,
483+
isAutoSave: () => true,
486484
});
487485

488486
get card() {
@@ -492,35 +490,6 @@ export default class SpecPreview extends GlimmerComponent<Signature> {
492490
return this.cardResource.card as Spec;
493491
}
494492

495-
private onCardLoaded = (
496-
oldCard: CardDef | undefined,
497-
newCard: CardDef | undefined,
498-
) => {
499-
if (oldCard) {
500-
this.cardResource.api.unsubscribeFromChanges(oldCard, this.onCardChange);
501-
}
502-
if (newCard) {
503-
this.cardResource.api.subscribeToChanges(newCard, this.onCardChange);
504-
}
505-
};
506-
507-
private onCardChange = () => {
508-
this.initiateAutoSaveTask.perform();
509-
};
510-
511-
private initiateAutoSaveTask = restartableTask(async () => {
512-
if (this.card) {
513-
await timeout(this.environmentService.autoSaveDelayMs);
514-
await this.saveCard.perform(this.card);
515-
}
516-
});
517-
518-
private saveCard = restartableTask(async (card: Spec) => {
519-
// these saves can happen so fast that we'll make sure to wait at
520-
// least 500ms for human consumption
521-
await all([this.cardService.saveModel(card), timeout(500)]);
522-
});
523-
524493
get specType() {
525494
return this.card?.specType as SpecType;
526495
}

packages/host/app/components/operator-mode/stack-item.gts

+3-88
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { registerDestructor } from '@ember/destroyable';
21
import { fn } from '@ember/helper';
32
import { on } from '@ember/modifier';
43
import { action } from '@ember/object';
@@ -14,9 +13,7 @@ import Component from '@glimmer/component';
1413
import { tracked, cached } from '@glimmer/tracking';
1514

1615
import ExclamationCircle from '@cardstack/boxel-icons/exclamation-circle';
17-
import { formatDistanceToNow } from 'date-fns';
1816
import {
19-
task,
2017
restartableTask,
2118
timeout,
2219
waitForProperty,
@@ -124,18 +121,11 @@ export default class OperatorModeStackItem extends Component<Signature> {
124121

125122
// @tracked private selectedCards = new TrackedArray<CardDef>([]);
126123
@tracked private selectedCards = new TrackedArray<CardDefOrId>([]);
127-
@tracked private isSaving = false;
128124
@tracked private animationType:
129125
| 'opening'
130126
| 'closing'
131127
| 'movingForward'
132128
| undefined = 'opening';
133-
@tracked private hasUnsavedChanges = false;
134-
@tracked private lastSaved: number | undefined;
135-
@tracked private lastSavedMsg: string | undefined;
136-
@tracked private lastSaveError: Error | undefined;
137-
private refreshSaveMsg: number | undefined;
138-
private subscribedCard: CardDef | undefined;
139129
private contentEl: HTMLElement | undefined;
140130
private containerEl: HTMLElement | undefined;
141131
private itemEl: HTMLElement | undefined;
@@ -162,7 +152,6 @@ export default class OperatorModeStackItem extends Component<Signature> {
162152
constructor(owner: Owner, args: Signature['Args']) {
163153
super(owner, args);
164154
this.loadCard.perform();
165-
this.subscribeToCard.perform();
166155
this.args.setupStackItem(this.args.item, {
167156
clearSelections: this.clearSelections,
168157
doWithStableScroll: this.doWithStableScroll.perform,
@@ -376,86 +365,12 @@ export default class OperatorModeStackItem extends Component<Signature> {
376365
}
377366
});
378367

379-
private subscribeToCard = task(async () => {
380-
await this.args.item.ready();
381-
// TODO how do we make sure that this is called after the error is cleared?
382-
// Address this as part of SSE support for card errors
383-
if (!this.cardError) {
384-
this.subscribedCard = this.card;
385-
let api = this.args.item.api;
386-
registerDestructor(this, this.cleanup);
387-
api.subscribeToChanges(this.subscribedCard, this.onCardChange);
388-
this.refreshSaveMsg = setInterval(
389-
() => this.calculateLastSavedMsg(),
390-
10 * 1000,
391-
) as unknown as number;
392-
}
393-
});
394-
395-
private cleanup = () => {
396-
if (this.subscribedCard) {
397-
let api = this.args.item.api;
398-
api.unsubscribeFromChanges(this.subscribedCard, this.onCardChange);
399-
clearInterval(this.refreshSaveMsg);
400-
}
401-
};
402-
403-
private onCardChange = () => {
404-
this.initiateAutoSaveTask.perform();
405-
};
406-
407-
private initiateAutoSaveTask = restartableTask(async () => {
408-
this.hasUnsavedChanges = true;
409-
await timeout(this.environmentService.autoSaveDelayMs);
410-
try {
411-
this.isSaving = true;
412-
this.lastSaveError = undefined;
413-
await timeout(25);
414-
await this.args.saveCard(this.card);
415-
this.hasUnsavedChanges = false;
416-
this.lastSaved = Date.now();
417-
} catch (error) {
418-
// error will already be logged in CardService
419-
this.lastSaveError = error as Error;
420-
} finally {
421-
this.isSaving = false;
422-
this.calculateLastSavedMsg();
423-
}
424-
});
425-
426-
private calculateLastSavedMsg() {
427-
let savedMessage: string | undefined;
428-
if (this.lastSaveError) {
429-
savedMessage = `Failed to save: ${this.getErrorMessage(
430-
this.lastSaveError,
431-
)}`;
432-
} else if (this.lastSaved) {
433-
savedMessage = `Saved ${formatDistanceToNow(this.lastSaved, {
434-
addSuffix: true,
435-
})}`;
436-
}
437-
// runs frequently, so only change a tracked property if the value has changed
438-
if (this.lastSavedMsg != savedMessage) {
439-
this.lastSavedMsg = savedMessage;
440-
}
441-
}
442-
443-
private getErrorMessage(error: Error) {
444-
if ((error as any).responseHeaders?.get('x-blocked-by-waf-rule')) {
445-
return 'Rejected by firewall';
446-
}
447-
if (error.message) {
448-
return error.message;
449-
}
450-
return 'Unknown error';
451-
}
452-
453368
private doneEditing = restartableTask(async () => {
454369
let item = this.args.item;
455370
let { request } = item;
456371
// if the card is actually different do the save and dismiss, otherwise
457372
// just change the stack item's format to isolated
458-
if (this.hasUnsavedChanges) {
373+
if (item.autoSaveState?.hasUnsavedChanges) {
459374
// we dont want to have the user wait for the save to complete before
460375
// dismissing edit mode so intentionally not awaiting here
461376
let updatedCard = await this.args.saveCard(item.card);
@@ -675,9 +590,9 @@ export default class OperatorModeStackItem extends Component<Signature> {
675590
<CardHeader
676591
@cardTypeDisplayName={{this.headerTitle}}
677592
@cardTypeIcon={{cardTypeIcon @item.card}}
678-
@isSaving={{this.isSaving}}
593+
@isSaving={{@item.autoSaveState.isSaving}}
679594
@isTopCard={{this.isTopCard}}
680-
@lastSavedMessage={{this.lastSavedMsg}}
595+
@lastSavedMessage={{@item.autoSaveState.lastSavedErrorMsg}}
681596
@moreOptionsMenuItems={{this.moreOptionsMenuItems}}
682597
@realmInfo={{realmInfo}}
683598
@onEdit={{if this.canEdit (fn @publicAPI.editCard this.card)}}

packages/host/app/lib/stack-item.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,13 @@ export class StackItem {
4848
if (cardResource) {
4949
this.cardResource = cardResource;
5050
} else if (url) {
51-
this.cardResource = getCard(owner, () => url!.href);
51+
this.cardResource = getCard(owner, () => url!.href, {
52+
isAutoSave: () => true,
53+
});
5254
} else if (newCard) {
5355
this.cardResource = getCard(owner, () => newCard!.doc, {
5456
relativeTo: newCard.relativeTo,
57+
isAutoSave: () => true,
5558
});
5659
}
5760

@@ -77,6 +80,10 @@ export class StackItem {
7780
throw new Error(`This StackItem has no card set`);
7881
}
7982

83+
get autoSaveState() {
84+
return this.cardResource?.autoSaveState;
85+
}
86+
8087
get title() {
8188
if (this.cardResource?.card) {
8289
return this.cardResource.card.title;

0 commit comments

Comments
 (0)