Skip to content

Commit 074f654

Browse files
Akos Kittakittaakos
authored andcommitted
fix: sketch Save As preserves the folder structure
This commit rewrites how IDE copies sketches as part of the _Save As_ operation. Instead of copying to the destination, IDE copies the sketch into a temporary location, then to the desired destination. This commit drops [`cpy`](https://www.npmjs.com/package/cpy). Ref: sindresorhus/cpy@47b89a7 Closes #2077 Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
1 parent 3eef857 commit 074f654

File tree

5 files changed

+220
-140
lines changed

5 files changed

+220
-140
lines changed

arduino-ide-extension/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@
6363
"auth0-js": "^9.23.2",
6464
"btoa": "^1.2.1",
6565
"classnames": "^2.3.1",
66-
"cpy": "^10.0.0",
6766
"cross-fetch": "^3.1.5",
6867
"dateformat": "^3.0.3",
6968
"deepmerge": "^4.2.2",

arduino-ide-extension/src/common/protocol/sketches-service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export namespace SketchesError {
88
export const Codes = {
99
NotFound: 5001,
1010
InvalidName: 5002,
11+
InvalidFolderName: 5003,
1112
};
1213
export const NotFound = ApplicationError.declare(
1314
Codes.NotFound,
@@ -27,6 +28,15 @@ export namespace SketchesError {
2728
};
2829
}
2930
);
31+
export const InvalidFolderName = ApplicationError.declare(
32+
Codes.InvalidFolderName,
33+
(message: string, invalidFolderName: string) => {
34+
return {
35+
message,
36+
data: { invalidFolderName },
37+
};
38+
}
39+
);
3040
}
3141

3242
export const SketchesServicePath = '/services/sketches-service';

arduino-ide-extension/src/node/sketches-service-impl.ts

Lines changed: 97 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,55 @@
1-
import { injectable, inject, named } from '@theia/core/shared/inversify';
2-
import { promises as fs, realpath, lstat, Stats, constants } from 'node:fs';
3-
import os from 'node:os';
4-
import temp from 'temp';
5-
import path from 'node:path';
6-
import glob from 'glob';
7-
import crypto from 'node:crypto';
8-
import PQueue from 'p-queue';
1+
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
2+
import { ILogger } from '@theia/core/lib/common/logger';
3+
import { nls } from '@theia/core/lib/common/nls';
4+
import { isWindows } from '@theia/core/lib/common/os';
5+
import { Deferred } from '@theia/core/lib/common/promise-util';
6+
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
97
import type { Mutable } from '@theia/core/lib/common/types';
108
import URI from '@theia/core/lib/common/uri';
11-
import { ILogger } from '@theia/core/lib/common/logger';
129
import { FileUri } from '@theia/core/lib/node/file-uri';
13-
import { ConfigServiceImpl } from './config-service-impl';
10+
import { inject, injectable, named } from '@theia/core/shared/inversify';
11+
import glob from 'glob';
12+
import crypto from 'node:crypto';
13+
import {
14+
CopyOptions,
15+
Stats,
16+
constants,
17+
promises as fs,
18+
lstat,
19+
realpath,
20+
} from 'node:fs';
21+
import os from 'node:os';
22+
import path, { join } from 'node:path';
23+
import PQueue from 'p-queue';
24+
import temp from 'temp';
25+
import { NotificationServiceServer } from '../common/protocol';
1426
import {
15-
SketchesService,
1627
Sketch,
17-
SketchRef,
1828
SketchContainer,
29+
SketchRef,
1930
SketchesError,
31+
SketchesService,
2032
} from '../common/protocol/sketches-service';
21-
import { NotificationServiceServer } from '../common/protocol';
22-
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
23-
import { CoreClientAware } from './core-client-provider';
33+
import {
34+
firstToLowerCase,
35+
firstToUpperCase,
36+
startsWithUpperCase,
37+
} from '../common/utils';
2438
import {
2539
ArchiveSketchRequest,
2640
LoadSketchRequest,
2741
} from './cli-protocol/cc/arduino/cli/commands/v1/commands_pb';
28-
import { Deferred } from '@theia/core/lib/common/promise-util';
29-
import { escapeRegExpCharacters } from '@theia/core/lib/common/strings';
30-
import { ServiceError } from './service-error';
42+
import { ConfigServiceImpl } from './config-service-impl';
43+
import { CoreClientAware } from './core-client-provider';
3144
import {
3245
IsTempSketch,
33-
maybeNormalizeDrive,
3446
TempSketchPrefix,
3547
Win32DriveRegex,
48+
maybeNormalizeDrive,
3649
} from './is-temp-sketch';
37-
import { join } from 'node:path';
38-
import { ErrnoException } from './utils/errors';
39-
import { isWindows } from '@theia/core/lib/common/os';
40-
import {
41-
firstToLowerCase,
42-
firstToUpperCase,
43-
startsWithUpperCase,
44-
} from '../common/utils';
50+
import { ServiceError } from './service-error';
4551
import { SettingsReader } from './settings-reader';
52+
import { ErrnoException } from './utils/errors';
4653

