Skip to content
Draft
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
32 changes: 31 additions & 1 deletion packages/host/app/tools/switch-submode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ import type OperatorModeStateService from '../services/operator-mode-state-servi
import type StoreService from '../services/store';
import type * as BaseToolModule from '@cardstack/base/command';

// Models sometimes hand this tool the SEARCH/REPLACE file header instead of a
// path — "https://realm/card.gts (new)" — or prose. Strip the new-file marker,
// which is harmless, and refuse anything that is not one file URL or realm
// resource identifier, so the tab is never pointed at a path that cannot exist.
export function cleanCodePath(raw: string | undefined): string | undefined {
if (raw == null) {
return undefined;
}
let path = raw.trim().replace(/\s*\(new\)\s*$/i, '');
if (!path) {
return undefined;
}
if (/\s/.test(path) || !/^(https?:\/\/|@[a-z0-9-]+\/)/i.test(path)) {
throw new Error(
`codePath must be a single file URL or realm resource identifier, got "${raw}"`,
);
}
return path;
}

export default class SwitchSubmodeTool extends HostBaseTool<
typeof BaseToolModule.SwitchSubmodeInput,
typeof BaseToolModule.SwitchSubmodeResult | undefined
Expand Down Expand Up @@ -62,14 +82,24 @@ export default class SwitchSubmodeTool extends HostBaseTool<
case Submodes.Code: {
let lastId = this.lastCardInRightMostStack;
let codePath =
input.codePath ??
cleanCodePath(input.codePath) ??
(lastId
? this.lastStackItem?.type === 'file'
? lastId
: lastId + '.json'
: null);
let codeRRI = codePath ? rri(codePath) : null;
let currentSubmode = this.operatorModeStateService.state.submode;
// Already in code mode on that very file: there is nothing to switch.
// A model that re-asserts its position gets an applied result at
// once instead of the file being re-resolved and re-opened.
if (
currentSubmode === Submodes.Code &&
codeRRI &&
this.operatorModeStateService.codePathString === codeRRI
) {
break;
}
let finalCodePath = codeRRI;
if (
codeRRI &&
Expand Down
93 changes: 92 additions & 1 deletion packages/host/tests/integration/tools/switch-submode-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { localId } from '@cardstack/runtime-common';

import RealmService from '@cardstack/host/services/realm';
import type StoreService from '@cardstack/host/services/store';
import SwitchSubmodeTool from '@cardstack/host/tools/switch-submode';
import SwitchSubmodeTool, {
cleanCodePath,
} from '@cardstack/host/tools/switch-submode';

import {
setupIntegrationTestRealm,
Expand Down Expand Up @@ -192,6 +194,95 @@ module('Integration | tools | switch-submode', function (hooks) {
assert.strictEqual(content, '');
});

test('a codePath carrying the SEARCH/REPLACE new-file marker is cleaned before use', async function (assert) {
let toolService = getService('tool-service');
let cardService = getService('card-service');
let operatorModeStateService = getService('operator-mode-state-service');
operatorModeStateService.restore({
stacks: [[]],
submode: 'interact',
});
let switchSubmodeCommand = new SwitchSubmodeTool(toolService.toolContext);
let fileUrl = `${testRealmURL}marked-file.gts`;

await switchSubmodeCommand.execute({
submode: 'code',
codePath: `${fileUrl} (new)`,
createFile: true,
});

assert.strictEqual(operatorModeStateService.state?.codePath?.href, fileUrl);
let { status, content } = await cardService.getSource(new URL(fileUrl));
assert.strictEqual(status, 200);
assert.strictEqual(content, '');
});

test('a codePath that is not a file URL is rejected', async function (assert) {
let toolService = getService('tool-service');
let operatorModeStateService = getService('operator-mode-state-service');
operatorModeStateService.restore({
stacks: [[]],
submode: 'interact',
});
let switchSubmodeCommand = new SwitchSubmodeTool(toolService.toolContext);

await assert.rejects(
switchSubmodeCommand.execute({
submode: 'code',
codePath: 'Wedding card def — SEARCH/REPLACE (new)',
}),
/codePath must be a single file URL/,
);
assert.strictEqual(
operatorModeStateService.state?.submode,
'interact',
'the tab stays where it was',
);

assert.strictEqual(cleanCodePath(undefined), undefined);
assert.strictEqual(cleanCodePath(' '), undefined);
assert.strictEqual(
cleanCodePath('@cardstack/base/card-api.gts (new)'),
'@cardstack/base/card-api.gts',
);
});

test('switching to the file already open in code mode is a no-op', async function (assert) {
let toolService = getService('tool-service');
let cardService = getService('card-service');
let operatorModeStateService = getService('operator-mode-state-service');
operatorModeStateService.restore({
stacks: [[]],
submode: 'interact',
});
let switchSubmodeCommand = new SwitchSubmodeTool(toolService.toolContext);
let fileUrl = `${testRealmURL}already-open.gts`;

await switchSubmodeCommand.execute({
submode: 'code',
codePath: fileUrl,
createFile: true,
});
await cardService.saveSource(new URL(fileUrl), 'export {};', 'editor');
assert.strictEqual(operatorModeStateService.state?.codePath?.href, fileUrl);

let result = await switchSubmodeCommand.execute({
submode: 'code',
codePath: fileUrl,
createFile: true,
});

assert.notOk(result, 'no result card for a no-op');
assert.strictEqual(operatorModeStateService.state?.submode, 'code');
assert.strictEqual(operatorModeStateService.state?.codePath?.href, fileUrl);
let { content } = await cardService.getSource(new URL(fileUrl));
assert.strictEqual(content, 'export {};', 'the file was not touched');
let sibling = await cardService.getSource(
new URL(`${testRealmURL}already-open-1.gts`),
);
assert.strictEqual(sibling.status, 404, 'no sibling file was created');
});

test('createFile reuses an existing blank file', async function (assert) {
assert.expect(5);

Expand Down
Loading