Skip to content

fix: sketch Save As #2292

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jan 15, 2024
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
1 change: 0 additions & 1 deletion arduino-ide-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@
"auth0-js": "^9.14.0",
"btoa": "^1.2.1",
"classnames": "^2.3.1",
"cpy": "^10.0.0",
"cross-fetch": "^3.1.5",
"dateformat": "^3.0.3",
"deepmerge": "^4.2.2",
Expand Down
33 changes: 29 additions & 4 deletions arduino-ide-extension/src/browser/contributions/save-as-sketch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ 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';
Expand Down Expand Up @@ -35,7 +37,29 @@ export class SaveAsSketch extends CloudSketchContribution {

override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(SaveAsSketch.Commands.SAVE_AS_SKETCH, {
execute: (args) => this.saveAs(args),
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);
}
},
});
}

Expand All @@ -58,13 +82,14 @@ export class SaveAsSketch extends CloudSketchContribution {
* Resolves `true` if the sketch was successfully saved as something.
*/
private async saveAs(
{
params = SaveAsSketch.Options.DEFAULT
): Promise<boolean> {
const {
execOnlyIfTemp,
openAfterMove,
wipeOriginal,
markAsRecentlyOpened,
}: SaveAsSketch.Options = SaveAsSketch.Options.DEFAULT
): Promise<boolean> {
} = params;
assertConnectedToBackend({
connectionStatusService: this.connectionStatusService,
messageService: this.messageService,
Expand Down
26 changes: 26 additions & 0 deletions arduino-ide-extension/src/common/protocol/sketches-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export namespace SketchesError {
export const Codes = {
NotFound: 5001,
InvalidName: 5002,
InvalidFolderName: 5003,
SketchAlreadyContainsThisFile: 5004,
};
export const NotFound = ApplicationError.declare(
Codes.NotFound,
Expand All @@ -27,6 +29,30 @@ export namespace SketchesError {
};
}
);
export const InvalidFolderName = ApplicationError.declare(
Codes.InvalidFolderName,
(message: string, invalidFolderName: string) => {
return {
message,
data: { invalidFolderName },
};
}
);
// https://github.com/arduino/arduino-ide/issues/827
export const SketchAlreadyContainsThisFile = ApplicationError.declare(
Codes.SketchAlreadyContainsThisFile,
(
message: string,
sourceSketchName: string,
targetSketchName: string,
existingSketchFilename: string
) => {
return {
message,
data: { sourceSketchName, targetSketchName, existingSketchFilename },
};
}
);
}

export const SketchesServicePath = '/services/sketches-service';
Expand Down
167 changes: 126 additions & 41 deletions arduino-ide-extension/src/node/sketches-service-impl.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,55 @@
import { injectable, inject, named } from '@theia/core/shared/inversify';
import { promises as fs, realpath, lstat, Stats, constants } from 'node:fs';
import os from 'node:os';
import temp from 'temp';
import path from 'node:path';
import glob from 'glob';
import crypto from 'node:crypto';
import PQueue from 'p-queue';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ILogger } from '@theia/core/lib/common/logger';
import { nls } from '@theia/core/lib/common/nls';
import { isWindows } from '@theia/core/lib/common/os';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
import type { Mutable } from '@theia/core/lib/common/types';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { FileUri } from '@theia/core/lib/node/file-uri';
import { ConfigServiceImpl } from './config-service-impl';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import glob from 'glob';
import crypto from 'node:crypto';
import {
CopyOptions,
Stats,
constants,
promises as fs,
lstat,
realpath,
} from 'node:fs';
import os from 'node:os';
import path, { join } from 'node:path';
import PQueue from 'p-queue';
import temp from 'temp';
import { NotificationServiceServer } from '../common/protocol';
import {
SketchesService,
Sketch,
SketchRef,
SketchContainer,
SketchRef,
SketchesError,
SketchesService,
} from '../common/protocol/sketches-service';
import { NotificationServiceServer } from '../common/protocol';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { CoreClientAware } from './core-client-provider';
import {
firstToLowerCase,
firstToUpperCase,
startsWithUpperCase,
} from '../common/utils';
import {
ArchiveSketchRequest,
LoadSketchRequest,
} from './cli-protocol/cc/arduino/cli/commands/v1/commands_pb';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
import { ServiceError } from './service-error';
import { ConfigServiceImpl } from './config-service-impl';
import { CoreClientAware } from './core-client-provider';
import {
IsTempSketch,
maybeNormalizeDrive,
TempSketchPrefix,
Win32DriveRegex,
maybeNormalizeDrive,
} from './is-temp-sketch';
import { join } from 'node:path';
import { ErrnoException } from './utils/errors';
import { isWindows } from '@theia/core/lib/common/os';
import {
firstToLowerCase,
firstToUpperCase,
startsWithUpperCase,
} from '../common/utils';
import { ServiceError } from './service-error';
import { SettingsReader } from './settings-reader';
import { ErrnoException } from './utils/errors';

const RecentSketches = 'recent-sketches.json';
const DefaultIno = `void setup() {
Expand Down Expand Up @@ -510,26 +517,104 @@ export class SketchesServiceImpl
}
const sourceFolderBasename = path.basename(source);
const destinationFolderBasename = path.basename(destination);
let filter;

const errorMessage = Sketch.validateSketchFolderName(
destinationFolderBasename
);
if (errorMessage) {
const message = `${nls.localize(
'arduino/sketch/invalidSketchFolderNameMessage',
"Invalid sketch folder name: '{0}'",
destinationFolderBasename
)} ${errorMessage}`;
throw SketchesError.InvalidFolderName(message, destinationFolderBasename);
}

// verify a possible name collision with existing ino files
if (sourceFolderBasename !== destinationFolderBasename) {
try {
const otherInoBasename = `${destinationFolderBasename}.ino`;
const otherInoPath = join(source, otherInoBasename);
const stat = await fs.stat(otherInoPath);
if (stat.isFile()) {
const message = nls.localize(
'arduino/sketch/sketchAlreadyContainsThisFileError',
"The sketch already contains a file named '{0}'",
otherInoBasename
);
// if can read the file, it will be a collision
throw SketchesError.SketchAlreadyContainsThisFile(
message,
sourceFolderBasename,
destinationFolderBasename,
otherInoBasename
);
}
// Otherwise, it's OK, if it is an existing directory
} catch (err) {
// It's OK if the file is missing.
if (!ErrnoException.isENOENT(err)) {
throw err;
}
}
}

let filter: CopyOptions['filter'];
if (onlySketchFiles) {
const sketchFilePaths = Sketch.uris(sketch).map(FileUri.fsPath);
filter = (file: { path: string }) => sketchFilePaths.includes(file.path);
// The Windows paths, can be a trash (see below). Hence, it must be resolved with Node.js.
// After resolving the path, the drive letter is still a gamble (can be upper or lower case) and could result in a false negative match.
// Here, all sketch file paths must be resolved by Node.js, to provide the same drive letter casing.
const sketchFilePaths = await Promise.all(
Sketch.uris(sketch)
.map(FileUri.fsPath)
.map((path) => fs.realpath(path))
);
filter = async (s) => {
// On Windows, the source path could start with a complete trash. For example, \\\\?\\c:\\Users\\kittaakos\\AppData\\Local\\Temp\\.arduinoIDE-unsaved20231024-9300-1hp64fi.g8yh\\sketch_nov24d.
// The path must be resolved.
const resolvedSource = await fs.realpath(s);
if (sketchFilePaths.includes(resolvedSource)) {
return true;
}
const stat = await fs.stat(resolvedSource);
if (stat.isFile()) {
return false;
}
// Copy the folder if any of the sketch file path starts with this folder
return sketchFilePaths.some((sketchFilePath) =>
sketchFilePath.startsWith(resolvedSource)
);
};
} else {
filter = () => true;
}
const cpyModule = await import('cpy');
const cpy = cpyModule.default;
await cpy(sourceFolderBasename, destination, {
rename: (basename) =>
sourceFolderBasename !== destinationFolderBasename &&
basename === `${sourceFolderBasename}.ino`
? `${destinationFolderBasename}.ino`
: basename,

const tempRoot = await this.createTempFolder();
const temp = join(tempRoot, destinationFolderBasename);
await fs.mkdir(temp, { recursive: true });

// copy to temp folder
await fs.cp(source, temp, {
filter,
cwd: path.dirname(source),
recursive: true,
force: true,
});
const copiedSketch = await this.doLoadSketch(destinationUri, false);
return copiedSketch;

// rename the main sketch file
await fs.rename(
join(temp, `${sourceFolderBasename}.ino`),
join(temp, `${destinationFolderBasename}.ino`)
);

// copy to destination
try {
await fs.cp(temp, destination, { recursive: true, force: true });
const copiedSketch = await this.doLoadSketch(destinationUri, false);
return copiedSketch;
} finally {
// remove temp
fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 }); // no await
}
}

async archive(sketch: Sketch, destinationUri: string): Promise<string> {
Expand Down
Loading