Skip to content

Commit f34b8f8

Browse files
author
Akos Kitta
committed
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 Signed-off-by: Akos Kitta <[email protected]>
1 parent 503533d commit f34b8f8

File tree

5 files changed

+209
-139
lines changed

5 files changed

+209
-139
lines changed

Diff for: arduino-ide-extension/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@
6363
"auth0-js": "^9.14.0",
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",

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

+10
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';

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

+86-40
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,65 @@ 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) {
515535
const sketchFilePaths = Sketch.uris(sketch).map(FileUri.fsPath);
516-
filter = (file: { path: string }) => sketchFilePaths.includes(file.path);
536+
filter = async (s) => {
537+
if (sketchFilePaths.includes(s)) {
538+
return true;
539+
}
540+
const stat = await fs.stat(s);
541+
if (stat.isFile()) {
542+
return false;
543+
}
544+
// Copy the folder if any of the sketch file path starts with this folder
545+
return sketchFilePaths.some((sketchFilePath) =>
546+
sketchFilePath.startsWith(s)
547+
);
548+
};
517549
} else {
518550
filter = () => true;
519551
}
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,
552+
553+
const tempRoot = await this.createTempFolder();
554+
const temp = join(tempRoot, destinationFolderBasename);
555+
await fs.mkdir(temp, { recursive: true });
556+
557+
// copy to temp folder
558+
await fs.cp(source, temp, {
528559
filter,
529-
cwd: path.dirname(source),
560+
recursive: true,
561+
force: true,
530562
});
531-
const copiedSketch = await this.doLoadSketch(destinationUri, false);
532-
return copiedSketch;
563+
564+
// rename the main sketch file
565+
await fs.rename(
566+
join(temp, `${sourceFolderBasename}.ino`),
567+
join(temp, `${destinationFolderBasename}.ino`)
568+
);
569+
570+
// copy to destination
571+
try {
572+
await fs.cp(temp, destination, { recursive: true, force: true });
573+
const copiedSketch = await this.doLoadSketch(destinationUri, false);
574+
return copiedSketch;
575+
} finally {
576+
// remove temp
577+
fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 }); // no await
578+
}
533579
}
534580

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

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

+109-21
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.fsPath(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)