From 8ab70f48f8c485e4ba953ef821570292f6f91056 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 30 Jul 2020 19:58:14 +0200 Subject: [PATCH] fixed save-as. added sketchload Signed-off-by: Akos Kitta --- .../browser/arduino-frontend-contribution.tsx | 26 +- .../browser/arduino-ide-frontend-module.ts | 2 + .../src/browser/contributions/close-sketch.ts | 4 +- .../src/browser/contributions/contribution.ts | 30 +- .../contributions/edit-contributions.ts | 33 ++- .../contributions/open-sketch-external.ts | 2 +- .../browser/contributions/save-as-sketch.ts | 2 +- .../browser/contributions/sketch-control.ts | 6 +- .../browser/contributions/upload-sketch.ts | 2 +- .../browser/contributions/verify-sketch.ts | 2 +- .../src/browser/data/arduino.color-theme.json | 4 +- .../src/browser/style/main.css | 9 + .../browser/theia/editor/editor-manager.ts | 4 +- .../theia/workspace/workspace-service.ts | 12 +- .../tool-output/client-service-impl.ts | 2 +- .../protocol/sketches-service-client-impl.ts | 49 ++++ .../src/common/protocol/sketches-service.ts | 12 +- arduino-ide-extension/src/node/fs-extra.ts | 6 + .../src/node/sketches-service-impl.ts | 267 +++++++++++++++--- 19 files changed, 360 insertions(+), 114 deletions(-) create mode 100644 arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index 9b595384..aeab2a30 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -260,13 +260,11 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut } protected async openSketchFiles(uri: string): Promise { - const uris = await this.sketchService.getSketchFiles(uri); - for (const uri of uris) { + const sketch = await this.sketchService.loadSketch(uri); + await this.editorManager.open(new URI(sketch.mainFileUri)); + for (const uri of [...sketch.otherSketchFileUris, ...sketch.additionalFileUris]) { await this.editorManager.open(new URI(uri)); } - if (uris.length) { - await this.editorManager.open(new URI(uris[0])); // Make sure the sketch file has the focus. - } } registerColors(colors: ColorRegistry): void { @@ -313,6 +311,24 @@ export class ArduinoFrontendContribution implements FrontendApplicationContribut hc: 'activityBar.inactiveForeground' }, description: 'Background color of the toolbar items when hovering over them. Such as Upload, Verify, etc.' + }, + { + id: 'arduino.output.background', + defaults: { + dark: 'editorWidget.background', + light: 'editorWidget.background', + hc: 'editorWidget.background' + }, + description: 'Background color of the Output view.' + }, + { + id: 'arduino.output.foreground', + defaults: { + dark: 'editorWidget.foreground', + light: 'editorWidget.foreground', + hc: 'editorWidget.foreground' + }, + description: 'Color of the text in the Output view.' } ); } diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index 80b742d6..9ee4ce97 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -15,6 +15,7 @@ import { ArduinoLanguageGrammarContribution } from './language/arduino-language- import { LibraryService, LibraryServicePath } from '../common/protocol/library-service'; import { BoardsService, BoardsServicePath, BoardsServiceClient } from '../common/protocol/boards-service'; import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service'; +import { SketchesServiceClientImpl } from '../common/protocol/sketches-service-client-impl'; import { CoreService, CoreServicePath, CoreServiceClient } from '../common/protocol/core-service'; import { BoardsListWidget } from './boards/boards-list-widget'; import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution'; @@ -151,6 +152,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // Sketch list service bind(SketchesService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, SketchesServicePath)).inSingletonScope(); + bind(SketchesServiceClientImpl).toSelf().inSingletonScope(); // Config service bind(ConfigService).toDynamicValue(context => { diff --git a/arduino-ide-extension/src/browser/contributions/close-sketch.ts b/arduino-ide-extension/src/browser/contributions/close-sketch.ts index 3e2f1651..43d99578 100644 --- a/arduino-ide-extension/src/browser/contributions/close-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/close-sketch.ts @@ -15,12 +15,12 @@ export class CloseSketch extends SketchContribution { registerCommands(registry: CommandRegistry): void { registry.registerCommand(CloseSketch.Commands.CLOSE_SKETCH, { execute: async () => { - const sketch = await this.currentSketch(); + const sketch = await this.sketchServiceClient.currentSketch(); if (!sketch) { return; } const isTemp = await this.sketchService.isTemp(sketch); - const uri = await this.currentSketchFile(); + const uri = await this.sketchServiceClient.currentSketchFile(); if (!uri) { return; } diff --git a/arduino-ide-extension/src/browser/contributions/contribution.ts b/arduino-ide-extension/src/browser/contributions/contribution.ts index 6fdb13ab..39ac0950 100644 --- a/arduino-ide-extension/src/browser/contributions/contribution.ts +++ b/arduino-ide-extension/src/browser/contributions/contribution.ts @@ -1,7 +1,6 @@ import { inject, injectable, interfaces } from 'inversify'; import URI from '@theia/core/lib/common/uri'; import { ILogger } from '@theia/core/lib/common/logger'; -import { notEmpty } from '@theia/core/lib/common/objects'; import { FileSystem } from '@theia/filesystem/lib/common'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { MessageService } from '@theia/core/lib/common/message-service'; @@ -11,8 +10,9 @@ import { MenuModelRegistry, MenuContribution } from '@theia/core/lib/common/menu import { KeybindingRegistry, KeybindingContribution } from '@theia/core/lib/browser/keybinding'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { Command, CommandRegistry, CommandContribution, CommandService } from '@theia/core/lib/common/command'; -import { SketchesService, ConfigService, FileSystemExt, Sketch } from '../../common/protocol'; import { EditorMode } from '../editor-mode'; +import { SketchesServiceClientImpl } from '../../common/protocol/sketches-service-client-impl'; +import { SketchesService, ConfigService, FileSystemExt, Sketch } from '../../common/protocol'; export { Command, CommandRegistry, MenuModelRegistry, KeybindingRegistry, TabBarToolbarRegistry, URI, Sketch, open }; @@ -69,30 +69,8 @@ export abstract class SketchContribution extends Contribution { @inject(OpenerService) protected readonly openerService: OpenerService; - protected async currentSketch(): Promise { - const sketches = (await Promise.all(this.workspaceService.tryGetRoots().map(({ uri }) => this.sketchService.getSketchFolder(uri)))).filter(notEmpty); - if (!sketches.length) { - return; - } - if (sketches.length > 1) { - console.log(`Multiple sketch folders were found in the workspace. Falling back to the first one. Sketch folders: ${JSON.stringify(sketches)}`); - } - return sketches[0]; - } - - protected async currentSketchFile(): Promise { - const sketch = await this.currentSketch(); - if (sketch) { - const uri = new URI(sketch.uri).resolve(`${sketch.name}.ino`).toString(); - const exists = await this.fileSystem.exists(uri); - if (!exists) { - this.messageService.warn(`Could not find sketch file: ${uri}`); - return undefined; - } - return uri; - } - return undefined; - } + @inject(SketchesServiceClientImpl) + protected readonly sketchServiceClient: SketchesServiceClientImpl; } diff --git a/arduino-ide-extension/src/browser/contributions/edit-contributions.ts b/arduino-ide-extension/src/browser/contributions/edit-contributions.ts index 7d551e19..c8983bce 100644 --- a/arduino-ide-extension/src/browser/contributions/edit-contributions.ts +++ b/arduino-ide-extension/src/browser/contributions/edit-contributions.ts @@ -151,33 +151,29 @@ ${value} registerKeybindings(registry: KeybindingRegistry): void { registry.registerKeybinding({ command: EditContributions.Commands.COPY_FOR_FORUM.id, - keybinding: 'CtrlCmd+Shift+C' + keybinding: 'CtrlCmd+Shift+C', + when: 'editorFocus' }); registry.registerKeybinding({ command: EditContributions.Commands.COPY_FOR_GITHUB.id, - keybinding: 'CtrlCmd+Alt+C' + keybinding: 'CtrlCmd+Alt+C', + when: 'editorFocus' }); registry.registerKeybinding({ command: EditContributions.Commands.GO_TO_LINE.id, - keybinding: 'CtrlCmd+L' + keybinding: 'CtrlCmd+L', + when: 'editorFocus' }); registry.registerKeybinding({ command: EditContributions.Commands.TOGGLE_COMMENT.id, - keybinding: 'CtrlCmd+/' - }); - registry.registerKeybinding({ - command: EditContributions.Commands.INDENT_LINES.id, - keybinding: 'Tab' - }); - registry.registerKeybinding({ - command: EditContributions.Commands.OUTDENT_LINES.id, - keybinding: 'Shift+Tab' + keybinding: 'CtrlCmd+/', + when: 'editorFocus' }); registry.registerKeybinding({ command: EditContributions.Commands.INCREASE_FONT_SIZE.id, - keybinding: 'CtrlCmd+=' // TODO: compare with the Java IDE. It uses `⌘+`. There is no `+` on EN_US. + keybinding: 'CtrlCmd+=' }); registry.registerKeybinding({ command: EditContributions.Commands.DECREASE_FONT_SIZE.id, @@ -213,8 +209,15 @@ ${value} } protected async currentValue(): Promise { - const currentEditor = await this.current() - return currentEditor?.getValue(); + const currentEditor = await this.current(); + if (currentEditor) { + const selection = currentEditor.getSelection(); + if (!selection || selection.isEmpty()) { + return currentEditor.getValue(); + } + return currentEditor.getModel()?.getValueInRange(selection); + } + return undefined; } protected async run(commandId: string): Promise { diff --git a/arduino-ide-extension/src/browser/contributions/open-sketch-external.ts b/arduino-ide-extension/src/browser/contributions/open-sketch-external.ts index 33383bc3..10fb7290 100644 --- a/arduino-ide-extension/src/browser/contributions/open-sketch-external.ts +++ b/arduino-ide-extension/src/browser/contributions/open-sketch-external.ts @@ -28,7 +28,7 @@ export class OpenSketchExternal extends SketchContribution { } protected async openExternal(): Promise { - const uri = await this.currentSketchFile(); + const uri = await this.sketchServiceClient.currentSketchFile(); if (uri) { const exists = this.fileSystem.exists(uri); if (exists) { diff --git a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts index 7a198415..c93fff27 100644 --- a/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/save-as-sketch.ts @@ -32,7 +32,7 @@ export class SaveAsSketch extends SketchContribution { * Resolves `true` if the sketch was successfully saved as something. */ async saveAs({ execOnlyIfTemp, openAfterMove }: SaveAsSketch.Options = SaveAsSketch.Options.DEFAULT): Promise { - const sketch = await this.currentSketch(); + const sketch = await this.sketchServiceClient.currentSketch(); if (!sketch) { return false; } diff --git a/arduino-ide-extension/src/browser/contributions/sketch-control.ts b/arduino-ide-extension/src/browser/contributions/sketch-control.ts index f4cebf5a..58c04b69 100644 --- a/arduino-ide-extension/src/browser/contributions/sketch-control.ts +++ b/arduino-ide-extension/src/browser/contributions/sketch-control.ts @@ -26,7 +26,7 @@ export class SketchControl extends SketchContribution { isVisible: widget => this.shell.getWidgets('main').indexOf(widget) !== -1, execute: async () => { this.toDisposeBeforeCreateNewContextMenu.dispose(); - const sketch = await this.currentSketch(); + const sketch = await this.sketchServiceClient.currentSketch(); if (!sketch) { return; } @@ -40,8 +40,8 @@ export class SketchControl extends SketchContribution { return; } - const uris = await this.sketchService.getSketchFiles(sketch.uri); - // TODO: order them! The Java IDE orders them by tab index. Use the shell and the editor manager to achieve it. + const { mainFileUri, otherSketchFileUris, additionalFileUris } = await this.sketchService.loadSketch(sketch.uri); + const uris = [mainFileUri, ...otherSketchFileUris, ...additionalFileUris]; for (let i = 0; i < uris.length; i++) { const uri = new URI(uris[i]); const command = { id: `arduino-focus-file--${uri.toString()}` }; diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index c50379e4..ed0b569d 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -73,7 +73,7 @@ export class UploadSketch extends SketchContribution { } async uploadSketch(usingProgrammer: boolean = false): Promise { - const uri = await this.currentSketchFile(); + const uri = await this.sketchServiceClient.currentSketchFile(); if (!uri) { return; } diff --git a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts index 3e64e5fa..9ac9e369 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -57,7 +57,7 @@ export class VerifySketch extends SketchContribution { } async verifySketch(): Promise { - const uri = await this.currentSketchFile(); + const uri = await this.sketchServiceClient.currentSketchFile(); if (!uri) { return; } diff --git a/arduino-ide-extension/src/browser/data/arduino.color-theme.json b/arduino-ide-extension/src/browser/data/arduino.color-theme.json index 3e5078c3..7a93fb98 100644 --- a/arduino-ide-extension/src/browser/data/arduino.color-theme.json +++ b/arduino-ide-extension/src/browser/data/arduino.color-theme.json @@ -108,7 +108,9 @@ "secondaryButton.hoverBackground": "#dae3e3", "arduino.branding.primary": "#00979d", "arduino.branding.secondary": "#b5c8c9", - "arduino.foreground": "#edf1f1" + "arduino.foreground": "#edf1f1", + "arduino.output.background": "#000000", + "arduino.output.foreground": "#ffffff" }, "type": "light", "name": "Arduino" diff --git a/arduino-ide-extension/src/browser/style/main.css b/arduino-ide-extension/src/browser/style/main.css index 14d5f040..532a2860 100644 --- a/arduino-ide-extension/src/browser/style/main.css +++ b/arduino-ide-extension/src/browser/style/main.css @@ -155,3 +155,12 @@ border: 1px solid var(--theia-arduino-toolbar-background); padding: 2px 0px 2px 9px; } + +#outputView .monaco-editor .lines-content.monaco-editor-background { + background-color: var(--theia-arduino-output-background); +} + +.monaco-editor { +/* #outputView .monaco-editor .inputarea.ime-input { */ + color: var(--theia-arduino-output-foreground); +} diff --git a/arduino-ide-extension/src/browser/theia/editor/editor-manager.ts b/arduino-ide-extension/src/browser/theia/editor/editor-manager.ts index 1681943f..14143691 100644 --- a/arduino-ide-extension/src/browser/theia/editor/editor-manager.ts +++ b/arduino-ide-extension/src/browser/theia/editor/editor-manager.ts @@ -17,7 +17,9 @@ export class EditorManager extends TheiaEditorManager { const { editor } = widget; if (editor instanceof MonacoEditor) { const codeEditor = editor.getControl(); - codeEditor.updateOptions({ readOnly }); + const lineNumbersMinChars = 2; + const overviewRulerLanes = 0; + codeEditor.updateOptions({ readOnly, lineNumbersMinChars, overviewRulerLanes }); } } return widget; diff --git a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts index cce01a80..335e3758 100644 --- a/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts +++ b/arduino-ide-extension/src/browser/theia/workspace/workspace-service.ts @@ -7,7 +7,6 @@ import { ApplicationServer } from '@theia/core/lib/common/application-protocol'; import { FrontendApplication } from '@theia/core/lib/browser/frontend-application'; import { FocusTracker, Widget } from '@theia/core/lib/browser'; import { WorkspaceService as TheiaWorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; -import { EditorMode } from '../../editor-mode'; import { ConfigService } from '../../../common/protocol/config-service'; import { SketchesService } from '../../../common/protocol/sketches-service'; import { ArduinoWorkspaceRootResolver } from '../../arduino-workspace-resolver'; @@ -24,9 +23,6 @@ export class WorkspaceService extends TheiaWorkspaceService { @inject(LabelProvider) protected readonly labelProvider: LabelProvider; - @inject(EditorMode) - protected readonly editorMode: EditorMode; - @inject(MessageService) protected readonly messageService: MessageService; @@ -82,13 +78,7 @@ export class WorkspaceService extends TheiaWorkspaceService { if (!exists) { return false; } - // The workspace root location must exist. However, when opening a workspace root in pro-mode, - // the workspace root must not be a sketch folder. It can be the default sketch directory, or any other directories, for instance. - if (this.editorMode.proMode) { - return true; - } - const sketchFolder = await this.sketchService.isSketchFolder(uri); - return sketchFolder; + return this.sketchService.isSketchFolder(uri); } protected onCurrentWidgetChange({ newValue }: FocusTracker.IChangedArgs): void { diff --git a/arduino-ide-extension/src/browser/tool-output/client-service-impl.ts b/arduino-ide-extension/src/browser/tool-output/client-service-impl.ts index 5f99b80f..66ed0078 100644 --- a/arduino-ide-extension/src/browser/tool-output/client-service-impl.ts +++ b/arduino-ide-extension/src/browser/tool-output/client-service-impl.ts @@ -12,7 +12,7 @@ export class ToolOutputServiceClientImpl implements ToolOutputServiceClient { onNewOutput(tool: string, text: string): void { const name = `Arduino: ${tool}`; // Zen-mode: we do not reveal the output for daemon messages. - const show = tool === 'daemon' ? Promise.resolve() : this.commandService.executeCommand(OutputCommands.SHOW.id, { name, options: { preserveFocus: false } }); + const show = tool === 'daemon22' ? Promise.resolve() : this.commandService.executeCommand(OutputCommands.SHOW.id, { name, options: { preserveFocus: false } }); show.then(() => this.commandService.executeCommand(OutputCommands.APPEND.id, { name, text })); } diff --git a/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts new file mode 100644 index 00000000..009c9645 --- /dev/null +++ b/arduino-ide-extension/src/common/protocol/sketches-service-client-impl.ts @@ -0,0 +1,49 @@ +import { inject, injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { notEmpty } from '@theia/core/lib/common/objects'; +import { FileSystem } from '@theia/filesystem/lib/common'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; +import { Sketch, SketchesService } from '../../common/protocol'; + +@injectable() +export class SketchesServiceClientImpl { + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(SketchesService) + protected readonly sketchService: SketchesService; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + async currentSketch(): Promise { + const sketches = (await Promise.all(this.workspaceService.tryGetRoots().map(({ uri }) => this.sketchService.getSketchFolder(uri)))).filter(notEmpty); + if (!sketches.length) { + return undefined; + } + if (sketches.length > 1) { + console.log(`Multiple sketch folders were found in the workspace. Falling back to the first one. Sketch folders: ${JSON.stringify(sketches)}`); + } + return sketches[0]; + } + + async currentSketchFile(): Promise { + const sketch = await this.currentSketch(); + if (sketch) { + const uri = new URI(sketch.uri).resolve(`${sketch.name}.ino`).toString(); + const exists = await this.fileSystem.exists(uri); + if (!exists) { + this.messageService.warn(`Could not find sketch file: ${uri}`); + return undefined; + } + return uri; + } + return undefined; + } + +} diff --git a/arduino-ide-extension/src/common/protocol/sketches-service.ts b/arduino-ide-extension/src/common/protocol/sketches-service.ts index 9a092b1a..5438216a 100644 --- a/arduino-ide-extension/src/common/protocol/sketches-service.ts +++ b/arduino-ide-extension/src/common/protocol/sketches-service.ts @@ -8,7 +8,12 @@ export interface SketchesService { */ getSketches(uri?: string): Promise; - getSketchFiles(uri: string): Promise; + /** + * This is the TS implementation of `SketchLoad` from the CLI and should be replaced with a gRPC call eventually. + * See: https://github.com/arduino/arduino-cli/issues/837 + * Based on: https://github.com/arduino/arduino-cli/blob/eef3705c4afcba4317ec38b803d9ffce5dd59a28/arduino/builder/sketch.go#L100-L215 + */ + loadSketch(uri: string): Promise; /** * Creates a new sketch folder in the temp location. @@ -40,7 +45,10 @@ export interface SketchesService { export interface Sketch { readonly name: string; - readonly uri: string; + readonly uri: string; // `LocationPath` + readonly mainFileUri: string; // `MainFile` + readonly otherSketchFileUris: string[]; // `OtherSketchFiles` + readonly additionalFileUris: string[]; // `AdditionalFiles` } export namespace Sketch { export function is(arg: any): arg is Sketch { diff --git a/arduino-ide-extension/src/node/fs-extra.ts b/arduino-ide-extension/src/node/fs-extra.ts index 187fec59..dbbf8bcb 100644 --- a/arduino-ide-extension/src/node/fs-extra.ts +++ b/arduino-ide-extension/src/node/fs-extra.ts @@ -1,12 +1,16 @@ import * as fs from 'fs'; import { promisify } from 'util'; +export const constants = fs.constants; + export const existsSync = fs.existsSync; export const lstatSync = fs.lstatSync; export const readdirSync = fs.readdirSync; export const statSync = fs.statSync; export const writeFileSync = fs.writeFileSync; export const readFileSync = fs.readFileSync; +export const accessSync = fs.accessSync; +export const renameSync = fs.renameSync; export const exists = promisify(fs.exists); export const lstat = promisify(fs.lstat); @@ -14,6 +18,8 @@ export const readdir = promisify(fs.readdir); export const stat = promisify(fs.stat); export const writeFile = promisify(fs.writeFile); export const readFile = promisify(fs.readFile); +export const access = promisify(fs.access); +export const rename = promisify(fs.rename); export const watchFile = fs.watchFile; export const unwatchFile = fs.unwatchFile; diff --git a/arduino-ide-extension/src/node/sketches-service-impl.ts b/arduino-ide-extension/src/node/sketches-service-impl.ts index 500f4067..b779de4a 100644 --- a/arduino-ide-extension/src/node/sketches-service-impl.ts +++ b/arduino-ide-extension/src/node/sketches-service-impl.ts @@ -2,14 +2,24 @@ import { injectable, inject } from 'inversify'; import * as os from 'os'; import * as temp from 'temp'; import * as path from 'path'; -import * as fs from './fs-extra'; import { ncp } from 'ncp'; +import { Stats } from 'fs'; +import * as fs from './fs-extra'; +import URI from '@theia/core/lib/common/uri'; import { FileUri, BackendApplicationContribution } from '@theia/core/lib/node'; import { ConfigService } from '../common/protocol/config-service'; import { SketchesService, Sketch } from '../common/protocol/sketches-service'; -import URI from '@theia/core/lib/common/uri'; -export const ALLOWED_FILE_EXTENSIONS = ['.c', '.cpp', '.h', '.hh', '.hpp', '.s', '.pde', '.ino']; + +// As currently implemented on Linux, +// the maximum number of symbolic links that will be followed while resolving a pathname is 40 +const MAX_FILESYSTEM_DEPTH = 40; + +export namespace Extensions { + export const MAIN = ['.ino', '.pde']; + export const SOURCE = ['.c', '.cpp', '.s']; + export const ADDITIONAL = ['.h', '.c', '.hpp', '.hh', '.cpp', '.s']; +} // TODO: `fs`: use async API @injectable() @@ -43,46 +53,208 @@ export class SketchesServiceImpl implements SketchesService, BackendApplicationC for (const fileName of fileNames) { const filePath = path.join(fsPath, fileName); if (await this.isSketchFolder(FileUri.create(filePath).toString())) { - const stat = await fs.stat(filePath); - sketches.push({ - mtimeMs: stat.mtimeMs, - name: fileName, - uri: FileUri.create(filePath).toString() - }); + try { + const stat = await fs.stat(filePath); + const sketch = await this.loadSketch(FileUri.create(filePath).toString()); + sketches.push({ + ...sketch, + mtimeMs: stat.mtimeMs + }); + } catch { + console.warn(`Could not load sketch from ${filePath}.`); + } } } return sketches.sort((left, right) => right.mtimeMs - left.mtimeMs); } /** - * Return all allowed files. - * File extensions: 'c', 'cpp', 'h', 'hh', 'hpp', 's', 'pde', 'ino' + * This is the TS implementation of `SketchLoad` from the CLI. + * See: https://github.com/arduino/arduino-cli/issues/837 + * Based on: https://github.com/arduino/arduino-cli/blob/eef3705c4afcba4317ec38b803d9ffce5dd59a28/arduino/builder/sketch.go#L100-L215 */ - async getSketchFiles(uri: string): Promise { - const uris: string[] = []; - const fsPath = FileUri.fsPath(uri); - if (fs.lstatSync(fsPath).isDirectory()) { - if (await this.isSketchFolder(uri)) { - const basename = path.basename(fsPath) - const fileNames = await fs.readdir(fsPath); - for (const fileName of fileNames) { - const filePath = path.join(fsPath, fileName); - if (ALLOWED_FILE_EXTENSIONS.indexOf(path.extname(filePath)) !== -1 - && fs.existsSync(filePath) - && fs.lstatSync(filePath).isFile()) { - const uri = FileUri.create(filePath).toString(); - if (fileName === basename + '.ino') { - uris.unshift(uri); // The sketch file is the first. - } else { - uris.push(uri); - } + async loadSketch(uri: string): Promise { + const sketchPath = FileUri.fsPath(uri); + const exists = await fs.exists(sketchPath); + if (!exists) { + throw new Error(`${uri} does not exist.`); + } + const stat = await fs.lstat(sketchPath); + let sketchFolder: string | undefined; + let mainSketchFile: string | undefined; + + // If a sketch folder was passed, save the parent and point sketchPath to the main sketch file + if (stat.isDirectory()) { + sketchFolder = sketchPath; + // Allowed extensions are .ino and .pde (but not both) + for (const extension of Extensions.MAIN) { + const candidateSketchFile = path.join(sketchPath, `${path.basename(sketchPath)}${extension}`); + const candidateExists = await fs.exists(candidateSketchFile); + if (candidateExists) { + if (!mainSketchFile) { + mainSketchFile = candidateSketchFile; + } else { + throw new Error(`Multiple main sketch files found (${path.basename(mainSketchFile)}, ${path.basename(candidateSketchFile)})`); } } } - return uris; + + // Check main file was found. + if (!mainSketchFile) { + throw new Error(`Unable to find a sketch file in directory ${sketchFolder}`); + } + + // Check main file is readable. + try { + await fs.access(mainSketchFile, fs.constants.R_OK); + } catch { + throw new Error('Unable to open the main sketch file.'); + } + + const mainSketchFileStat = await fs.lstat(mainSketchFile); + if (mainSketchFileStat.isDirectory()) { + throw new Error(`Sketch must not be a directory.`); + } + } else { + sketchFolder = path.dirname(sketchPath); + mainSketchFile = sketchPath; + } + + const files: string[] = []; + let rootVisited = false; + const err = await this.simpleLocalWalk(sketchFolder, MAX_FILESYSTEM_DEPTH, async (fsPath: string, info: Stats, error: Error | undefined) => { + if (error) { + console.log(`Error during sketch processing: ${error}`); + return error; + } + const name = path.basename(fsPath); + if (info.isDirectory()) { + if (rootVisited) { + if (name.startsWith('.') || name === 'CVS' || name === 'RCS') { + return new SkipDir(); + } + } else { + rootVisited = true + } + return undefined; + } + + if (name.startsWith('.')) { + return undefined; + } + const ext = path.extname(fsPath); + const isMain = Extensions.MAIN.indexOf(ext) !== -1; + const isAdditional = Extensions.ADDITIONAL.indexOf(ext) !== -1; + if (!isMain && !isAdditional) { + return undefined; + } + + try { + await fs.access(fsPath, fs.constants.R_OK); + files.push(fsPath); + } catch { } + + return undefined; + }); + + if (err) { + console.error(`There was an error while collecting the sketch files: ${sketchPath}`) + throw err; + } + + return this.newSketch(sketchFolder, mainSketchFile, files); + + } + + private newSketch(sketchFolderPath: string, mainFilePath: string, allFilesPaths: string[]): Sketch { + let mainFile: string | undefined; + const paths = new Set(); + for (const p of allFilesPaths) { + if (p === mainFilePath) { + mainFile = p; + } else { + paths.add(p); + } + } + if (!mainFile) { + throw new Error('Could not locate main sketch file.'); + } + const additionalFiles: string[] = []; + const otherSketchFiles: string[] = []; + for (const p of Array.from(paths)) { + const ext = path.extname(p); + if (Extensions.MAIN.indexOf(ext) !== -1) { + if (path.dirname(p) === sketchFolderPath) { + otherSketchFiles.push(p); + } + } else if (Extensions.ADDITIONAL.indexOf(ext) !== -1) { + // XXX: this is a caveat with the CLI, we do not know the `buildPath`. + // https://github.com/arduino/arduino-cli/blob/0483882b4f370c288d5318913657bbaa0325f534/arduino/sketch/sketch.go#L108-L110 + additionalFiles.push(p); + } else { + throw new Error(`Unknown sketch file extension '${ext}'.`); + } + } + additionalFiles.sort(); + otherSketchFiles.sort(); + + return { + uri: FileUri.create(sketchFolderPath).toString(), + mainFileUri: FileUri.create(mainFile).toString(), + name: path.basename(sketchFolderPath), + additionalFileUris: additionalFiles.map(p => FileUri.create(p).toString()), + otherSketchFileUris: otherSketchFiles.map(p => FileUri.create(p).toString()) + } + } + + protected async simpleLocalWalk( + root: string, + maxDepth: number, + walk: (fsPath: string, info: Stats | undefined, err: Error | undefined) => Promise): Promise { + + let { info, err } = await this.lstat(root); + if (err) { + return walk(root, undefined, err); + } + if (!info) { + return new Error(`Could not stat file: ${root}.`); + } + err = await walk(root, info, err); + if (err instanceof SkipDir) { + return undefined; + } + + if (info.isDirectory()) { + if (maxDepth <= 0) { + return walk(root, info, new Error(`Filesystem bottom is too deep (directory recursion or filesystem really deep): ${root}`)); + } + maxDepth--; + const files: string[] = []; + try { + files.push(...await fs.readdir(root)); + } catch { } + for (const file of files) { + err = await this.simpleLocalWalk(path.join(root, file), maxDepth, walk); + if (err instanceof SkipDir) { + return undefined; + } + } + } + + return undefined; + } + + private async lstat(fsPath: string): Promise<{ info: Stats, err: undefined } | { info: undefined, err: Error }> { + const exists = await fs.exists(fsPath); + if (!exists) { + return { info: undefined, err: new Error(`${fsPath} does not exist`) }; + } + try { + const info = await fs.lstat(fsPath); + return { info, err: undefined }; + } catch (err) { + return { info: undefined, err }; } - const sketchDir = path.dirname(fsPath); - return this.getSketchFiles(FileUri.create(sketchDir).toString()); } async createNewSketch(): Promise { @@ -129,10 +301,7 @@ void loop() { } `, { encoding: 'utf8' }); - return { - name: sketchName, - uri: FileUri.create(sketchDir).toString() - } + return this.loadSketch(FileUri.create(sketchDir).toString()); } async getSketchFolder(uri: string): Promise { @@ -142,10 +311,7 @@ void loop() { let currentUri = new URI(uri); while (currentUri && !currentUri.path.isRoot) { if (await this.isSketchFolder(currentUri.toString())) { - return { - name: currentUri.path.base, - uri: currentUri.toString() - }; + return this.loadSketch(currentUri.toString()); } currentUri = currentUri.parent; } @@ -173,20 +339,35 @@ void loop() { async copy(sketch: Sketch, { destinationUri }: { destinationUri: string }): Promise { const source = FileUri.fsPath(sketch.uri); - if (await !fs.exists(source)) { + const exists = await fs.exists(source); + if (!exists) { throw new Error(`Sketch does not exist: ${sketch}`); } const destination = FileUri.fsPath(destinationUri); await new Promise((resolve, reject) => { - ncp.ncp(source, destination, error => { + ncp.ncp(source, destination, async error => { if (error) { reject(error); return; } - resolve(); + const newName = path.basename(destination); + try { + await fs.rename(path.join(destination, new URI(sketch.mainFileUri).path.base), path.join(destination, `${newName}.ino`)); + await this.loadSketch(destinationUri); // Sanity check. + resolve(); + } catch (e) { + reject(e); + } }); }); return FileUri.create(destination).toString(); } } + +class SkipDir extends Error { + constructor() { + super('skip this directory'); + Object.setPrototypeOf(this, SkipDir.prototype); + } +}