Skip to content
Merged
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 packages/base/command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,19 @@ 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);
}

export class WriteTextFileInput extends CardDef {
@field content = contains(StringField);
@field realm = contains(StringField);
@field path = contains(StringField);
@field overwrite = contains(BooleanField);
@field useNonConflictingFilename = contains(BooleanField);
Comment on lines 131 to +132
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it should be an error in the command if the caller passes both overwrite and useNonConflictingFilename as true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 good idea

}

export class CreateInstanceInput extends CardDef {
Expand Down
33 changes: 5 additions & 28 deletions packages/host/app/commands/patch-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -218,34 +220,9 @@ export default class PatchCodeCommand extends HostBaseCommand<
return originalUrl;
}

return await this.findNonConflictingFilename(originalUrl);
}

private async findNonConflictingFilename(fileUrl: string): Promise<string> {
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<boolean> {
Expand Down
53 changes: 41 additions & 12 deletions packages/host/app/commands/switch-submode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,24 +46,48 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<

protected async run(
input: BaseCommandModule.SwitchSubmodeInput,
): Promise<undefined> {
): Promise<BaseCommandModule.SwitchSubmodeResult | undefined> {
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;
Comment thread
jurgenwerk marked this conversation as resolved.
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,
});
}
}
await this.operatorModeStateService.updateCodePath(finalCodeUrl);
break;
}
default:
throw new Error(`invalid submode specified: ${input.submode}`);
}
Expand All @@ -69,5 +96,7 @@ export default class SwitchSubmodeCommand extends HostBaseCommand<
if (this.operatorModeStateService.workspaceChooserOpened) {
this.operatorModeStateService.closeWorkspaceChooser();
}

return resultCard;
}
}
87 changes: 62 additions & 25 deletions packages/host/app/commands/write-text-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand All @@ -26,40 +29,74 @@ export default class WriteTextFileCommand extends HostBaseCommand<

protected async run(
input: BaseCommandModule.WriteTextFileInput,
): Promise<undefined> {
): Promise<BaseCommandModule.FileUrlCard> {
if (input.overwrite && input.useNonConflictingFilename) {
throw new Error(
'Cannot use both overwrite and useNonConflictingFilename.',
);
}
let realm;
if (input.realm) {
realm = this.realm.realmOfURL(new URL(input.realm));
if (!realm) {
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 (existing.ok || existing.status === 406) {
throw new Error(`File already exists: ${input.path}`);
}
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}`,
);
}
Comment thread
jurgenwerk marked this conversation as resolved.
} else {
let existing = await this.cardService.getSource(url);
if (existing.status === 200 || existing.status === 406) {
throw new Error(`File already exists: ${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<boolean> {
let getSourceResult = await this.cardService.getSource(new URL(fileUrl));
return getSourceResult.status !== 404;
}
}
4 changes: 3 additions & 1 deletion packages/host/app/components/matrix/room-message-command.gts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,11 @@ export default class RoomMessageCommand extends Component<Signature> {
}

private get shouldDisplayResultCard() {
let commandName = this.args.messageCommand.name ?? '';
return (
!!this.commandResultCard.card &&
this.args.messageCommand.name !== 'checkCorrectness'
commandName !== 'checkCorrectness' &&
!commandName.startsWith('switch-submode')
);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/host/app/utils/file-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export async function findNonConflictingFilename(
fileUrl: string,
fileExists: (fileUrl: string) => Promise<boolean>,
): Promise<string> {
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 };
}
Loading
Loading