mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-10-09 03:18:33 +00:00

The Save As operation is halted and the problem clearly communicated to the user when there is a collision between the target primary source file name and existing secondary source files of the sketch. Closes #827 Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { Dialog } from '@theia/core/lib/browser/dialogs';
|
|
import { NavigatableWidget } from '@theia/core/lib/browser/navigatable';
|
|
import { Saveable } from '@theia/core/lib/browser/saveable';
|
|
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
|
|
import { WindowService } from '@theia/core/lib/browser/window/window-service';
|
|
import { ApplicationError } from '@theia/core/lib/common/application-error';
|
|
import { nls } from '@theia/core/lib/common/nls';
|
|
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
|
|
import { WorkspaceInput } from '@theia/workspace/lib/browser/workspace-service';
|
|
import { SketchesError } from '../../common/protocol';
|
|
import { StartupTasks } from '../../electron-common/startup-task';
|
|
import { ArduinoMenus } from '../menu/arduino-menus';
|
|
import { CurrentSketch } from '../sketches-service-client-impl';
|
|
import { CloudSketchContribution } from './cloud-contribution';
|
|
import {
|
|
Command,
|
|
CommandRegistry,
|
|
KeybindingRegistry,
|
|
MenuModelRegistry,
|
|
Sketch,
|
|
URI,
|
|
} from './contribution';
|
|
import { DeleteSketch } from './delete-sketch';
|
|
import {
|
|
RenameCloudSketch,
|
|
RenameCloudSketchParams,
|
|
} from './rename-cloud-sketch';
|
|
import { assertConnectedToBackend } from './save-sketch';
|
|
|
|
@injectable()
|
|
export class SaveAsSketch extends CloudSketchContribution {
|
|
@inject(ApplicationShell)
|
|
private readonly shell: ApplicationShell;
|
|
@inject(WindowService)
|
|
private readonly windowService: WindowService;
|
|
|
|
override registerCommands(registry: CommandRegistry): void {
|
|
registry.registerCommand(SaveAsSketch.Commands.SAVE_AS_SKETCH, {
|
|
execute: async (args) => {
|
|
try {
|
|
return await this.saveAs(args);
|
|
} catch (err) {
|
|
let message = String(err);
|
|
if (ApplicationError.is(err)) {
|
|
if (SketchesError.SketchAlreadyContainsThisFile.is(err)) {
|
|
message = nls.localize(
|
|
'arduino/sketch/sketchAlreadyContainsThisFileMessage',
|
|
'Failed to save sketch "{0}" as "{1}". {2}',
|
|
err.data.sourceSketchName,
|
|
err.data.targetSketchName,
|
|
err.message
|
|
);
|
|
} else {
|
|
message = err.message;
|
|
}
|
|
} else if (err instanceof Error) {
|
|
message = err.message;
|
|
}
|
|
this.messageService.error(message);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
override registerMenus(registry: MenuModelRegistry): void {
|
|
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
|
|
commandId: SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
|
|
label: nls.localizeByDefault('Save As...'),
|
|
order: '7',
|
|
});
|
|
}
|
|
|
|
override registerKeybindings(registry: KeybindingRegistry): void {
|
|
registry.registerKeybinding({
|
|
command: SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
|
|
keybinding: 'CtrlCmd+Shift+S',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolves `true` if the sketch was successfully saved as something.
|
|
*/
|
|
private async saveAs(
|
|
params = SaveAsSketch.Options.DEFAULT
|
|
): Promise<boolean> {
|
|
const {
|
|
execOnlyIfTemp,
|
|
openAfterMove,
|
|
wipeOriginal,
|
|
markAsRecentlyOpened,
|
|
} = params;
|
|
assertConnectedToBackend({
|
|
connectionStatusService: this.connectionStatusService,
|
|
messageService: this.messageService,
|
|
});
|
|
const sketch = await this.sketchServiceClient.currentSketch();
|
|
if (!CurrentSketch.isValid(sketch)) {
|
|
return false;
|
|
}
|
|
|
|
let destinationUri: string | undefined;
|
|
const cloudUri = this.createFeatures.cloudUri(sketch);
|
|
if (cloudUri) {
|
|
destinationUri = await this.createCloudCopy({ cloudUri, sketch });
|
|
} else {
|
|
destinationUri = await this.createLocalCopy(sketch, execOnlyIfTemp);
|
|
}
|
|
if (!destinationUri) {
|
|
return false;
|
|
}
|
|
|
|
const copiedSketch = await this.sketchesService.copy(sketch, {
|
|
destinationUri,
|
|
});
|
|
const newWorkspaceUri = copiedSketch.uri;
|
|
|
|
await saveOntoCopiedSketch(
|
|
sketch,
|
|
newWorkspaceUri,
|
|
this.shell,
|
|
this.editorManager
|
|
);
|
|
if (markAsRecentlyOpened) {
|
|
this.sketchesService.markAsRecentlyOpened(newWorkspaceUri);
|
|
}
|
|
const options: WorkspaceInput & StartupTasks = {
|
|
preserveWindow: true,
|
|
tasks: [],
|
|
};
|
|
if (openAfterMove) {
|
|
this.windowService.setSafeToShutDown();
|
|
if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) {
|
|
options.tasks.push({
|
|
command: DeleteSketch.Commands.DELETE_SKETCH.id,
|
|
args: [{ toDelete: sketch.uri }],
|
|
});
|
|
}
|
|
this.workspaceService.open(new URI(newWorkspaceUri), options);
|
|
}
|
|
return !!newWorkspaceUri;
|
|
}
|
|
|
|
private async createCloudCopy(
|
|
params: RenameCloudSketchParams
|
|
): Promise<string | undefined> {
|
|
return this.commandService.executeCommand<string>(
|
|
RenameCloudSketch.Commands.RENAME_CLOUD_SKETCH.id,
|
|
params
|
|
);
|
|
}
|
|
|
|
private async createLocalCopy(
|
|
sketch: Sketch,
|
|
execOnlyIfTemp?: boolean
|
|
): Promise<string | undefined> {
|
|
const isTemp = await this.sketchesService.isTemp(sketch);
|
|
if (!isTemp && !!execOnlyIfTemp) {
|
|
return undefined;
|
|
}
|
|
|
|
const sketchUri = new URI(sketch.uri);
|
|
const sketchbookDirUri = await this.defaultUri();
|
|
// If the sketch is temp, IDE2 proposes the default sketchbook folder URI.
|
|
// If the sketch is not temp, but not contained in the default sketchbook folder, IDE2 proposes the default location.
|
|
// Otherwise, it proposes the parent folder of the current sketch.
|
|
const containerDirUri = isTemp
|
|
? sketchbookDirUri
|
|
: !sketchbookDirUri.isEqualOrParent(sketchUri)
|
|
? sketchbookDirUri
|
|
: sketchUri.parent;
|
|
const exists = await this.fileService.exists(
|
|
containerDirUri.resolve(sketch.name)
|
|
);
|
|
|
|
// If target does not exist, propose a `directories.user`/${sketch.name} path
|
|
// If target exists, propose `directories.user`/${sketch.name}_copy_${yyyymmddHHMMss}
|
|
// IDE2 must never prompt an invalid sketch folder name (https://github.com/arduino/arduino-ide/pull/1833#issuecomment-1412569252)
|
|
const defaultUri = containerDirUri.resolve(
|
|
Sketch.toValidSketchFolderName(sketch.name, exists)
|
|
);
|
|
const defaultPath = await this.fileService.fsPath(defaultUri);
|
|
return await this.promptLocalSketchFolderDestination(sketch, defaultPath);
|
|
}
|
|
|
|
/**
|
|
* Prompts for the new sketch folder name until a valid one is give,
|
|
* then resolves with the destination sketch folder URI string,
|
|
* or `undefined` if the operation was canceled.
|
|
*/
|
|
private async promptLocalSketchFolderDestination(
|
|
sketch: Sketch,
|
|
defaultPath: string
|
|
): Promise<string | undefined> {
|
|
let sketchFolderDestinationUri: string | undefined;
|
|
while (!sketchFolderDestinationUri) {
|
|
const { filePath } = await this.dialogService.showSaveDialog({
|
|
title: nls.localize(
|
|
'arduino/sketch/saveFolderAs',
|
|
'Save sketch folder as...'
|
|
),
|
|
defaultPath,
|
|
});
|
|
if (!filePath) {
|
|
return undefined;
|
|
}
|
|
const destinationUri = await this.fileSystemExt.getUri(filePath);
|
|
// The new location of the sketch cannot be inside the location of current sketch.
|
|
// https://github.com/arduino/arduino-ide/issues/1882
|
|
let dialogContent: InvalidSketchFolderDialogContent | undefined;
|
|
if (new URI(sketch.uri).isEqualOrParent(new URI(destinationUri))) {
|
|
dialogContent = {
|
|
message: nls.localize(
|
|
'arduino/sketch/invalidSketchFolderLocationMessage',
|
|
"Invalid sketch folder location: '{0}'",
|
|
filePath
|
|
),
|
|
details: nls.localize(
|
|
'arduino/sketch/invalidSketchFolderLocationDetails',
|
|
'You cannot save a sketch into a folder inside itself.'
|
|
),
|
|
question: nls.localize(
|
|
'arduino/sketch/editInvalidSketchFolderLocationQuestion',
|
|
'Do you want to try saving the sketch to a different location?'
|
|
),
|
|
};
|
|
}
|
|
if (!dialogContent) {
|
|
const sketchFolderName = new URI(destinationUri).path.base;
|
|
const errorMessage = Sketch.validateSketchFolderName(sketchFolderName);
|
|
if (errorMessage) {
|
|
dialogContent = {
|
|
message: nls.localize(
|
|
'arduino/sketch/invalidSketchFolderNameMessage',
|
|
"Invalid sketch folder name: '{0}'",
|
|
sketchFolderName
|
|
),
|
|
details: errorMessage,
|
|
question: nls.localize(
|
|
'arduino/sketch/editInvalidSketchFolderQuestion',
|
|
'Do you want to try saving the sketch with a different name?'
|
|
),
|
|
};
|
|
}
|
|
}
|
|
if (dialogContent) {
|
|
const message = `
|
|
${dialogContent.message}
|
|
|
|
${dialogContent.details}
|
|
|
|
${dialogContent.question}`.trim();
|
|
defaultPath = filePath;
|
|
const { response } = await this.dialogService.showMessageBox({
|
|
message,
|
|
buttons: [Dialog.CANCEL, Dialog.YES],
|
|
});
|
|
// cancel
|
|
if (response === 0) {
|
|
return undefined;
|
|
}
|
|
} else {
|
|
sketchFolderDestinationUri = destinationUri;
|
|
}
|
|
}
|
|
return sketchFolderDestinationUri;
|
|
}
|
|
}
|
|
|
|
interface InvalidSketchFolderDialogContent {
|
|
readonly message: string;
|
|
readonly details: string;
|
|
readonly question: string;
|
|
}
|
|
|
|
export namespace SaveAsSketch {
|
|
export namespace Commands {
|
|
export const SAVE_AS_SKETCH: Command = {
|
|
id: 'arduino-save-as-sketch',
|
|
};
|
|
}
|
|
export interface Options {
|
|
readonly execOnlyIfTemp?: boolean;
|
|
readonly openAfterMove?: boolean;
|
|
/**
|
|
* Ignored if `openAfterMove` is `false`.
|
|
*/
|
|
readonly wipeOriginal?: boolean;
|
|
readonly markAsRecentlyOpened?: boolean;
|
|
}
|
|
export namespace Options {
|
|
export const DEFAULT: Options = {
|
|
execOnlyIfTemp: false,
|
|
openAfterMove: true,
|
|
wipeOriginal: false,
|
|
markAsRecentlyOpened: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function saveOntoCopiedSketch(
|
|
sketch: Sketch,
|
|
newSketchFolderUri: string,
|
|
shell: ApplicationShell,
|
|
editorManager: EditorManager
|
|
): Promise<void> {
|
|
const widgets = shell.widgets;
|
|
const snapshots = new Map<string, Saveable.Snapshot>();
|
|
for (const widget of widgets) {
|
|
const saveable = Saveable.getDirty(widget);
|
|
const uri = NavigatableWidget.getUri(widget);
|
|
if (!uri) {
|
|
continue;
|
|
}
|
|
const uriString = uri.toString();
|
|
let relativePath: string;
|
|
if (uriString.includes(sketch.uri) && saveable && saveable.createSnapshot) {
|
|
// The main file will change its name during the copy process
|
|
// We need to store the new name in the map
|
|
if (sketch.mainFileUri === uriString) {
|
|
const lastPart = new URI(newSketchFolderUri).path.base + uri.path.ext;
|
|
relativePath = '/' + lastPart;
|
|
} else {
|
|
relativePath = uri.toString().substring(sketch.uri.length);
|
|
}
|
|
snapshots.set(relativePath, saveable.createSnapshot());
|
|
}
|
|
}
|
|
await Promise.all(
|
|
Array.from(snapshots.entries()).map(async ([path, snapshot]) => {
|
|
const widgetUri = new URI(newSketchFolderUri + path);
|
|
try {
|
|
const widget = await editorManager.getOrCreateByUri(widgetUri);
|
|
const saveable = Saveable.get(widget);
|
|
if (saveable && saveable.applySnapshot) {
|
|
saveable.applySnapshot(snapshot);
|
|
await saveable.save();
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
})
|
|
);
|
|
}
|