Skip to content

Commit c646a7c

Browse files
authored
Merge pull request #3867 from cardstack/cs-9640-when-trying-to-create-a-new-card-with-ai-it-will-sometimes
Add createFile param to the switch submode command
2 parents c09fe83 + 2920284 commit c646a7c

8 files changed

Lines changed: 374 additions & 66 deletions

File tree

packages/base/command.gts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,19 @@ export class FileContents extends CardDef {
117117
export class SwitchSubmodeInput extends CardDef {
118118
@field submode = contains(StringField);
119119
@field codePath = contains(StringField);
120+
@field createFile = contains(BooleanField);
121+
}
122+
123+
export class SwitchSubmodeResult extends CardDef {
124+
@field codePath = contains(StringField);
120125
}
121126

122127
export class WriteTextFileInput extends CardDef {
123128
@field content = contains(StringField);
124129
@field realm = contains(StringField);
125130
@field path = contains(StringField);
126131
@field overwrite = contains(BooleanField);
132+
@field useNonConflictingFilename = contains(BooleanField);
127133
}
128134

129135
export class CreateInstanceInput extends CardDef {

packages/host/app/commands/patch-code.ts

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import HostBaseCommand from '../lib/host-base-command';
88
import { parseSearchReplace } from '../lib/search-replace-block-parsing';
99
import { isReady } from '../resources/file';
1010

11+
import { findNonConflictingFilename } from '../utils/file-name';
12+
1113
import ApplySearchReplaceBlockCommand from './apply-search-replace-block';
1214
import LintAndFixCommand from './lint-and-fix';
1315

@@ -218,34 +220,9 @@ export default class PatchCodeCommand extends HostBaseCommand<
218220
return originalUrl;
219221
}
220222

221-
return await this.findNonConflictingFilename(originalUrl);
222-
}
223-
224-
private async findNonConflictingFilename(fileUrl: string): Promise<string> {
225-
let MAX_ATTEMPTS = 100;
226-
let { baseName, extension } = this.parseFilename(fileUrl);
227-
228-
for (let counter = 1; counter < MAX_ATTEMPTS; counter++) {
229-
let candidateUrl = `${baseName}-${counter}${extension}`;
230-
let exists = await this.fileExists(candidateUrl);
231-
232-
if (!exists) {
233-
return candidateUrl;
234-
}
235-
}
236-
237-
return `${baseName}-${MAX_ATTEMPTS}${extension}`;
238-
}
239-
240-
private parseFilename(fileUrl: string): {
241-
baseName: string;
242-
extension: string;
243-
} {
244-
let extensionMatch = fileUrl.match(/\.([^.]+)$/);
245-
let extension = extensionMatch?.[0] || '';
246-
let baseName = fileUrl.replace(/\.([^.]+)$/, '');
247-
248-
return { baseName, extension };
223+
return await findNonConflictingFilename(originalUrl, (candidateUrl) =>
224+
this.fileExists(candidateUrl),
225+
);
249226
}
250227

