diff --git a/packages/base/command.gts b/packages/base/command.gts index 538c333b97..5d9c0c9efd 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -117,6 +117,12 @@ export class FileContents extends CardDef { export class SwitchSubmodeInput extends CardDef { @field submode = contains(StringField); @field codePath = contains(StringField); + @field createFile = contains(BooleanField); +} + +export class SwitchSubmodeResult extends CardDef { + @field codePath = contains(StringField); + @field requestedCodePath = contains(StringField); } export class WriteTextFileInput extends CardDef { @@ -124,6 +130,7 @@ export class WriteTextFileInput extends CardDef { @field realm = contains(StringField); @field path = contains(StringField); @field overwrite = contains(BooleanField); + @field useNonConflictingFilename = contains(BooleanField); } export class CreateInstanceInput extends CardDef { diff --git a/packages/host/app/commands/patch-code.ts b/packages/host/app/commands/patch-code.ts index 8ab1015324..970f9c192d 100644 --- a/packages/host/app/commands/patch-code.ts +++ b/packages/host/app/commands/patch-code.ts @@ -8,6 +8,8 @@ import HostBaseCommand from '../lib/host-base-command'; import { parseSearchReplace } from '../lib/search-replace-block-parsing'; import { isReady } from '../resources/file'; +import { findNonConflictingFilename } from '../utils/file-name'; + import ApplySearchReplaceBlockCommand from './apply-search-replace-block'; import LintAndFixCommand from './lint-and-fix'; @@ -218,34 +220,9 @@ export default class PatchCodeCommand extends HostBaseCommand< return originalUrl; } - return await this.findNonConflictingFilename(originalUrl); - } - - private async findNonConflictingFilename(fileUrl: string): Promise { - let MAX_ATTEMPTS = 100; - let { baseName, extension } = this.parseFilename(fileUrl); - - for (let counter = 1; counter < MAX_ATTEMPTS; counter++) { - let candidateUrl = `${baseName}-${counter}${extension}`; - let exists = await this.fileExists(candidateUrl); - - if (!exists) { - return candidateUrl; - } - } - - return `${baseName}-${MAX_ATTEMPTS}${extension}`; - } - - private parseFilename(fileUrl: string): { - baseName: string; - extension: string; - } { - let extensionMatch = fileUrl.match(/\.([^.]+)$/); - let extension = extensionMatch?.[0] || ''; - let baseName = fileUrl.replace(/\.([^.]+)$/, ''); - - return { baseName, extension }; + return await findNonConflictingFilename(originalUrl, (candidateUrl) => + this.fileExists(candidateUrl), + ); } private async fileExists(fileUrl: string): Promise { diff --git a/packages/host/app/commands/switch-submode.ts b/packages/host/app/commands/switch-submode.ts index c08b78d6e5..aebc568f08 100644 --- a/packages/host/app/commands/switch-submode.ts +++ b/packages/host/app/commands/switch-submode.ts @@ -6,11 +6,14 @@ import { Submodes } from '../components/submode-switcher'; import HostBaseCommand from '../lib/host-base-command'; +import WriteTextFileCommand from './write-text-file'; + import type OperatorModeStateService from '../services/operator-mode-state-service'; import type StoreService from '../services/store'; export default class SwitchSubmodeCommand extends HostBaseCommand< - typeof BaseCommandModule.SwitchSubmodeInput + typeof BaseCommandModule.SwitchSubmodeInput, + typeof BaseCommandModule.SwitchSubmodeResult | undefined > { @service declare private operatorModeStateService: OperatorModeStateService; @service declare private store: StoreService; @@ -43,24 +46,49 @@ export default class SwitchSubmodeCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.SwitchSubmodeInput, - ): Promise { + ): Promise { + let resultCard: BaseCommandModule.SwitchSubmodeResult | undefined; switch (input.submode) { case Submodes.Interact: await this.operatorModeStateService.updateCodePath(null); break; - case Submodes.Code: - if (input.codePath) { - await this.operatorModeStateService.updateCodePath( - new URL(input.codePath), - ); - } else { - await this.operatorModeStateService.updateCodePath( - this.lastCardInRightMostStack - ? new URL(this.lastCardInRightMostStack + '.json') - : null, + case Submodes.Code: { + let codePath = + input.codePath ?? + (this.lastCardInRightMostStack + ? this.lastCardInRightMostStack + '.json' + : null); + let codeUrl = codePath ? new URL(codePath) : null; + let currentSubmode = this.operatorModeStateService.state.submode; + let finalCodeUrl = codeUrl; + if ( + codeUrl && + input.createFile && + currentSubmode === Submodes.Interact + ) { + let writeTextFileCommand = new WriteTextFileCommand( + this.commandContext, ); + let writeResult = await writeTextFileCommand.execute({ + path: codeUrl.href, + content: '', + useNonConflictingFilename: true, + }); + if (writeResult.fileUrl !== codeUrl.href) { + let newCodeUrl = new URL(writeResult.fileUrl); + finalCodeUrl = newCodeUrl; + + let commandModule = await this.loadCommandModule(); + const { SwitchSubmodeResult } = commandModule; + resultCard = new SwitchSubmodeResult({ + codePath: newCodeUrl.href, + requestedCodePath: codeUrl.href, + }); + } } + await this.operatorModeStateService.updateCodePath(finalCodeUrl); break; + } default: throw new Error(`invalid submode specified: ${input.submode}`); } @@ -69,5 +97,7 @@ export default class SwitchSubmodeCommand extends HostBaseCommand< if (this.operatorModeStateService.workspaceChooserOpened) { this.operatorModeStateService.closeWorkspaceChooser(); } + + return resultCard; } } diff --git a/packages/host/app/commands/write-text-file.ts b/packages/host/app/commands/write-text-file.ts index 6d09c1f620..8becedbcf9 100644 --- a/packages/host/app/commands/write-text-file.ts +++ b/packages/host/app/commands/write-text-file.ts @@ -3,14 +3,17 @@ import { service } from '@ember/service'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import HostBaseCommand from '../lib/host-base-command'; +import { findNonConflictingFilename } from '../utils/file-name'; -import type NetworkService from '../services/network'; +import type CardService from '../services/card-service'; +import type { SaveType } from '../services/card-service'; import type RealmService from '../services/realm'; export default class WriteTextFileCommand extends HostBaseCommand< - typeof BaseCommandModule.WriteTextFileInput + typeof BaseCommandModule.WriteTextFileInput, + typeof BaseCommandModule.FileUrlCard > { - @service declare private network: NetworkService; + @service declare private cardService: CardService; @service declare private realm: RealmService; description = `Write a text file to a realm, such as a module or a card.`; @@ -26,7 +29,7 @@ export default class WriteTextFileCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.WriteTextFileInput, - ): Promise { + ): Promise { let realm; if (input.realm) { realm = this.realm.realmOfURL(new URL(input.realm)); @@ -34,32 +37,61 @@ export default class WriteTextFileCommand extends HostBaseCommand< throw new Error(`Invalid or unknown realm provided: ${input.realm}`); } } - if (input.path.startsWith('/')) { - input.path = input.path.slice(1); + let path = input.path; + if (path.startsWith('/')) { + path = path.slice(1); } - let url = new URL(input.path, realm?.href); + let url = new URL(path, realm?.href); + let finalUrl = url; + let shouldWrite = true; if (!input.overwrite) { - let existing = await this.network.authedFetch(url); + if (input.useNonConflictingFilename) { + let existing = await this.cardService.getSource(url); + if (existing.status === 404) { + shouldWrite = true; + } else if (existing.status === 200) { + if (existing.content.trim() !== '') { + let nonConflictingUrl = await findNonConflictingFilename( + url.href, + (candidateUrl) => this.fileExists(candidateUrl), + ); + finalUrl = new URL(nonConflictingUrl); + } else { + shouldWrite = input.content.trim() !== ''; + } + } else { + throw new Error( + `Error checking if file exists at ${url}: ${existing.status}`, + ); + } + } else { + let existing = await this.cardService.getSource(url); + if (existing.status === 200 || existing.status === 406) { + throw new Error(`File already exists: ${path}`); + } - if (existing.ok || existing.status === 406) { - throw new Error(`File already exists: ${input.path}`); - } - - if (existing.status !== 404) { - throw new Error( - `Error checking if file exists at ${input.path}: ${existing.statusText} (${existing.status})`, - ); + if (existing.status !== 404) { + let errorDetails = existing.content?.trim() + ? `${existing.content} (${existing.status})` + : `${existing.status}`; + throw new Error( + `Error checking if file exists at ${path}: ${errorDetails}`, + ); + } } } - let response = await this.network.authedFetch(url, { - method: 'POST', - headers: { - Accept: 'application/vnd.card+source', - }, - body: input.content, - }); - if (!response.ok) { - throw new Error(`Failed to write file ${url}: ${response.statusText}`); + if (shouldWrite) { + let saveType: SaveType = input.overwrite ? 'editor' : 'create-file'; + await this.cardService.saveSource(finalUrl, input.content, saveType); } + + let commandModule = await this.loadCommandModule(); + const { FileUrlCard } = commandModule; + return new FileUrlCard({ fileUrl: finalUrl.href }); + } + + private async fileExists(fileUrl: string): Promise { + let getSourceResult = await this.cardService.getSource(new URL(fileUrl)); + return getSourceResult.status !== 404; } } diff --git a/packages/host/app/components/matrix/room-message-command.gts b/packages/host/app/components/matrix/room-message-command.gts index 0993e93fdf..ff415bb60d 100644 --- a/packages/host/app/components/matrix/room-message-command.gts +++ b/packages/host/app/components/matrix/room-message-command.gts @@ -135,9 +135,11 @@ export default class RoomMessageCommand extends Component { } private get shouldDisplayResultCard() { + let commandName = this.args.messageCommand.name ?? ''; return ( !!this.commandResultCard.card && - this.args.messageCommand.name !== 'checkCorrectness' + commandName !== 'checkCorrectness' && + !commandName.startsWith('switch-submode') ); } diff --git a/packages/host/app/utils/file-name.ts b/packages/host/app/utils/file-name.ts new file mode 100644 index 0000000000..f22bea7826 --- /dev/null +++ b/packages/host/app/utils/file-name.ts @@ -0,0 +1,38 @@ +export async function findNonConflictingFilename( + fileUrl: string, + fileExists: (fileUrl: string) => Promise, +): Promise { + let maxAttempts = 100; + let { baseName, extension } = parseFilename(fileUrl); + + for (let counter = 1; counter < maxAttempts; counter++) { + let candidateUrl = `${baseName}-${counter}${extension}`; + let exists = await fileExists(candidateUrl); + + if (!exists) { + return candidateUrl; + } + } + + const finalCandidateUrl = `${baseName}-${maxAttempts}${extension}`; + const finalExists = await fileExists(finalCandidateUrl); + + if (!finalExists) { + return finalCandidateUrl; + } + + throw new Error( + `Unable to find non-conflicting filename for "${fileUrl}" after ${maxAttempts} attempts.`, + ); +} + +function parseFilename(fileUrl: string): { + baseName: string; + extension: string; +} { + let extensionMatch = fileUrl.match(/\.([^.]+)$/); + let extension = extensionMatch?.[0] || ''; + let baseName = fileUrl.replace(/\.([^.]+)$/, ''); + + return { baseName, extension }; +} diff --git a/packages/host/tests/integration/commands/switch-submode-test.gts b/packages/host/tests/integration/commands/switch-submode-test.gts index 0c6f1f6f90..39a525859d 100644 --- a/packages/host/tests/integration/commands/switch-submode-test.gts +++ b/packages/host/tests/integration/commands/switch-submode-test.gts @@ -123,4 +123,111 @@ module('Integration | commands | switch-submode', function (hooks) { 'Workspace chooser should be closed after switching submode', ); }); + + test('createFile creates a blank file when it does not exist', async function (assert) { + assert.expect(5); + + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + operatorModeStateService.restore({ + stacks: [[]], + submode: 'interact', + }); + let switchSubmodeCommand = new SwitchSubmodeCommand( + commandService.commandContext, + ); + let fileUrl = `${testRealmURL}new-file.gts`; + + let result = await switchSubmodeCommand.execute({ + submode: 'code', + codePath: fileUrl, + createFile: true, + }); + + assert.strictEqual(operatorModeStateService.state?.submode, 'code'); + assert.strictEqual(operatorModeStateService.state?.codePath?.href, fileUrl); + assert.notOk(result, 'no result card when using requested path'); + + let { status, content } = await cardService.getSource(new URL(fileUrl)); + assert.strictEqual(status, 200); + assert.strictEqual(content, ''); + }); + + test('createFile picks a non-conflicting filename when the target exists', async function (assert) { + assert.expect(6); + + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + operatorModeStateService.restore({ + stacks: [[]], + submode: 'interact', + }); + let switchSubmodeCommand = new SwitchSubmodeCommand( + commandService.commandContext, + ); + let fileUrl = `${testRealmURL}existing-file.gts`; + let newFileUrl = `${testRealmURL}existing-file-1.gts`; + + await cardService.saveSource( + new URL(fileUrl), + 'existing content', + 'create-file', + ); + + let result = await switchSubmodeCommand.execute({ + submode: 'code', + codePath: fileUrl, + createFile: true, + }); + + assert.ok(result, 'returns a result card with the new filename'); + assert.strictEqual(result?.codePath, newFileUrl); + assert.strictEqual(result?.requestedCodePath, fileUrl); + assert.strictEqual( + operatorModeStateService.state?.codePath?.href, + newFileUrl, + ); + + let { status, content } = await cardService.getSource(new URL(newFileUrl)); + assert.strictEqual(status, 200); + assert.strictEqual(content, ''); + }); + + test('createFile reuses an existing blank file', async function (assert) { + assert.expect(5); + + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let operatorModeStateService = getService('operator-mode-state-service'); + operatorModeStateService.restore({ + stacks: [[]], + submode: 'interact', + }); + let switchSubmodeCommand = new SwitchSubmodeCommand( + commandService.commandContext, + ); + let fileUrl = `${testRealmURL}empty-file.gts`; + + await cardService.saveSource(new URL(fileUrl), '', 'create-file'); + + let result = await switchSubmodeCommand.execute({ + submode: 'code', + codePath: fileUrl, + createFile: true, + }); + + assert.strictEqual(operatorModeStateService.state?.codePath?.href, fileUrl); + assert.notOk(result, 'no result card when using existing blank file'); + + let { status, content } = await cardService.getSource(new URL(fileUrl)); + assert.strictEqual(status, 200); + assert.strictEqual(content, ''); + + let nonConflicting = await cardService.getSource( + new URL(`${testRealmURL}empty-file-1.gts`), + ); + assert.strictEqual(nonConflicting.status, 404); + }); }); diff --git a/packages/host/tests/integration/commands/write-text-file-test.gts b/packages/host/tests/integration/commands/write-text-file-test.gts index 9b1cde85ca..924342403f 100644 --- a/packages/host/tests/integration/commands/write-text-file-test.gts +++ b/packages/host/tests/integration/commands/write-text-file-test.gts @@ -134,6 +134,97 @@ module('Integration | commands | write-text-file', function (hooks) { assert.strictEqual(content, 'Hello with slash!'); }); + test('useNonConflictingFilename writes to a new file when content exists', async function (assert) { + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let writeTextFileCommand = new WriteTextFileCommand( + commandService.commandContext, + ); + await cardService.saveSource( + new URL('test.txt', testRealmURL), + 'Already here', + 'create-file', + ); + + let result = await writeTextFileCommand.execute({ + path: 'test.txt', + content: 'Hello!', + realm: testRealmURL, + useNonConflictingFilename: true, + }); + + assert.strictEqual(result.fileUrl, `${testRealmURL}test-1.txt`); + + let originalResponse = await fetch(new URL('test.txt', testRealmURL)); + let originalContent = await originalResponse.text(); + assert.strictEqual(originalContent, 'Already here'); + + let newResponse = await fetch(new URL('test-1.txt', testRealmURL)); + let newContent = await newResponse.text(); + assert.strictEqual(newContent, 'Hello!'); + }); + + test('useNonConflictingFilename reuses an existing blank file', async function (assert) { + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let writeTextFileCommand = new WriteTextFileCommand( + commandService.commandContext, + ); + await cardService.saveSource( + new URL('empty.txt', testRealmURL), + '', + 'create-file', + ); + + let result = await writeTextFileCommand.execute({ + path: 'empty.txt', + content: '', + realm: testRealmURL, + useNonConflictingFilename: true, + }); + + assert.strictEqual(result.fileUrl, `${testRealmURL}empty.txt`); + + let { status, content } = await cardService.getSource( + new URL('empty.txt', testRealmURL), + ); + assert.strictEqual(status, 200); + assert.strictEqual(content, ''); + + let nonConflicting = await cardService.getSource( + new URL('empty-1.txt', testRealmURL), + ); + assert.strictEqual(nonConflicting.status, 404); + }); + + test('useNonConflictingFilename writes into an existing blank file when content is provided', async function (assert) { + let commandService = getService('command-service'); + let cardService = getService('card-service'); + let writeTextFileCommand = new WriteTextFileCommand( + commandService.commandContext, + ); + await cardService.saveSource( + new URL('empty.txt', testRealmURL), + '', + 'create-file', + ); + + let result = await writeTextFileCommand.execute({ + path: 'empty.txt', + content: 'Now filled', + realm: testRealmURL, + useNonConflictingFilename: true, + }); + + assert.strictEqual(result.fileUrl, `${testRealmURL}empty.txt`); + + let { status, content } = await cardService.getSource( + new URL('empty.txt', testRealmURL), + ); + assert.strictEqual(status, 200); + assert.strictEqual(content, 'Now filled'); + }); + test('throws an error when an invalid realm is provided', async function (assert) { let commandService = getService('command-service'); let writeTextFileCommand = new WriteTextFileCommand(