diff --git a/packages/core/package.json b/packages/core/package.json index 521044a2abe..24d72f42655 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3653,6 +3653,12 @@ "key": "ctrl+shift+v", "mac": "cmd+shift+v", "when": "editorTextFocus && editorLangId == asl || editorTextFocus && editorLangId == asl-yaml" + }, + { + "command": "noop", + "key": "ctrl+z", + "mac": "cmd+z", + "when": "aws.stepFunctions.isWorkflowStudioFocused" } ], "grammars": [ diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 17615b2bbb9..9f2bd12235a 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -24,6 +24,7 @@ "AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", "AWS.stepFunctions.workflowStudio.actions.progressMessage": "Opening asl file in Workflow Studio", "AWS.stepFunctions.workflowStudio.actions.saveSuccessMessage": "{0} has been saved", + "AWS.stepFunctions.workflowStudio.actions.invalidJson": "The Workflow Studio editor was not opened because the JSON in the file is invalid. To access Workflow Studio, please fix the JSON and manually reopen the integration.", "AWS.configuration.description.awssam.debug.api": "API Gateway configuration", "AWS.configuration.description.awssam.debug.api.clientCertId": "The API Gateway client certificate ID", "AWS.configuration.description.awssam.debug.api.headers": "Additional HTTP headers", diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index acce40a440b..ed283929777 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -17,6 +17,11 @@ "allowedValues": ["Create", "Update"], "description": "SSM Publish Document operation type" }, + { + "name": "isInvalidJson", + "type": "boolean", + "description": "Indicates whether the message contains an invalid JSON definition." + }, { "name": "starterTemplate", "type": "string", @@ -527,8 +532,8 @@ ] }, { - "name": "stepfunctions_saveFile", - "description": "Called after the user saves local ASL file (inlcuding autosave) from VSCode editor or Workflow Studio", + "name": "stepfunctions_syncFile", + "description": "Triggered when Workflow Studio auto-syncs to the local file or when unsaved local changes are saved from Workflow Studio or VSCode.", "metadata": [ { "type": "id", @@ -541,6 +546,9 @@ { "type": "source", "required": true + }, + { + "type": "isInvalidJson" } ] }, diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 5068bc59e13..9baa6907403 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -23,6 +23,7 @@ type contextKey = | 'aws.explorer.showAuthView' | 'aws.toolkit.amazonq.dismissed' | 'aws.toolkit.amazonqInstall.dismissed' + | 'aws.stepFunctions.isWorkflowStudioFocused' // Deprecated/legacy names. New keys should start with "aws.". | 'codewhisperer.activeLine' | 'gumby.isPlanAvailable' diff --git a/packages/core/src/stepFunctions/utils.ts b/packages/core/src/stepFunctions/utils.ts index c94dbb4eb1f..80e188e40e9 100644 --- a/packages/core/src/stepFunctions/utils.ts +++ b/packages/core/src/stepFunctions/utils.ts @@ -223,6 +223,30 @@ export async function isDocumentValid(text: string, textDocument?: vscode.TextDo return isValid } +/** + * Checks if the JSON content in a text document is invalid. + * Returns `true` for invalid JSON; `false` for valid JSON, empty content, or non-JSON files. + * + * @param textDocument - The text document to check. + * @returns `true` if invalid; `false` otherwise. + */ +export const isInvalidJsonFile = (textDocument: vscode.TextDocument): boolean => { + const documentContent = textDocument.getText().trim() + // An empty file or whitespace-only text is considered valid JSON for our use case + return textDocument.fileName.toLowerCase().endsWith('.json') && documentContent + ? isInvalidJson(documentContent) + : false +} + +const isInvalidJson = (content: string): boolean => { + try { + JSON.parse(content) + return false + } catch { + return true + } +} + const descriptor = { maxItemsComputed: (v: unknown) => Math.trunc(Math.max(0, Number(v))), ['format.enable']: Boolean, diff --git a/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts b/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts index 1daf7470783..5826a8d2e27 100644 --- a/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts +++ b/packages/core/src/stepFunctions/workflowStudio/handleMessage.ts @@ -12,6 +12,7 @@ import { InitResponseMessage, FileChangedMessage, FileChangeEventTrigger, + SyncFileRequestMessage, } from './types' import { submitFeedback } from '../../feedback/vue/submitFeedback' import { placeholder } from '../../shared/vscode/commands2' @@ -38,8 +39,8 @@ export async function handleMessage(message: Message, context: WebviewContext) { case Command.SAVE_FILE: void saveFileMessageHandler(message as SaveFileRequestMessage, context) break - case Command.AUTO_SAVE_FILE: - void autoSaveFileMessageHandler(message as SaveFileRequestMessage, context) + case Command.AUTO_SYNC_FILE: + void autoSyncFileMessageHandler(message as SyncFileRequestMessage, context) break case Command.CLOSE_WFS: void closeCustomEditorMessageHandler(context) @@ -131,17 +132,16 @@ export function closeCustomEditorMessageHandler(context: WebviewContext) { * @param context The webview context containing the necessary information for saving the file. */ async function saveFileMessageHandler(request: SaveFileRequestMessage, context: WebviewContext) { - await telemetry.stepfunctions_saveFile.run(async (span) => { + await telemetry.stepfunctions_syncFile.run(async (span) => { span.record({ id: context.fileId, saveType: 'MANUAL_SAVE', source: 'WORKFLOW_STUDIO', + isInvalidJson: request.isInvalidJson, }) try { - await saveWorkspace(context, request.fileContents) await context.textDocument.save() - void vscode.window.showInformationMessage( localize( 'AWS.stepFunctions.workflowStudio.actions.saveSuccessMessage', @@ -156,34 +156,30 @@ async function saveFileMessageHandler(request: SaveFileRequestMessage, context: } /** - * Handler for auto saving a file from the webview which updates the workspace but does not save the file. - * Triggered on every code change from WFS. + * Handler for auto syncing a file from the webview which updates the workspace but does not save the file. + * Triggered on every code change from WFS, including invalid JSON. * @param request The request message containing the file contents. * @param context The webview context containing the necessary information for saving the file. */ -async function autoSaveFileMessageHandler(request: SaveFileRequestMessage, context: WebviewContext) { - await telemetry.stepfunctions_saveFile.run(async (span) => { +async function autoSyncFileMessageHandler(request: SyncFileRequestMessage, context: WebviewContext) { + await telemetry.stepfunctions_syncFile.run(async (span) => { span.record({ id: context.fileId, - saveType: 'AUTO_SAVE', + saveType: 'AUTO_SYNC', source: 'WORKFLOW_STUDIO', + isInvalidJson: request.isInvalidJson, }) try { - await saveWorkspace(context, request.fileContents) + const edit = new vscode.WorkspaceEdit() + edit.replace( + context.textDocument.uri, + new vscode.Range(0, 0, context.textDocument.lineCount, 0), + request.fileContents + ) + await vscode.workspace.applyEdit(edit) } catch (err) { throw ToolkitError.chain(err, 'Could not autosave asl file.', { code: 'AutoSaveFailed' }) } }) } - -/** - * Saves to the workspace with the provided file contents. - * @param context The webview context containing the necessary information for saving the file. - * @param fileContents The file contents to save. - */ -async function saveWorkspace(context: WebviewContext, fileContents: string) { - const edit = new vscode.WorkspaceEdit() - edit.replace(context.textDocument.uri, new vscode.Range(0, 0, context.textDocument.lineCount, 0), fileContents) - await vscode.workspace.applyEdit(edit) -} diff --git a/packages/core/src/stepFunctions/workflowStudio/types.ts b/packages/core/src/stepFunctions/workflowStudio/types.ts index 7d03a027fd3..668b4ace8ab 100644 --- a/packages/core/src/stepFunctions/workflowStudio/types.ts +++ b/packages/core/src/stepFunctions/workflowStudio/types.ts @@ -34,7 +34,7 @@ export enum MessageType { export enum Command { INIT = 'INIT', SAVE_FILE = 'SAVE_FILE', - AUTO_SAVE_FILE = 'AUTO_SAVE_FILE', + AUTO_SYNC_FILE = 'AUTO_SYNC_FILE', FILE_CHANGED = 'FILE_CHANGED', LOAD_STAGE = 'LOAD_STAGE', OPEN_FEEDBACK = 'OPEN_FEEDBACK', @@ -45,12 +45,6 @@ export type FileWatchInfo = { fileContents: string } -export enum SaveCompleteSubType { - SAVED = 'SAVED', - SAVE_SKIPPED_SAME_CONTENT = 'SAVE_SKIPPED_SAME_CONTENT', - SAVE_FAILED = 'SAVE_FAILED', -} - export interface Message { command: Command messageType: MessageType @@ -71,5 +65,9 @@ export interface InitResponseMessage extends Omit } export interface SaveFileRequestMessage extends Message { + isInvalidJson: boolean +} + +export interface SyncFileRequestMessage extends SaveFileRequestMessage { fileContents: string } diff --git a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts index 8637816beae..a33e9efec75 100644 --- a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts +++ b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditor.ts @@ -11,6 +11,8 @@ import { broadcastFileChange } from './handleMessage' import { FileWatchInfo, WebviewContext } from './types' import { CancellationError } from '../../shared/utilities/timeoutUtils' import { handleMessage } from './handleMessage' +import { isInvalidJsonFile } from '../utils' +import { setContext } from '../../shared/vscode/setContext' /** * The main class for Workflow Studio Editor. This class handles the creation and management @@ -139,11 +141,12 @@ export class WorkflowStudioEditor { // The text document acts as our model, thus we send and event to the webview on file save to trigger update contextObject.disposables.push( vscode.workspace.onDidSaveTextDocument(async () => { - await telemetry.stepfunctions_saveFile.run(async (span) => { + await telemetry.stepfunctions_syncFile.run(async (span) => { span.record({ id: contextObject.fileId, saveType: 'MANUAL_SAVE', source: 'VSCODE', + isInvalidJson: isInvalidJsonFile(contextObject.textDocument), }) await broadcastFileChange(contextObject, 'MANUAL_SAVE') }) @@ -152,18 +155,31 @@ export class WorkflowStudioEditor { // Handle messages from the webview this.disposables.push( - this.webviewPanel.webview.onDidReceiveMessage((message) => - handleMessage(message, contextObject) - ) + this.webviewPanel.webview.onDidReceiveMessage(async (message) => { + await handleMessage(message, contextObject) + }) + ) + + // Track webview focus to suppress VSCode's default undo, as WFS has its own + await setContext('aws.stepFunctions.isWorkflowStudioFocused', true) + this.disposables.push( + this.webviewPanel.onDidChangeViewState(async (event) => { + if (event.webviewPanel.active) { + await setContext('aws.stepFunctions.isWorkflowStudioFocused', true) + } else { + await setContext('aws.stepFunctions.isWorkflowStudioFocused', false) + } + }) ) // When the panel is closed, dispose of any disposables/remove subscriptions this.disposables.push( - this.webviewPanel.onDidDispose(() => { + this.webviewPanel.onDidDispose(async () => { if (this.isPanelDisposed) { return } + await setContext('aws.stepFunctions.isWorkflowStudioFocused', false) this.isPanelDisposed = true resolve() this.onVisualizationDisposeEmitter.fire() diff --git a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts index 17db7424565..d431f663189 100644 --- a/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts +++ b/packages/core/src/stepFunctions/workflowStudio/workflowStudioEditorProvider.ts @@ -13,11 +13,12 @@ import globals from '../../shared/extensionGlobals' import { getRandomString, getStringHash } from '../../shared/utilities/textUtilities' import { ToolkitError } from '../../shared/errors' import { WorkflowStudioEditor } from './workflowStudioEditor' +import { i18n } from '../../shared/i18n-helper' +import { isInvalidJsonFile } from '../utils' -// TODO: switch to production mode: change isLocalDev to false and add CDN link -const isLocalDev = true +const isLocalDev = false const localhost = 'http://127.0.0.1:3002' -const cdn = 'TBD' +const cdn = 'https://d5t62uwepi9lu.cloudfront.net' let clientId = '' /** @@ -30,7 +31,7 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv public static readonly viewType = 'workflowStudio.asl' /** - * Registers a new custom editor provider for `.tc.json` files. + * Registers a new custom editor provider for asl files. * @remarks This should only be called once per extension. * @param context The extension context */ @@ -115,6 +116,21 @@ export class WorkflowStudioEditorProvider implements vscode.CustomTextEditorProv _token: vscode.CancellationToken ): Promise { await telemetry.stepfunctions_openWorkflowStudio.run(async () => { + // For invalid JSON, open default editor and show warning message + if (isInvalidJsonFile(document)) { + await vscode.commands.executeCommand('vscode.openWith', document.uri, 'default') + webviewPanel.dispose() + void vscode.window.showWarningMessage(i18n('AWS.stepFunctions.workflowStudio.actions.invalidJson')) + + throw ToolkitError.chain( + 'Invalid JSON file', + 'The Workflow Studio editor was not opened because the JSON in the file is invalid', + { + code: 'InvalidJSONContent', + } + ) + } + if (!this.webviewHtml) { await this.fetchWebviewHtml() }