251228
private async fileExists(fileUrl: string): Promise<boolean> {

packages/host/app/commands/switch-submode.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import { Submodes } from '../components/submode-switcher';
66

77
import HostBaseCommand from '../lib/host-base-command';
88

9+
import WriteTextFileCommand from './write-text-file';
10+
911
import type OperatorModeStateService from '../services/operator-mode-state-service';
1012
import type StoreService from '../services/store';
1113

1214
export default class SwitchSubmodeCommand extends HostBaseCommand<
13-
typeof BaseCommandModule.SwitchSubmodeInput
15+
typeof BaseCommandModule.SwitchSubmodeInput,
16+
typeof BaseCommandModule.SwitchSubmodeResult | undefined
1417
> {
1518
@service declare private operatorModeStateService: OperatorModeStateService;
1619
@service declare private store: StoreService;
@@ -43,24 +46,48 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<
4346

4447
protected async run(
4548
input: BaseCommandModule.SwitchSubmodeInput,
46-
): Promise<undefined> {
49+
): Promise<BaseCommandModule.SwitchSubmodeResult | undefined> {
50+
let resultCard: BaseCommandModule.SwitchSubmodeResult | undefined;
4751
switch (input.submode) {
4852
case Submodes.Interact:
4953
await this.operatorModeStateService.updateCodePath(null);
5054
break;
51-
case Submodes.Code:
52-
if (input.codePath) {
53-
await this.operatorModeStateService.updateCodePath(
54-
new URL(input.codePath),
55-
);
56-
} else {
57-
await this.operatorModeStateService.updateCodePath(
58-
this.lastCardInRightMostStack
59-
? new URL(this.lastCardInRightMostStack + '.json')
60-
: null,
55+
case Submodes.Code: {
56+
let codePath =
57+
input.codePath ??
58+
(this.lastCardInRightMostStack
59+
? this.lastCardInRightMostStack + '.json'
60+
: null);
61+
let codeUrl = codePath ? new URL(codePath) : null;
62+
let currentSubmode = this.operatorModeStateService.state.submode;
63+
let finalCodeUrl = codeUrl;
64+
if (
65+
codeUrl &&
66+
input.createFile &&
67+
currentSubmode === Submodes.Interact
68+
) {
69+
let writeTextFileCommand = new WriteTextFileCommand(
70+
this.commandContext,
6171
);
72+
let writeResult = await writeTextFileCommand.execute({
73+
path: codeUrl.href,
74+
content: '',
75+
useNonConflictingFilename: true,
76+
});
77+
if (writeResult.fileUrl !== codeUrl.href) {
78+
let newCodeUrl = new URL(writeResult.fileUrl);
79+
finalCodeUrl = newCodeUrl;
80+
81+
let commandModule = await this.loadCommandModule();
82+
const { SwitchSubmodeResult } = commandModule;
83+
resultCard = new SwitchSubmodeResult({
84+
codePath: newCodeUrl.href,
85+
});
86+
}
6287
}
88+
await this.operatorModeStateService.updateCodePath(finalCodeUrl);
6389
break;
90+
}
6491
default:
6592
throw new Error(`invalid submode specified: ${input.submode}`);
6693
}
@@ -69,5 +96,7 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<
6996
if (this.operatorModeStateService.workspaceChooserOpened) {
7097
this.operatorModeStateService.closeWorkspaceChooser();
7198
}
99+
100+
return resultCard;
72101
}
73102
}

packages/host/app/commands/write-text-file.ts

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import { service } from '@ember/service';
33
import type * as BaseCommandModule from 'https://cardstack.com/base/command';
44

55
import HostBaseCommand from '../lib/host-base-command';
6+
import { findNonConflictingFilename } from '../utils/file-name';
67

7-
import type NetworkService from '../services/network';
8+
import type CardService from '../services/card-service';
9+
import type { SaveType } from '../services/card-service';
810
import type RealmService from '../services/realm';
911

1012
export default class WriteTextFileCommand extends HostBaseCommand<
11-
typeof BaseCommandModule.WriteTextFileInput
13+
typeof BaseCommandModule.WriteTextFileInput,
14+
typeof BaseCommandModule.FileUrlCard
1215
> {
13-
@service declare private network: NetworkService;
16+
@service declare private cardService: CardService;
1417
@service declare private realm: RealmService;
1518

1619
description = `Write a text file to a realm, such as a module or a card.`;
@@ -26,40 +29,74 @@ export default class WriteTextFileCommand extends HostBaseCommand<
2629

2730
protected async run(
2831
input: BaseCommandModule.WriteTextFileInput,
29-
): Promise<undefined> {
32+
): Promise<BaseCommandModule.FileUrlCard> {
33+
if (input.overwrite && input.useNonConflictingFilename) {
34+
throw new Error(
35+
'Cannot use both overwrite and useNonConflictingFilename.',
36+
);
37+
}
3038
let realm;
3139
if (input.realm) {
3240
realm = this.realm.realmOfURL(new URL(input.realm));
3341
if (!realm) {
3442
throw new Error(`Invalid or unknown realm provided: ${input.realm}`);
3543
}
3644
}
37-
if (input.path.startsWith('/')) {
38-
input.path = input.path.slice(1);
45+
let path = input.path;
46+
if (path.startsWith('/')) {
47+
path = path.slice(1);
3948
}
40-
let url = new URL(input.path, realm?.href);
49+
let url = new URL(path, realm?.href);
50+
let finalUrl = url;
51+
let shouldWrite = true;
4152
if (!input.overwrite) {
42-
let existing = await this.network.authedFetch(url);
43-
44-
if (existing.ok || existing.status === 406) {
45-
throw new Error(`File already exists: ${input.path}`);
46-
}
53+
if (input.useNonConflictingFilename) {
54+
let existing = await this.cardService.getSource(url);
55+
if (existing.status === 404) {
56+
shouldWrite = true;
57+
} else if (existing.status === 200) {
58+
if (existing.content.trim() !== '') {
59+
let nonConflictingUrl = await findNonConflictingFilename(
60+
url.href,
61+
(candidateUrl) => this.fileExists(candidateUrl),
62+
);
63+
finalUrl = new URL(nonConflictingUrl);
64+
} else {
65+
shouldWrite = input.content.trim() !== '';
66+
}
67+
} else {
68+
throw new Error(
69+
`Error checking if file exists at ${url}: ${existing.status}`,
70+
);
71+
}
72+
} else {
73+
let existing = await this.cardService.getSource(url);
74+
if (existing.status === 200 || existing.status === 406) {
75+
throw new Error(`File already exists: ${path}`);
76+
}
4777

48-
if (existing.status !== 404) {
49-
throw new Error(
50-
`Error checking if file exists at ${input.path}: ${existing.statusText} (${existing.status})`,
51-
);
78+
if (existing.status !== 404) {
79+
let errorDetails = existing.content?.trim()
80+
? `${existing.content} (${existing.status})`
81+
: `${existing.status}`;
82+
throw new Error(
83+
`Error checking if file exists at ${path}: ${errorDetails}`,
84+
);
85+
}
5286
}
5387
}
54-
let response = await this.network.authedFetch(url, {
55-
method: 'POST',
56-
headers: {
57-
Accept: 'application/vnd.card+source',
58-
},
59-
body: input.content,
60-
});
61-
if (!response.ok) {
62-
throw new Error(`Failed to write file ${url}: ${response.statusText}`);
88+
if (shouldWrite) {
89+
let saveType: SaveType = input.overwrite ? 'editor' : 'create-file';
90+
await this.cardService.saveSource(finalUrl, input.content, saveType);
6391
}
92+
93+
let commandModule = await this.loadCommandModule();
94+
const { FileUrlCard } = commandModule;
95+
return new FileUrlCard({ fileUrl: finalUrl.href });
96+
}
97+
98+
private async fileExists(fileUrl: string): Promise<boolean> {
99+
let getSourceResult = await this.cardService.getSource(new URL(fileUrl));
100+
return getSourceResult.status !== 404;
64101
}
65102
}

packages/host/app/components/matrix/room-message-command.gts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,11 @@ export default class RoomMessageCommand extends Component<Signature> {
135135
}
136136

137137
private get shouldDisplayResultCard() {
138+
let commandName = this.args.messageCommand.name ?? '';
138139
return (
139140
!!this.commandResultCard.card &&
140-
this.args.messageCommand.name !== 'checkCorrectness'
141+
commandName !== 'checkCorrectness' &&
142+
!commandName.startsWith('switch-submode')
141143
);
142144
}
143145

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export async function findNonConflictingFilename(
2+
fileUrl: string,
3+
fileExists: (fileUrl: string) => Promise<boolean>,
4+
): Promise<string> {
5+
let maxAttempts = 100;
6+
let { baseName, extension } = parseFilename(fileUrl);
7+
8+
for (let counter = 1; counter < maxAttempts; counter++) {
9+
let candidateUrl = `${baseName}-${counter}${extension}`;
10+
let exists = await fileExists(candidateUrl);
11+
12+
if (!exists) {
13+
return candidateUrl;
14+
}
15+
}
16+
17+
const finalCandidateUrl = `${baseName}-${maxAttempts}${extension}`;
18+
const finalExists = await fileExists(finalCandidateUrl);
19+
20+
if (!finalExists) {
21+
return finalCandidateUrl;
22+
}
23+
24+
throw new Error(
25+
`Unable to find non-conflicting filename for "${fileUrl}" after ${maxAttempts} attempts.`,
26+
);
27+
}
28+
29+
function parseFilename(fileUrl: string): {
30+
baseName: string;
31+
extension: string;
32+
} {
33+
let extensionMatch = fileUrl.match(/\.([^.]+)$/);
34+
let extension = extensionMatch?.[0] || '';
35+
let baseName = fileUrl.replace(/\.([^.]+)$/, '');
36+
37+
return { baseName, extension };
38+
}

0 commit comments

Comments
 (0)