4754
const RecentSketches = 'recent-sketches.json';
4855
const DefaultIno = `void setup() {
@@ -510,26 +517,75 @@ export class SketchesServiceImpl
510517
}
511518
const sourceFolderBasename = path.basename(source);
512519
const destinationFolderBasename = path.basename(destination);
513-
let filter;
520+
521+
const errorMessage = Sketch.validateSketchFolderName(
522+
destinationFolderBasename
523+
);
524+
if (errorMessage) {
525+
const message = `${nls.localize(
526+
'arduino/sketch/invalidSketchFolderNameMessage',
527+
"Invalid sketch folder name: '{0}'",
528+
destinationFolderBasename
529+
)} ${errorMessage}`;
530+
throw SketchesError.InvalidFolderName(message, destinationFolderBasename);
531+
}
532+
533+
let filter: CopyOptions['filter'];
514534
if (onlySketchFiles) {
515-
const sketchFilePaths = Sketch.uris(sketch).map(FileUri.fsPath);
516-
filter = (file: { path: string }) => sketchFilePaths.includes(file.path);
535+
// The Windows paths, can be a trash (see below). Hence, it must be resolved with Node.js.
536+
// 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.
537+
// Here, all sketch file paths must be resolved by Node.js, to provide the same drive letter casing.
538+
const sketchFilePaths = await Promise.all(
539+
Sketch.uris(sketch)
540+
.map(FileUri.fsPath)
541+
.map((path) => fs.realpath(path))
542+
);
543+
filter = async (s) => {
544+
// 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.
545+
// The path must be resolved.
546+
const resolvedSource = await fs.realpath(s);
547+
if (sketchFilePaths.includes(resolvedSource)) {
548+
return true;
549+
}
550+
const stat = await fs.stat(resolvedSource);
551+
if (stat.isFile()) {
552+
return false;
553+
}
554+
// Copy the folder if any of the sketch file path starts with this folder
555+
return sketchFilePaths.some((sketchFilePath) =>
556+
sketchFilePath.startsWith(resolvedSource)
557+
);
558+
};
517559
} else {
518560
filter = () => true;
519561
}
520-
const cpyModule = await import('cpy');
521-
const cpy = cpyModule.default;
522-
await cpy(sourceFolderBasename, destination, {
523-
rename: (basename) =>
524-
sourceFolderBasename !== destinationFolderBasename &&
525-
basename === `${sourceFolderBasename}.ino`
526-
? `${destinationFolderBasename}.ino`
527-
: basename,
562+
563+
const tempRoot = await this.createTempFolder();
564+
const temp = join(tempRoot, destinationFolderBasename);
565+
await fs.mkdir(temp, { recursive: true });
566+
567+
// copy to temp folder
568+
await fs.cp(source, temp, {
528569
filter,
529-
cwd: path.dirname(source),
570+
recursive: true,
571+
force: true,
530572
});
531-
const copiedSketch = await this.doLoadSketch(destinationUri, false);
532-
return copiedSketch;
573+
574+
// rename the main sketch file
575+
await fs.rename(
576+
join(temp, `${sourceFolderBasename}.ino`),
577+
join(temp, `${destinationFolderBasename}.ino`)
578+
);
579+
580+
// copy to destination
581+
try {
582+
await fs.cp(temp, destination, { recursive: true, force: true });
583+
const copiedSketch = await this.doLoadSketch(destinationUri, false);
584+
return copiedSketch;
585+
} finally {
586+
// remove temp
587+
fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 }); // no await
588+
}
533589
}
534590

535591
async archive(sketch: Sketch, destinationUri: string): Promise<string> {

arduino-ide-extension/src/test/node/sketches-service-impl.slow-test.ts

Lines changed: 109 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import { Container } from '@theia/core/shared/inversify';
88
import { expect } from 'chai';
99
import { promises as fs } from 'node:fs';
1010
import { basename, join } from 'node:path';
11+
import { rejects } from 'node:assert/strict';
1112
import { sync as rimrafSync } from 'rimraf';
1213
import temp from 'temp';
13-
import { Sketch, SketchesService } from '../../common/protocol';
14+
import { Sketch, SketchesError, SketchesService } from '../../common/protocol';
1415
import {
1516
isAccessibleSketchPath,
1617
SketchesServiceImpl,
@@ -138,12 +139,31 @@ describe('sketches-service-impl', () => {
138139

139140
after(() => toDispose.dispose());
140141

141-
describe('copy', () => {
142-
it('should copy a sketch when the destination does not exist', async function () {
143-
this.timeout(testTimeout);
142+
describe('copy', function () {
143+
this.timeout(testTimeout);
144+
this.slow(250);
145+
146+
it('should error when the destination sketch folder name is invalid', async () => {
147+
const sketchesService =
148+
container.get<SketchesServiceImpl>(SketchesService);
149+
const tempDirPath = await sketchesService['createTempFolder']();
150+
const destinationPath = join(tempDirPath, 'invalid with spaces');
151+
const sketch = await sketchesService.createNewSketch();
152+
toDispose.push(disposeSketch(sketch));
153+
await rejects(
154+
sketchesService.copy(sketch, {
155+
destinationUri: FileUri.create(destinationPath).toString(),
156+
}),
157+
SketchesError.InvalidFolderName.is
158+
);
159+
});
160+
161+
it('should copy a sketch when the destination does not exist', async () => {
144162
const sketchesService =
145163
container.get<SketchesServiceImpl>(SketchesService);
146-
const destinationPath = await sketchesService['createTempFolder']();
164+
const tempDirPath = await sketchesService['createTempFolder']();
165+
const destinationPath = join(tempDirPath, 'Does_Not_Exist_but_valid');
166+
await rejects(fs.readdir(destinationPath), ErrnoException.isENOENT);
147167
let sketch = await sketchesService.createNewSketch();
148168
toDispose.push(disposeSketch(sketch));
149169
const sourcePath = FileUri.fsPath(sketch.uri);
@@ -187,11 +207,11 @@ describe('sketches-service-impl', () => {
187207
).to.be.true;
188208
});
189209

190-
it("should copy only sketch files if 'onlySketchFiles' is true", async function () {
191-
this.timeout(testTimeout);
210+
it("should copy only sketch files if 'onlySketchFiles' is true", async () => {
192211
const sketchesService =
193212
container.get<SketchesServiceImpl>(SketchesService);
194-
const destinationPath = await sketchesService['createTempFolder']();
213+
const tempDirPath = await sketchesService['createTempFolder']();
214+
const destinationPath = join(tempDirPath, 'OnlySketchFiles');
195215
let sketch = await sketchesService.createNewSketch();
196216
toDispose.push(disposeSketch(sketch));
197217
const sourcePath = FileUri.fsPath(sketch.uri);
@@ -207,11 +227,25 @@ describe('sketches-service-impl', () => {
207227
const logContent = 'log file content';
208228
const logPath = join(sourcePath, logBasename);
209229
await fs.writeFile(logPath, logContent, { encoding: 'utf8' });
230+
const srcPath = join(sourcePath, 'src');
231+
await fs.mkdir(srcPath, { recursive: true });
232+
const libInSrcBasename = 'lib_in_src.cpp';
233+
const libInSrcContent = 'lib in src content';
234+
const libInSrcPath = join(srcPath, libInSrcBasename);
235+
await fs.writeFile(libInSrcPath, libInSrcContent, { encoding: 'utf8' });
236+
const logInSrcBasename = 'inols-clangd-err_in_src.log';
237+
const logInSrcContent = 'log file content in src';
238+
const logInSrcPath = join(srcPath, logInSrcBasename);
239+
await fs.writeFile(logInSrcPath, logInSrcContent, { encoding: 'utf8' });
210240

211241
sketch = await sketchesService.loadSketch(sketch.uri);
212242
expect(Sketch.isInSketch(FileUri.create(libPath), sketch)).to.be.true;
213243
expect(Sketch.isInSketch(FileUri.create(headerPath), sketch)).to.be.true;
214244
expect(Sketch.isInSketch(FileUri.create(logPath), sketch)).to.be.false;
245+
expect(Sketch.isInSketch(FileUri.create(libInSrcPath), sketch)).to.be
246+
.true;
247+
expect(Sketch.isInSketch(FileUri.create(logInSrcPath), sketch)).to.be
248+
.false;
215249
const reloadedLogContent = await fs.readFile(logPath, {
216250
encoding: 'utf8',
217251
});
@@ -249,20 +283,25 @@ describe('sketches-service-impl', () => {
249283
copied
250284
)
251285
).to.be.false;
252-
try {
253-
await fs.readFile(join(destinationPath, logBasename), {
254-
encoding: 'utf8',
255-
});
256-
expect.fail(
257-
'Log file must not exist in the destination. Expected ENOENT when loading the log file.'
258-
);
259-
} catch (err) {
260-
expect(ErrnoException.isENOENT(err)).to.be.true;
261-
}
286+
expect(
287+
Sketch.isInSketch(
288+
FileUri.create(join(destinationPath, 'src', libInSrcBasename)),
289+
copied
290+
)
291+
).to.be.true;
292+
expect(
293+
Sketch.isInSketch(
294+
FileUri.create(join(destinationPath, 'src', logInSrcBasename)),
295+
copied
296+
)
297+
).to.be.false;
298+
await rejects(
299+
fs.readFile(join(destinationPath, logBasename)),
300+
ErrnoException.isENOENT
301+
);
262302
});
263303

264-
it('should copy sketch inside the sketch folder', async function () {
265-
this.timeout(testTimeout);
304+
it('should copy sketch inside the sketch folder', async () => {
266305
const sketchesService =
267306
container.get<SketchesServiceImpl>(SketchesService);
268307
let sketch = await sketchesService.createNewSketch();
@@ -309,6 +348,55 @@ describe('sketches-service-impl', () => {
309348
).to.be.true;
310349
});
311350

351+
it('should not modify the subfolder structure', async () => {
352+
const sketchesService =
353+
container.get<SketchesServiceImpl>(SketchesService);
354+
const tempDirPath = await sketchesService['createTempFolder']();
355+
const destinationPath = join(tempDirPath, 'HasSubfolders_copy');
356+
await fs.mkdir(destinationPath, { recursive: true });
357+
let sketch = await sketchesService.createNewSketch('HasSubfolders');
358+
toDispose.push(disposeSketch(sketch));
359+
360+
const sourcePath = FileUri.fsPath(sketch.uri);
361+
const srcPath = join(sourcePath, 'src');
362+
await fs.mkdir(srcPath, { recursive: true });
363+
const headerPath = join(srcPath, 'FomSubfolder.h');
364+
await fs.writeFile(headerPath, '// empty', { encoding: 'utf8' });
365+
366+
sketch = await sketchesService.loadSketch(sketch.uri);
367+
368+
expect(sketch.mainFileUri).to.be.equal(
369+
FileUri.create(join(sourcePath, 'HasSubfolders.ino')).toString()
370+
);
371+
expect(sketch.additionalFileUris).to.be.deep.equal([
372+
FileUri.create(join(srcPath, 'FomSubfolder.h')).toString(),
373+
]);
374+
expect(sketch.otherSketchFileUris).to.be.empty;
375+
expect(sketch.rootFolderFileUris).to.be.empty;
376+
377+
const destinationUri = FileUri.create(destinationPath).toString();
378+
const copySketch = await sketchesService.copy(sketch, { destinationUri });
379+
toDispose.push(disposeSketch(copySketch));
380+
expect(copySketch.mainFileUri).to.be.equal(
381+
FileUri.create(
382+
join(destinationPath, 'HasSubfolders_copy.ino')
383+
).toString()
384+
);
385+
expect(copySketch.additionalFileUris).to.be.deep.equal([
386+
FileUri.create(
387+
join(destinationPath, 'src', 'FomSubfolder.h')
388+
).toString(),
389+
]);
390+
expect(copySketch.otherSketchFileUris).to.be.empty;
391+
expect(copySketch.rootFolderFileUris).to.be.empty;
392+
393+
const actualHeaderContent = await fs.readFile(
394+
join(destinationPath, 'src', 'FomSubfolder.h'),
395+
{ encoding: 'utf8' }
396+
);
397+
expect(actualHeaderContent).to.be.equal('// empty');
398+
});
399+
312400
it('should copy sketch with overwrite when source and destination sketch folder names are the same', async function () {
313401
this.timeout(testTimeout);
314402
const sketchesService =
@@ -346,7 +434,7 @@ describe('sketches-service-impl', () => {
346434
[
347435
'<',
348436
'>',
349-
'chevrons',
437+
'lt+gt',
350438
{
351439
predicate: () => isWindows,
352440
why: '< (less than) and > (greater than) are reserved characters on Windows (https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions)',

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy