Compare commits

..

36 Commits
2.0.3 ... #1733

Author SHA1 Message Date
Akos Kitta
88cd571080 fix: restored breadcrumbs.enabled
Ref: #1733
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-15 14:46:12 +01:00
Akos Kitta
9ffe421fab fix: restored window.titleBarStyle
Ref: #1733

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-15 14:32:08 +01:00
Akos Kitta
d68bc4abdb feat: rename, deletion, and validation support
Closes #1599
Closes #1825
Closes #649
Closes #1847
Closes #1882

Co-authored-by: Akos Kitta <a.kitta@arduino.cc>
Co-authored-by: per1234 <accounts@perglass.com>

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-15 14:09:36 +01:00
github-actions[bot]
4f07515ee8 Updated themes (#1836)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-02-15 09:27:23 +01:00
Akos Kitta
25b545d4c4 fix: added GH token for vscode-ripgrep download
Otherwise, yarn install hits an HTTP 403 due to the rate-limiter.

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-13 15:28:39 +01:00
Akos Kitta
79b6b7ecc0 fix: library search boosting
Closes #1106

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-10 14:29:26 +01:00
Akos Kitta
5d264ef5b6 fix: show board info based on the selected port
include serial number of board if available

Closes #1489
Closes #1435

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-02-06 09:20:44 +01:00
dependabot[bot]
f63ee85fa3 build(deps): Bump svenstaro/upload-release-action from 2.4.0 to 2.4.1
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.4.0 to 2.4.1.
- [Release notes](https://github.com/svenstaro/upload-release-action/releases)
- [Changelog](https://github.com/svenstaro/upload-release-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/svenstaro/upload-release-action/compare/2.4.0...2.4.1)

---
updated-dependencies:
- dependency-name: svenstaro/upload-release-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-05 07:55:22 -08:00
per1234
083a7069f0 Add leading+trailing line break to "Copy for Forum" content
Arduino IDE's "Edit > Copy for Forum (Markdown)" feature copies the contents of the currently selected editor tab to the
clipboard, with "fenced code block" markup added to provide correct formatting when the content is posted to a platform
supporting Markdown language such as Arduino Forum, GitHub, Stack Exchange, etc.

One of the most common points of friction between the volunteer helpers on the forum and the newcomers requesting
assistance is the failure to use the correct markup when posting code. In the best case scenario the code and thread is
made less readable and less convenient to copy to the IDE for further investigation. Components of the code often
resemble markup, which causes it to be corrupted by the forum's renderer.

Even in cases where the user was conscientious enough to attempt to add the right markup, which is facilitated by tools
such as "Copy for Forum (Markdown)", they often still don't get it quite right. So it is worthwhile to make efforts to
make it less likely for the markup to be inadvertently invalidated.

"Fenced code block" markup must be on its own lines before and after the code content. Someone not familiar with
Markdown won't know this fact and may simply paste the content copied via "Copy for Forum (Markdown)" without manually
adding a newline before and after. Since the code content is preceded and succeeded by a newline, they will not have any
visual indication of a problem.

Adding a newline before and after the content will ensure the markup is valid regardless of the context it is pasted
into. In cases where the user did add a newline and this introduces a redundant line break in the forum post, it will
not have any effect on the rendered content because the additional newlines are ignored by the renderer.
2023-02-03 04:58:15 -08:00
Akos Kitta
f5621db85d fix: flaky compiler test
- The gRPC core client provider requires an initialized config service when processing the error message received during the `InitRequest`
 - Additional logging in the config service.
 - The tests log into the console.

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-27 10:23:51 +01:00
Akos Kitta
658f117e93 test: added compiler + build output path test
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-24 09:06:07 +01:00
Akos Kitta
6140ae525c feat: handle when starting debug session failed (#1809)
If the sketch has not been verified, IDE2 offers the user a verify action.

Closes #808

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-23 09:03:49 +01:00
dependabot[bot]
afb02da806 build(deps): Bump svenstaro/upload-release-action from 2.3.0 to 2.4.0
Bumps [svenstaro/upload-release-action](https://github.com/svenstaro/upload-release-action) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/svenstaro/upload-release-action/releases)
- [Changelog](https://github.com/svenstaro/upload-release-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/svenstaro/upload-release-action/compare/2.3.0...2.4.0)

---
updated-dependencies:
- dependency-name: svenstaro/upload-release-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-18 22:08:34 -08:00
Akos Kitta
692f29fe1a fix: sketchbook container building
Closes #1185

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-17 13:54:26 +01:00
Akos Kitta
40e797966f fix: start the LS with the board specific settings
restart the LS when board settings of the running LS has changed

Closes #1029

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-16 11:39:22 +01:00
Akos Kitta
a15a94a339 fix: workaround for # in the app path
Closes #1815
Ref eclipse-theia/theia#12064

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-16 09:43:20 +01:00
Akos Kitta
ca687cfe40 fix: remove unused module
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-16 09:43:20 +01:00
Akos Kitta
32e17745f1 chore: Use 0.7.4 LS with the URL encoding fixes
Closes #1124

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-16 09:43:20 +01:00
Akos Kitta
432f3654df fix: restart LS on lib/core change, client re-init
Closes #670

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-16 09:38:27 +01:00
Akos Kitta
197cea2a60 fix: aligned Add File... behavior with IDE 1.x
- code files will be copied to sketch folder root
 - other files go under the `data` folder in the sketch folder root

Closes #284

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-11 09:23:50 +01:00
Akos Kitta
b2bf368db9 fix: update Examples and Include Library menu after ZIP install
Closes #659

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-11 09:23:23 +01:00
Akos Kitta
287b2e3f41 feat: removed encoding from the status bar
Closes #1393

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-11 09:22:53 +01:00
Akos Kitta
da0fecfd0f feat: no remote fetch when IDE gets CLI version
the CLI version is retrieved from the `package.json` of the extension:
`arduino.cli.version`

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2023-01-11 09:22:29 +01:00
Akos Kitta
76f9f635d8 feat: configure sketchbook location without restart
Closes #1764
Closes #796
Closes #569
Closes #655

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-21 15:44:10 +01:00
Self Not Found
3f05396222 Replace socks with socks5 in proxy protocol (#1776)
The net/http package in arduino-cli supports socks5 as scheme rather than socks.

Co-authored-by: per1234 <accounts@perglass.com>
2022-12-21 14:34:09 +01:00
Self Not Found
644e6079b3 Remove trailing colon when parsing the protocol from URL (#1778)
* Remove trailing colon when parsing the protocol from URL
* Fix bug
2022-12-21 14:32:46 +01:00
Akos Kitta
1d342cdbd0 build: use a local npm registry for app packaging
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-15 12:29:26 +01:00
Akos Kitta
908ec4c544 fix: menu item enablement issues
Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-15 09:21:45 +01:00
Frank Palazzolo
7c86f1f9d3 Fix corruption of incoming UTF-8-encoded serial data (#1758)
* Fix corruption of incoming UTF-8-encoded serial data
* Make TextDecoder() readonly because it can be

Co-authored-by: z3bra <111462146+z3bra5hax@users.noreply.github.com>
2022-12-13 09:01:51 +01:00
Akos Kitta
f8c01e379c fix: list view filtering when opening the view
When the widget is opened the first time with any search options,
the widget might miss the refresh event as it does not happen at
instantiation time. This PR ensures that all list widget refresh
events wait until the React component is rendered and is visible
in the UI.

This change also ensures that the debounced search will run with
the most recent search options by setting the `trailing` property
of the debounced function to `true`.

Closes #1740

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-08 08:17:23 +01:00
Akos Kitta
af468a73bc feat: show the selected board config value on menu
closes #343

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-08 08:16:02 +01:00
per1234
d3a863911c Restore certificate check compatibility w/ RC2-40-CBC encrypted PKS#12
The "Check Certificates" GitHub Actions workflow uses OpenSSL to check for problems with the project's signing
certificates.

Certificates exported to PKS#12 archive files using older tools may have been encrypted using the "RC2-40-CBC"
algorithm.

Due to the availability of more secure modern alternatives, default support for RC2-40-CBC encryption was dropped in
OpenSSL 3.x.

The macOS signing certificate uses this RC2-40-CBC encryption.

The "Check Certificates" GitHub Actions workflow runs on the `ubuntu-latest` runner. Previously, this runner used Ubuntu
20.04. This has now changed to Ubuntu 22.04. With the operating system update came an OpenSSL update from 1.1.1f to
3.0.2. This caused the workflow runs to fail on the macOS certificate job:

Error outputting keys and certificates
80FBB0C5087F0000:error:0308010C:digital envelope routines:inner_evp_generic_fetch:unsupported:../crypto/evp/evp_fetch.c:349:Global default library context, Algorithm (RC2-40-CBC : 0), Properties ()

Even though no longer done by default, OpenSSL still supports RC2-40-CBC encryption via its "legacy" provider. So
compatibility with the certificate is restored by adding the `-legacy` flag to the `openssl pkcs12` commands.
2022-12-07 03:09:40 -08:00
Akos Kitta
c4172ee8e1 fix: theme service binding
cleaned up unused symbol

closes #1742

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-07 09:52:12 +01:00
Akos Kitta
ed8ed15168 fix: Preferences menu enablement defect
Manually update the menus when executing the command.

Also disabled the menu when the settings dialog is opened.

closes #1735

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
2022-12-07 09:51:55 +01:00
per1234
32f0426f01 Fix formatting of generated release notes
The "Arduino IDE" GitHub Actions workflow generates a changelog from the commits since the last tag.

This changelog is published in multiple ways:

- Printed to workflow run logs
- Uploaded to Arduino's download server (mostly useful for the nightly builds)
- Initial version of release notes

For the last, the changelog text must be passed from the dedicated changelog generation workflow step to the release
step. This is done via workflow job output.

At the time the system was set up, outputs for workflow `run` steps were set using the `set-output` workflow command.
That "workflow command" system was later determined by GitHub to have potential security vulnerabilities, so it was
replaced with a `GITHUB_OUTPUT` environment file.

The "Arduino IDE" workflow was migrated to the new "environment file" approach. It was later discovered that there was
an undocumented breaking change in the method for handling multi-line strings in workflow step outputs between the old
"workflow command" system and the new "environment file". This resulted in the initial release notes having an incorrect
format. For example, what would previously have been formatted like this:

- Updated translation files (#1606) [23c7f5f]
- Use 0.29.0 CLI in IDE2 (#1683) [f1144ef]

Was now formatted like this:

- Updated translation files (#1606) [23c7f5f]%0A - Use 0.29.0 CLI in IDE2 (#1683) [f1144ef]%0A

The solution is to remove the commands that did the escaping of the changelog text in a manner that is no longer
supported and replace them with a "here document"-style format.

A random number is used as the "delimiter" (limit string) per the security recommendations in the official GitHub
documentation. Note that even though the multiline strings handling documentation was placed under the environment
variable section, it also applies to setting outputs.
2022-12-07 00:44:21 -08:00
Alberto Iannaccone
200c00244b 2.0.4 2022-12-06 04:13:54 -08:00
158 changed files with 9791 additions and 4265 deletions

View File

@@ -15,7 +15,6 @@ module.exports = {
'.browser_modules/*',
'docs/*',
'scripts/*',
'electron/*',
'electron-app/*',
'plugins/*',
'arduino-ide-extension/src/node/cli-protocol',

View File

@@ -180,10 +180,14 @@ jobs:
fi
fi
echo -e "$BODY"
OUTPUT_SAFE_BODY="${BODY//'%'/'%25'}"
OUTPUT_SAFE_BODY="${OUTPUT_SAFE_BODY//$'\n'/'%0A'}"
OUTPUT_SAFE_BODY="${OUTPUT_SAFE_BODY//$'\r'/'%0D'}"
echo "BODY=$OUTPUT_SAFE_BODY" >> $GITHUB_OUTPUT
# Set workflow step output
# See: https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
DELIMITER="$RANDOM"
echo "BODY<<$DELIMITER" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "$DELIMITER" >> $GITHUB_OUTPUT
echo "$BODY" > CHANGELOG.txt
- name: Upload Changelog [GitHub Actions]
@@ -231,7 +235,7 @@ jobs:
echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Publish Release [GitHub]
uses: svenstaro/upload-release-action@2.3.0
uses: svenstaro/upload-release-action@2.4.1
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
release_name: ${{ steps.tag_name.outputs.TAG_NAME }}

View File

@@ -59,7 +59,9 @@ jobs:
(
openssl pkcs12 \
-in "${{ env.CERTIFICATE_PATH }}" \
-noout -passin env:CERTIFICATE_PASSWORD
-legacy \
-noout \
-passin env:CERTIFICATE_PASSWORD
) || (
echo "::error::Verification of ${{ matrix.certificate.identifier }} failed!!!"
exit 1
@@ -87,6 +89,7 @@ jobs:
openssl pkcs12 \
-in "${{ env.CERTIFICATE_PATH }}" \
-clcerts \
-legacy \
-nodes \
-passin env:CERTIFICATE_PASSWORD
) | (

View File

@@ -48,6 +48,8 @@ jobs:
- name: Install dependencies
run: yarn
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check for errors
run: yarn i18n:check

4
.gitignore vendored
View File

@@ -4,7 +4,7 @@ node_modules/
lib/
downloads/
build/
Examples/
arduino-ide-extension/Examples/
!electron/build/
src-gen/
webpack.config.js
@@ -21,3 +21,5 @@ scripts/themes/tokens
.env
# content trace files for electron
electron-app/traces
# any Arduino LS generated log files
inols*.log

2
.vscode/launch.json vendored
View File

@@ -14,7 +14,6 @@
".",
"--log-level=debug",
"--hostname=localhost",
"--no-cluster",
"--app-project-path=${workspaceRoot}/electron-app",
"--remote-debugging-port=9222",
"--no-app-auto-install",
@@ -52,7 +51,6 @@
".",
"--log-level=debug",
"--hostname=localhost",
"--no-cluster",
"--app-project-path=${workspaceRoot}/electron-app",
"--remote-debugging-port=9222",
"--no-app-auto-install",

View File

@@ -2,6 +2,9 @@
"files.exclude": {
"**/lib": false
},
"search.exclude": {
"arduino-ide-extension/src/test/node/__test_sketchbook__": true
},
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true

View File

@@ -1,6 +1,6 @@
{
"name": "arduino-ide-extension",
"version": "2.0.3",
"version": "2.0.4",
"description": "An extension for Theia building the Arduino IDE",
"license": "AGPL-3.0-or-later",
"scripts": {
@@ -17,6 +17,7 @@
"build": "tsc && ncp ./src/node/cli-protocol/ ./lib/node/cli-protocol/ && yarn lint",
"watch": "tsc -w",
"test": "mocha \"./lib/test/**/*.test.js\"",
"test:slow": "mocha \"./lib/test/**/*.slow-test.js\" --slow 5000",
"test:watch": "mocha --watch --watch-files lib \"./lib/test/**/*.test.js\""
},
"dependencies": {
@@ -66,10 +67,13 @@
"auth0-js": "^9.14.0",
"btoa": "^1.2.1",
"classnames": "^2.3.1",
"cross-fetch": "^3.1.5",
"dateformat": "^3.0.3",
"deepmerge": "2.0.1",
"electron-updater": "^4.6.5",
"fast-json-stable-stringify": "^2.1.0",
"fast-safe-stringify": "^2.1.1",
"filename-reserved-regex": "^2.0.0",
"glob": "^7.1.6",
"google-protobuf": "^3.20.1",
"hash.js": "^1.1.7",
@@ -168,7 +172,7 @@
"version": "14.0.0"
},
"languageServer": {
"version": "0.7.2"
"version": "0.7.4"
}
}
}

View File

@@ -23,7 +23,7 @@ import {
SketchesService,
SketchesServicePath,
} from '../common/protocol/sketches-service';
import { SketchesServiceClientImpl } from '../common/protocol/sketches-service-client-impl';
import { SketchesServiceClientImpl } from './sketches-service-client-impl';
import { CoreService, CoreServicePath } from '../common/protocol/core-service';
import { BoardsListWidget } from './boards/boards-list-widget';
import { BoardsListWidgetFrontendContribution } from './boards/boards-widget-frontend-contribution';
@@ -127,7 +127,7 @@ import { PreferencesContribution as TheiaPreferencesContribution } from '@theia/
import { PreferencesContribution } from './theia/preferences/preferences-contribution';
import { QuitApp } from './contributions/quit-app';
import { SketchControl } from './contributions/sketch-control';
import { Settings } from './contributions/settings';
import { OpenSettings } from './contributions/open-settings';
import { WorkspaceCommandContribution } from './theia/workspace/workspace-commands';
import { WorkspaceDeleteHandler as TheiaWorkspaceDeleteHandler } from '@theia/workspace/lib/browser/workspace-delete-handler';
import { WorkspaceDeleteHandler } from './theia/workspace/workspace-delete-handler';
@@ -323,8 +323,8 @@ import { NewCloudSketch } from './contributions/new-cloud-sketch';
import { SketchbookCompositeWidget } from './widgets/sketchbook/sketchbook-composite-widget';
import { WindowTitleUpdater } from './theia/core/window-title-updater';
import { WindowTitleUpdater as TheiaWindowTitleUpdater } from '@theia/core/lib/browser/window/window-title-updater';
import { ThemeService } from './theia/core/theming';
import { ThemeService as TheiaThemeService } from '@theia/core/lib/browser/theming';
import { ThemeServiceWithDB } from './theia/core/theming';
import { ThemeServiceWithDB as TheiaThemeServiceWithDB } from '@theia/monaco/lib/browser/monaco-indexed-db';
import { MonacoThemingService } from './theia/monaco/monaco-theming-service';
import { MonacoThemingService as TheiaMonacoThemingService } from '@theia/monaco/lib/browser/monaco-theming-service';
import { TypeHierarchyServiceProvider } from './theia/typehierarchy/type-hierarchy-service';
@@ -343,6 +343,10 @@ import { DebugWidget } from '@theia/debug/lib/browser/view/debug-widget';
import { DebugViewModel } from '@theia/debug/lib/browser/view/debug-view-model';
import { DebugSessionWidget } from '@theia/debug/lib/browser/view/debug-session-widget';
import { DebugConfigurationWidget } from '@theia/debug/lib/browser/view/debug-configuration-widget';
import { ConfigServiceClient } from './config/config-service-client';
import { ValidateSketch } from './contributions/validate-sketch';
import { RenameCloudSketch } from './contributions/rename-cloud-sketch';
import { CreateFeatures } from './create/create-features';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
// Commands and toolbar items
@@ -404,6 +408,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
)
)
.inSingletonScope();
bind(ConfigServiceClient).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(ConfigServiceClient);
// Boards service
bind(BoardsService)
@@ -691,7 +697,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
Contribution.configure(bind, EditContributions);
Contribution.configure(bind, QuitApp);
Contribution.configure(bind, SketchControl);
Contribution.configure(bind, Settings);
Contribution.configure(bind, OpenSettings);
Contribution.configure(bind, BurnBootloader);
Contribution.configure(bind, BuiltInExamples);
Contribution.configure(bind, LibraryExamples);
@@ -726,6 +732,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
Contribution.configure(bind, UpdateIndexes);
Contribution.configure(bind, InterfaceScale);
Contribution.configure(bind, NewCloudSketch);
Contribution.configure(bind, ValidateSketch);
Contribution.configure(bind, RenameCloudSketch);
bindContributionProvider(bind, StartupTaskProvider);
bind(StartupTaskProvider).toService(BoardsServiceProvider); // to reuse the boards config in another window
@@ -886,6 +894,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
);
bind(CreateApi).toSelf().inSingletonScope();
bind(SketchCache).toSelf().inSingletonScope();
bind(CreateFeatures).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(CreateFeatures);
bind(ShareSketchDialog).toSelf().inSingletonScope();
bind(AuthenticationClientService).toSelf().inSingletonScope();
@@ -959,8 +969,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
rebind(TheiaWindowTitleUpdater).toService(WindowTitleUpdater);
// register Arduino themes
bind(ThemeService).toSelf().inSingletonScope();
rebind(TheiaThemeService).toService(ThemeService);
bind(ThemeServiceWithDB).toSelf().inSingletonScope();
rebind(TheiaThemeServiceWithDB).toService(ThemeServiceWithDB);
bind(MonacoThemingService).toSelf().inSingletonScope();
rebind(TheiaMonacoThemingService).toService(MonacoThemingService);

View File

@@ -1,68 +0,0 @@
import { URI } from '@theia/core/shared/vscode-uri';
import { isWindows } from '@theia/core/lib/common/os';
import { notEmpty } from '@theia/core/lib/common/objects';
import { MaybePromise } from '@theia/core/lib/common/types';
/**
* Class for determining the default workspace location from the
* `location.hash`, the historical workspace locations, and recent sketch files.
*
* The following logic is used for determining the default workspace location:
* - `hash` points to an existing location?
* - Yes
* - `validate location`. Is valid sketch location?
* - Yes
* - Done.
* - No
* - `try open recent workspace roots`, then `try open last modified sketches`, finally `create new sketch`.
* - No
* - `try open recent workspace roots`, then `try open last modified sketches`, finally `create new sketch`.
*/
namespace ArduinoWorkspaceRootResolver {
export interface InitOptions {
readonly isValid: (uri: string) => MaybePromise<boolean>;
}
export interface ResolveOptions {
readonly hash?: string;
readonly recentWorkspaces: string[];
// Gathered from the default sketch folder. The default sketch folder is defined by the CLI.
readonly recentSketches: string[];
}
}
export class ArduinoWorkspaceRootResolver {
constructor(protected options: ArduinoWorkspaceRootResolver.InitOptions) {}
async resolve(
options: ArduinoWorkspaceRootResolver.ResolveOptions
): Promise<{ uri: string } | undefined> {
const { hash, recentWorkspaces, recentSketches } = options;
for (const uri of [
this.hashToUri(hash),
...recentWorkspaces,
...recentSketches,
].filter(notEmpty)) {
const valid = await this.isValid(uri);
if (valid) {
return { uri };
}
}
return undefined;
}
protected isValid(uri: string): MaybePromise<boolean> {
return this.options.isValid(uri);
}
// Note: here, the `hash` was defined as new `URI(yourValidFsPath).path` so we have to map it to a valid FS path first.
// This is important for Windows only and a NOOP on POSIX.
// Note: we set the `new URI(myValidUri).path.toString()` as the `hash`. See:
// - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L143 and
// - https://github.com/eclipse-theia/theia/blob/8196e9dcf9c8de8ea0910efeb5334a974f426966/packages/workspace/src/browser/workspace-service.ts#L423
protected hashToUri(hash: string | undefined): string | undefined {
if (hash && hash.length > 1 && hash.startsWith('#')) {
const path = decodeURI(hash.slice(1)).replace(/\\/g, '/'); // Trim the leading `#`, decode the URI and replace Windows separators
return URI.file(path.slice(isWindows && hash.startsWith('/') ? 1 : 0)).toString();
}
return undefined;
}
}

View File

@@ -6,6 +6,7 @@ import { DisposableCollection } from '@theia/core/lib/common/disposable';
import {
Board,
Port,
BoardConfig as ProtocolBoardConfig,
BoardWithPackage,
} from '../../common/protocol/boards-service';
import { NotificationCenter } from '../notification-center';
@@ -18,10 +19,7 @@ import { nls } from '@theia/core/lib/common';
import { FrontendApplicationState } from '@theia/core/lib/common/frontend-application-state';
export namespace BoardsConfig {
export interface Config {
selectedBoard?: Board;
selectedPort?: Port;
}
export type Config = ProtocolBoardConfig;
export interface Props {
readonly boardsServiceProvider: BoardsServiceProvider;

View File

@@ -80,16 +80,16 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
string,
Disposable & { label: string }
>();
let selectedValue = '';
for (const value of values) {
const id = `${fqbn}-${option}--${value.value}`;
const command = { id };
const selectedValue = value.value;
const handler = {
execute: () =>
this.boardsDataStore.selectConfigOption({
fqbn,
option,
selectedValue,
selectedValue: value.value,
}),
isToggled: () => value.selected,
};
@@ -100,8 +100,14 @@ export class BoardsDataMenuUpdater implements FrontendApplicationContribution {
{ label: value.label }
)
);
if (value.selected) {
selectedValue = value.label;
}
}
this.menuRegistry.registerSubmenu(menuPath, label);
this.menuRegistry.registerSubmenu(
menuPath,
`${label}${selectedValue ? `: "${selectedValue}"` : ''}`
);
this.toDisposeOnBoardChange.pushAll([
...commands.values(),
Disposable.create(() =>

View File

@@ -30,11 +30,11 @@ export class BoardsDataStore implements FrontendApplicationContribution {
@inject(LocalStorageService)
protected readonly storageService: LocalStorageService;
protected readonly onChangedEmitter = new Emitter<void>();
protected readonly onChangedEmitter = new Emitter<string[]>();
onStart(): void {
this.notificationCenter.onPlatformDidInstall(async ({ item }) => {
let shouldFireChanged = false;
const dataDidChangePerFqbn: string[] = [];
for (const fqbn of item.boards
.map(({ fqbn }) => fqbn)
.filter(notEmpty)
@@ -49,18 +49,18 @@ export class BoardsDataStore implements FrontendApplicationContribution {
data = details.configOptions;
if (data.length) {
await this.storageService.setData(key, data);
shouldFireChanged = true;
dataDidChangePerFqbn.push(fqbn);
}
}
}
}
if (shouldFireChanged) {
this.fireChanged();
if (dataDidChangePerFqbn.length) {
this.fireChanged(...dataDidChangePerFqbn);
}
});
}
get onChanged(): Event<void> {
get onChanged(): Event<string[]> {
return this.onChangedEmitter.event;
}
@@ -116,7 +116,7 @@ export class BoardsDataStore implements FrontendApplicationContribution {
fqbn,
data: { ...data, selectedProgrammer },
});
this.fireChanged();
this.fireChanged(fqbn);
return true;
}
@@ -146,7 +146,7 @@ export class BoardsDataStore implements FrontendApplicationContribution {
return false;
}
await this.setData({ fqbn, data });
this.fireChanged();
this.fireChanged(fqbn);
return true;
}
@@ -190,8 +190,8 @@ export class BoardsDataStore implements FrontendApplicationContribution {
}
}
protected fireChanged(): void {
this.onChangedEmitter.fire();
protected fireChanged(...fqbn: string[]): void {
this.onChangedEmitter.fire(fqbn);
}
}

View File

@@ -30,7 +30,6 @@ export class BoardsListWidget extends ListWidget<BoardsPackage, BoardSearch> {
searchable: service,
installable: service,
itemLabel: (item: BoardsPackage) => item.name,
itemDeprecated: (item: BoardsPackage) => item.deprecated,
itemRenderer,
filterRenderer,
defaultSearchOptions: { query: '', type: 'All' },

View File

@@ -0,0 +1,102 @@
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import { deepClone } from '@theia/core/lib/common/objects';
import URI from '@theia/core/lib/common/uri';
import {
inject,
injectable,
postConstruct,
} from '@theia/core/shared/inversify';
import { ConfigService, ConfigState } from '../../common/protocol';
import { NotificationCenter } from '../notification-center';
@injectable()
export class ConfigServiceClient implements FrontendApplicationContribution {
@inject(ConfigService)
private readonly delegate: ConfigService;
@inject(NotificationCenter)
private readonly notificationCenter: NotificationCenter;
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
@inject(MessageService)
private readonly messageService: MessageService;
private readonly didChangeSketchDirUriEmitter = new Emitter<
URI | undefined
>();
private readonly didChangeDataDirUriEmitter = new Emitter<URI | undefined>();
private readonly toDispose = new DisposableCollection(
this.didChangeSketchDirUriEmitter,
this.didChangeDataDirUriEmitter
);
private config: ConfigState | undefined;
@postConstruct()
protected init(): void {
this.appStateService.reachedState('ready').then(async () => {
const config = await this.delegate.getConfiguration();
this.use(config);
});
}
onStart(): void {
this.notificationCenter.onConfigDidChange((config) => this.use(config));
}
onStop(): void {
this.toDispose.dispose();
}
get onDidChangeSketchDirUri(): Event<URI | undefined> {
return this.didChangeSketchDirUriEmitter.event;
}
get onDidChangeDataDirUri(): Event<URI | undefined> {
return this.didChangeDataDirUriEmitter.event;
}
/**
* CLI config related error messages if any.
*/
tryGetMessages(): string[] | undefined {
return this.config?.messages;
}
/**
* `directories.user`
*/
tryGetSketchDirUri(): URI | undefined {
return this.config?.config?.sketchDirUri
? new URI(this.config?.config?.sketchDirUri)
: undefined;
}
/**
* `directories.data`
*/
tryGetDataDirUri(): URI | undefined {
return this.config?.config?.dataDirUri
? new URI(this.config?.config?.dataDirUri)
: undefined;
}
private use(config: ConfigState): void {
const oldConfig = deepClone(this.config);
this.config = config;
if (oldConfig?.config?.sketchDirUri !== this.config?.config?.sketchDirUri) {
this.didChangeSketchDirUriEmitter.fire(this.tryGetSketchDirUri());
}
if (oldConfig?.config?.dataDirUri !== this.config?.config?.dataDirUri) {
this.didChangeDataDirUriEmitter.fire(this.tryGetDataDirUri());
}
if (this.config.messages?.length) {
const message = this.config.messages.join(' ');
// toast the error later otherwise it might not show up in IDE2
setTimeout(() => this.messageService.error(message), 1_000);
}
}
}

View File

@@ -41,22 +41,16 @@ export class About extends Contribution {
}
async showAbout(): Promise<void> {
const {
version,
commit,
status: cliStatus,
} = await this.configService.getVersion();
const version = await this.configService.getVersion();
const buildDate = this.buildDate;
const detail = (showAll: boolean) =>
nls.localize(
'arduino/about/detail',
'Version: {0}\nDate: {1}{2}\nCLI Version: {3}{4} [{5}]\n\n{6}',
'Version: {0}\nDate: {1}{2}\nCLI Version: {3}\n\n{4}',
remote.app.getVersion(),
buildDate ? buildDate : nls.localize('', 'dev build'),
buildDate && showAll ? ` (${this.ago(buildDate)})` : '',
version,
cliStatus ? ` ${cliStatus}` : '',
commit,
nls.localize(
'arduino/about/copyright',
'Copyright © {0} Arduino SA',

View File

@@ -7,10 +7,11 @@ import {
CommandRegistry,
MenuModelRegistry,
URI,
Sketch,
} from './contribution';
import { FileDialogService } from '@theia/filesystem/lib/browser';
import { nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class AddFile extends SketchContribution {
@@ -46,9 +47,7 @@ export class AddFile extends SketchContribution {
if (!toAddUri) {
return;
}
const sketchUri = new URI(sketch.uri);
const filename = toAddUri.path.base;
const targetUri = sketchUri.resolve('data').resolve(filename);
const { uri: targetUri, filename } = this.resolveTarget(sketch, toAddUri);
const exists = await this.fileService.exists(targetUri);
if (exists) {
const { response } = await remote.dialog.showMessageBox({
@@ -80,6 +79,22 @@ export class AddFile extends SketchContribution {
}
);
}
// https://github.com/arduino/arduino-ide/issues/284#issuecomment-1364533662
// File the file to add has one of the following extension, it goes to the sketch folder root: .ino, .h, .cpp, .c, .S
// Otherwise, the files goes to the `data` folder inside the sketch folder root.
private resolveTarget(
sketch: Sketch,
toAddUri: URI
): { uri: URI; filename: string } {
const path = toAddUri.path;
const filename = path.base;
let root = new URI(sketch.uri);
if (!Sketch.Extensions.CODE_FILES.includes(path.ext)) {
root = root.resolve('data');
}
return { uri: root.resolve(filename), filename: filename };
}
}
export namespace AddFile {

View File

@@ -2,7 +2,6 @@ import { inject, injectable } from '@theia/core/shared/inversify';
import * as remote from '@theia/core/electron-shared/@electron/remote';
import URI from '@theia/core/lib/common/uri';
import { ConfirmDialog } from '@theia/core/lib/browser/dialogs';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { ArduinoMenus } from '../menu/arduino-menus';
import { LibraryService, ResponseServiceClient } from '../../common/protocol';
import { ExecuteWithProgress } from '../../common/protocol/progressible';
@@ -16,9 +15,6 @@ import { nls } from '@theia/core/lib/common';
@injectable()
export class AddZipLibrary extends SketchContribution {
@inject(EnvVariablesServer)
private readonly envVariableServer: EnvVariablesServer;
@inject(ResponseServiceClient)
private readonly responseService: ResponseServiceClient;

View File

@@ -1,7 +1,6 @@
import { injectable } from '@theia/core/shared/inversify';
import * as remote from '@theia/core/electron-shared/@electron/remote';
import * as dateFormat from 'dateformat';
import URI from '@theia/core/lib/common/uri';
import { ArduinoMenus } from '../menu/arduino-menus';
import {
SketchContribution,
@@ -10,7 +9,7 @@ import {
MenuModelRegistry,
} from './contribution';
import { nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class ArchiveSketch extends SketchContribution {
@@ -29,10 +28,7 @@ export class ArchiveSketch extends SketchContribution {
}
private async archiveSketch(): Promise<void> {
const [sketch, config] = await Promise.all([
this.sketchServiceClient.currentSketch(),
this.configService.getConfiguration(),
]);
const sketch = await this.sketchServiceClient.currentSketch();
if (!CurrentSketch.isValid(sketch)) {
return;
}
@@ -40,9 +36,9 @@ export class ArchiveSketch extends SketchContribution {
new Date(),
'yymmdd'
)}a.zip`;
const defaultPath = await this.fileService.fsPath(
new URI(config.sketchDirUri).resolve(archiveBasename)
);
const defaultContainerUri = await this.defaultUri();
const defaultUri = defaultContainerUri.resolve(archiveBasename);
const defaultPath = await this.fileService.fsPath(defaultUri);
const { filePath, canceled } = await remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
@@ -60,7 +56,7 @@ export class ArchiveSketch extends SketchContribution {
if (!destinationUri) {
return;
}
await this.sketchService.archive(sketch, destinationUri.toString());
await this.sketchesService.archive(sketch, destinationUri.toString());
this.messageService.info(
nls.localize(
'arduino/sketch/createdArchive',

View File

@@ -20,6 +20,7 @@ import {
InstalledBoardWithPackage,
AvailablePorts,
Port,
getBoardInfo,
} from '../../common/protocol';
import { SketchContribution, Command, CommandRegistry } from './contribution';
import { nls } from '@theia/core/lib/common';
@@ -49,52 +50,28 @@ export class BoardSelection extends SketchContribution {
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(BoardSelection.Commands.GET_BOARD_INFO, {
execute: async () => {
const { selectedBoard, selectedPort } =
this.boardsServiceProvider.boardsConfig;
if (!selectedBoard) {
this.messageService.info(
nls.localize(
'arduino/board/selectBoardForInfo',
'Please select a board to obtain board info.'
)
);
const boardInfo = await getBoardInfo(
this.boardsServiceProvider.boardsConfig.selectedPort,
this.boardsService.getState()
);
if (typeof boardInfo === 'string') {
this.messageService.info(boardInfo);
return;
}
if (!selectedBoard.fqbn) {
this.messageService.info(
nls.localize(
'arduino/board/platformMissing',
"The platform for the selected '{0}' board is not installed.",
selectedBoard.name
)
);
return;
}
if (!selectedPort) {
this.messageService.info(
nls.localize(
'arduino/board/selectPortForInfo',
'Please select a port to obtain board info.'
)
);
return;
}
const boardDetails = await this.boardsService.getBoardDetails({
fqbn: selectedBoard.fqbn,
});
if (boardDetails) {
const { VID, PID } = boardDetails;
const detail = `BN: ${selectedBoard.name}
const { BN, VID, PID, SN } = boardInfo;
const detail = `
BN: ${BN}
VID: ${VID}
PID: ${PID}`;
await remote.dialog.showMessageBox(remote.getCurrentWindow(), {
message: nls.localize('arduino/board/boardInfo', 'Board Info'),
title: nls.localize('arduino/board/boardInfo', 'Board Info'),
type: 'info',
detail,
buttons: [nls.localize('vscode/issueMainService/ok', 'OK')],
});
}
PID: ${PID}
SN: ${SN}
`.trim();
await remote.dialog.showMessageBox(remote.getCurrentWindow(), {
message: nls.localize('arduino/board/boardInfo', 'Board Info'),
title: nls.localize('arduino/board/boardInfo', 'Board Info'),
type: 'info',
detail,
buttons: [nls.localize('vscode/issueMainService/ok', 'OK')],
});
},
});
}
@@ -155,10 +132,7 @@ PID: ${PID}`;
);
// Ports submenu
const portsSubmenuPath = [
...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
'2_ports',
];
const portsSubmenuPath = ArduinoMenus.TOOLS__PORTS_SUBMENU;
const portsSubmenuLabel = config.selectedPort?.address;
this.menuModelRegistry.registerSubmenu(
portsSubmenuPath,

View File

@@ -20,7 +20,7 @@ import {
URI,
} from './contribution';
import { Dialog } from '@theia/core/lib/browser/dialogs';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import { SaveAsSketch } from './save-as-sketch';
/**
@@ -185,7 +185,7 @@ export class Close extends SketchContribution {
private async isCurrentSketchTemp(): Promise<false | Sketch> {
const currentSketch = await this.sketchServiceClient.currentSketch();
if (CurrentSketch.isValid(currentSketch)) {
const isTemp = await this.sketchService.isTemp(currentSketch);
const isTemp = await this.sketchesService.isTemp(currentSketch);
if (isTemp) {
return currentSketch;
}

View File

@@ -0,0 +1,121 @@
import { CompositeTreeNode } from '@theia/core/lib/browser/tree';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { CreateApi } from '../create/create-api';
import { CreateFeatures } from '../create/create-features';
import { CreateUri } from '../create/create-uri';
import { Create, isNotFound } from '../create/typings';
import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model';
import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget';
import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
import { SketchContribution } from './contribution';
export function sketchAlreadyExists(input: string): string {
return nls.localize(
'arduino/cloudSketch/alreadyExists',
"Cloud sketch '{0}' already exists.",
input
);
}
export function sketchNotFound(input: string): string {
return nls.localize(
'arduino/cloudSketch/notFound',
"Could not pull the cloud sketch '{0}'. It does not exist.",
input
);
}
export const synchronizingSketchbook = nls.localize(
'arduino/cloudSketch/synchronizingSketchbook',
'Synchronizing sketchbook...'
);
export function pullingSketch(input: string): string {
return nls.localize(
'arduino/cloudSketch/pulling',
"Synchronizing sketchbook, pulling '{0}'...",
input
);
}
export function pushingSketch(input: string): string {
return nls.localize(
'arduino/cloudSketch/pushing',
"Synchronizing sketchbook, pushing '{0}'...",
input
);
}
@injectable()
export abstract class CloudSketchContribution extends SketchContribution {
@inject(SketchbookWidgetContribution)
private readonly widgetContribution: SketchbookWidgetContribution;
@inject(CreateApi)
protected readonly createApi: CreateApi;
@inject(CreateFeatures)
protected readonly createFeatures: CreateFeatures;
protected async treeModel(): Promise<
(CloudSketchbookTreeModel & { root: CompositeTreeNode }) | undefined
> {
const { enabled, session } = this.createFeatures;
if (enabled && session) {
const widget = await this.widgetContribution.widget;
const treeModel = this.treeModelFrom(widget);
if (treeModel) {
const root = treeModel.root;
if (CompositeTreeNode.is(root)) {
return treeModel as CloudSketchbookTreeModel & {
root: CompositeTreeNode;
};
}
}
}
return undefined;
}
protected async pull(
sketch: Create.Sketch
): Promise<CloudSketchbookTree.CloudSketchDirNode | undefined> {
const treeModel = await this.treeModel();
if (!treeModel) {
return undefined;
}
const id = CreateUri.toUri(sketch).path.toString();
const node = treeModel.getNode(id);
if (!node) {
throw new Error(
`Could not find cloud sketchbook tree node with ID: ${id}.`
);
}
if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) {
throw new Error(
`Cloud sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.`
);
}
try {
await treeModel.sketchbookTree().pull({ node });
return node;
} catch (err) {
if (isNotFound(err)) {
await treeModel.refresh();
this.messageService.error(sketchNotFound(sketch.name));
return undefined;
}
throw err;
}
}
private treeModelFrom(
widget: SketchbookWidget
): CloudSketchbookTreeModel | undefined {
for (const treeWidget of widget.getTreeWidgets()) {
if (treeWidget instanceof CloudSketchbookTreeWidget) {
const model = treeWidget.model;
if (model instanceof CloudSketchbookTreeModel) {
return model;
}
}
}
return undefined;
}
}

View File

@@ -12,6 +12,7 @@ import { MaybePromise } from '@theia/core/lib/common/types';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { MessageService } from '@theia/core/lib/common/message-service';
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { open, OpenerService } from '@theia/core/lib/browser/opener-service';
import {
@@ -40,10 +41,9 @@ import { SettingsService } from '../dialogs/settings/settings';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../common/protocol/sketches-service-client-impl';
} from '../sketches-service-client-impl';
import {
SketchesService,
ConfigService,
FileSystemExt,
Sketch,
CoreService,
@@ -61,6 +61,8 @@ import { BoardsDataStore } from '../boards/boards-data-store';
import { NotificationManager } from '../theia/messages/notifications-manager';
import { MessageType } from '@theia/core/lib/common/message-service-protocol';
import { WorkspaceService } from '../theia/workspace/workspace-service';
import { MainMenuManager } from '../../common/main-menu-manager';
import { ConfigServiceClient } from '../config/config-service-client';
export {
Command,
@@ -106,6 +108,9 @@ export abstract class Contribution
@inject(FrontendApplicationStateService)
protected readonly appStateService: FrontendApplicationStateService;
@inject(MainMenuManager)
protected readonly menuManager: MainMenuManager;
@postConstruct()
protected init(): void {
this.appStateService.reachedState('ready').then(() => this.onReady());
@@ -138,11 +143,11 @@ export abstract class SketchContribution extends Contribution {
@inject(FileSystemExt)
protected readonly fileSystemExt: FileSystemExt;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(ConfigServiceClient)
protected readonly configService: ConfigServiceClient;
@inject(SketchesService)
protected readonly sketchService: SketchesService;
protected readonly sketchesService: SketchesService;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@@ -156,6 +161,9 @@ export abstract class SketchContribution extends Contribution {
@inject(OutputChannelManager)
protected readonly outputChannelManager: OutputChannelManager;
@inject(EnvVariablesServer)
protected readonly envVariableServer: EnvVariablesServer;
protected async sourceOverride(): Promise<Record<string, string>> {
const override: Record<string, string> = {};
const sketch = await this.sketchServiceClient.currentSketch();
@@ -169,6 +177,25 @@ export abstract class SketchContribution extends Contribution {
}
return override;
}
/**
* Defaults to `directories.user` if defined and not CLI config errors were detected.
* Otherwise, the URI of the user home directory.
*/
protected async defaultUri(): Promise<URI> {
const errors = this.configService.tryGetMessages();
let defaultUri = this.configService.tryGetSketchDirUri();
if (!defaultUri || errors?.length) {
// Fall back to user home when the `directories.user` is not available or there are known CLI config errors
defaultUri = new URI(await this.envVariableServer.getHomeDirUri());
}
return defaultUri;
}
protected async defaultPath(): Promise<string> {
const defaultUri = await this.defaultUri();
return this.fileService.fsPath(defaultUri);
}
}
@injectable()

View File

@@ -3,7 +3,12 @@ import { Event, Emitter } from '@theia/core/lib/common/event';
import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin';
import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import { NotificationCenter } from '../notification-center';
import { Board, BoardsService, ExecutableService } from '../../common/protocol';
import {
Board,
BoardsService,
ExecutableService,
Sketch,
} from '../../common/protocol';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
URI,
@@ -13,12 +18,11 @@ import {
TabBarToolbarRegistry,
} from './contribution';
import { MaybePromise, MenuModelRegistry, nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import { ArduinoMenus } from '../menu/arduino-menus';
import { MainMenuManager } from '../../common/main-menu-manager';
const COMPILE_FOR_DEBUG_KEY = 'arduino-compile-for-debug';
@injectable()
export class Debug extends SketchContribution {
@inject(HostedPluginSupport)
@@ -36,9 +40,6 @@ export class Debug extends SketchContribution {
@inject(BoardsServiceProvider)
private readonly boardsServiceProvider: BoardsServiceProvider;
@inject(MainMenuManager)
private readonly mainMenuManager: MainMenuManager;
/**
* If `undefined`, debugging is enabled. Otherwise, the reason why it's disabled.
*/
@@ -186,7 +187,7 @@ export class Debug extends SketchContribution {
if (!CurrentSketch.isValid(sketch)) {
return;
}
const ideTempFolderUri = await this.sketchService.getIdeTempFolderUri(
const ideTempFolderUri = await this.sketchesService.getIdeTempFolderUri(
sketch
);
const [cliPath, sketchPath, configPath] = await Promise.all([
@@ -203,7 +204,28 @@ export class Debug extends SketchContribution {
sketchPath,
configPath,
};
return this.commandService.executeCommand('arduino.debug.start', config);
try {
await this.commandService.executeCommand('arduino.debug.start', config);
} catch (err) {
if (await this.isSketchNotVerifiedError(err, sketch)) {
const yes = nls.localize('vscode/extensionsUtils/yes', 'Yes');
const answer = await this.messageService.error(
nls.localize(
'arduino/debug/sketchIsNotCompiled',
"Sketch '{0}' must be verified before starting a debug session. Please verify the sketch and start debugging again. Do you want to verify the sketch now?",
sketch.name
),
yes
);
if (answer === yes) {
this.commandService.executeCommand('arduino-verify-sketch');
}
} else {
this.messageService.error(
err instanceof Error ? err.message : String(err)
);
}
}
}
get compileForDebug(): boolean {
@@ -215,7 +237,24 @@ export class Debug extends SketchContribution {
const oldState = this.compileForDebug;
const newState = !oldState;
window.localStorage.setItem(COMPILE_FOR_DEBUG_KEY, String(newState));
this.mainMenuManager.update();
this.menuManager.update();
}
private async isSketchNotVerifiedError(
err: unknown,
sketch: Sketch
): Promise<boolean> {
if (err instanceof Error) {
try {
const tempBuildPaths = await this.sketchesService.tempBuildPath(sketch);
return tempBuildPaths.some((tempBuildPath) =>
err.message.includes(tempBuildPath)
);
} catch {
return false;
}
}
return false;
}
}
export namespace Debug {

View File

@@ -1,32 +1,131 @@
import { injectable } from '@theia/core/shared/inversify';
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { ipcRenderer } from '@theia/core/electron-shared/electron';
import { Dialog } from '@theia/core/lib/browser/dialogs';
import { NavigatableWidget } from '@theia/core/lib/browser/navigatable-types';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { nls } from '@theia/core/lib/common/nls';
import type { MaybeArray } from '@theia/core/lib/common/types';
import URI from '@theia/core/lib/common/uri';
import type { Widget } from '@theia/core/shared/@phosphor/widgets';
import { inject, injectable } from '@theia/core/shared/inversify';
import { SketchesError } from '../../common/protocol';
import {
Command,
CommandRegistry,
SketchContribution,
Sketch,
} from './contribution';
import { SCHEDULE_DELETION_SIGNAL } from '../../electron-common/electron-messages';
import { Sketch } from '../contributions/contribution';
import { isNotFound } from '../create/typings';
import { Command, CommandRegistry } from './contribution';
import { CloudSketchContribution } from './cloud-contribution';
export interface DeleteSketchParams {
/**
* Either the URI of the sketch folder or the sketch to delete.
*/
readonly toDelete: string | Sketch;
/**
* If `true`, the currently opened sketch is expected to be deleted.
* Hence, the editors must be closed, the sketch will be scheduled
* for deletion, and the browser window will close or navigate away.
* If `false`, the sketch will be scheduled for deletion,
* but the current window remains open. If `force`, the window will
* navigate away, but IDE2 won't open any confirmation dialogs.
*/
readonly willNavigateAway?: boolean | 'force';
}
@injectable()
export class DeleteSketch extends SketchContribution {
export class DeleteSketch extends CloudSketchContribution {
@inject(ApplicationShell)
private readonly shell: ApplicationShell;
@inject(WindowService)
private readonly windowService: WindowService;
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(DeleteSketch.Commands.DELETE_SKETCH, {
execute: (uri: string) => this.deleteSketch(uri),
execute: (params: DeleteSketchParams) => this.deleteSketch(params),
});
}
private async deleteSketch(uri: string): Promise<void> {
const sketch = await this.loadSketch(uri);
if (!sketch) {
console.info(`Sketch not found at ${uri}. Skipping deletion.`);
private async deleteSketch(params: DeleteSketchParams): Promise<void> {
const { toDelete, willNavigateAway } = params;
let sketch: Sketch;
if (typeof toDelete === 'string') {
const resolvedSketch = await this.loadSketch(toDelete);
if (!resolvedSketch) {
console.info(
`Failed to load the sketch. It was not found at '${toDelete}'. Skipping deletion.`
);
return;
}
sketch = resolvedSketch;
} else {
sketch = toDelete;
}
if (!willNavigateAway) {
this.scheduleDeletion(sketch);
return;
}
return this.sketchService.deleteSketch(sketch);
const cloudUri = this.createFeatures.cloudUri(sketch);
if (willNavigateAway !== 'force') {
const { response } = await remote.dialog.showMessageBox({
title: nls.localizeByDefault('Delete'),
type: 'question',
buttons: [Dialog.CANCEL, Dialog.OK],
message: cloudUri
? nls.localize(
'theia/workspace/deleteCloudSketch',
"The cloud sketch '{0}' will be permanently deleted from the Arduino servers and the local caches. This action is irreversible. Do you want to delete the current sketch?",
sketch.name
)
: nls.localize(
'theia/workspace/deleteCurrentSketch',
"The sketch '{0}' will be permanently deleted. This action is irreversible. Do you want to delete the current sketch?",
sketch.name
),
});
// cancel
if (response === 0) {
return;
}
}
if (cloudUri) {
const posixPath = cloudUri.path.toString();
const cloudSketch = this.createApi.sketchCache.getSketch(posixPath);
if (!cloudSketch) {
throw new Error(
`Cloud sketch with path '${posixPath}' was not cached. Cache: ${this.createApi.sketchCache.toString()}`
);
}
try {
// IDE2 cannot use DELETE directory as the server responses with HTTP 500 if it's missing.
// https://github.com/arduino/arduino-ide/issues/1825#issuecomment-1406301406
await this.createApi.deleteSketch(cloudSketch.path);
} catch (err) {
if (!isNotFound(err)) {
throw err;
} else {
console.info(
`Could not delete the cloud sketch with path '${posixPath}'. It does not exist.`
);
}
}
}
await Promise.all([
...Sketch.uris(sketch).map((uri) =>
this.closeWithoutSaving(new URI(uri))
),
]);
this.windowService.setSafeToShutDown();
this.scheduleDeletion(sketch);
return window.close();
}
private scheduleDeletion(sketch: Sketch): void {
ipcRenderer.send(SCHEDULE_DELETION_SIGNAL, sketch);
}
private async loadSketch(uri: string): Promise<Sketch | undefined> {
try {
const sketch = await this.sketchService.loadSketch(uri);
const sketch = await this.sketchesService.loadSketch(uri);
return sketch;
} catch (err) {
if (SketchesError.NotFound.is(err)) {
@@ -35,6 +134,13 @@ export class DeleteSketch extends SketchContribution {
throw err;
}
}
// fix: https://github.com/eclipse-theia/theia/issues/12107
private async closeWithoutSaving(uri: URI): Promise<void> {
const affected = getAffected(this.shell.widgets, uri);
const toClose = [...affected].map(([, widget]) => widget);
await this.shell.closeMany(toClose, { save: false });
}
}
export namespace DeleteSketch {
export namespace Commands {
@@ -43,3 +149,20 @@ export namespace DeleteSketch {
};
}
}
function getAffected<T extends Widget>(
widgets: Iterable<T>,
context: MaybeArray<URI>
): [URI, T & NavigatableWidget][] {
const uris = Array.isArray(context) ? context : [context];
const result: [URI, T & NavigatableWidget][] = [];
for (const widget of widgets) {
if (NavigatableWidget.is(widget)) {
const resourceUri = widget.getResourceUri();
if (resourceUri && uris.some((uri) => uri.isEqualOrParent(resourceUri))) {
result.push([resourceUri, widget]);
}
}
}
return result;
}

View File

@@ -57,9 +57,11 @@ export class EditContributions extends Contribution {
execute: async () => {
const value = await this.currentValue();
if (value !== undefined) {
this.clipboardService.writeText(`\`\`\`cpp
this.clipboardService.writeText(`
\`\`\`cpp
${value}
\`\`\``);
\`\`\`
`);
}
},
});

View File

@@ -12,7 +12,6 @@ import {
} from '@theia/core/lib/common/disposable';
import { OpenSketch } from './open-sketch';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus';
import { MainMenuManager } from '../../common/main-menu-manager';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { ExamplesService } from '../../common/protocol/examples-service';
import {
@@ -30,6 +29,7 @@ import {
CoreService,
} from '../../common/protocol';
import { nls } from '@theia/core/lib/common';
import { unregisterSubmenu } from '../menu/arduino-menus';
@injectable()
export abstract class Examples extends SketchContribution {
@@ -37,10 +37,7 @@ export abstract class Examples extends SketchContribution {
private readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
private readonly menuRegistry: MenuModelRegistry;
@inject(MainMenuManager)
protected readonly menuManager: MainMenuManager;
protected readonly menuRegistry: MenuModelRegistry;
@inject(ExamplesService)
protected readonly examplesService: ExamplesService;
@@ -51,6 +48,9 @@ export abstract class Examples extends SketchContribution {
@inject(BoardsServiceProvider)
protected readonly boardsServiceClient: BoardsServiceProvider;
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
protected readonly toDispose = new DisposableCollection();
protected override init(): void {
@@ -58,6 +58,12 @@ export abstract class Examples extends SketchContribution {
this.boardsServiceClient.onBoardsConfigChanged(({ selectedBoard }) =>
this.handleBoardChanged(selectedBoard)
);
this.notificationCenter.onDidReinitialize(() =>
this.update({
board: this.boardsServiceClient.boardsConfig.selectedBoard,
// No force refresh. The core client was already refreshed.
})
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
@@ -124,6 +130,11 @@ export abstract class Examples extends SketchContribution {
const { label } = sketchContainerOrPlaceholder;
submenuPath = [...menuPath, label];
this.menuRegistry.registerSubmenu(submenuPath, label, subMenuOptions);
this.toDispose.push(
Disposable.create(() =>
unregisterSubmenu(submenuPath, this.menuRegistry)
)
);
sketches.push(...sketchContainerOrPlaceholder.sketches);
children.push(...sketchContainerOrPlaceholder.children);
} else {
@@ -190,7 +201,7 @@ export abstract class Examples extends SketchContribution {
private async clone(uri: string): Promise<Sketch | undefined> {
try {
const sketch = await this.sketchService.cloneExample(uri);
const sketch = await this.sketchesService.cloneExample(uri);
return sketch;
} catch (err) {
if (SketchesError.NotFound.is(err)) {
@@ -243,9 +254,6 @@ export class BuiltInExamples extends Examples {
@injectable()
export class LibraryExamples extends Examples {
@inject(NotificationCenter)
private readonly notificationCenter: NotificationCenter;
private readonly queue = new PQueue({ autoStart: true, concurrency: 1 });
override onStart(): void {

View File

@@ -17,7 +17,7 @@ import { SketchContribution, Command, CommandRegistry } from './contribution';
import { NotificationCenter } from '../notification-center';
import { nls } from '@theia/core/lib/common';
import * as monaco from '@theia/monaco-editor-core';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class IncludeLibrary extends SketchContribution {
@@ -53,6 +53,7 @@ export class IncludeLibrary extends SketchContribution {
this.notificationCenter.onLibraryDidUninstall(() =>
this.updateMenuActions()
);
this.notificationCenter.onDidReinitialize(() => this.updateMenuActions());
}
override async onReady(): Promise<void> {

View File

@@ -1,15 +1,20 @@
import { Mutex } from 'async-mutex';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Mutex } from 'async-mutex';
import {
ArduinoDaemon,
assertSanitizedFqbn,
BoardsService,
ExecutableService,
sanitizeFqbn,
} from '../../common/protocol';
import { HostedPluginEvents } from '../hosted-plugin-events';
import { SketchContribution, URI } from './contribution';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import { BoardsConfig } from '../boards/boards-config';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { HostedPluginEvents } from '../hosted-plugin-events';
import { NotificationCenter } from '../notification-center';
import { SketchContribution, URI } from './contribution';
import { BoardsDataStore } from '../boards/boards-data-store';
@injectable()
export class InoLanguage extends SketchContribution {
@@ -28,8 +33,15 @@ export class InoLanguage extends SketchContribution {
@inject(BoardsServiceProvider)
private readonly boardsServiceProvider: BoardsServiceProvider;
@inject(NotificationCenter)
private readonly notificationCenter: NotificationCenter;
@inject(BoardsDataStore)
private readonly boardDataStore: BoardsDataStore;
private readonly toDispose = new DisposableCollection();
private readonly languageServerStartMutex = new Mutex();
private languageServerFqbn?: string;
private languageServerStartMutex = new Mutex();
override onReady(): void {
const start = (
@@ -43,27 +55,61 @@ export class InoLanguage extends SketchContribution {
}
}
};
this.boardsServiceProvider.onBoardsConfigChanged(start);
this.hostedPluginEvents.onPluginsDidStart(() =>
start(this.boardsServiceProvider.boardsConfig)
);
this.hostedPluginEvents.onPluginsWillUnload(
() => (this.languageServerFqbn = undefined)
);
this.preferences.onPreferenceChanged(
({ preferenceName, oldValue, newValue }) => {
if (oldValue !== newValue) {
switch (preferenceName) {
case 'arduino.language.log':
case 'arduino.language.realTimeDiagnostics':
start(this.boardsServiceProvider.boardsConfig, true);
const forceRestart = () => {
start(this.boardsServiceProvider.boardsConfig, true);
};
this.toDispose.pushAll([
this.boardsServiceProvider.onBoardsConfigChanged(start),
this.hostedPluginEvents.onPluginsDidStart(() =>
start(this.boardsServiceProvider.boardsConfig)
),
this.hostedPluginEvents.onPluginsWillUnload(
() => (this.languageServerFqbn = undefined)
),
this.preferences.onPreferenceChanged(
({ preferenceName, oldValue, newValue }) => {
if (oldValue !== newValue) {
switch (preferenceName) {
case 'arduino.language.log':
case 'arduino.language.realTimeDiagnostics':
forceRestart();
}
}
}
}
);
),
this.notificationCenter.onLibraryDidInstall(() => forceRestart()),
this.notificationCenter.onLibraryDidUninstall(() => forceRestart()),
this.notificationCenter.onPlatformDidInstall(() => forceRestart()),
this.notificationCenter.onPlatformDidUninstall(() => forceRestart()),
this.notificationCenter.onDidReinitialize(() => forceRestart()),
this.boardDataStore.onChanged((dataChangePerFqbn) => {
if (this.languageServerFqbn) {
const sanitizedFqbn = sanitizeFqbn(this.languageServerFqbn);
if (!sanitizeFqbn) {
throw new Error(
`Failed to sanitize the FQBN of the running language server. FQBN with the board settings was: ${this.languageServerFqbn}`
);
}
const matchingFqbn = dataChangePerFqbn.find(
(fqbn) => sanitizedFqbn === fqbn
);
const { boardsConfig } = this.boardsServiceProvider;
if (
matchingFqbn &&
boardsConfig.selectedBoard?.fqbn === matchingFqbn
) {
start(boardsConfig);
}
}
}),
]);
start(this.boardsServiceProvider.boardsConfig);
}
onStop(): void {
this.toDispose.dispose();
}
private async startLanguageServer(
fqbn: string,
name: string | undefined,
@@ -101,11 +147,18 @@ export class InoLanguage extends SketchContribution {
}
return;
}
if (!forceStart && fqbn === this.languageServerFqbn) {
assertSanitizedFqbn(fqbn);
const fqbnWithConfig = await this.boardDataStore.appendConfigToFqbn(fqbn);
if (!fqbnWithConfig) {
throw new Error(
`Failed to append boards config to the FQBN. Original FQBN was: ${fqbn}`
);
}
if (!forceStart && fqbnWithConfig === this.languageServerFqbn) {
// NOOP
return;
}
this.logger.info(`Starting language server: ${fqbn}`);
this.logger.info(`Starting language server: ${fqbnWithConfig}`);
const log = this.preferences.get('arduino.language.log');
const realTimeDiagnostics = this.preferences.get(
'arduino.language.realTimeDiagnostics'
@@ -141,7 +194,7 @@ export class InoLanguage extends SketchContribution {
log: currentSketchPath ? currentSketchPath : log,
cliDaemonInstance: '1',
board: {
fqbn,
fqbn: fqbnWithConfig,
name: name ? `"${name}"` : undefined,
},
realTimeDiagnostics,
@@ -150,7 +203,7 @@ export class InoLanguage extends SketchContribution {
),
]);
} catch (e) {
console.log(`Failed to start language server for ${fqbn}`, e);
console.log(`Failed to start language server. Original FQBN: ${fqbn}`, e);
this.languageServerFqbn = undefined;
} finally {
release();

View File

@@ -1,31 +1,17 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { injectable } from '@theia/core/shared/inversify';
import {
Contribution,
Command,
MenuModelRegistry,
KeybindingRegistry,
} from './contribution';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus';
import {
CommandRegistry,
DisposableCollection,
MaybePromise,
nls,
} from '@theia/core/lib/common';
import { ArduinoMenus } from '../menu/arduino-menus';
import { CommandRegistry, MaybePromise, nls } from '@theia/core/lib/common';
import { Settings } from '../dialogs/settings/settings';
import { MainMenuManager } from '../../common/main-menu-manager';
import debounce = require('lodash.debounce');
@injectable()
export class InterfaceScale extends Contribution {
@inject(MenuModelRegistry)
private readonly menuRegistry: MenuModelRegistry;
@inject(MainMenuManager)
private readonly mainMenuManager: MainMenuManager;
private readonly menuActionsDisposables = new DisposableCollection();
private fontScalingEnabled: InterfaceScale.FontScalingEnabled = {
increase: true,
decrease: true,
@@ -62,63 +48,22 @@ export class InterfaceScale extends Contribution {
}
override registerMenus(registry: MenuModelRegistry): void {
this.menuActionsDisposables.dispose();
const increaseFontSizeMenuAction = {
registry.registerMenuAction(ArduinoMenus.EDIT__FONT_CONTROL_GROUP, {
commandId: InterfaceScale.Commands.INCREASE_FONT_SIZE.id,
label: nls.localize(
'arduino/editor/increaseFontSize',
'Increase Font Size'
),
order: '0',
};
const decreaseFontSizeMenuAction = {
});
registry.registerMenuAction(ArduinoMenus.EDIT__FONT_CONTROL_GROUP, {
commandId: InterfaceScale.Commands.DECREASE_FONT_SIZE.id,
label: nls.localize(
'arduino/editor/decreaseFontSize',
'Decrease Font Size'
),
order: '1',
};
if (this.fontScalingEnabled.increase) {
this.menuActionsDisposables.push(
registry.registerMenuAction(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
increaseFontSizeMenuAction
)
);
} else {
this.menuActionsDisposables.push(
registry.registerMenuNode(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
new PlaceholderMenuNode(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
increaseFontSizeMenuAction.label,
{ order: increaseFontSizeMenuAction.order }
)
)
);
}
if (this.fontScalingEnabled.decrease) {
this.menuActionsDisposables.push(
this.menuRegistry.registerMenuAction(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
decreaseFontSizeMenuAction
)
);
} else {
this.menuActionsDisposables.push(
this.menuRegistry.registerMenuNode(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
new PlaceholderMenuNode(
ArduinoMenus.EDIT__FONT_CONTROL_GROUP,
decreaseFontSizeMenuAction.label,
{ order: decreaseFontSizeMenuAction.order }
)
)
);
}
this.mainMenuManager.update();
});
}
private updateFontScalingEnabled(): void {
@@ -153,7 +98,7 @@ export class InterfaceScale extends Contribution {
);
if (isChanged) {
this.fontScalingEnabled = fontScalingEnabled;
this.registerMenus(this.menuRegistry);
this.menuManager.update();
}
}

View File

@@ -1,76 +1,39 @@
import { DialogError } from '@theia/core/lib/browser/dialogs';
import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { CompositeTreeNode } from '@theia/core/lib/browser/tree';
import { Widget } from '@theia/core/lib/browser/widgets/widget';
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { MenuModelRegistry } from '@theia/core/lib/common/menu';
import {
Progress,
ProgressUpdate,
} from '@theia/core/lib/common/message-service-protocol';
import { Progress } from '@theia/core/lib/common/message-service-protocol';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceInputDialogProps } from '@theia/workspace/lib/browser/workspace-input-dialog';
import { v4 } from 'uuid';
import { MainMenuManager } from '../../common/main-menu-manager';
import type { AuthenticationSession } from '../../node/auth/types';
import { AuthenticationClientService } from '../auth/authentication-client-service';
import { CreateApi } from '../create/create-api';
import { injectable } from '@theia/core/shared/inversify';
import { CreateUri } from '../create/create-uri';
import { Create } from '../create/typings';
import { isConflict } from '../create/typings';
import { ArduinoMenus } from '../menu/arduino-menus';
import { WorkspaceInputDialog } from '../theia/workspace/workspace-input-dialog';
import {
TaskFactoryImpl,
WorkspaceInputDialogWithProgress,
} from '../theia/workspace/workspace-input-dialog';
import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model';
import { CloudSketchbookTreeWidget } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-widget';
import { SketchbookCommands } from '../widgets/sketchbook/sketchbook-commands';
import { SketchbookWidget } from '../widgets/sketchbook/sketchbook-widget';
import { SketchbookWidgetContribution } from '../widgets/sketchbook/sketchbook-widget-contribution';
import { Command, CommandRegistry, Contribution, URI } from './contribution';
import { Command, CommandRegistry, Sketch } from './contribution';
import {
CloudSketchContribution,
pullingSketch,
sketchAlreadyExists,
synchronizingSketchbook,
} from './cloud-contribution';
@injectable()
export class NewCloudSketch extends Contribution {
@inject(CreateApi)
private readonly createApi: CreateApi;
@inject(SketchbookWidgetContribution)
private readonly widgetContribution: SketchbookWidgetContribution;
@inject(AuthenticationClientService)
private readonly authenticationService: AuthenticationClientService;
@inject(MainMenuManager)
private readonly mainMenuManager: MainMenuManager;
export class NewCloudSketch extends CloudSketchContribution {
private readonly toDispose = new DisposableCollection();
private _session: AuthenticationSession | undefined;
private _enabled: boolean;
override onReady(): void {
this.toDispose.pushAll([
this.authenticationService.onSessionDidChange((session) => {
const oldSession = this._session;
this._session = session;
if (!!oldSession !== !!this._session) {
this.mainMenuManager.update();
}
}),
this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => {
if (preferenceName === 'arduino.cloud.enabled') {
const oldEnabled = this._enabled;
this._enabled = Boolean(newValue);
if (this._enabled !== oldEnabled) {
this.mainMenuManager.update();
}
}
}),
this.createFeatures.onDidChangeEnabled(() => this.menuManager.update()),
this.createFeatures.onDidChangeSession(() => this.menuManager.update()),
]);
this._enabled = this.preferences['arduino.cloud.enabled'];
this._session = this.authenticationService.session;
if (this._session) {
this.mainMenuManager.update();
if (this.createFeatures.session) {
this.menuManager.update();
}
}
@@ -80,16 +43,16 @@ export class NewCloudSketch extends Contribution {
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(NewCloudSketch.Commands.NEW_CLOUD_SKETCH, {
execute: () => this.createNewSketch(),
isEnabled: () => !!this._session,
isVisible: () => this._enabled,
execute: () => this.createNewSketch(true),
isEnabled: () => Boolean(this.createFeatures.session),
isVisible: () => this.createFeatures.enabled,
});
}
override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
commandId: NewCloudSketch.Commands.NEW_CLOUD_SKETCH.id,
label: nls.localize('arduino/cloudSketch/new', 'New Remote Sketch'),
label: nls.localize('arduino/cloudSketch/new', 'New Cloud Sketch'),
order: '1',
});
}
@@ -102,154 +65,95 @@ export class NewCloudSketch extends Contribution {
}
private async createNewSketch(
skipShowErrorMessageOnOpen: boolean,
initialValue?: string | undefined
): Promise<unknown> {
const widget = await this.widgetContribution.widget;
const treeModel = this.treeModelFrom(widget);
if (!treeModel) {
return undefined;
}
const rootNode = CompositeTreeNode.is(treeModel.root)
? treeModel.root
: undefined;
if (!rootNode) {
return undefined;
}
return this.openWizard(rootNode, treeModel, initialValue);
}
private withProgress(
value: string,
treeModel: CloudSketchbookTreeModel
): (progress: Progress) => Promise<unknown> {
return async (progress: Progress) => {
let result: Create.Sketch | undefined | 'conflict';
try {
progress.report({
message: nls.localize(
'arduino/cloudSketch/creating',
"Creating remote sketch '{0}'...",
value
),
});
result = await this.createApi.createSketch(value);
} catch (err) {
if (isConflict(err)) {
result = 'conflict';
} else {
throw err;
}
} finally {
if (result) {
progress.report({
message: nls.localize(
'arduino/cloudSketch/synchronizing',
"Synchronizing sketchbook, pulling '{0}'...",
value
),
});
await treeModel.refresh();
}
}
if (result === 'conflict') {
return this.createNewSketch(value);
}
if (result) {
return this.open(treeModel, result);
}
return undefined;
};
}
private async open(
treeModel: CloudSketchbookTreeModel,
newSketch: Create.Sketch
): Promise<URI | undefined> {
const id = CreateUri.toUri(newSketch).path.toString();
const node = treeModel.getNode(id);
if (!node) {
throw new Error(
`Could not find remote sketchbook tree node with Tree node ID: ${id}.`
): Promise<void> {
const treeModel = await this.treeModel();
if (treeModel) {
const rootNode = treeModel.root;
return this.openWizard(
rootNode,
treeModel,
skipShowErrorMessageOnOpen,
initialValue
);
}
if (!CloudSketchbookTree.CloudSketchDirNode.is(node)) {
throw new Error(
`Remote sketchbook tree node expected to represent a directory but it did not. Tree node ID: ${id}.`
);
}
try {
await treeModel.sketchbookTree().pull({ node });
} catch (err) {
if (isNotFound(err)) {
await treeModel.refresh();
this.messageService.error(
nls.localize(
'arduino/newCloudSketch/notFound',
"Could not pull the remote sketch '{0}'. It does not exist.",
newSketch.name
)
);
return undefined;
}
throw err;
}
return this.commandService.executeCommand(
SketchbookCommands.OPEN_NEW_WINDOW.id,
{ node }
);
}
private treeModelFrom(
widget: SketchbookWidget
): CloudSketchbookTreeModel | undefined {
for (const treeWidget of widget.getTreeWidgets()) {
if (treeWidget instanceof CloudSketchbookTreeWidget) {
const model = treeWidget.model;
if (model instanceof CloudSketchbookTreeModel) {
return model;
}
}
}
return undefined;
}
private async openWizard(
rootNode: CompositeTreeNode,
treeModel: CloudSketchbookTreeModel,
skipShowErrorMessageOnOpen: boolean,
initialValue?: string | undefined
): Promise<unknown> {
): Promise<void> {
const existingNames = rootNode.children
.filter(CloudSketchbookTree.CloudSketchDirNode.is)
.map(({ fileStat }) => fileStat.name);
return new NewCloudSketchDialog(
{
title: nls.localize(
'arduino/newCloudSketch/newSketchTitle',
'Name of a new Remote Sketch'
),
parentUri: CreateUri.root,
initialValue,
validate: (input) => {
if (existingNames.includes(input)) {
return nls.localize(
'arduino/newCloudSketch/sketchAlreadyExists',
"Remote sketch '{0}' already exists.",
input
);
}
// This is how https://create.arduino.cc/editor/ works when renaming a sketch.
if (/^[0-9a-zA-Z_]{1,36}$/.test(input)) {
return '';
}
return nls.localize(
'arduino/newCloudSketch/invalidSketchName',
'The name must consist of basic letters, numbers, or underscores. The maximum length is 36 characters.'
);
const taskFactory = new TaskFactoryImpl((value) =>
this.createNewSketchWithProgress(treeModel, value)
);
try {
const dialog = new WorkspaceInputDialogWithProgress(
{
title: nls.localize(
'arduino/newCloudSketch/newSketchTitle',
'Name of the new Cloud Sketch'
),
parentUri: CreateUri.root,
initialValue,
validate: (input) => {
if (existingNames.includes(input)) {
return sketchAlreadyExists(input);
}
return Sketch.validateCloudSketchFolderName(input) ?? '';
},
},
},
this.labelProvider,
(value) => this.withProgress(value, treeModel)
).open();
this.labelProvider,
taskFactory
);
await dialog.open(skipShowErrorMessageOnOpen);
if (dialog.taskResult) {
this.openInNewWindow(dialog.taskResult);
}
} catch (err) {
if (isConflict(err)) {
await treeModel.refresh();
return this.createNewSketch(false, taskFactory.value ?? initialValue);
}
throw err;
}
}
private createNewSketchWithProgress(
treeModel: CloudSketchbookTreeModel,
value: string
): (
progress: Progress
) => Promise<CloudSketchbookTree.CloudSketchDirNode | undefined> {
return async (progress: Progress) => {
progress.report({
message: nls.localize(
'arduino/cloudSketch/creating',
"Creating cloud sketch '{0}'...",
value
),
});
const sketch = await this.createApi.createSketch(value);
progress.report({ message: synchronizingSketchbook });
await treeModel.refresh();
progress.report({ message: pullingSketch(sketch.name) });
const node = await this.pull(sketch);
return node;
};
}
private openInNewWindow(
node: CloudSketchbookTree.CloudSketchDirNode
): Promise<void> {
return this.commandService.executeCommand(
SketchbookCommands.OPEN_NEW_WINDOW.id,
{ node }
);
}
}
export namespace NewCloudSketch {
@@ -259,115 +163,3 @@ export namespace NewCloudSketch {
};
}
}
function isConflict(err: unknown): boolean {
return isErrorWithStatusOf(err, 409);
}
function isNotFound(err: unknown): boolean {
return isErrorWithStatusOf(err, 404);
}
function isErrorWithStatusOf(
err: unknown,
status: number
): err is Error & { status: number } {
if (err instanceof Error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const object = err as any;
return 'status' in object && object.status === status;
}
return false;
}
@injectable()
class NewCloudSketchDialog extends WorkspaceInputDialog {
constructor(
@inject(WorkspaceInputDialogProps)
protected override readonly props: WorkspaceInputDialogProps,
@inject(LabelProvider)
protected override readonly labelProvider: LabelProvider,
private readonly withProgress: (
value: string
) => (progress: Progress) => Promise<unknown>
) {
super(props, labelProvider);
}
protected override async accept(): Promise<void> {
if (!this.resolve) {
return;
}
this.acceptCancellationSource.cancel();
this.acceptCancellationSource = new CancellationTokenSource();
const token = this.acceptCancellationSource.token;
const value = this.value;
const error = await this.isValid(value, 'open');
if (token.isCancellationRequested) {
return;
}
if (!DialogError.getResult(error)) {
this.setErrorMessage(error);
} else {
const spinner = document.createElement('div');
spinner.classList.add('spinner');
const disposables = new DisposableCollection();
try {
this.toggleButtons(true);
disposables.push(Disposable.create(() => this.toggleButtons(false)));
const closeParent = this.closeCrossNode.parentNode;
closeParent?.removeChild(this.closeCrossNode);
disposables.push(
Disposable.create(() => {
closeParent?.appendChild(this.closeCrossNode);
})
);
this.errorMessageNode.classList.add('progress');
disposables.push(
Disposable.create(() =>
this.errorMessageNode.classList.remove('progress')
)
);
const errorParent = this.errorMessageNode.parentNode;
errorParent?.insertBefore(spinner, this.errorMessageNode);
disposables.push(
Disposable.create(() => errorParent?.removeChild(spinner))
);
const cancellationSource = new CancellationTokenSource();
const progress: Progress = {
id: v4(),
cancel: () => cancellationSource.cancel(),
report: (update: ProgressUpdate) => {
this.setProgressMessage(update);
},
result: Promise.resolve(value),
};
await this.withProgress(value)(progress);
} finally {
disposables.dispose();
}
this.resolve(value);
Widget.detach(this);
}
}
private toggleButtons(disabled: boolean): void {
if (this.acceptButton) {
this.acceptButton.disabled = disabled;
}
if (this.closeButton) {
this.closeButton.disabled = disabled;
}
}
private setProgressMessage(update: ProgressUpdate): void {
if (update.work && update.work.done === update.work.total) {
this.errorMessageNode.innerText = '';
} else {
if (update.message) {
this.errorMessageNode.innerText = update.message;
}
}
}
}

View File

@@ -35,7 +35,7 @@ export class NewSketch extends SketchContribution {
async newSketch(): Promise<void> {
try {
const sketch = await this.sketchService.createNewSketch();
const sketch = await this.sketchesService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri));
} catch (e) {
await this.messageService.error(e.toString());

View File

@@ -47,7 +47,7 @@ export class OpenRecentSketch extends SketchContribution {
}
private update(forceUpdate?: boolean): void {
this.sketchService
this.sketchesService
.recentlyOpenedSketches(forceUpdate)
.then((sketches) => this.refreshMenu(sketches));
}

View File

@@ -1,32 +1,34 @@
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import type { Settings } from '../dialogs/settings/settings';
import { SettingsDialog } from '../dialogs/settings/settings-dialog';
import { ArduinoMenus } from '../menu/arduino-menus';
import {
Command,
MenuModelRegistry,
CommandRegistry,
SketchContribution,
KeybindingRegistry,
MenuModelRegistry,
SketchContribution,
} from './contribution';
import { ArduinoMenus } from '../menu/arduino-menus';
import { Settings as Preferences } from '../dialogs/settings/settings';
import { SettingsDialog } from '../dialogs/settings/settings-dialog';
import { nls } from '@theia/core/lib/common';
@injectable()
export class Settings extends SketchContribution {
export class OpenSettings extends SketchContribution {
@inject(SettingsDialog)
protected readonly settingsDialog: SettingsDialog;
private readonly settingsDialog: SettingsDialog;
protected settingsOpened = false;
private settingsOpened = false;
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(Settings.Commands.OPEN, {
registry.registerCommand(OpenSettings.Commands.OPEN, {
execute: async () => {
let settings: Preferences | undefined = undefined;
let settings: Settings | undefined = undefined;
try {
this.settingsOpened = true;
this.menuManager.update();
settings = await this.settingsDialog.open();
} finally {
this.settingsOpened = false;
this.menuManager.update();
}
if (settings) {
await this.settingsService.update(settings);
@@ -41,7 +43,7 @@ export class Settings extends SketchContribution {
override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.FILE__PREFERENCES_GROUP, {
commandId: Settings.Commands.OPEN.id,
commandId: OpenSettings.Commands.OPEN.id,
label:
nls.localize(
'vscode/preferences.contribution/preferences',
@@ -57,13 +59,13 @@ export class Settings extends SketchContribution {
override registerKeybindings(registry: KeybindingRegistry): void {
registry.registerKeybinding({
command: Settings.Commands.OPEN.id,
command: OpenSettings.Commands.OPEN.id,
keybinding: 'CtrlCmd+,',
});
}
}
export namespace Settings {
export namespace OpenSettings {
export namespace Commands {
export const OPEN: Command = {
id: 'arduino-settings-open',

View File

@@ -39,7 +39,7 @@ export class OpenSketchFiles extends SketchContribution {
focusMainSketchFile = false
): Promise<void> {
try {
const sketch = await this.sketchService.loadSketch(uri.toString());
const sketch = await this.sketchesService.loadSketch(uri.toString());
const { mainFileUri, rootFolderFileUris } = sketch;
for (const uri of [mainFileUri, ...rootFolderFileUris]) {
await this.ensureOpened(uri);
@@ -112,7 +112,7 @@ export class OpenSketchFiles extends SketchContribution {
await wait(250); // let IDE2 open the editor and toast the error message, then open the modal dialog
const movedSketch = await promptMoveSketch(invalidMainSketchUri, {
fileService: this.fileService,
sketchService: this.sketchService,
sketchesService: this.sketchesService,
labelProvider: this.labelProvider,
});
if (movedSketch) {
@@ -125,7 +125,7 @@ export class OpenSketchFiles extends SketchContribution {
}
private async openFallbackSketch(): Promise<void> {
const sketch = await this.sketchService.createNewSketch();
const sketch = await this.sketchesService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri), { preserveWindow: true });
}

View File

@@ -71,7 +71,7 @@ export class OpenSketch extends SketchContribution {
}
const uri = SketchLocation.toUri(toOpen);
try {
await this.sketchService.loadSketch(uri.toString());
await this.sketchesService.loadSketch(uri.toString());
} catch (err) {
if (SketchesError.NotFound.is(err)) {
this.messageService.error(err.message);
@@ -82,10 +82,7 @@ export class OpenSketch extends SketchContribution {
}
private async selectSketch(): Promise<Sketch | undefined> {
const config = await this.configService.getConfiguration();
const defaultPath = await this.fileService.fsPath(
new URI(config.sketchDirUri)
);
const defaultPath = await this.defaultPath();
const { filePaths } = await remote.dialog.showOpenDialog(
remote.getCurrentWindow(),
{
@@ -109,14 +106,14 @@ export class OpenSketch extends SketchContribution {
}
const sketchFilePath = filePaths[0];
const sketchFileUri = await this.fileSystemExt.getUri(sketchFilePath);
const sketch = await this.sketchService.getSketchFolder(sketchFileUri);
const sketch = await this.sketchesService.getSketchFolder(sketchFileUri);
if (sketch) {
return sketch;
}
if (Sketch.isSketchFile(sketchFileUri)) {
return promptMoveSketch(sketchFileUri, {
fileService: this.fileService,
sketchService: this.sketchService,
sketchesService: this.sketchesService,
labelProvider: this.labelProvider,
});
}
@@ -135,11 +132,11 @@ export async function promptMoveSketch(
sketchFileUri: string | URI,
options: {
fileService: FileService;
sketchService: SketchesService;
sketchesService: SketchesService;
labelProvider: LabelProvider;
}
): Promise<Sketch | undefined> {
const { fileService, sketchService, labelProvider } = options;
const { fileService, sketchesService, labelProvider } = options;
const uri =
sketchFileUri instanceof URI ? sketchFileUri : new URI(sketchFileUri);
const name = uri.path.name;
@@ -179,6 +176,6 @@ export async function promptMoveSketch(
uri,
new URI(newSketchUri.resolve(nameWithExt).toString())
);
return sketchService.getSketchFolder(newSketchUri.toString());
return sketchesService.getSketchFolder(newSketchUri.toString());
}
}

View File

@@ -0,0 +1,166 @@
import { CompositeTreeNode } from '@theia/core/lib/browser/tree';
import { Progress } from '@theia/core/lib/common/message-service-protocol';
import { nls } from '@theia/core/lib/common/nls';
import { injectable } from '@theia/core/shared/inversify';
import { CreateUri } from '../create/create-uri';
import { isConflict } from '../create/typings';
import {
TaskFactoryImpl,
WorkspaceInputDialogWithProgress,
} from '../theia/workspace/workspace-input-dialog';
import { CloudSketchbookTree } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree';
import { CloudSketchbookTreeModel } from '../widgets/cloud-sketchbook/cloud-sketchbook-tree-model';
import {
CloudSketchContribution,
pullingSketch,
pushingSketch,
sketchAlreadyExists,
synchronizingSketchbook,
} from './cloud-contribution';
import { Command, CommandRegistry, Sketch, URI } from './contribution';
export interface RenameCloudSketchParams {
readonly cloudUri: URI;
readonly sketch: Sketch;
}
@injectable()
export class RenameCloudSketch extends CloudSketchContribution {
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(RenameCloudSketch.Commands.RENAME_CLOUD_SKETCH, {
execute: (params: RenameCloudSketchParams) =>
this.renameSketch(params, true),
});
}
private async renameSketch(
params: RenameCloudSketchParams,
skipShowErrorMessageOnOpen: boolean,
initValue: string = params.sketch.name
): Promise<string | undefined> {
const treeModel = await this.treeModel();
if (treeModel) {
const posixPath = params.cloudUri.path.toString();
const node = treeModel.getNode(posixPath);
const parentNode = node?.parent;
if (
CloudSketchbookTree.CloudSketchDirNode.is(node) &&
CompositeTreeNode.is(parentNode)
) {
return this.openWizard(
params,
node,
parentNode,
treeModel,
skipShowErrorMessageOnOpen,
initValue
);
}
}
return undefined;
}
private async openWizard(
params: RenameCloudSketchParams,
node: CloudSketchbookTree.CloudSketchDirNode,
parentNode: CompositeTreeNode,
treeModel: CloudSketchbookTreeModel,
skipShowErrorMessageOnOpen: boolean,
initialValue?: string | undefined
): Promise<string | undefined> {
const parentUri = CloudSketchbookTree.CloudSketchDirNode.is(parentNode)
? parentNode.uri
: CreateUri.root;
const existingNames = parentNode.children
.filter(CloudSketchbookTree.CloudSketchDirNode.is)
.map(({ fileStat }) => fileStat.name);
const taskFactory = new TaskFactoryImpl((value) =>
this.renameSketchWithProgress(params, node, treeModel, value)
);
try {
const dialog = new WorkspaceInputDialogWithProgress(
{
title: nls.localize(
'arduino/renameCloudSketch/renameSketchTitle',
'New name of the Cloud Sketch'
),
parentUri,
initialValue,
validate: (input) => {
if (existingNames.includes(input)) {
return sketchAlreadyExists(input);
}
return Sketch.validateCloudSketchFolderName(input) ?? '';
},
},
this.labelProvider,
taskFactory
);
await dialog.open(skipShowErrorMessageOnOpen);
return dialog.taskResult;
} catch (err) {
if (isConflict(err)) {
await treeModel.refresh();
return this.renameSketch(
params,
false,
taskFactory.value ?? initialValue
);
}
throw err;
}
}
private renameSketchWithProgress(
params: RenameCloudSketchParams,
node: CloudSketchbookTree.CloudSketchDirNode,
treeModel: CloudSketchbookTreeModel,
value: string
): (progress: Progress) => Promise<string | undefined> {
return async (progress: Progress) => {
const fromName = params.cloudUri.path.name;
const fromPosixPath = params.cloudUri.path.toString();
const toPosixPath = params.cloudUri.parent.resolve(value).path.toString();
// push
progress.report({ message: pushingSketch(params.sketch.name) });
await treeModel.sketchbookTree().push(node);
// rename
progress.report({
message: nls.localize(
'arduino/cloudSketch/renaming',
"Renaming cloud sketch from '{0}' to '{1}'...",
fromName,
value
),
});
await this.createApi.rename(fromPosixPath, toPosixPath);
// sync
progress.report({
message: synchronizingSketchbook,
});
this.createApi.sketchCache.init(); // invalidate the cache
await this.createApi.sketches(); // IDE2 must pull all sketches to find the new one
const sketch = this.createApi.sketchCache.getSketch(toPosixPath);
if (!sketch) {
return undefined;
}
await treeModel.refresh();
// pull
progress.report({ message: pullingSketch(sketch.name) });
const pulledNode = await this.pull(sketch);
return pulledNode
? node.uri.parent.resolve(sketch.name).toString()
: undefined;
};
}
}
export namespace RenameCloudSketch {
export namespace Commands {
export const RENAME_CLOUD_SKETCH: Command = {
id: 'arduino-rename-cloud-sketch',
};
}
}

View File

@@ -1,28 +1,34 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import * as remote from '@theia/core/electron-shared/@electron/remote';
import * as dateFormat from 'dateformat';
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 { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceInput } from '@theia/workspace/lib/browser/workspace-service';
import { StartupTask } from '../../electron-common/startup-task';
import { ArduinoMenus } from '../menu/arduino-menus';
import { CurrentSketch } from '../sketches-service-client-impl';
import { CloudSketchContribution } from './cloud-contribution';
import {
SketchContribution,
URI,
Command,
CommandRegistry,
MenuModelRegistry,
KeybindingRegistry,
MenuModelRegistry,
Sketch,
URI,
} from './contribution';
import { nls } from '@theia/core/lib/common';
import { ApplicationShell, NavigatableWidget, Saveable } from '@theia/core/lib/browser';
import { WindowService } from '@theia/core/lib/browser/window/window-service';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { WorkspaceInput } from '@theia/workspace/lib/browser';
import { StartupTask } from '../../electron-common/startup-task';
import { DeleteSketch } from './delete-sketch';
import {
RenameCloudSketch,
RenameCloudSketchParams,
} from './rename-cloud-sketch';
@injectable()
export class SaveAsSketch extends SketchContribution {
export class SaveAsSketch extends CloudSketchContribution {
@inject(ApplicationShell)
private readonly applicationShell: ApplicationShell;
@inject(WindowService)
private readonly windowService: WindowService;
@@ -35,7 +41,7 @@ export class SaveAsSketch extends SketchContribution {
override registerMenus(registry: MenuModelRegistry): void {
registry.registerMenuAction(ArduinoMenus.FILE__SKETCH_GROUP, {
commandId: SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
label: nls.localize('vscode/fileCommands/saveAs', 'Save As...'),
label: nls.localizeByDefault('Save As...'),
order: '7',
});
}
@@ -58,21 +64,70 @@ export class SaveAsSketch extends SketchContribution {
markAsRecentlyOpened,
}: SaveAsSketch.Options = SaveAsSketch.Options.DEFAULT
): Promise<boolean> {
const [sketch, configuration] = await Promise.all([
this.sketchServiceClient.currentSketch(),
this.configService.getConfiguration(),
]);
const sketch = await this.sketchServiceClient.currentSketch();
if (!CurrentSketch.isValid(sketch)) {
return false;
}
const isTemp = await this.sketchService.isTemp(sketch);
if (!isTemp && !!execOnlyIfTemp) {
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 newWorkspaceUri = await this.sketchesService.copy(sketch, {
destinationUri,
});
if (!newWorkspaceUri) {
return false;
}
await this.saveOntoCopiedSketch(sketch, newWorkspaceUri);
if (markAsRecentlyOpened) {
this.sketchesService.markAsRecentlyOpened(newWorkspaceUri);
}
const options: WorkspaceInput & StartupTask.Owner = {
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 = new URI(configuration.sketchDirUri);
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.
@@ -87,91 +142,157 @@ export class SaveAsSketch extends SketchContribution {
// 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(
exists
? `${sketch.name}_copy_${dateFormat(new Date(), 'yyyymmddHHMMss')}`
: sketch.name
Sketch.toValidSketchFolderName(sketch.name, exists)
);
const defaultPath = await this.fileService.fsPath(defaultUri);
const { filePath, canceled } = await remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
title: nls.localize(
'arduino/sketch/saveFolderAs',
'Save sketch folder as...'
),
defaultPath,
}
);
if (!filePath || canceled) {
return false;
}
const destinationUri = await this.fileSystemExt.getUri(filePath);
if (!destinationUri) {
return false;
}
const workspaceUri = await this.sketchService.copy(sketch, {
destinationUri,
});
if (workspaceUri) {
await this.saveOntoCopiedSketch(sketch.mainFileUri, sketch.uri, workspaceUri);
if (markAsRecentlyOpened) {
this.sketchService.markAsRecentlyOpened(workspaceUri);
}
}
const options: WorkspaceInput & StartupTask.Owner = {
preserveWindow: true,
tasks: [],
};
if (workspaceUri && openAfterMove) {
this.windowService.setSafeToShutDown();
if (wipeOriginal || (openAfterMove && execOnlyIfTemp)) {
options.tasks.push({
command: DeleteSketch.Commands.DELETE_SKETCH.id,
args: [sketch.uri],
});
}
this.workspaceService.open(new URI(workspaceUri), options);
}
return !!workspaceUri;
return await this.promptLocalSketchFolderDestination(sketch, defaultPath);
}
private async saveOntoCopiedSketch(mainFileUri: string, sketchUri: string, newSketchUri: string): Promise<void> {
/**
* 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 remote.dialog.showSaveDialog(
remote.getCurrentWindow(),
{
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 remote.dialog.showMessageBox(
remote.getCurrentWindow(),
{
message,
buttons: [Dialog.CANCEL, Dialog.YES],
}
);
// cancel
if (response === 0) {
return undefined;
}
} else {
sketchFolderDestinationUri = destinationUri;
}
}
return sketchFolderDestinationUri;
}
private async saveOntoCopiedSketch(
sketch: Sketch,
newSketchFolderUri: string
): Promise<void> {
const widgets = this.applicationShell.widgets;
const snapshots = new Map<string, object>();
const snapshots = new Map<string, Saveable.Snapshot>();
for (const widget of widgets) {
const saveable = Saveable.getDirty(widget);
const uri = NavigatableWidget.getUri(widget);
const uriString = uri?.toString();
if (!uri) {
continue;
}
const uriString = uri.toString();
let relativePath: string;
if (uri && uriString!.includes(sketchUri) && saveable && saveable.createSnapshot) {
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 (mainFileUri === uriString) {
const lastPart = new URI(newSketchUri).path.base + uri.path.ext;
if (sketch.mainFileUri === uriString) {
const lastPart = new URI(newSketchFolderUri).path.base + uri.path.ext;
relativePath = '/' + lastPart;
} else {
relativePath = uri.toString().substring(sketchUri.length);
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(newSketchUri + path);
try {
const widget = await this.editorManager.getOrCreateByUri(widgetUri);
const saveable = Saveable.get(widget);
if (saveable && saveable.applySnapshot) {
saveable.applySnapshot(snapshot);
await saveable.save();
await Promise.all(
Array.from(snapshots.entries()).map(async ([path, snapshot]) => {
const widgetUri = new URI(newSketchFolderUri + path);
try {
const widget = await this.editorManager.getOrCreateByUri(widgetUri);
const saveable = Saveable.get(widget);
if (saveable && saveable.applySnapshot) {
saveable.applySnapshot(snapshot);
await saveable.save();
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
}));
})
);
}
}
interface InvalidSketchFolderDialogContent {
readonly message: string;
readonly details: string;
readonly question: string;
}
export namespace SaveAsSketch {
export namespace Commands {
export const SAVE_AS_SKETCH: Command = {

View File

@@ -10,7 +10,7 @@ import {
KeybindingRegistry,
} from './contribution';
import { nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
@injectable()
export class SaveSketch extends SketchContribution {
@@ -40,7 +40,7 @@ export class SaveSketch extends SketchContribution {
if (!CurrentSketch.isValid(sketch)) {
return;
}
const isTemp = await this.sketchService.isTemp(sketch);
const isTemp = await this.sketchesService.isTemp(sketch);
if (isTemp) {
return this.commandService.executeCommand(
SaveAsSketch.Commands.SAVE_AS_SKETCH.id,

View File

@@ -1,50 +1,34 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { CommonCommands } from '@theia/core/lib/browser/common-frontend-contribution';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import { WorkspaceCommands } from '@theia/workspace/lib/browser';
import { ContextMenuRenderer } from '@theia/core/lib/browser/context-menu-renderer';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
import { ArduinoMenus } from '../menu/arduino-menus';
import { CurrentSketch } from '../sketches-service-client-impl';
import {
URI,
SketchContribution,
Command,
CommandRegistry,
MenuModelRegistry,
KeybindingRegistry,
TabBarToolbarRegistry,
MenuModelRegistry,
open,
SketchContribution,
TabBarToolbarRegistry,
URI,
} from './contribution';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../common/protocol/sketches-service-client-impl';
import { LocalCacheFsProvider } from '../local-cache/local-cache-fs-provider';
import { nls } from '@theia/core/lib/common';
@injectable()
export class SketchControl extends SketchContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
private readonly shell: ApplicationShell;
@inject(MenuModelRegistry)
protected readonly menuRegistry: MenuModelRegistry;
private readonly menuRegistry: MenuModelRegistry;
@inject(ContextMenuRenderer)
protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(EditorManager)
protected override readonly editorManager: EditorManager;
@inject(SketchesServiceClientImpl)
protected readonly sketchesServiceClient: SketchesServiceClientImpl;
@inject(LocalCacheFsProvider)
protected readonly localCacheFsProvider: LocalCacheFsProvider;
private readonly contextMenuRenderer: ContextMenuRenderer;
protected readonly toDisposeBeforeCreateNewContextMenu =
new DisposableCollection();
@@ -57,107 +41,57 @@ export class SketchControl extends SketchContribution {
this.shell.getWidgets('main').indexOf(widget) !== -1,
execute: async () => {
this.toDisposeBeforeCreateNewContextMenu.dispose();
let parentElement: HTMLElement | undefined = undefined;
const target = document.getElementById(
SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id
);
if (target instanceof HTMLElement) {
parentElement = target.parentElement ?? undefined;
}
if (!parentElement) {
return;
}
const sketch = await this.sketchServiceClient.currentSketch();
if (!CurrentSketch.isValid(sketch)) {
return;
}
const target = document.getElementById(
SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id
this.menuRegistry.registerMenuAction(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
{
commandId: WorkspaceCommands.FILE_RENAME.id,
label: nls.localize('vscode/fileActions/rename', 'Rename'),
order: '1',
}
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
WorkspaceCommands.FILE_RENAME
)
)
);
this.menuRegistry.registerMenuAction(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
{
commandId: WorkspaceCommands.FILE_DELETE.id,
label: nls.localize('vscode/fileActions/delete', 'Delete'),
order: '2',
}
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
WorkspaceCommands.FILE_DELETE
)
)
);
if (!(target instanceof HTMLElement)) {
return;
}
const { parentElement } = target;
if (!parentElement) {
return;
}
const { mainFileUri, rootFolderFileUris } = sketch;
const uris = [mainFileUri, ...rootFolderFileUris];
const parentSketchUri = this.editorManager.currentEditor
?.getResourceUri()
?.toString();
const parentSketch = await this.sketchService.getSketchFolder(
parentSketchUri || ''
);
// if the current file is in the current opened sketch, show extra menus
if (
sketch &&
parentSketch &&
parentSketch.uri === sketch.uri &&
this.allowRename(parentSketch.uri)
) {
this.menuRegistry.registerMenuAction(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
{
commandId: WorkspaceCommands.FILE_RENAME.id,
label: nls.localize('vscode/fileActions/rename', 'Rename'),
order: '1',
}
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
WorkspaceCommands.FILE_RENAME
)
)
);
} else {
const renamePlaceholder = new PlaceholderMenuNode(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
nls.localize('vscode/fileActions/rename', 'Rename')
);
this.menuRegistry.registerMenuNode(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
renamePlaceholder
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuNode(renamePlaceholder.id)
)
);
}
if (
sketch &&
parentSketch &&
parentSketch.uri === sketch.uri &&
this.allowDelete(parentSketch.uri)
) {
this.menuRegistry.registerMenuAction(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
{
commandId: WorkspaceCommands.FILE_DELETE.id, // TODO: customize delete. Wipe sketch if deleting main file. Close window.
label: nls.localize('vscode/fileActions/delete', 'Delete'),
order: '2',
}
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuAction(
WorkspaceCommands.FILE_DELETE
)
)
);
} else {
const deletePlaceholder = new PlaceholderMenuNode(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
nls.localize('vscode/fileActions/delete', 'Delete')
);
this.menuRegistry.registerMenuNode(
ArduinoMenus.SKETCH_CONTROL__CONTEXT__MAIN_GROUP,
deletePlaceholder
);
this.toDisposeBeforeCreateNewContextMenu.push(
Disposable.create(() =>
this.menuRegistry.unregisterMenuNode(deletePlaceholder.id)
)
);
}
for (let i = 0; i < uris.length; i++) {
const uri = new URI(uris[i]);
@@ -193,6 +127,7 @@ export class SketchControl extends SketchContribution {
parentElement.getBoundingClientRect().top +
parentElement.offsetHeight,
},
showDisabled: true,
};
this.contextMenuRenderer.render(options);
},
@@ -249,27 +184,6 @@ export class SketchControl extends SketchContribution {
command: SketchControl.Commands.OPEN_SKETCH_CONTROL__TOOLBAR.id,
});
}
protected isCloudSketch(uri: string): boolean {
try {
const cloudCacheLocation = this.localCacheFsProvider.from(new URI(uri));
if (cloudCacheLocation) {
return true;
}
return false;
} catch {
return false;
}
}
protected allowRename(uri: string): boolean {
return !this.isCloudSketch(uri);
}
protected allowDelete(uri: string): boolean {
return !this.isCloudSketch(uri);
}
}
export namespace SketchControl {

View File

@@ -3,7 +3,7 @@ import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { inject, injectable } from '@theia/core/shared/inversify';
import { FileSystemFrontendContribution } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import { Sketch, SketchContribution } from './contribution';
import { OpenSketchFiles } from './open-sketch-files';
@@ -38,7 +38,7 @@ export class SketchFilesTracker extends SketchContribution {
type === FileChangeType.ADDED &&
resource.parent.toString() === sketch.uri
) {
const reloadedSketch = await this.sketchService.loadSketch(
const reloadedSketch = await this.sketchesService.loadSketch(
sketch.uri
);
if (Sketch.isInSketch(resource, reloadedSketch)) {

View File

@@ -11,6 +11,7 @@ import { nls } from '@theia/core/lib/common/nls';
export class Sketchbook extends Examples {
override onStart(): void {
this.sketchServiceClient.onSketchbookDidChange(() => this.update());
this.configService.onDidChangeSketchDirUri(() => this.update());
}
override async onReady(): Promise<void> {
@@ -18,7 +19,7 @@ export class Sketchbook extends Examples {
}
protected override update(): void {
this.sketchService.getSketches({}).then((container) => {
this.sketchesService.getSketches({}).then((container) => {
this.register(container);
this.menuManager.update();
});

View File

@@ -12,7 +12,6 @@ import {
PreferenceScope,
PreferenceService,
} from '@theia/core/lib/browser/preferences/preference-service';
import { ArduinoPreferences } from '../arduino-preferences';
import {
arduinoCert,
certificateList,
@@ -31,22 +30,29 @@ export class UploadCertificate extends Contribution {
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(ArduinoFirmwareUploader)
protected readonly arduinoFirmwareUploader: ArduinoFirmwareUploader;
protected dialogOpened = false;
override onStart(): void {
this.preferences.onPreferenceChanged(({ preferenceName }) => {
if (preferenceName === 'arduino.board.certificates') {
this.menuManager.update();
}
});
}
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(UploadCertificate.Commands.OPEN, {
execute: async () => {
try {
this.dialogOpened = true;
this.menuManager.update();
await this.dialog.open();
} finally {
this.dialogOpened = false;
this.menuManager.update();
}
},
isEnabled: () => !this.dialogOpened,
@@ -54,7 +60,7 @@ export class UploadCertificate extends Contribution {
registry.registerCommand(UploadCertificate.Commands.REMOVE_CERT, {
execute: async (certToRemove) => {
const certs = this.arduinoPreferences.get('arduino.board.certificates');
const certs = this.preferences.get('arduino.board.certificates');
this.preferenceService.set(
'arduino.board.certificates',
@@ -75,7 +81,6 @@ export class UploadCertificate extends Contribution {
.join(' ')}`
);
},
isEnabled: () => true,
});
registry.registerCommand(UploadCertificate.Commands.OPEN_CERT_CONTEXT, {
@@ -89,7 +94,6 @@ export class UploadCertificate extends Contribution {
args: [args.cert],
});
},
isEnabled: () => true,
});
}

View File

@@ -21,9 +21,11 @@ export class UploadFirmware extends Contribution {
execute: async () => {
try {
this.dialogOpened = true;
this.menuManager.update();
await this.dialog.open();
} finally {
this.dialogOpened = false;
this.menuManager.update();
}
},
isEnabled: () => !this.dialogOpened,

View File

@@ -1,6 +1,6 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { CoreService, Port } from '../../common/protocol';
import { CoreService, Port, sanitizeFqbn } from '../../common/protocol';
import { ArduinoMenus } from '../menu/arduino-menus';
import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import {
@@ -12,7 +12,7 @@ import {
CoreServiceContribution,
} from './contribution';
import { deepClone, nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import type { VerifySketchParams } from './verify-sketch';
import { UserFields } from './user-fields';
@@ -106,6 +106,7 @@ export class UploadSketch extends CoreServiceContribution {
// toggle the toolbar button and menu item state.
// uploadInProgress will be set to false whether the upload fails or not
this.uploadInProgress = true;
this.menuManager.update();
this.boardsServiceProvider.snapshotBoardDiscoveryOnUpload();
this.onDidChangeEmitter.fire();
this.clearVisibleNotification();
@@ -150,6 +151,7 @@ export class UploadSketch extends CoreServiceContribution {
this.handleError(e);
} finally {
this.uploadInProgress = false;
this.menuManager.update();
this.boardsServiceProvider.attemptPostUploadAutoSelect();
this.onDidChangeEmitter.fire();
}
@@ -168,7 +170,7 @@ export class UploadSketch extends CoreServiceContribution {
const [fqbn, { selectedProgrammer: programmer }, verify, verbose] =
await Promise.all([
verifyOptions.fqbn, // already decorated FQBN
this.boardsDataStore.getData(this.sanitizeFqbn(verifyOptions.fqbn)),
this.boardsDataStore.getData(sanitizeFqbn(verifyOptions.fqbn)),
this.preferences.get('arduino.upload.verify'),
this.preferences.get('arduino.upload.verbose'),
]);
@@ -205,19 +207,6 @@ export class UploadSketch extends CoreServiceContribution {
}
return port;
}
/**
* Converts the `VENDOR:ARCHITECTURE:BOARD_ID[:MENU_ID=OPTION_ID[,MENU2_ID=OPTION_ID ...]]` FQBN to
* `VENDOR:ARCHITECTURE:BOARD_ID` format.
* See the details of the `{build.fqbn}` entry in the [specs](https://arduino.github.io/arduino-cli/latest/platform-specification/#global-predefined-properties).
*/
private sanitizeFqbn(fqbn: string | undefined): string | undefined {
if (!fqbn) {
return undefined;
}
const [vendor, arch, id] = fqbn.split(':');
return `${vendor}:${arch}:${id}`;
}
}
export namespace UploadSketch {

View File

@@ -1,9 +1,9 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import { DisposableCollection, nls } from '@theia/core/lib/common';
import { nls } from '@theia/core/lib/common';
import { BoardUserField, CoreError } from '../../common/protocol';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import { UserFieldsDialog } from '../dialogs/user-fields/user-fields-dialog';
import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus';
import { ArduinoMenus } from '../menu/arduino-menus';
import { MenuModelRegistry, Contribution } from './contribution';
import { UploadSketch } from './upload-sketch';
@@ -12,7 +12,6 @@ export class UserFields extends Contribution {
private boardRequiresUserFields = false;
private userFieldsSet = false;
private readonly cachedUserFields: Map<string, BoardUserField[]> = new Map();
private readonly menuActionsDisposables = new DisposableCollection();
@inject(UserFieldsDialog)
private readonly userFieldsDialog: UserFieldsDialog;
@@ -20,42 +19,22 @@ export class UserFields extends Contribution {
@inject(BoardsServiceProvider)
private readonly boardsServiceProvider: BoardsServiceProvider;
@inject(MenuModelRegistry)
private readonly menuRegistry: MenuModelRegistry;
protected override init(): void {
super.init();
this.boardsServiceProvider.onBoardsConfigChanged(async () => {
const userFields =
await this.boardsServiceProvider.selectedBoardUserFields();
this.boardRequiresUserFields = userFields.length > 0;
this.registerMenus(this.menuRegistry);
this.menuManager.update();
});
}
override registerMenus(registry: MenuModelRegistry): void {
this.menuActionsDisposables.dispose();
if (this.boardRequiresUserFields) {
this.menuActionsDisposables.push(
registry.registerMenuAction(ArduinoMenus.SKETCH__MAIN_GROUP, {
commandId: UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.id,
label: UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.label,
order: '2',
})
);
} else {
this.menuActionsDisposables.push(
registry.registerMenuNode(
ArduinoMenus.SKETCH__MAIN_GROUP,
new PlaceholderMenuNode(
ArduinoMenus.SKETCH__MAIN_GROUP,
// commandId: UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.id,
UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.label,
{ order: '2' }
)
)
);
}
registry.registerMenuAction(ArduinoMenus.SKETCH__MAIN_GROUP, {
commandId: UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.id,
label: UploadSketch.Commands.UPLOAD_WITH_CONFIGURATION.label,
order: '2',
});
}
private selectedFqbnAddress(): string | undefined {

View File

@@ -0,0 +1,202 @@
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { Dialog } from '@theia/core/lib/browser/dialogs';
import { nls } from '@theia/core/lib/common/nls';
import { Deferred, waitForEvent } from '@theia/core/lib/common/promise-util';
import { injectable } from '@theia/core/shared/inversify';
import { WorkspaceCommands } from '@theia/workspace/lib/browser/workspace-commands';
import { CurrentSketch } from '../sketches-service-client-impl';
import { CloudSketchContribution } from './cloud-contribution';
import { Sketch, URI } from './contribution';
import { SaveAsSketch } from './save-as-sketch';
@injectable()
export class ValidateSketch extends CloudSketchContribution {
override onReady(): void {
this.validate();
}
private async validate(): Promise<void> {
const result = await this.promptFixActions();
if (!result) {
const yes = await this.prompt(
nls.localize('arduino/validateSketch/abortFixTitle', 'Invalid sketch'),
nls.localize(
'arduino/validateSketch/abortFixMessage',
"The sketch is still invalid. Do you want to fix the remaining problems? By clicking '{0}', a new sketch will open.",
Dialog.NO
),
[Dialog.NO, Dialog.YES]
);
if (yes) {
return this.validate();
}
const sketch = await this.sketchesService.createNewSketch();
this.workspaceService.open(new URI(sketch.uri), {
preserveWindow: true,
});
}
}
/**
* Returns with an array of actions the user has to perform to fix the invalid sketch.
*/
private validateSketch(
sketch: Sketch,
dataDirUri: URI | undefined
): FixAction[] {
// sketch code file validation errors first as they do not require window reload
const actions = Sketch.uris(sketch)
.filter((uri) => uri !== sketch.mainFileUri)
.map((uri) => new URI(uri))
.filter((uri) => Sketch.Extensions.CODE_FILES.includes(uri.path.ext))
.map((uri) => ({
uri,
error: this.doValidate(sketch, dataDirUri, uri.path.name),
}))
.filter(({ error }) => Boolean(error))
.map((object) => <{ uri: URI; error: string }>object)
.map(({ uri, error }) => ({
execute: async () => {
const unknown =
(await this.promptRenameSketchFile(uri, error)) &&
(await this.commandService.executeCommand(
WorkspaceCommands.FILE_RENAME.id,
uri
));
return !!unknown;
},
}));
// sketch folder + main sketch file last as it requires a `Save as...` and the window reload
const sketchFolderName = new URI(sketch.uri).path.base;
const sketchFolderNameError = this.doValidate(
sketch,
dataDirUri,
sketchFolderName
);
if (sketchFolderNameError) {
actions.push({
execute: async () => {
const unknown =
(await this.promptRenameSketch(sketch, sketchFolderNameError)) &&
(await this.commandService.executeCommand(
SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
<SaveAsSketch.Options>{
markAsRecentlyOpened: true,
openAfterMove: true,
wipeOriginal: true,
}
));
return !!unknown;
},
});
}
return actions;
}
private doValidate(
sketch: Sketch,
dataDirUri: URI | undefined,
toValidate: string
): string | undefined {
const cloudUri = this.createFeatures.isCloud(sketch, dataDirUri);
return cloudUri
? Sketch.validateCloudSketchFolderName(toValidate)
: Sketch.validateSketchFolderName(toValidate);
}
private async currentSketch(): Promise<Sketch> {
const sketch = this.sketchServiceClient.tryGetCurrentSketch();
if (CurrentSketch.isValid(sketch)) {
return sketch;
}
const deferred = new Deferred<Sketch>();
const disposable = this.sketchServiceClient.onCurrentSketchDidChange(
(sketch) => {
if (CurrentSketch.isValid(sketch)) {
disposable.dispose();
deferred.resolve(sketch);
}
}
);
return deferred.promise;
}
private async promptFixActions(): Promise<boolean> {
const maybeDataDirUri = this.configService.tryGetDataDirUri();
const [sketch, dataDirUri] = await Promise.all([
this.currentSketch(),
maybeDataDirUri ??
waitForEvent(this.configService.onDidChangeDataDirUri, 5_000),
]);
const fixActions = this.validateSketch(sketch, dataDirUri);
for (const fixAction of fixActions) {
const result = await fixAction.execute();
if (!result) {
return false;
}
}
return true;
}
private async promptRenameSketch(
sketch: Sketch,
error: string
): Promise<boolean> {
return this.prompt(
nls.localize(
'arduino/validateSketch/renameSketchFolderTitle',
'Invalid sketch name'
),
nls.localize(
'arduino/validateSketch/renameSketchFolderMessage',
"The sketch '{0}' cannot be used. {1} To get rid of this message, rename the sketch. Do you want to rename the sketch now?",
sketch.name,
error
)
);
}
private async promptRenameSketchFile(
uri: URI,
error: string
): Promise<boolean> {
return this.prompt(
nls.localize(
'arduino/validateSketch/renameSketchFileTitle',
'Invalid sketch filename'
),
nls.localize(
'arduino/validateSketch/renameSketchFileMessage',
"The sketch file '{0}' cannot be used. {1} Do you want to rename the sketch file now?",
uri.path.base,
error
)
);
}
private async prompt(
title: string,
message: string,
buttons: string[] = [Dialog.CANCEL, Dialog.OK]
): Promise<boolean> {
const { response } = await remote.dialog.showMessageBox(
remote.getCurrentWindow(),
{
title,
message,
type: 'warning',
buttons,
}
);
// cancel
if (response === 0) {
return false;
}
return true;
}
}
interface FixAction {
execute(): Promise<boolean>;
}

View File

@@ -11,7 +11,7 @@ import {
TabBarToolbarRegistry,
} from './contribution';
import { nls } from '@theia/core/lib/common';
import { CurrentSketch } from '../../common/protocol/sketches-service-client-impl';
import { CurrentSketch } from '../sketches-service-client-impl';
import { CoreService } from '../../common/protocol';
import { CoreErrorHandler } from './core-error-handler';
@@ -21,11 +21,18 @@ export interface VerifySketchParams {
*/
readonly exportBinaries?: boolean;
/**
* If `true`, there won't be any UI indication of the verify command. It's `false` by default.
* If `true`, there won't be any UI indication of the verify command in the toolbar. It's `false` by default.
*/
readonly silent?: boolean;
}
/**
* - `"idle"` when neither verify, nor upload is running,
* - `"explicit-verify"` when only verify is running triggered by the user, and
* - `"automatic-verify"` is when the automatic verify phase is running as part of an upload triggered by the user.
*/
type VerifyProgress = 'idle' | 'explicit-verify' | 'automatic-verify';
@injectable()
export class VerifySketch extends CoreServiceContribution {
@inject(CoreErrorHandler)
@@ -33,22 +40,24 @@ export class VerifySketch extends CoreServiceContribution {
private readonly onDidChangeEmitter = new Emitter<void>();
private readonly onDidChange = this.onDidChangeEmitter.event;
private verifyInProgress = false;
private verifyProgress: VerifyProgress = 'idle';
override registerCommands(registry: CommandRegistry): void {
registry.registerCommand(VerifySketch.Commands.VERIFY_SKETCH, {
execute: (params?: VerifySketchParams) => this.verifySketch(params),
isEnabled: () => !this.verifyInProgress,
isEnabled: () => this.verifyProgress === 'idle',
});
registry.registerCommand(VerifySketch.Commands.EXPORT_BINARIES, {
execute: () => this.verifySketch({ exportBinaries: true }),
isEnabled: () => !this.verifyInProgress,
isEnabled: () => this.verifyProgress === 'idle',
});
registry.registerCommand(VerifySketch.Commands.VERIFY_SKETCH_TOOLBAR, {
isVisible: (widget) =>
ArduinoToolbar.is(widget) && widget.side === 'left',
isEnabled: () => !this.verifyInProgress,
isToggled: () => this.verifyInProgress,
isEnabled: () => this.verifyProgress !== 'explicit-verify',
// toggled only when verify is running, but not toggled when automatic verify is running before the upload
// https://github.com/arduino/arduino-ide/pull/1750#pullrequestreview-1214762975
isToggled: () => this.verifyProgress === 'explicit-verify',
execute: () =>
registry.executeCommand(VerifySketch.Commands.VERIFY_SKETCH.id),
});
@@ -99,15 +108,16 @@ export class VerifySketch extends CoreServiceContribution {
private async verifySketch(
params?: VerifySketchParams
): Promise<CoreService.Options.Compile | undefined> {
if (this.verifyInProgress) {
if (this.verifyProgress !== 'idle') {
return undefined;
}
try {
if (!params?.silent) {
this.verifyInProgress = true;
this.onDidChangeEmitter.fire();
}
this.verifyProgress = params?.silent
? 'automatic-verify'
: 'explicit-verify';
this.onDidChangeEmitter.fire();
this.menuManager.update();
this.clearVisibleNotification();
this.coreErrorHandler.reset();
@@ -139,10 +149,9 @@ export class VerifySketch extends CoreServiceContribution {
this.handleError(e);
return undefined;
} finally {
this.verifyInProgress = false;
if (!params?.silent) {
this.onDidChangeEmitter.fire();
}
this.verifyProgress = 'idle';
this.onDidChangeEmitter.fire();
this.menuManager.update();
}
}

View File

@@ -1,12 +1,16 @@
import { injectable, inject } from '@theia/core/shared/inversify';
import { MaybePromise } from '@theia/core/lib/common/types';
import { inject, injectable } from '@theia/core/shared/inversify';
import { fetch } from 'cross-fetch';
import { SketchesService } from '../../common/protocol';
import { ArduinoPreferences } from '../arduino-preferences';
import { AuthenticationClientService } from '../auth/authentication-client-service';
import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache';
import * as createPaths from './create-paths';
import { posix } from './create-paths';
import { AuthenticationClientService } from '../auth/authentication-client-service';
import { ArduinoPreferences } from '../arduino-preferences';
import { SketchCache } from '../widgets/cloud-sketchbook/cloud-sketch-cache';
import { Create, CreateError } from './typings';
export interface ResponseResultProvider {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response: Response): Promise<any>;
}
export namespace ResponseResultProvider {
@@ -15,6 +19,8 @@ export namespace ResponseResultProvider {
export const JSON: ResponseResultProvider = (response) => response.json();
}
// TODO: check if this is still needed: https://github.com/electron/electron/issues/18733
// The original issue was reported for Electron 5.x and 6.x. Theia uses 15.x
export function Utf8ArrayToStr(array: Uint8Array): string {
let out, i, c;
let char2, char3;
@@ -61,20 +67,13 @@ type ResourceType = 'f' | 'd';
@injectable()
export class CreateApi {
@inject(SketchCache)
protected sketchCache: SketchCache;
protected authenticationService: AuthenticationClientService;
protected arduinoPreferences: ArduinoPreferences;
public init(
authenticationService: AuthenticationClientService,
arduinoPreferences: ArduinoPreferences
): CreateApi {
this.authenticationService = authenticationService;
this.arduinoPreferences = arduinoPreferences;
return this;
}
readonly sketchCache: SketchCache;
@inject(AuthenticationClientService)
private readonly authenticationService: AuthenticationClientService;
@inject(ArduinoPreferences)
private readonly arduinoPreferences: ArduinoPreferences;
@inject(SketchesService)
private readonly sketchesService: SketchesService;
getSketchSecretStat(sketch: Create.Sketch): Create.Resource {
return {
@@ -129,10 +128,13 @@ export class CreateApi {
async createSketch(
posixPath: string,
content: string = CreateApi.defaultInoContent
contentProvider: MaybePromise<string> = this.sketchesService.defaultInoContent()
): Promise<Create.Sketch> {
const url = new URL(`${this.domain()}/sketches`);
const headers = await this.headers();
const [headers, content] = await Promise.all([
this.headers(),
contentProvider,
]);
const payload = {
ino: btoa(content),
path: posixPath,
@@ -291,7 +293,7 @@ export class CreateApi {
this.sketchCache.addSketch(sketch);
let file = '';
if (sketch && sketch.secrets) {
if (sketch.secrets) {
for (const item of sketch.secrets) {
file += `#define ${item.name} "${item.value}"\r\n`;
}
@@ -381,7 +383,7 @@ export class CreateApi {
return;
}
// do not upload "do_not_sync" files/directoris and their descendants
// do not upload "do_not_sync" files/directories and their descendants
const segments = posixPath.split(posix.sep) || [];
if (
segments.some((segment) => Create.do_not_sync_files.includes(segment))
@@ -415,6 +417,21 @@ export class CreateApi {
await this.delete(posixPath, 'd');
}
/**
* `sketchPath` is not the POSIX path but the path with the user UUID, username, etc.
* See [Create.Resource#path](./typings.ts). Unlike other endpoints, it does not support the `$HOME`
* variable substitution. The DELETE directory endpoint is bogus and responses with HTTP 500
* instead of 404 when deleting a non-existing resource.
*/
async deleteSketch(sketchPath: string): Promise<void> {
const url = new URL(`${this.domain()}/sketches/byPath/${sketchPath}`);
const headers = await this.headers();
await this.run(url, {
method: 'DELETE',
headers,
});
}
private async delete(posixPath: string, type: ResourceType): Promise<void> {
const url = new URL(
`${this.domain()}/files/${type}/$HOME/sketches_v2${posixPath}`
@@ -475,14 +492,12 @@ export class CreateApi {
}
private async run<T>(
requestInfo: RequestInfo | URL,
requestInfo: URL,
init: RequestInit | undefined,
resultProvider: ResponseResultProvider = ResponseResultProvider.JSON
): Promise<T> {
const response = await fetch(
requestInfo instanceof URL ? requestInfo.toString() : requestInfo,
init
);
console.debug(`HTTP ${init?.method}: ${requestInfo.toString()}`);
const response = await fetch(requestInfo.toString(), init);
if (!response.ok) {
let details: string | undefined = undefined;
try {
@@ -516,19 +531,3 @@ export class CreateApi {
return this.authenticationService.session?.accessToken || '';
}
}
export namespace CreateApi {
export const defaultInoContent = `/*
*/
void setup() {
}
void loop() {
}
`;
}

View File

@@ -0,0 +1,95 @@
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Sketch } from '../../common/protocol';
import { AuthenticationSession } from '../../node/auth/types';
import { ArduinoPreferences } from '../arduino-preferences';
import { AuthenticationClientService } from '../auth/authentication-client-service';
import { LocalCacheFsProvider } from '../local-cache/local-cache-fs-provider';
@injectable()
export class CreateFeatures implements FrontendApplicationContribution {
@inject(ArduinoPreferences)
private readonly preferences: ArduinoPreferences;
@inject(AuthenticationClientService)
private readonly authenticationService: AuthenticationClientService;
@inject(LocalCacheFsProvider)
private readonly localCacheFsProvider: LocalCacheFsProvider;
private readonly onDidChangeSessionEmitter = new Emitter<
AuthenticationSession | undefined
>();
private readonly onDidChangeEnabledEmitter = new Emitter<boolean>();
private readonly toDispose = new DisposableCollection(
this.onDidChangeSessionEmitter,
this.onDidChangeEnabledEmitter
);
private _enabled: boolean;
private _session: AuthenticationSession | undefined;
onStart(): void {
this.toDispose.pushAll([
this.authenticationService.onSessionDidChange((session) => {
const oldSession = this._session;
this._session = session;
if (!!oldSession !== !!this._session) {
this.onDidChangeSessionEmitter.fire(this._session);
}
}),
this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => {
if (preferenceName === 'arduino.cloud.enabled') {
const oldEnabled = this._enabled;
this._enabled = Boolean(newValue);
if (this._enabled !== oldEnabled) {
this.onDidChangeEnabledEmitter.fire(this._enabled);
}
}
}),
]);
this._enabled = this.preferences['arduino.cloud.enabled'];
this._session = this.authenticationService.session;
}
onStop(): void {
this.toDispose.dispose();
}
get onDidChangeSession(): Event<AuthenticationSession | undefined> {
return this.onDidChangeSessionEmitter.event;
}
get onDidChangeEnabled(): Event<boolean> {
return this.onDidChangeEnabledEmitter.event;
}
get enabled(): boolean {
return this._enabled;
}
get session(): AuthenticationSession | undefined {
return this._session;
}
/**
* `true` if the sketch is under `directories.data/RemoteSketchbook`. Otherwise, `false`.
* Returns with `undefined` if `dataDirUri` is `undefined`.
*/
isCloud(sketch: Sketch, dataDirUri: URI | undefined): boolean | undefined {
if (!dataDirUri) {
console.warn(
`Could not decide whether the sketch ${sketch.uri} is cloud or local. The 'directories.data' location was not available from the CLI config.`
);
return undefined;
}
return dataDirUri.isEqualOrParent(new URI(sketch.uri));
}
cloudUri(sketch: Sketch): URI | undefined {
if (!this.session) {
return undefined;
}
return this.localCacheFsProvider.from(new URI(sketch.uri));
}
}

View File

@@ -189,10 +189,6 @@ export class CreateFsProvider
FileSystemProviderErrorCode.NoPermissions
);
}
return this.createApi.init(
this.authenticationService,
this.arduinoPreferences
);
return this.createApi;
}
}

View File

@@ -71,3 +71,23 @@ export class CreateError extends Error {
Object.setPrototypeOf(this, CreateError.prototype);
}
}
export type ConflictError = CreateError & { status: 409 };
export function isConflict(err: unknown): err is ConflictError {
return isErrorWithStatusOf(err, 409);
}
export type NotFoundError = CreateError & { status: 404 };
export function isNotFound(err: unknown): err is NotFoundError {
return isErrorWithStatusOf(err, 404);
}
function isErrorWithStatusOf(
err: unknown,
status: number
): err is CreateError & { status: number } {
if (err instanceof CreateError) {
return err.status === status;
}
return false;
}

View File

@@ -14,7 +14,7 @@
"editor.foreground": "#dae3e3",
"editor.lineHighlightBackground": "#434f5410",
"editor.selectionBackground": "#00818480",
"editorCursor.foreground": "#434f54",
"editorCursor.foreground": "#dae3e3",
"editorWhitespace.foreground": "#bfbfbf",
"editorWidget.background": "#171e21",
"editorWidget.foreground": "#dae3e3",
@@ -67,7 +67,8 @@
"tree.indentGuidesStroke": "#374146",
"tab.unfocusedActiveForeground": "#dae3e3",
"tab.inactiveBackground": "#171e21",
"textLink.foreground": "#0ca1a6"
"textLink.foreground": "#0ca1a6",
"errorForeground": "#df7365"
},
"tokenColors": [
{

View File

@@ -14,7 +14,7 @@
"editor.foreground": "#4e5b61",
"editor.lineHighlightBackground": "#434f5410",
"editor.selectionBackground": "#7fcbcdb3",
"editorCursor.foreground": "#434f54",
"editorCursor.foreground": "#4e5b61",
"editorWhitespace.foreground": "#bfbfbf",
"editorWidget.background": "#f7f9f9",
"editorWidget.foreground": "#4e5b61",
@@ -67,7 +67,8 @@
"tree.indentGuidesStroke": "#dae3e3",
"tab.unfocusedActiveForeground": "#4e5b61",
"tab.inactiveBackground": "#ecf1f1",
"textLink.foreground": "#008184"
"textLink.foreground": "#008184",
"errorForeground": "#df7365"
},
"tokenColors": [
{

View File

@@ -2,7 +2,7 @@ import * as React from '@theia/core/shared/react';
import { inject, injectable } from '@theia/core/shared/inversify';
import { Widget } from '@theia/core/shared/@phosphor/widgets';
import { Message } from '@theia/core/shared/@phosphor/messaging';
import { clipboard } from 'electron';
import { clipboard } from '@theia/core/electron-shared/@electron/remote';
import { ReactWidget, DialogProps } from '@theia/core/lib/browser';
import { AbstractDialog } from '../theia/dialogs/dialogs';
import { CreateApi } from '../create/create-api';

View File

@@ -406,7 +406,7 @@ export class SettingsComponent extends React.Component<
}
onChange={this.socksProtocolDidChange}
/>
SOCKS
SOCKS5
</label>
</form>
<div className="flex-line proxy-settings">
@@ -682,7 +682,7 @@ export class SettingsComponent extends React.Component<
): void => {
if (this.state.network !== 'none') {
const network = this.cloneProxySettings;
network.protocol = event.target.checked ? 'http' : 'socks';
network.protocol = event.target.checked ? 'http' : 'socks5';
this.setState({ network });
}
};
@@ -692,7 +692,7 @@ export class SettingsComponent extends React.Component<
): void => {
if (this.state.network !== 'none') {
const network = this.cloneProxySettings;
network.protocol = event.target.checked ? 'socks' : 'http';
network.protocol = event.target.checked ? 'socks5' : 'http';
this.setState({ network });
}
};

View File

@@ -27,6 +27,7 @@ import {
import { ElectronCommands } from '@theia/core/lib/electron-browser/menu/electron-menu-contribution';
import { DefaultTheme } from '@theia/application-package/lib/application-props';
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
import type { FileStat } from '@theia/filesystem/lib/common/files';
export const WINDOW_SETTING = 'window';
export const EDITOR_SETTING = 'editor';
@@ -171,7 +172,15 @@ export class SettingsService {
this.preferenceService.get<boolean>(SHOW_ALL_FILES_SETTING, false),
this.configService.getConfiguration(),
]);
const { additionalUrls, sketchDirUri, network } = cliConfig;
const {
config = {
additionalUrls: [],
sketchDirUri: '',
network: Network.Default(),
},
} = cliConfig;
const { additionalUrls, sketchDirUri, network } = config;
const sketchbookPath = await this.fileService.fsPath(new URI(sketchDirUri));
return {
editorFontSize,
@@ -223,7 +232,11 @@ export class SettingsService {
try {
const { sketchbookPath, editorFontSize, themeId } = await settings;
const sketchbookDir = await this.fileSystemExt.getUri(sketchbookPath);
if (!(await this.fileService.exists(new URI(sketchbookDir)))) {
let sketchbookStat: FileStat | undefined = undefined;
try {
sketchbookStat = await this.fileService.resolve(new URI(sketchbookDir));
} catch {}
if (!sketchbookStat || !sketchbookStat.isDirectory) {
return nls.localize(
'arduino/preferences/invalid.sketchbook.location',
'Invalid sketchbook location: {0}',
@@ -274,10 +287,19 @@ export class SettingsService {
network,
sketchbookShowAllFiles,
} = this._settings;
const [config, sketchDirUri] = await Promise.all([
const [cliConfig, sketchDirUri] = await Promise.all([
this.configService.getConfiguration(),
this.fileSystemExt.getUri(sketchbookPath),
]);
const { config } = cliConfig;
if (!config) {
// Do not check for any error messages. The config might has errors (such as invalid directories.user) right before saving the new values.
return nls.localize(
'arduino/preferences/noCliConfig',
'Could not load the CLI configuration'
);
}
(config as any).additionalUrls = additionalUrls;
(config as any).sketchDirUri = sketchDirUri;
(config as any).network = network;

View File

@@ -41,7 +41,6 @@ export class LibraryListWidget extends ListWidget<
searchable: service,
installable: service,
itemLabel: (item: LibraryPackage) => item.name,
itemDeprecated: (item: LibraryPackage) => item.deprecated,
itemRenderer,
filterRenderer,
defaultSearchOptions: { query: '', type: 'All', topic: 'All' },

View File

@@ -88,8 +88,25 @@ export class LocalCacheFsProvider
}
protected async init(fileService: FileService): Promise<void> {
const config = await this.configService.getConfiguration();
this._localCacheRoot = new URI(config.dataDirUri);
const { config } = await this.configService.getConfiguration();
// Any possible CLI config errors are ignored here. IDE2 does not verify the `directories.data` folder.
// If the data dir is accessible, IDE2 creates the cache folder for the cloud sketches. Otherwise, it does not.
// The data folder can be configured outside of the IDE2, and the new data folder will be picked up with a
// subsequent IDE2 start.
if (!config?.dataDirUri) {
return; // the deferred promise will never resolve
}
const localCacheUri = new URI(config.dataDirUri);
try {
await fileService.access(localCacheUri);
} catch (err) {
console.error(
`'directories.data' location is inaccessible at ${config.dataDirUri}`,
err
);
return;
}
this._localCacheRoot = localCacheUri;
for (const segment of ['RemoteSketchbook', 'ArduinoCloud']) {
this._localCacheRoot = this._localCacheRoot.resolve(segment);
await fileService.createFolder(this._localCacheRoot);

View File

@@ -97,6 +97,11 @@ export namespace ArduinoMenus {
export const TOOLS__BOARD_SELECTION_GROUP = [...TOOLS, '2_board_selection'];
// Core settings, such as `Processor` and `Programmers` for the board and `Burn Bootloader`
export const TOOLS__BOARD_SETTINGS_GROUP = [...TOOLS, '3_board_settings'];
// `Tool` > `Ports` (always visible https://github.com/arduino/arduino-ide/issues/655)
export const TOOLS__PORTS_SUBMENU = [
...ArduinoMenus.TOOLS__BOARD_SELECTION_GROUP,
'2_ports',
];
// -- Help
// `Getting Started`, `Environment`, `Troubleshooting`, etc.

View File

@@ -18,7 +18,7 @@ import {
AttachedBoardsChangeEvent,
BoardsPackage,
LibraryPackage,
Config,
ConfigState,
Sketch,
ProgressMessage,
} from '../common/protocol';
@@ -37,6 +37,7 @@ export class NotificationCenter
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
private readonly didReinitializeEmitter = new Emitter<void>();
private readonly indexUpdateDidCompleteEmitter =
new Emitter<IndexUpdateDidCompleteParams>();
private readonly indexUpdateWillStartEmitter =
@@ -47,9 +48,7 @@ export class NotificationCenter
new Emitter<IndexUpdateDidFailParams>();
private readonly daemonDidStartEmitter = new Emitter<string>();
private readonly daemonDidStopEmitter = new Emitter<void>();
private readonly configDidChangeEmitter = new Emitter<{
config: Config | undefined;
}>();
private readonly configDidChangeEmitter = new Emitter<ConfigState>();
private readonly platformDidInstallEmitter = new Emitter<{
item: BoardsPackage;
}>();
@@ -57,7 +56,7 @@ export class NotificationCenter
item: BoardsPackage;
}>();
private readonly libraryDidInstallEmitter = new Emitter<{
item: LibraryPackage;
item: LibraryPackage | 'zip-install';
}>();
private readonly libraryDidUninstallEmitter = new Emitter<{
item: LibraryPackage;
@@ -71,6 +70,7 @@ export class NotificationCenter
new Emitter<FrontendApplicationState>();
private readonly toDispose = new DisposableCollection(
this.didReinitializeEmitter,
this.indexUpdateWillStartEmitter,
this.indexUpdateDidProgressEmitter,
this.indexUpdateDidCompleteEmitter,
@@ -85,6 +85,7 @@ export class NotificationCenter
this.attachedBoardsDidChangeEmitter
);
readonly onDidReinitialize = this.didReinitializeEmitter.event;
readonly onIndexUpdateDidComplete = this.indexUpdateDidCompleteEmitter.event;
readonly onIndexUpdateWillStart = this.indexUpdateWillStartEmitter.event;
readonly onIndexUpdateDidProgress = this.indexUpdateDidProgressEmitter.event;
@@ -115,6 +116,10 @@ export class NotificationCenter
this.toDispose.dispose();
}
notifyDidReinitialize(): void {
this.didReinitializeEmitter.fire();
}
notifyIndexUpdateWillStart(params: IndexUpdateWillStartParams): void {
this.indexUpdateWillStartEmitter.fire(params);
}
@@ -139,7 +144,7 @@ export class NotificationCenter
this.daemonDidStopEmitter.fire();
}
notifyConfigDidChange(event: { config: Config | undefined }): void {
notifyConfigDidChange(event: ConfigState): void {
this.configDidChangeEmitter.fire(event);
}
@@ -151,7 +156,9 @@ export class NotificationCenter
this.platformDidUninstallEmitter.fire(event);
}
notifyLibraryDidInstall(event: { item: LibraryPackage }): void {
notifyLibraryDidInstall(event: {
item: LibraryPackage | 'zip-install';
}): void {
this.libraryDidInstallEmitter.fire(event);
}

View File

@@ -0,0 +1,293 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Emitter } from '@theia/core/lib/common/event';
import { notEmpty } from '@theia/core/lib/common/objects';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application';
import { Sketch, SketchesService } from '../common/protocol';
import { ConfigServiceClient } from './config/config-service-client';
import {
SketchContainer,
SketchesError,
SketchRef,
} from '../common/protocol/sketches-service';
import {
ARDUINO_CLOUD_FOLDER,
REMOTE_SKETCHBOOK_FOLDER,
} from './utils/constants';
import * as monaco from '@theia/monaco-editor-core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
const READ_ONLY_FILES = ['sketch.json'];
const READ_ONLY_FILES_REMOTE = ['thingProperties.h', 'thingsProperties.h'];
export type CurrentSketch = Sketch | 'invalid';
export namespace CurrentSketch {
export function isValid(arg: CurrentSketch | undefined): arg is Sketch {
return !!arg && arg !== 'invalid';
}
}
@injectable()
export class SketchesServiceClientImpl
implements FrontendApplicationContribution
{
@inject(FileService)
private readonly fileService: FileService;
@inject(SketchesService)
private readonly sketchesService: SketchesService;
@inject(WorkspaceService)
private readonly workspaceService: WorkspaceService;
@inject(ConfigServiceClient)
private readonly configService: ConfigServiceClient;
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
private sketches = new Map<string, SketchRef>();
private onSketchbookDidChangeEmitter = new Emitter<{
created: SketchRef[];
removed: SketchRef[];
}>();
readonly onSketchbookDidChange = this.onSketchbookDidChangeEmitter.event;
private currentSketchDidChangeEmitter = new Emitter<CurrentSketch>();
readonly onCurrentSketchDidChange = this.currentSketchDidChangeEmitter.event;
private toDisposeBeforeWatchSketchbookDir = new DisposableCollection();
private toDispose = new DisposableCollection(
this.onSketchbookDidChangeEmitter,
this.currentSketchDidChangeEmitter,
this.toDisposeBeforeWatchSketchbookDir
);
private _currentSketch: CurrentSketch | undefined;
private currentSketchLoaded = new Deferred<CurrentSketch>();
onStart(): void {
const sketchDirUri = this.configService.tryGetSketchDirUri();
this.watchSketchbookDir(sketchDirUri);
const refreshCurrentSketch = async () => {
const currentSketch = await this.loadCurrentSketch();
this.useCurrentSketch(currentSketch);
};
this.toDispose.push(
this.configService.onDidChangeSketchDirUri((sketchDirUri) => {
this.watchSketchbookDir(sketchDirUri);
refreshCurrentSketch();
})
);
this.appStateService
.reachedState('started_contributions')
.then(refreshCurrentSketch);
}
private async watchSketchbookDir(
sketchDirUri: URI | undefined
): Promise<void> {
this.toDisposeBeforeWatchSketchbookDir.dispose();
if (!sketchDirUri) {
return;
}
const container = await this.sketchesService.getSketches({
uri: sketchDirUri.toString(),
});
for (const sketch of SketchContainer.toArray(container)) {
this.sketches.set(sketch.uri, sketch);
}
this.toDisposeBeforeWatchSketchbookDir.pushAll([
Disposable.create(() => this.sketches.clear()),
// Watch changes in the sketchbook to update `File` > `Sketchbook` menu items.
this.fileService.watch(sketchDirUri, {
recursive: true,
excludes: [],
}),
this.fileService.onDidFilesChange(async (event) => {
for (const { type, resource } of event.changes) {
// The file change events have higher precedence in the current sketch over the sketchbook.
if (
CurrentSketch.isValid(this._currentSketch) &&
new URI(this._currentSketch.uri).isEqualOrParent(resource)
) {
// https://github.com/arduino/arduino-ide/pull/1351#pullrequestreview-1086666656
// On a sketch file rename, the FS watcher will contain two changes:
// - Deletion of the original file,
// - Update of the new file,
// Hence, `UPDATE` events must be processed but only and if only there is a `DELETED` change in the same event.
// Otherwise, IDE2 would ask CLI to reload the sketch content on every save event in IDE2.
if (type === FileChangeType.UPDATED && event.changes.length === 1) {
// If the event contains only one `UPDATE` change, it cannot be a rename.
return;
}
let reloadedSketch: Sketch | undefined = undefined;
try {
reloadedSketch = await this.sketchesService.loadSketch(
this._currentSketch.uri
);
} catch (err) {
if (!SketchesError.NotFound.is(err)) {
throw err;
}
}
if (!reloadedSketch) {
return;
}
if (!Sketch.sameAs(this._currentSketch, reloadedSketch)) {
this.useCurrentSketch(reloadedSketch, true);
}
return;
}
// We track main sketch files changes only. // TODO: check sketch folder changes. One can rename the folder without renaming the `.ino` file.
if (sketchDirUri.isEqualOrParent(resource)) {
if (Sketch.isSketchFile(resource)) {
if (type === FileChangeType.ADDED) {
try {
const toAdd = await this.sketchesService.loadSketch(
resource.parent.toString()
);
if (!this.sketches.has(toAdd.uri)) {
console.log(
`New sketch '${toAdd.name}' was created in sketchbook '${sketchDirUri}'.`
);
this.sketches.set(toAdd.uri, toAdd);
this.fireSoon(toAdd, 'created');
}
} catch {}
} else if (type === FileChangeType.DELETED) {
const uri = resource.parent.toString();
const toDelete = this.sketches.get(uri);
if (toDelete) {
console.log(
`Sketch '${toDelete.name}' was removed from sketchbook '${sketchDirUri}'.`
);
this.sketches.delete(uri);
this.fireSoon(toDelete, 'removed');
}
}
}
}
}
}),
]);
}
private useCurrentSketch(
currentSketch: CurrentSketch,
reassignPromise = false
) {
this._currentSketch = currentSketch;
if (reassignPromise) {
this.currentSketchLoaded = new Deferred();
}
this.currentSketchLoaded.resolve(this._currentSketch);
this.currentSketchDidChangeEmitter.fire(this._currentSketch);
}
onStop(): void {
this.toDispose.dispose();
}
private async loadCurrentSketch(): Promise<CurrentSketch> {
const sketches = (
await Promise.all(
this.workspaceService
.tryGetRoots()
.map(({ resource }) =>
this.sketchesService.getSketchFolder(resource.toString())
)
)
).filter(notEmpty);
if (!sketches.length) {
return 'invalid';
}
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 currentSketch(): Promise<CurrentSketch> {
return this.currentSketchLoaded.promise;
}
tryGetCurrentSketch(): CurrentSketch | undefined {
return this._currentSketch;
}
async currentSketchFile(): Promise<string | undefined> {
const currentSketch = await this.currentSketch();
if (CurrentSketch.isValid(currentSketch)) {
return currentSketch.mainFileUri;
}
return undefined;
}
private fireSoonHandle?: number;
private bufferedSketchbookEvents: {
type: 'created' | 'removed';
sketch: SketchRef;
}[] = [];
private fireSoon(sketch: SketchRef, type: 'created' | 'removed'): void {
this.bufferedSketchbookEvents.push({ type, sketch });
if (typeof this.fireSoonHandle === 'number') {
window.clearTimeout(this.fireSoonHandle);
}
this.fireSoonHandle = window.setTimeout(() => {
const event: { created: SketchRef[]; removed: SketchRef[] } = {
created: [],
removed: [],
};
for (const { type, sketch } of this.bufferedSketchbookEvents) {
if (type === 'created') {
event.created.push(sketch);
} else {
event.removed.push(sketch);
}
}
this.onSketchbookDidChangeEmitter.fire(event);
this.bufferedSketchbookEvents.length = 0;
}, 100);
}
/**
* `true` if the `uri` is not contained in any of the opened workspaces. Otherwise, `false`.
*/
isReadOnly(uri: URI | monaco.Uri | string): boolean {
const toCheck = uri instanceof URI ? uri : new URI(uri);
if (toCheck.scheme === 'user-storage') {
return false;
}
const isCloudSketch = toCheck
.toString()
.includes(`${REMOTE_SKETCHBOOK_FOLDER}/${ARDUINO_CLOUD_FOLDER}`);
const filesToCheck = [
...READ_ONLY_FILES,
...(isCloudSketch ? READ_ONLY_FILES_REMOTE : []),
];
if (filesToCheck.includes(toCheck?.path?.base)) {
return true;
}
const readOnly = !this.workspaceService
.tryGetRoots()
.some(({ resource }) => resource.isEqualOrParent(toCheck));
return readOnly;
}
}

View File

@@ -100,6 +100,19 @@
background-color: var(--theia-titleBar-activeBackground);
}
#arduino-toolbar-panel {
background: var(--theia-titleBar-activeBackground);
color: var(--theia-titleBar-activeForeground);
display: flex;
min-height: var(--theia-private-menubar-height);
border-bottom: 1px solid var(--theia-titleBar-border);
}
#arduino-toolbar-panel:window-inactive,
#arduino-toolbar-panel:-moz-window-inactive {
background: var(--theia-titleBar-inactiveBackground);
color: var(--theia-titleBar-inactiveForeground);
}
#arduino-toolbar-container {
display: flex;
width: 100%;

View File

@@ -7,6 +7,8 @@ import {
SHELL_TABBAR_CONTEXT_MENU,
TabBar,
Widget,
Layout,
SplitPanel,
} from '@theia/core/lib/browser';
import {
ConnectionStatus,
@@ -17,6 +19,11 @@ import { MessageService } from '@theia/core/lib/common/message-service';
import { inject, injectable } from '@theia/core/shared/inversify';
import { ToolbarAwareTabBar } from './tab-bars';
interface WidgetOptions
extends Omit<TheiaApplicationShell.WidgetOptions, 'area'> {
area?: TheiaApplicationShell.Area | 'toolbar';
}
@injectable()
export class ApplicationShell extends TheiaApplicationShell {
@inject(MessageService)
@@ -24,10 +31,11 @@ export class ApplicationShell extends TheiaApplicationShell {
@inject(ConnectionStatusService)
private readonly connectionStatusService: ConnectionStatusService;
private toolbarPanel: Panel;
override async addWidget(
widget: Widget,
options: Readonly<TheiaApplicationShell.WidgetOptions> = {}
options: Readonly<WidgetOptions> = {}
): Promise<void> {
// By default, Theia open a widget **next** to the currently active in the target area.
// Instead of this logic, we want to open the new widget after the last of the target area.
@@ -37,8 +45,12 @@ export class ApplicationShell extends TheiaApplicationShell {
);
return;
}
if (options.area === 'toolbar') {
this.toolbarPanel.addWidget(widget);
return;
}
const area = options.area || 'main';
let ref: Widget | undefined = options.ref;
const area: TheiaApplicationShell.Area = options.area || 'main';
if (!ref && (area === 'main' || area === 'bottom')) {
const tabBar = this.getTabBarFor(area);
if (tabBar) {
@@ -48,7 +60,10 @@ export class ApplicationShell extends TheiaApplicationShell {
}
}
}
return super.addWidget(widget, { ...options, ref });
return super.addWidget(widget, {
...(<TheiaApplicationShell.WidgetOptions>options),
ref,
});
}
override handleEvent(): boolean {
@@ -56,6 +71,46 @@ export class ApplicationShell extends TheiaApplicationShell {
return false;
}
protected override initializeShell(): void {
this.toolbarPanel = this.createToolbarPanel();
super.initializeShell();
}
private createToolbarPanel(): Panel {
const toolbarPanel = new Panel();
toolbarPanel.id = 'arduino-toolbar-panel';
toolbarPanel.show();
return toolbarPanel;
}
protected override createLayout(): Layout {
const bottomSplitLayout = this.createSplitLayout(
[this.mainPanel, this.bottomPanel],
[1, 0],
{ orientation: 'vertical', spacing: 0 }
);
const panelForBottomArea = new SplitPanel({ layout: bottomSplitLayout });
panelForBottomArea.id = 'theia-bottom-split-panel';
const leftRightSplitLayout = this.createSplitLayout(
[
this.leftPanelHandler.container,
panelForBottomArea,
this.rightPanelHandler.container,
],
[0, 1, 0],
{ orientation: 'horizontal', spacing: 0 }
);
const panelForSideAreas = new SplitPanel({ layout: leftRightSplitLayout });
panelForSideAreas.id = 'theia-left-right-split-panel';
return this.createBoxLayout(
[this.topPanel, this.toolbarPanel, panelForSideAreas, this.statusBar],
[0, 0, 1, 0],
{ direction: 'top-to-bottom', spacing: 0 }
);
}
// Avoid hiding top panel as we use it for arduino toolbar
protected override createTopPanel(): Panel {
const topPanel = super.createTopPanel();

View File

@@ -46,7 +46,8 @@ export class CommonFrontendContribution extends TheiaCommonFrontendContribution
CommonCommands.SELECT_ICON_THEME,
CommonCommands.SELECT_COLOR_THEME,
CommonCommands.ABOUT_COMMAND,
CommonCommands.SAVE_WITHOUT_FORMATTING, // Patched for https://github.com/eclipse-theia/theia/pull/8877
CommonCommands.SAVE_WITHOUT_FORMATTING, // Patched for https://github.com/eclipse-theia/theia/pull/8877,
CommonCommands.NEW_UNTITLED_FILE,
]) {
registry.unregisterMenuAction(command);
}

View File

@@ -1,30 +1,35 @@
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { Title, Widget } from '@theia/core/shared/@phosphor/widgets';
import { ILogger } from '@theia/core/lib/common/logger';
import { EditorWidget } from '@theia/editor/lib/browser';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
import { TabBarDecoratorService as TheiaTabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-decorator';
import { ConfigService } from '../../../common/protocol/config-service';
import { ConfigServiceClient } from '../../config/config-service-client';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
@injectable()
export class TabBarDecoratorService extends TheiaTabBarDecoratorService {
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(ConfigServiceClient)
private readonly configService: ConfigServiceClient;
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
@inject(ILogger)
protected readonly logger: ILogger;
protected dataDirUri: URI | undefined;
private dataDirUri: URI | undefined;
@postConstruct()
protected init(): void {
this.configService
.getConfiguration()
.then(({ dataDirUri }) => (this.dataDirUri = new URI(dataDirUri)))
.catch((err) =>
this.logger.error(`Failed to determine the data directory: ${err}`)
);
const fireDidChange = () =>
this.appStateService
.reachedState('ready')
.then(() => this.fireDidChangeDecorations());
this.dataDirUri = this.configService.tryGetDataDirUri();
this.configService.onDidChangeDataDirUri((dataDirUri) => {
this.dataDirUri = dataDirUri;
fireDidChange();
});
if (this.dataDirUri) {
fireDidChange();
}
}
override getDecorations(title: Title<Widget>): WidgetDecoration.Data[] {

View File

@@ -23,11 +23,6 @@ export class TabBarRenderer extends TheiaTabBarRenderer {
}
export class ToolbarAwareTabBar extends TheiaToolbarAwareTabBar {
protected override async updateBreadcrumbs(): Promise<void> {
// NOOP
// IDE2 does not use breadcrumbs.
}
private readonly doUpdateToolbar = debounce(() => super.updateToolbar(), 500);
protected override updateToolbar(): void {
// Unlike Theia, IDE2 debounces the toolbar updates with 500ms

View File

@@ -1,6 +1,6 @@
import { ThemeService as TheiaThemeService } from '@theia/core/lib/browser/theming';
import type { Theme } from '@theia/core/lib/common/theme';
import { injectable } from '@theia/core/shared/inversify';
import { ThemeServiceWithDB as TheiaThemeServiceWithDB } from '@theia/monaco/lib/browser/monaco-indexed-db';
export namespace ArduinoThemes {
export const Light: Theme = {
@@ -18,7 +18,7 @@ export namespace ArduinoThemes {
}
@injectable()
export class ThemeService extends TheiaThemeService {
export class ThemeServiceWithDB extends TheiaThemeServiceWithDB {
protected override init(): void {
this.register(ArduinoThemes.Light, ArduinoThemes.Dark);
super.init();

View File

@@ -10,7 +10,7 @@ import { OutputWidget } from '@theia/output/lib/browser/output-widget';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
@injectable()
export class WidgetManager extends TheiaWidgetManager {

View File

@@ -14,7 +14,7 @@ import { SketchesService } from '../../../common/protocol';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { DebugConfigurationModel } from './debug-configuration-model';
import {
FileOperationError,

View File

@@ -10,4 +10,12 @@ export class EditorContribution extends TheiaEditorContribution {
): void {
// NOOP
}
protected override updateEncodingStatus(
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
editor: TextEditor | undefined
): void {
// https://github.com/arduino/arduino-ide/issues/1393
// NOOP
}
}

View File

@@ -6,7 +6,7 @@ import { EditorWidgetFactory as TheiaEditorWidgetFactory } from '@theia/editor/l
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { SketchesService, Sketch } from '../../../common/protocol';
import { nls } from '@theia/core/lib/common';

View File

@@ -5,31 +5,23 @@ import {
} from '@theia/core/shared/inversify';
import { Diagnostic } from '@theia/core/shared/vscode-languageserver-types';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core';
import { Marker } from '@theia/markers/lib/common/marker';
import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browser/problem/problem-manager';
import { ConfigService } from '../../../common/protocol/config-service';
import { ConfigServiceClient } from '../../config/config-service-client';
import debounce = require('lodash.debounce');
@injectable()
export class ProblemManager extends TheiaProblemManager {
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(ConfigServiceClient)
private readonly configService: ConfigServiceClient;
@inject(ILogger)
protected readonly logger: ILogger;
protected dataDirUri: URI | undefined;
private dataDirUri: URI | undefined;
@postConstruct()
protected override init(): void {
super.init();
this.configService
.getConfiguration()
.then(({ dataDirUri }) => (this.dataDirUri = new URI(dataDirUri)))
.catch((err) =>
this.logger.error(`Failed to determine the data directory: ${err}`)
);
this.dataDirUri = this.configService.tryGetDataDirUri();
this.configService.onDidChangeDataDirUri((uri) => (this.dataDirUri = uri));
}
override setMarkers(

View File

@@ -6,7 +6,7 @@ import {
} from '@theia/core/lib/common/disposable';
import { EditorServiceOverrides, MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoEditorProvider as TheiaMonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { SketchesServiceClientImpl } from '../../sketches-service-client-impl';
import * as monaco from '@theia/monaco-editor-core';
import type { ReferencesModel } from '@theia/monaco-editor-core/esm/vs/editor/contrib/gotoSymbol/browser/referencesModel';

View File

@@ -6,14 +6,16 @@ import { EditorPreferences } from '@theia/editor/lib/browser/editor-preferences'
import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter';
import { MonacoTextModelService as TheiaMonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { SketchesServiceClientImpl } from '../../sketches-service-client-impl';
@injectable()
export class MonacoTextModelService extends TheiaMonacoTextModelService {
@inject(SketchesServiceClientImpl)
protected readonly sketchesServiceClient: SketchesServiceClientImpl;
protected override async createModel(resource: Resource): Promise<MonacoEditorModel> {
protected override async createModel(
resource: Resource
): Promise<MonacoEditorModel> {
const factory = this.factories
.getContributions()
.find(({ scheme }) => resource.uri.scheme === scheme);

View File

@@ -1,34 +1,53 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { open } from '@theia/core/lib/browser/opener-service';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
import {
CommandRegistry,
CommandService,
} from '@theia/core/lib/common/command';
import { nls } from '@theia/core/lib/common/nls';
import { Path } from '@theia/core/lib/common/path';
import { waitForEvent } from '@theia/core/lib/common/promise-util';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import { MaybeArray } from '@theia/core/lib/common/types';
import URI from '@theia/core/lib/common/uri';
import {
UriAwareCommandHandler,
UriCommandHandler,
} from '@theia/core/lib/common/uri-command-handler';
import { inject, injectable } from '@theia/core/shared/inversify';
import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
import { FileStat } from '@theia/filesystem/lib/common/files';
import {
WorkspaceCommandContribution as TheiaWorkspaceCommandContribution,
WorkspaceCommands,
} from '@theia/workspace/lib/browser/workspace-commands';
import { Sketch, SketchesService } from '../../../common/protocol';
import { WorkspaceInputDialog } from './workspace-input-dialog';
import { Sketch } from '../../../common/protocol';
import { ConfigServiceClient } from '../../config/config-service-client';
import { CreateFeatures } from '../../create/create-features';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
import { SaveAsSketch } from '../../contributions/save-as-sketch';
import { nls } from '@theia/core/lib/common';
} from '../../sketches-service-client-impl';
import { WorkspaceInputDialog } from './workspace-input-dialog';
interface ValidationContext {
sketch: Sketch;
isCloud: boolean | undefined;
}
@injectable()
export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribution {
@inject(SketchesServiceClientImpl)
protected readonly sketchesServiceClient: SketchesServiceClientImpl;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(SketchesService)
protected readonly sketchService: SketchesService;
private readonly commandService: CommandService;
@inject(SketchesServiceClientImpl)
private readonly sketchesServiceClient: SketchesServiceClientImpl;
@inject(CreateFeatures)
private readonly createFeatures: CreateFeatures;
@inject(ApplicationShell)
private readonly shell: ApplicationShell;
@inject(ConfigServiceClient)
private readonly configServiceClient: ConfigServiceClient;
private _validationContext: ValidationContext | undefined;
override registerCommands(registry: CommandRegistry): void {
super.registerCommands(registry);
@@ -46,9 +65,14 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
execute: (uri) => this.renameFile(uri),
})
);
registry.unregisterCommand(WorkspaceCommands.FILE_DELETE);
registry.registerCommand(
WorkspaceCommands.FILE_DELETE,
this.newMultiUriAwareCommandHandler(this.deleteHandler)
);
}
protected async newFile(uri: URI | undefined): Promise<void> {
private async newFile(uri: URI | undefined): Promise<void> {
if (!uri) {
return;
}
@@ -67,51 +91,72 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
this.labelProvider
);
const name = await dialog.open();
const nameWithExt = this.maybeAppendInoExt(name);
if (nameWithExt) {
const fileUri = parentUri.resolve(nameWithExt);
await this.fileService.createFile(fileUri);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
const name = await this.openDialog(dialog, parentUri);
if (!name) {
return;
}
const nameWithExt = this.maybeAppendInoExt(name);
const fileUri = parentUri.resolve(nameWithExt);
await this.fileService.createFile(fileUri);
this.fireCreateNewFile({ parent: parentUri, uri: fileUri });
open(this.openerService, fileUri);
}
protected override async validateFileName(
name: string,
userInput: string,
parent: FileStat,
recursive = false
): Promise<string> {
// In the Java IDE the followings are the rules:
// - `name` without an extension should default to `name.ino`.
// - `name` with a single trailing `.` also defaults to `name.ino`.
const nameWithExt = this.maybeAppendInoExt(name);
const errorMessage = await super.validateFileName(
nameWithExt,
parent,
recursive
);
// If name does not have extension or ends with trailing dot (from IDE 1.x), treat it as an .ino file.
// If has extension,
// - if unsupported extension -> error
// - if has a code file extension -> apply folder name validation without the extension and use the Theia-based validation
// - if has any additional file extension -> use the default Theia-based validation
const fileInput = parseFileInput(userInput);
const { name, extension } = fileInput;
if (!Sketch.Extensions.ALL.includes(extension)) {
return invalidExtension(extension);
}
let errorMessage: string | undefined = undefined;
if (Sketch.Extensions.CODE_FILES.includes(extension)) {
errorMessage = this._validationContext?.isCloud
? Sketch.validateCloudSketchFolderName(name)
: Sketch.validateSketchFolderName(name);
}
if (errorMessage) {
return errorMessage;
return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput);
}
const extension = nameWithExt.split('.').pop();
if (!extension) {
return nls.localize(
'theia/workspace/invalidFilename',
'Invalid filename.'
); // XXX: this should not happen as we forcefully append `.ino` if it's not there.
errorMessage = await super.validateFileName(userInput, parent, recursive); // run the default Theia validation with the raw input.
if (errorMessage) {
return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput);
}
if (Sketch.Extensions.ALL.indexOf(`.${extension}`) === -1) {
return nls.localize(
'theia/workspace/invalidExtension',
'.{0} is not a valid extension',
extension
);
// It's a legacy behavior from IDE 1.x. Validate the file as if it were an `.ino` file.
// If user did not write the `.ino` extension or ended the user input with dot, run the default Theia validation with the inferred name.
if (extension === '.ino' && !userInput.endsWith('.ino')) {
userInput = `${name}${extension}`;
errorMessage = await super.validateFileName(userInput, parent, recursive);
}
return '';
return this.maybeRemapAlreadyExistsMessage(errorMessage ?? '', userInput);
}
protected maybeAppendInoExt(name: string | undefined): string {
// Remaps the Theia-based `A file or folder **$fileName** already exists at this location. Please choose a different name.` to a custom one.
private maybeRemapAlreadyExistsMessage(
errorMessage: string,
userInput: string
): string {
if (
errorMessage ===
nls.localizeByDefault(
'A file or folder **{0}** already exists at this location. Please choose a different name.',
this['trimFileName'](userInput)
)
) {
return fileAlreadyExists(userInput);
}
return errorMessage;
}
private maybeAppendInoExt(name: string): string {
if (!name) {
return '';
}
@@ -126,7 +171,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
return name;
}
protected async renameFile(uri: URI | undefined): Promise<void> {
protected async renameFile(uri: URI | undefined): Promise<unknown> {
if (!uri) {
return;
}
@@ -136,10 +181,7 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
}
// file belongs to another sketch, do not allow rename
const parentSketch = await this.sketchService.getSketchFolder(
uri.toString()
);
if (parentSketch && parentSketch.uri !== sketch.uri) {
if (!Sketch.isInSketch(uri, sketch)) {
return;
}
@@ -149,11 +191,10 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
openAfterMove: true,
wipeOriginal: true,
};
await this.commandService.executeCommand(
SaveAsSketch.Commands.SAVE_AS_SKETCH.id,
return await this.commandService.executeCommand<string>(
'arduino-save-as-sketch',
options
);
return;
}
const parent = await this.getParent(uri);
if (!parent) {
@@ -180,12 +221,243 @@ export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribut
},
this.labelProvider
);
const newName = await dialog.open();
const newNameWithExt = this.maybeAppendInoExt(newName);
if (newNameWithExt) {
const oldUri = uri;
const newUri = uri.parent.resolve(newNameWithExt);
this.fileService.move(oldUri, newUri);
const name = await this.openDialog(dialog, uri);
if (!name) {
return;
}
const nameWithExt = this.maybeAppendInoExt(name);
const oldUri = uri;
const newUri = uri.parent.resolve(nameWithExt);
return this.fileService.move(oldUri, newUri);
}
protected override newUriAwareCommandHandler(
handler: UriCommandHandler<URI>
): UriAwareCommandHandler<URI> {
return this.createUriAwareCommandHandler(handler);
}
protected override newMultiUriAwareCommandHandler(
handler: UriCommandHandler<URI[]>
): UriAwareCommandHandler<URI[]> {
return this.createUriAwareCommandHandler(handler, true);
}
private createUriAwareCommandHandler<T extends MaybeArray<URI>>(
delegate: UriCommandHandler<T>,
multi = false
): UriAwareCommandHandler<T> {
return new UriAwareCommandHandlerWithCurrentEditorFallback(
delegate,
this.selectionService,
this.shell,
this.sketchesServiceClient,
this.configServiceClient,
this.createFeatures,
{ multi }
);
}
private async openDialog(
dialog: WorkspaceInputDialog,
uri: URI
): Promise<string | undefined> {
try {
let dataDirUri = this.configServiceClient.tryGetDataDirUri();
if (!dataDirUri) {
dataDirUri = await waitForEvent(
this.configServiceClient.onDidChangeDataDirUri,
2_000
);
}
this.acquireValidationContext(uri, dataDirUri);
const name = await dialog.open(true);
return name;
} finally {
this._validationContext = undefined;
}
}
private acquireValidationContext(
uri: URI,
dataDirUri: URI | undefined
): void {
const sketch = this.sketchesServiceClient.tryGetCurrentSketch();
if (
CurrentSketch.isValid(sketch) &&
new URI(sketch.uri).isEqualOrParent(uri)
) {
const isCloud = this.createFeatures.isCloud(sketch, dataDirUri);
this._validationContext = { sketch, isCloud };
}
}
}
// (non-API) exported for tests
export function fileAlreadyExists(userInput: string): string {
return nls.localize(
'arduino/workspace/alreadyExists',
"'{0}' already exists.",
userInput
);
}
// (non-API) exported for tests
export function invalidExtension(extension: string): string {
return nls.localize(
'theia/workspace/invalidExtension',
'.{0} is not a valid extension',
extension.charAt(0) === '.' ? extension.slice(1) : extension
);
}
interface FileInput {
/**
* The raw text the user enters in the `<input>`.
*/
readonly raw: string;
/**
* This is the name without the extension. If raw is `'lib.cpp'`, then `name` will be `'lib'`. If raw is `'foo'` or `'foo.'` this value is `'foo'`.
*/
readonly name: string;
/**
* With the leading dot. For example `'.ino'` or `'.cpp'`.
*/
readonly extension: string;
}
export function parseFileInput(userInput: string): FileInput {
if (!userInput) {
return {
raw: '',
name: '',
extension: Sketch.Extensions.DEFAULT,
};
}
const path = new Path(userInput);
let extension = path.ext;
if (extension.trim() === '' || extension.trim() === '.') {
extension = Sketch.Extensions.DEFAULT;
}
return {
raw: userInput,
name: path.name,
extension,
};
}
/**
* By default, the Theia-based URI-aware command handler tries to retrieve the URI from the selection service.
* Delete/Rename from the tab-bar toolbar (`...`) is not active if the selection was never inside an editor.
* This implementation falls back to the current current title of the main panel if no URI can be retrieved from the parent classes.
* - https://github.com/arduino/arduino-ide/issues/1847
* - https://github.com/eclipse-theia/theia/issues/12139
*/
class UriAwareCommandHandlerWithCurrentEditorFallback<
T extends MaybeArray<URI>
> extends UriAwareCommandHandler<T> {
constructor(
delegate: UriCommandHandler<T>,
selectionService: SelectionService,
private readonly shell: ApplicationShell,
private readonly sketchesServiceClient: SketchesServiceClientImpl,
private readonly configServiceClient: ConfigServiceClient,
private readonly createFeatures: CreateFeatures,
options?: UriAwareCommandHandler.Options
) {
super(selectionService, delegate, options);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override getUri(...args: any[]): T | undefined {
const uri = super.getUri(...args);
if (!uri || (Array.isArray(uri) && !uri.length)) {
const fallbackUri = this.currentTitleOwnerUriFromMainPanel;
if (fallbackUri) {
return (this.isMulti() ? [fallbackUri] : fallbackUri) as T;
}
}
return uri;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override isEnabled(...args: any[]): boolean {
const [uri, ...others] = this.getArgsWithUri(...args);
if (uri) {
if (!this.isInSketch(uri)) {
return false;
}
if (this.affectsCloudSketchFolderWhenSignedOut(uri)) {
return false;
}
if (this.handler.isEnabled) {
return this.handler.isEnabled(uri, ...others);
}
return true;
}
return false;
}
// The `currentEditor` is broken after a rename. (https://github.com/eclipse-theia/theia/issues/12139)
// `ApplicationShell#currentWidget` might provide a wrong result just as the `getFocusedCodeEditor` and `getFocusedCodeEditor` of the `MonacoEditorService`
// Try to extract the URI from the current title of the main panel if it's an editor widget.
private get currentTitleOwnerUriFromMainPanel(): URI | undefined {
const owner = this.shell.mainPanel.currentTitle?.owner;
return owner instanceof EditorWidget
? owner.editor.getResourceUri()
: undefined;
}
private isInSketch(uri: T | undefined): boolean {
if (!uri) {
return false;
}
const sketch = this.sketchesServiceClient.tryGetCurrentSketch();
if (!CurrentSketch.isValid(sketch)) {
return false;
}
if (this.isMulti() && Array.isArray(uri)) {
return uri.every((u) => Sketch.isInSketch(u, sketch));
}
if (!this.isMulti() && uri instanceof URI) {
return Sketch.isInSketch(uri, sketch);
}
return false;
}
/**
* If the user is not logged in, deleting/renaming the main sketch file or the sketch folder of a cloud sketch is disabled.
*/
private affectsCloudSketchFolderWhenSignedOut(uri: T | undefined): boolean {
return (
!Boolean(this.createFeatures.session) &&
Boolean(this.isCurrentSketchCloud()) &&
this.affectsSketchFolder(uri)
);
}
private affectsSketchFolder(uri: T | undefined): boolean {
if (!uri) {
return false;
}
const sketch = this.sketchesServiceClient.tryGetCurrentSketch();
if (!CurrentSketch.isValid(sketch)) {
return false;
}
if (this.isMulti() && Array.isArray(uri)) {
return uri.map((u) => u.toString()).includes(sketch.mainFileUri);
}
if (!this.isMulti()) {
return sketch.mainFileUri === uri.toString();
}
return false;
}
private isCurrentSketchCloud(): boolean | undefined {
const sketch = this.sketchesServiceClient.tryGetCurrentSketch();
if (!CurrentSketch.isValid(sketch)) {
return false;
}
const dataDirUri = this.configServiceClient.tryGetDataDirUri();
return this.createFeatures.isCloud(sketch, dataDirUri);
}
}

View File

@@ -1,55 +1,36 @@
import { inject, injectable } from '@theia/core/shared/inversify';
import * as remote from '@theia/core/electron-shared/@electron/remote';
import { CommandService } from '@theia/core/lib/common/command';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import { WorkspaceDeleteHandler as TheiaWorkspaceDeleteHandler } from '@theia/workspace/lib/browser/workspace-delete-handler';
import { DeleteSketch } from '../../contributions/delete-sketch';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
import { nls } from '@theia/core/lib/common';
} from '../../sketches-service-client-impl';
@injectable()
export class WorkspaceDeleteHandler extends TheiaWorkspaceDeleteHandler {
@inject(CommandService)
private readonly commandService: CommandService;
@inject(SketchesServiceClientImpl)
protected readonly sketchesServiceClient: SketchesServiceClientImpl;
private readonly sketchesServiceClient: SketchesServiceClientImpl;
override async execute(uris: URI[]): Promise<void> {
const sketch = await this.sketchesServiceClient.currentSketch();
if (!CurrentSketch.isValid(sketch)) {
return;
}
// Deleting the main sketch file.
if (
uris
.map((uri) => uri.toString())
.some((uri) => uri === sketch.mainFileUri)
) {
const { response } = await remote.dialog.showMessageBox({
title: nls.localize('vscode/fileActions/delete', 'Delete'),
type: 'question',
buttons: [
nls.localize('vscode/issueMainService/cancel', 'Cancel'),
nls.localize('vscode/issueMainService/ok', 'OK'),
],
message: nls.localize(
'theia/workspace/deleteCurrentSketch',
'Do you want to delete the current sketch?'
),
});
if (response === 1) {
// OK
await Promise.all(
[
...sketch.additionalFileUris,
...sketch.otherSketchFileUris,
sketch.mainFileUri,
].map((uri) => this.closeWithoutSaving(new URI(uri)))
);
await this.fileService.delete(new URI(sketch.uri));
window.close();
}
return;
// Deleting the main sketch file means deleting the sketch folder and all its content.
if (uris.some((uri) => uri.toString() === sketch.mainFileUri)) {
return this.commandService.executeCommand(
DeleteSketch.Commands.DELETE_SKETCH.id,
{
toDelete: sketch,
willNavigateAway: true,
}
);
}
// Individual file deletion(s).
return super.execute(uris);
}
}

View File

@@ -1,15 +1,25 @@
import { inject } from '@theia/core/shared/inversify';
import { MaybePromise } from '@theia/core/lib/common/types';
import { MaybePromise } from '@theia/core';
import { Dialog, DialogError } from '@theia/core/lib/browser/dialogs';
import { LabelProvider } from '@theia/core/lib/browser/label-provider';
import { DialogError, DialogMode } from '@theia/core/lib/browser/dialogs';
import { CancellationTokenSource } from '@theia/core/lib/common/cancellation';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import type {
Progress,
ProgressUpdate,
} from '@theia/core/lib/common/message-service-protocol';
import { Widget } from '@theia/core/shared/@phosphor/widgets';
import { inject } from '@theia/core/shared/inversify';
import {
WorkspaceInputDialog as TheiaWorkspaceInputDialog,
WorkspaceInputDialogProps,
} from '@theia/workspace/lib/browser/workspace-input-dialog';
import { nls } from '@theia/core/lib/common';
import { v4 } from 'uuid';
export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog {
protected wasTouched = false;
private skipShowErrorMessageOnOpen: boolean;
constructor(
@inject(WorkspaceInputDialogProps)
@@ -19,27 +29,31 @@ export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog {
) {
super(props, labelProvider);
this.node.classList.add('workspace-input-dialog');
this.appendCloseButton(
nls.localize('vscode/issueMainService/cancel', 'Cancel')
);
this.appendCloseButton(Dialog.CANCEL);
}
protected override appendParentPath(): void {
// NOOP
}
override isValid(value: string, mode: DialogMode): MaybePromise<DialogError> {
if (value !== '') {
this.wasTouched = true;
}
return super.isValid(value, mode);
override isValid(value: string): MaybePromise<DialogError> {
return super.isValid(value, 'open');
}
override open(
skipShowErrorMessageOnOpen = false
): Promise<string | undefined> {
this.skipShowErrorMessageOnOpen = skipShowErrorMessageOnOpen;
return super.open();
}
protected override setErrorMessage(error: DialogError): void {
if (this.acceptButton) {
this.acceptButton.disabled = !DialogError.getResult(error);
}
if (this.wasTouched) {
if (this.skipShowErrorMessageOnOpen) {
this.skipShowErrorMessageOnOpen = false;
} else {
this.errorMessageNode.innerText = DialogError.getMessage(error);
}
}
@@ -54,3 +68,133 @@ export class WorkspaceInputDialog extends TheiaWorkspaceInputDialog {
return this.closeButton;
}
}
interface TaskFactory<T> {
createTask(value: string): (progress: Progress) => Promise<T>;
}
export class TaskFactoryImpl<T> implements TaskFactory<T> {
private _value: string | undefined;
constructor(private readonly task: TaskFactory<T>['createTask']) {}
get value(): string | undefined {
return this._value;
}
createTask(value: string): (progress: Progress) => Promise<T> {
this._value = value;
return this.task(this._value);
}
}
/**
* Workspace input dialog executing a long running operation with indefinite progress.
*/
export class WorkspaceInputDialogWithProgress<
T = unknown
> extends WorkspaceInputDialog {
private _taskResult: T | undefined;
constructor(
protected override readonly props: WorkspaceInputDialogProps,
protected override readonly labelProvider: LabelProvider,
/**
* The created task will provide the result. See `#taskResult`.
*/
private readonly taskFactory: TaskFactory<T>
) {
super(props, labelProvider);
}
get taskResult(): T | undefined {
return this._taskResult;
}
protected override async accept(): Promise<void> {
if (!this.resolve) {
return;
}
this.acceptCancellationSource.cancel();
this.acceptCancellationSource = new CancellationTokenSource();
const token = this.acceptCancellationSource.token;
const value = this.value;
const error = await this.isValid(value);
if (token.isCancellationRequested) {
return;
}
if (!DialogError.getResult(error)) {
this.setErrorMessage(error);
} else {
const spinner = document.createElement('div');
spinner.classList.add('spinner');
const disposables = new DisposableCollection();
try {
this.toggleButtons(true);
disposables.push(Disposable.create(() => this.toggleButtons(false)));
const closeParent = this.closeCrossNode.parentNode;
closeParent?.removeChild(this.closeCrossNode);
disposables.push(
Disposable.create(() => {
closeParent?.appendChild(this.closeCrossNode);
})
);
this.errorMessageNode.classList.add('progress');
disposables.push(
Disposable.create(() =>
this.errorMessageNode.classList.remove('progress')
)
);
const errorParent = this.errorMessageNode.parentNode;
errorParent?.insertBefore(spinner, this.errorMessageNode);
disposables.push(
Disposable.create(() => errorParent?.removeChild(spinner))
);
const cancellationSource = new CancellationTokenSource();
const progress: Progress = {
id: v4(),
cancel: () => cancellationSource.cancel(),
report: (update: ProgressUpdate) => {
this.setProgressMessage(update);
},
result: Promise.resolve(value),
};
const task = this.taskFactory.createTask(value);
this._taskResult = await task(progress);
this.resolve(value);
} catch (err) {
if (this.reject) {
this.reject(err);
} else {
throw err;
}
} finally {
Widget.detach(this);
disposables.dispose();
}
}
}
private toggleButtons(disabled: boolean): void {
if (this.acceptButton) {
this.acceptButton.disabled = disabled;
}
if (this.closeButton) {
this.closeButton.disabled = disabled;
}
}
private setProgressMessage(update: ProgressUpdate): void {
if (update.work && update.work.done === update.work.total) {
this.errorMessageNode.innerText = '';
} else {
if (update.message) {
this.errorMessageNode.innerText = update.message;
}
}
}
}

View File

@@ -23,7 +23,7 @@ import { WindowServiceExt } from '../core/window-service-ext';
@injectable()
export class WorkspaceService extends TheiaWorkspaceService {
@inject(SketchesService)
private readonly sketchService: SketchesService;
private readonly sketchesService: SketchesService;
@inject(WindowServiceExt)
private readonly windowServiceExt: WindowServiceExt;
@inject(ContributionProvider)
@@ -41,7 +41,7 @@ export class WorkspaceService extends TheiaWorkspaceService {
): Promise<FileStat | undefined> {
const stat = await super.toFileStat(uri);
if (!stat) {
const newSketchUri = await this.sketchService.createNewSketch();
const newSketchUri = await this.sketchesService.createNewSketch();
return this.toFileStat(newSketchUri.uri);
}
// When opening a file instead of a directory, IDE2 (and Theia) expects a workspace JSON file.
@@ -52,18 +52,18 @@ export class WorkspaceService extends TheiaWorkspaceService {
// If loading the sketch fails, create a fallback sketch and open the new temp sketch folder as the workspace root.
if (stat.isFile && stat.resource.path.ext === '.ino') {
try {
const sketch = await this.sketchService.loadSketch(
const sketch = await this.sketchesService.loadSketch(
stat.resource.toString()
);
return this.toFileStat(sketch.uri);
} catch (err) {
if (SketchesError.InvalidName.is(err)) {
this._workspaceError = err;
const newSketchUri = await this.sketchService.createNewSketch();
const newSketchUri = await this.sketchesService.createNewSketch();
return this.toFileStat(newSketchUri.uri);
} else if (SketchesError.NotFound.is(err)) {
this._workspaceError = err;
const newSketchUri = await this.sketchService.createNewSketch();
const newSketchUri = await this.sketchesService.createNewSketch();
return this.toFileStat(newSketchUri.uri);
}
throw err;

View File

@@ -9,7 +9,7 @@ import { Sketch } from '../../../common/protocol';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
@injectable()

View File

@@ -19,7 +19,8 @@ export class ArduinoToolbarContainer extends Widget {
this.toolbars = toolbars;
}
override onAfterAttach(msg: Message) {
override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
for (const toolbar of this.toolbars) {
Widget.attach(toolbar, this.node);
}
@@ -56,9 +57,11 @@ export class ArduinoToolbarContribution
);
}
onStart(app: FrontendApplication) {
app.shell.addWidget(this.arduinoToolbarContainer, {
area: 'top',
});
onStart(app: FrontendApplication): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options = <any>{
area: 'toolbar',
};
app.shell.addWidget(this.arduinoToolbarContainer, options);
}
}

View File

@@ -1,2 +1,2 @@
export const REMOTE_SKETCHBOOK_FOLDER = 'RemoteSketchbook';
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';
export const ARDUINO_CLOUD_FOLDER = 'ArduinoCloud';

View File

@@ -5,11 +5,3 @@
export function setURL(url: URL, data: any = {}): void {
history.pushState(data, '', url);
}
/**
* If available from the `window` object, then it means, the IDE2 has successfully patched the `MonacoThemingService#init` static method,
* and can wait the custom theme registration.
*/
export const MonacoThemeServiceIsReady = Symbol(
'@arduino-ide#monaco-theme-service-is-ready'
);

View File

@@ -39,4 +39,11 @@ export class SketchCache {
getSketch(path: string): Create.Sketch | null {
return this.sketches[path] || null;
}
toString(): string {
return JSON.stringify({
sketches: this.sketches,
fileStats: this.fileStats,
});
}
}

View File

@@ -26,8 +26,8 @@ export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidge
super();
this.id = 'cloud-sketchbook-composite-widget';
this.title.caption = nls.localize(
'arduino/cloud/remoteSketchbook',
'Remote Sketchbook'
'arduino/cloud/cloudSketchbook',
'Cloud Sketchbook'
);
this.title.iconClass = 'cloud-sketchbook-tree-icon';
}
@@ -55,8 +55,8 @@ export class CloudSketchbookCompositeWidget extends BaseSketchbookCompositeWidge
{this._session && (
<CreateNew
label={nls.localize(
'arduino/sketchbook/newRemoteSketch',
'New Remote Sketch'
'arduino/sketchbook/newCloudSketch',
'New Cloud Sketch'
)}
onClick={this.onDidClickCreateNew}
/>

View File

@@ -26,7 +26,7 @@ import { SketchbookCommands } from '../sketchbook/sketchbook-commands';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { Contribution } from '../../contributions/contribution';
import { ArduinoPreferences } from '../../arduino-preferences';
import { MainMenuManager } from '../../../common/main-menu-manager';
@@ -67,9 +67,9 @@ export namespace CloudSketchbookCommands {
export const TOGGLE_CLOUD_SKETCHBOOK = Command.toLocalizedCommand(
{
id: 'arduino-cloud-sketchbook--disable',
label: 'Show/Hide Remote Sketchbook',
label: 'Show/Hide Cloud Sketchbook',
},
'arduino/cloud/showHideRemoveSketchbook'
'arduino/cloud/showHideSketchbook'
);
export const PULL_SKETCH = Command.toLocalizedCommand(

View File

@@ -17,12 +17,11 @@ import {
LocalCacheUri,
} from '../../local-cache/local-cache-fs-provider';
import URI from '@theia/core/lib/common/uri';
import { SketchCache } from './cloud-sketch-cache';
import { Create } from '../../create/typings';
import { nls } from '@theia/core/lib/common/nls';
import { Deferred } from '@theia/core/lib/common/promise-util';
export function sketchBaseDir(sketch: Create.Sketch): FileStat {
function sketchBaseDir(sketch: Create.Sketch): FileStat {
// extract the sketch path
const [, path] = splitSketchPath(sketch.path);
const dirs = posixSegments(path);
@@ -42,7 +41,7 @@ export function sketchBaseDir(sketch: Create.Sketch): FileStat {
return baseDir;
}
export function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] {
function sketchesToFileStats(sketches: Create.Sketch[]): FileStat[] {
const sketchesBaseDirs: Record<string, FileStat> = {};
for (const sketch of sketches) {
@@ -64,8 +63,6 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
private readonly authenticationService: AuthenticationClientService;
@inject(LocalCacheFsProvider)
private readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(SketchCache)
private readonly sketchCache: SketchCache;
private _localCacheFsProviderReady: Deferred<void> | undefined;
@@ -127,8 +124,7 @@ export class CloudSketchbookTreeModel extends SketchbookTreeModel {
this.tree.root = undefined;
return;
}
this.createApi.init(this.authenticationService, this.arduinoPreferences);
this.sketchCache.init();
this.createApi.sketchCache.init();
const [sketches] = await Promise.all([
this.createApi.sketches(),
this.ensureLocalFsProviderReady(),

View File

@@ -9,7 +9,7 @@ import { CloudUserCommands } from '../../auth/cloud-user-commands';
import { NodeProps } from '@theia/core/lib/browser/tree/tree-widget';
import { TreeNode } from '@theia/core/lib/browser/tree';
import { CompositeTreeNode } from '@theia/core/lib/browser';
import { shell } from 'electron';
import { shell } from '@theia/core/electron-shared/@electron/remote';
import { SketchbookTreeWidget } from '../sketchbook/sketchbook-tree-widget';
import { nls } from '@theia/core/lib/common';

View File

@@ -1,8 +1,6 @@
import { SketchCache } from './cloud-sketch-cache';
import { inject, injectable } from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { MaybePromise } from '@theia/core/lib/common/types';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileStatNode } from '@theia/filesystem/lib/browser/file-tree';
import { Command } from '@theia/core/lib/common/command';
import { WidgetDecoration } from '@theia/core/lib/browser/widget-decoration';
@@ -28,8 +26,6 @@ import { CloudSketchbookCommands } from './cloud-sketchbook-contributions';
import { DoNotAskAgainConfirmDialog } from '../../dialogs/do-not-ask-again-dialog';
import { SketchbookTree } from '../sketchbook/sketchbook-tree';
import { firstToUpperCase } from '../../../common/utils';
import { ArduinoPreferences } from '../../arduino-preferences';
import { SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { WorkspaceNode } from '@theia/navigator/lib/browser/navigator-tree';
import { posix, splitSketchPath } from '../../create/create-paths';
@@ -46,29 +42,17 @@ type FilesToSync = {
};
@injectable()
export class CloudSketchbookTree extends SketchbookTree {
@inject(FileService)
protected override readonly fileService: FileService;
@inject(LocalCacheFsProvider)
protected readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(SketchCache)
protected readonly sketchCache: SketchCache;
@inject(ArduinoPreferences)
protected override readonly arduinoPreferences: ArduinoPreferences;
private readonly localCacheFsProvider: LocalCacheFsProvider;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
private readonly preferenceService: PreferenceService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
private readonly messageService: MessageService;
@inject(CreateApi)
protected readonly createApi: CreateApi;
private readonly createApi: CreateApi;
async pushPublicWarn(
node: CloudSketchbookTree.CloudSketchDirNode
@@ -93,15 +77,13 @@ export class CloudSketchbookTree extends SketchbookTree {
PreferenceScope.User
),
}).open();
if (!ok) {
return false;
}
return true;
return Boolean(ok);
} else {
return true;
}
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
async pull(arg: any): Promise<void> {
const {
// model,
@@ -145,7 +127,7 @@ export class CloudSketchbookTree extends SketchbookTree {
);
await this.sync(node.remoteUri, localUri);
this.sketchCache.purgeByPath(node.remoteUri.path.toString());
this.createApi.sketchCache.purgeByPath(node.remoteUri.path.toString());
node.commands = commandsCopy;
this.messageService.info(
@@ -213,7 +195,7 @@ export class CloudSketchbookTree extends SketchbookTree {
);
await this.sync(localUri, node.remoteUri);
this.sketchCache.purgeByPath(node.remoteUri.path.toString());
this.createApi.sketchCache.purgeByPath(node.remoteUri.path.toString());
node.commands = commandsCopy;
this.messageService.info(
@@ -229,7 +211,7 @@ export class CloudSketchbookTree extends SketchbookTree {
});
}
async recursiveURIs(uri: URI): Promise<URI[]> {
private async recursiveURIs(uri: URI): Promise<URI[]> {
// remote resources can be fetched one-shot via api
if (CreateUri.is(uri)) {
const resources = await this.createApi.readDirectory(
@@ -286,7 +268,7 @@ export class CloudSketchbookTree extends SketchbookTree {
}, {});
}
async getUrisMap(uri: URI) {
private async getUrisMap(uri: URI): Promise<Record<string, URI>> {
const basepath = uri.toString();
const exists = await this.fileService.exists(uri);
const uris =
@@ -294,7 +276,7 @@ export class CloudSketchbookTree extends SketchbookTree {
return uris;
}
async treeDiff(source: URI, dest: URI): Promise<FilesToSync> {
private async treeDiff(source: URI, dest: URI): Promise<FilesToSync> {
const [sourceURIs, destURIs] = await Promise.all([
this.getUrisMap(source),
this.getUrisMap(dest),
@@ -356,7 +338,7 @@ export class CloudSketchbookTree extends SketchbookTree {
}
}
async sync(source: URI, dest: URI) {
private async sync(source: URI, dest: URI): Promise<void> {
const { filesToWrite, filesToDelete } = await this.treeDiff(source, dest);
await Promise.all(
filesToWrite.map(async ({ source, dest }) => {
@@ -375,7 +357,9 @@ export class CloudSketchbookTree extends SketchbookTree {
);
}
override async resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
override async resolveChildren(
parent: CompositeTreeNode
): Promise<TreeNode[]> {
return (await super.resolveChildren(parent)).sort((a, b) => {
if (
WorkspaceNode.is(parent) &&
@@ -416,14 +400,16 @@ export class CloudSketchbookTree extends SketchbookTree {
CreateUri.is(node.remoteUri)
) {
let remoteFileStat: FileStat;
const cacheHit = this.sketchCache.getItem(node.remoteUri.path.toString());
const cacheHit = this.createApi.sketchCache.getItem(
node.remoteUri.path.toString()
);
if (cacheHit) {
remoteFileStat = cacheHit;
} else {
// not found, fetch and add it for future calls
remoteFileStat = await this.fileService.resolve(node.remoteUri);
if (remoteFileStat) {
this.sketchCache.addItem(remoteFileStat);
this.createApi.sketchCache.addItem(remoteFileStat);
}
}
@@ -453,6 +439,7 @@ export class CloudSketchbookTree extends SketchbookTree {
if (!CreateUri.is(childFs.resource)) {
let refUri = node.fileStat.resource;
if (node.fileStat.hasOwnProperty('remoteUri')) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
refUri = (node.fileStat as any).remoteUri;
}
remoteUri = refUri.resolve(childFs.name);
@@ -471,6 +458,7 @@ export class CloudSketchbookTree extends SketchbookTree {
}
protected override toNode(
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
fileStat: any,
parent: CompositeTreeNode
): FileNode | DirNode {
@@ -530,7 +518,7 @@ export class CloudSketchbookTree extends SketchbookTree {
* @returns
*/
protected override async augmentSketchNode(node: DirNode): Promise<void> {
const sketch = this.sketchCache.getSketch(
const sketch = this.createApi.sketchCache.getSketch(
node.fileStat.resource.path.toString()
);
@@ -594,7 +582,7 @@ export class CloudSketchbookTree extends SketchbookTree {
protected override async isSketchNode(node: DirNode): Promise<boolean> {
if (DirNode.is(node)) {
const sketch = this.sketchCache.getSketch(
const sketch = this.createApi.sketchCache.getSketch(
node.fileStat.resource.path.toString()
);
return !!sketch;
@@ -621,6 +609,7 @@ export class CloudSketchbookTree extends SketchbookTree {
if (DecoratedTreeNode.is(node)) {
for (const property of Object.keys(decorationData)) {
if (node.decorationData.hasOwnProperty(property)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (node.decorationData as any)[property];
}
}
@@ -661,7 +650,7 @@ export namespace CloudSketchbookTree {
commands?: Command[];
}
export namespace CloudSketchDirNode {
export function is(node: TreeNode): node is CloudSketchDirNode {
export function is(node: TreeNode | undefined): node is CloudSketchDirNode {
return SketchbookTree.SketchDirNode.is(node);
}

View File

@@ -147,7 +147,6 @@ export namespace ComponentList {
export interface Props<T extends ArduinoComponent> {
readonly items: T[];
readonly itemLabel: (item: T) => string;
readonly itemDeprecated: (item: T) => boolean;
readonly itemRenderer: ListItemRenderer<T>;
readonly install: (item: T, version?: Installable.Version) => Promise<void>;
readonly uninstall: (item: T) => Promise<void>;

View File

@@ -32,7 +32,7 @@ export class FilterableListContainer<
}
override componentDidMount(): void {
this.search = debounce(this.search, 500);
this.search = debounce(this.search, 500, { trailing: true });
this.search(this.state.searchOptions);
this.props.searchOptionsDidChange((newSearchOptions) => {
const { searchOptions } = this.state;
@@ -82,12 +82,11 @@ export class FilterableListContainer<
}
protected renderComponentList(): React.ReactNode {
const { itemLabel, itemDeprecated, itemRenderer } = this.props;
const { itemLabel, itemRenderer } = this.props;
return (
<ComponentList<T>
items={this.state.items}
itemLabel={itemLabel}
itemDeprecated={itemDeprecated}
itemRenderer={itemRenderer}
install={this.install.bind(this)}
uninstall={this.uninstall.bind(this)}
@@ -109,9 +108,7 @@ export class FilterableListContainer<
protected search(searchOptions: S): void {
const { searchable } = this.props;
searchable
.search(searchOptions)
.then((items) => this.setState({ items: this.props.sort(items) }));
searchable.search(searchOptions).then((items) => this.setState({ items }));
}
protected async install(
@@ -127,7 +124,7 @@ export class FilterableListContainer<
run: ({ progressId }) => install({ item, progressId, version }),
});
const items = await searchable.search(this.state.searchOptions);
this.setState({ items: this.props.sort(items) });
this.setState({ items });
}
protected async uninstall(item: T): Promise<void> {
@@ -155,7 +152,7 @@ export class FilterableListContainer<
run: ({ progressId }) => uninstall({ item, progressId }),
});
const items = await searchable.search(this.state.searchOptions);
this.setState({ items: this.props.sort(items) });
this.setState({ items });
}
}
@@ -168,7 +165,6 @@ export namespace FilterableListContainer {
readonly container: ListWidget<T, S>;
readonly searchable: Searchable<T, S>;
readonly itemLabel: (item: T) => string;
readonly itemDeprecated: (item: T) => boolean;
readonly itemRenderer: ListItemRenderer<T>;
readonly filterRenderer: FilterRenderer<S>;
readonly resolveFocus: (element: HTMLElement | undefined) => void;
@@ -192,7 +188,6 @@ export namespace FilterableListContainer {
progressId: string;
}) => Promise<void>;
readonly commandService: CommandService;
readonly sort: (items: T[]) => T[];
}
export interface State<T, S extends Searchable.Options> {

View File

@@ -7,6 +7,7 @@ import {
import { Widget } from '@theia/core/shared/@phosphor/widgets';
import { Message } from '@theia/core/shared/@phosphor/messaging';
import { Emitter } from '@theia/core/lib/common/event';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget';
import { CommandService } from '@theia/core/lib/common/command';
import { MessageService } from '@theia/core/lib/common/message-service';
@@ -42,6 +43,7 @@ export abstract class ListWidget<
* Do not touch or use it. It is for setting the focus on the `input` after the widget activation.
*/
protected focusNode: HTMLElement | undefined;
private readonly didReceiveFirstFocus = new Deferred();
protected readonly searchOptionsChangeEmitter = new Emitter<
Partial<S> | undefined
>();
@@ -51,11 +53,9 @@ export abstract class ListWidget<
*/
protected firstActivate = true;
protected readonly defaultSortComparator: (left: T, right: T) => number;
constructor(protected options: ListWidget.Options<T, S>) {
super();
const { id, label, iconClass, itemDeprecated, itemLabel } = options;
const { id, label, iconClass } = options;
this.id = id;
this.title.label = label;
this.title.caption = label;
@@ -65,15 +65,6 @@ export abstract class ListWidget<
this.node.tabIndex = 0; // To be able to set the focus on the widget.
this.scrollOptions = undefined;
this.toDispose.push(this.searchOptionsChangeEmitter);
this.defaultSortComparator = (left, right): number => {
// always put deprecated items at the bottom of the list
if (itemDeprecated(left)) {
return 1;
}
return itemLabel(left).localeCompare(itemLabel(right));
};
}
@postConstruct()
@@ -117,6 +108,7 @@ export abstract class ListWidget<
protected onFocusResolved = (element: HTMLElement | undefined): void => {
this.focusNode = element;
this.didReceiveFirstFocus.resolve();
};
protected async install({
@@ -141,30 +133,6 @@ export abstract class ListWidget<
return this.options.installable.uninstall({ item, progressId });
}
protected filterableListSort = (items: T[]): T[] => {
const isArduinoTypeComparator = (left: T, right: T) => {
const aIsArduinoType = left.types.includes('Arduino');
const bIsArduinoType = right.types.includes('Arduino');
if (aIsArduinoType && !bIsArduinoType && !left.deprecated) {
return -1;
}
if (!aIsArduinoType && bIsArduinoType && !right.deprecated) {
return 1;
}
return 0;
};
return items.sort((left, right) => {
return (
isArduinoTypeComparator(left, right) ||
this.defaultSortComparator(left, right)
);
});
};
render(): React.ReactNode {
return (
<FilterableListContainer<T, S>
@@ -175,14 +143,12 @@ export abstract class ListWidget<
install={this.install.bind(this)}
uninstall={this.uninstall.bind(this)}
itemLabel={this.options.itemLabel}
itemDeprecated={this.options.itemDeprecated}
itemRenderer={this.options.itemRenderer}
filterRenderer={this.options.filterRenderer}
searchOptionsDidChange={this.searchOptionsChangeEmitter.event}
messageService={this.messageService}
commandService={this.commandService}
responseService={this.responseService}
sort={this.filterableListSort}
/>
);
}
@@ -192,7 +158,9 @@ export abstract class ListWidget<
* If it is `undefined`, updates the view state by re-running the search with the current `filterText` term.
*/
refresh(searchOptions: Partial<S> | undefined): void {
this.searchOptionsChangeEmitter.fire(searchOptions);
this.didReceiveFirstFocus.promise.then(() =>
this.searchOptionsChangeEmitter.fire(searchOptions)
);
}
updateScrollBar(): void {
@@ -213,7 +181,6 @@ export namespace ListWidget {
readonly installable: Installable<T>;
readonly searchable: Searchable<T, S>;
readonly itemLabel: (item: T) => string;
readonly itemDeprecated: (item: T) => boolean;
readonly itemRenderer: ListItemRenderer<T>;
readonly filterRenderer: FilterRenderer<S>;
readonly defaultSearchOptions: S;

View File

@@ -1,8 +1,12 @@
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import {
inject,
injectable,
postConstruct,
} from '@theia/core/shared/inversify';
import URI from '@theia/core/lib/common/uri';
import { FileNode, FileTreeModel } from '@theia/filesystem/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ConfigService } from '../../../common/protocol';
import { ConfigServiceClient } from './../../config/config-service-client';
import { SketchbookTree } from './sketchbook-tree';
import { ArduinoPreferences } from '../../arduino-preferences';
import {
@@ -13,7 +17,10 @@ import {
} from '@theia/core/lib/browser/tree';
import { SketchbookCommands } from './sketchbook-commands';
import { OpenerService, open } from '@theia/core/lib/browser';
import { CurrentSketch, SketchesServiceClientImpl } from '../../../common/protocol/sketches-service-client-impl';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../sketches-service-client-impl';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
@@ -36,8 +43,8 @@ export class SketchbookTreeModel extends FileTreeModel {
@inject(CommandRegistry)
public readonly commandRegistry: CommandRegistry;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(ConfigServiceClient)
protected readonly configService: ConfigServiceClient;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@@ -59,6 +66,12 @@ export class SketchbookTreeModel extends FileTreeModel {
super.init();
this.reportBusyProgress();
this.initializeRoot();
this.toDispose.push(
this.configService.onDidChangeSketchDirUri(async () => {
await this.updateRoot();
this.selectRoot(this.root);
})
);
}
protected readonly pendingBusyProgress = new Map<string, Deferred<void>>();
@@ -121,6 +134,10 @@ export class SketchbookTreeModel extends FileTreeModel {
return;
}
const root = this.root;
this.selectRoot(root);
}
private selectRoot(root: TreeNode | undefined) {
if (CompositeTreeNode.is(root) && root.children.length === 1) {
const child = root.children[0];
if (
@@ -161,10 +178,12 @@ export class SketchbookTreeModel extends FileTreeModel {
}
protected async createRoot(): Promise<TreeNode | undefined> {
const config = await this.configService.getConfiguration();
const rootFileStats = await this.fileService.resolve(
new URI(config.sketchDirUri)
);
const sketchDirUri = this.configService.tryGetSketchDirUri();
const errors = this.configService.tryGetMessages();
if (!sketchDirUri || errors?.length) {
return undefined;
}
const rootFileStats = await this.fileService.resolve(sketchDirUri);
if (this.workspaceService.opened && rootFileStats.children) {
// filter out libraries and hardware
@@ -183,7 +202,10 @@ export class SketchbookTreeModel extends FileTreeModel {
/**
* Move the given source file or directory to the given target directory.
*/
override async move(source: TreeNode, target: TreeNode): Promise<URI | undefined> {
override async move(
source: TreeNode,
target: TreeNode
): Promise<URI | undefined> {
if (source.parent && WorkspaceRootNode.is(source)) {
// do not support moving a root folder
return undefined;

View File

@@ -21,7 +21,7 @@ import { ArduinoPreferences } from '../../arduino-preferences';
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { SelectableTreeNode } from '@theia/core/lib/browser/tree/tree-selection';
import { Sketch } from '../../contributions/contribution';
import { nls } from '@theia/core/lib/common';

View File

@@ -26,7 +26,7 @@ import {
import {
CurrentSketch,
SketchesServiceClientImpl,
} from '../../../common/protocol/sketches-service-client-impl';
} from '../../sketches-service-client-impl';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { URI } from '../../contributions/contribution';
import { WorkspaceInput } from '@theia/workspace/lib/browser';

View File

@@ -2,7 +2,7 @@ import { Installable } from './installable';
export interface ArduinoComponent {
readonly name: string;
readonly deprecated: boolean;
readonly deprecated?: boolean;
readonly author: string;
readonly summary: string;
readonly description: string;

View File

@@ -11,6 +11,7 @@ import {
Updatable,
} from '../nls';
import URI from '@theia/core/lib/common/uri';
import { MaybePromise } from '@theia/core/lib/common/types';
export type AvailablePorts = Record<string, [Port, Array<Board>]>;
export namespace AvailablePorts {
@@ -141,6 +142,16 @@ export const BoardsService = Symbol('BoardsService');
export interface BoardsService
extends Installable<BoardsPackage>,
Searchable<BoardsPackage, BoardSearch> {
install(options: {
item: BoardsPackage;
progressId?: string;
version?: Installable.Version;
noOverwrite?: boolean;
/**
* Only for testing to avoid confirmation dialogs from Windows User Access Control when installing a platform.
*/
skipPostInstall?: boolean;
}): Promise<void>;
getState(): Promise<AvailablePorts>;
getBoardDetails(options: { fqbn: string }): Promise<BoardDetails | undefined>;
getBoardPackage(options: { id: string }): Promise<BoardsPackage | undefined>;
@@ -623,3 +634,131 @@ export namespace Board {
}));
}
}
/**
* Throws an error if the `fqbn` argument is not sanitized. A sanitized FQBN has the `VENDOR:ARCHITECTURE:BOARD_ID` construct.
*/
export function assertSanitizedFqbn(fqbn: string): void {
if (fqbn.split(':').length !== 3) {
throw new Error(
`Expected a sanitized FQBN with three segments in the following format: 'VENDOR:ARCHITECTURE:BOARD_ID'. Got ${fqbn} instead.`
);
}
}
/**
* Converts the `VENDOR:ARCHITECTURE:BOARD_ID[:MENU_ID=OPTION_ID[,MENU2_ID=OPTION_ID ...]]` FQBN to
* `VENDOR:ARCHITECTURE:BOARD_ID` format.
* See the details of the `{build.fqbn}` entry in the [specs](https://arduino.github.io/arduino-cli/latest/platform-specification/#global-predefined-properties).
*/
export function sanitizeFqbn(fqbn: string | undefined): string | undefined {
if (!fqbn) {
return undefined;
}
const [vendor, arch, id] = fqbn.split(':');
return `${vendor}:${arch}:${id}`;
}
export interface BoardConfig {
selectedBoard?: Board;
selectedPort?: Port;
}
export interface BoardInfo {
/**
* Board name. Could be `'Unknown board`'.
*/
BN: string;
/**
* Vendor ID.
*/
VID: string;
/**
* Product ID.
*/
PID: string;
/**
* Serial number.
*/
SN: string;
}
export const selectPortForInfo = nls.localize(
'arduino/board/selectPortForInfo',
'Please select a port to obtain board info.'
);
export const nonSerialPort = nls.localize(
'arduino/board/nonSerialPort',
"Non-serial port, can't obtain info."
);
export const noNativeSerialPort = nls.localize(
'arduino/board/noNativeSerialPort',
"Native serial port, can't obtain info."
);
export const unknownBoard = nls.localize(
'arduino/board/unknownBoard',
'Unknown board'
);
/**
* The returned promise resolves to a `BoardInfo` if available to show in the UI or an info message explaining why showing the board info is not possible.
*/
export async function getBoardInfo(
selectedPort: Port | undefined,
availablePorts: MaybePromise<AvailablePorts>
): Promise<BoardInfo | string> {
if (!selectedPort) {
return selectPortForInfo;
}
// IDE2 must show the board info based on the selected port.
// https://github.com/arduino/arduino-ide/issues/1489
// IDE 1.x supports only serial port protocol
if (selectedPort.protocol !== 'serial') {
return nonSerialPort;
}
const selectedPortKey = Port.keyOf(selectedPort);
const state = await availablePorts;
const boardListOnSelectedPort = Object.entries(state).filter(
([portKey, [port]]) =>
portKey === selectedPortKey && isNonNativeSerial(port)
);
if (!boardListOnSelectedPort.length) {
return noNativeSerialPort;
}
const [, [port, boards]] = boardListOnSelectedPort[0];
if (boardListOnSelectedPort.length > 1 || boards.length > 1) {
console.warn(
`Detected more than one available boards on the selected port : ${JSON.stringify(
selectedPort
)}. Detected boards were: ${JSON.stringify(
boardListOnSelectedPort
)}. Using the first one: ${JSON.stringify([port, boards])}`
);
}
const board = boards[0];
const BN = board?.name ?? unknownBoard;
const VID = readProperty('vid', port);
const PID = readProperty('pid', port);
const SN = readProperty('serialNumber', port);
return { VID, PID, SN, BN };
}
// serial protocol with one or many detected boards or available VID+PID properties from the port
function isNonNativeSerial(port: Port): boolean {
return !!(
port.protocol === 'serial' &&
port.properties?.['vid'] &&
port.properties?.['pid']
);
}
function readProperty(property: string, port: Port): string {
return falsyToNullString(port.properties?.[property]);
}
function falsyToNullString(s: string | undefined): string {
return !!s ? s : '(null)';
}

View File

@@ -3,13 +3,13 @@ import { RecursivePartial } from '@theia/core/lib/common/types';
export const ConfigServicePath = '/services/config-service';
export const ConfigService = Symbol('ConfigService');
export interface ConfigService {
getVersion(): Promise<
Readonly<{ version: string; commit: string; status?: string }>
>;
getCliConfigFileUri(): Promise<string>;
getConfiguration(): Promise<Config>;
getVersion(): Promise<Readonly<string>>;
getConfiguration(): Promise<ConfigState>;
setConfiguration(config: Config): Promise<void>;
}
export type ConfigState =
| { config: undefined; messages: string[] }
| { config: Config; messages?: string[] };
export interface Daemon {
readonly port: string | number;
@@ -60,8 +60,10 @@ export namespace Network {
try {
// Patter: PROTOCOL://USER:PASS@HOSTNAME:PORT/
const { protocol, hostname, password, username, port } = new URL(raw);
// protocol in URL object contains a trailing colon
const newProtocol = protocol.replace(/:$/, '');
return {
protocol,
protocol: newProtocol,
hostname,
password,
username,
@@ -117,7 +119,16 @@ export interface Config {
readonly network: Network;
}
export namespace Config {
export function sameAs(left: Config, right: Config): boolean {
export function sameAs(
left: Config | undefined,
right: Config | undefined
): boolean {
if (!left) {
return !right;
}
if (!right) {
return false;
}
const leftUrls = left.additionalUrls.sort();
const rightUrls = right.additionalUrls.sort();
if (leftUrls.length !== rightUrls.length) {
@@ -148,7 +159,16 @@ export namespace AdditionalUrls {
export function stringify(additionalUrls: AdditionalUrls): string {
return additionalUrls.join(',');
}
export function sameAs(left: AdditionalUrls, right: AdditionalUrls): boolean {
export function sameAs(
left: AdditionalUrls | undefined,
right: AdditionalUrls | undefined
): boolean {
if (!left) {
return !right;
}
if (!right) {
return false;
}
if (left.length !== right.length) {
return false;
}

View File

@@ -2,7 +2,7 @@ import type { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-facto
import type {
AttachedBoardsChangeEvent,
BoardsPackage,
Config,
ConfigState,
ProgressMessage,
Sketch,
IndexType,
@@ -39,6 +39,11 @@ export interface IndexUpdateDidFailParams extends IndexUpdateParams {
}
export interface NotificationServiceClient {
// The cached state of the core client. Libraries, examples, etc. has been updated.
// This can happen without an index update. For example, changing the `directories.user` location.
// An index update always implicitly involves a re-initialization without notifying via this method.
notifyDidReinitialize(): void;
// Index
notifyIndexUpdateWillStart(params: IndexUpdateWillStartParams): void;
notifyIndexUpdateDidProgress(progressMessage: ProgressMessage): void;
@@ -50,14 +55,16 @@ export interface NotificationServiceClient {
notifyDaemonDidStop(): void;
// CLI config
notifyConfigDidChange(event: { config: Config | undefined }): void;
notifyConfigDidChange(event: ConfigState): void;
// Platforms
notifyPlatformDidInstall(event: { item: BoardsPackage }): void;
notifyPlatformDidUninstall(event: { item: BoardsPackage }): void;
// Libraries
notifyLibraryDidInstall(event: { item: LibraryPackage }): void;
notifyLibraryDidInstall(event: {
item: LibraryPackage | 'zip-install';
}): void;
notifyLibraryDidUninstall(event: { item: LibraryPackage }): void;
// Boards discovery

View File

@@ -1,4 +1,5 @@
import URI from '@theia/core/lib/common/uri';
import type { ArduinoComponent } from './arduino-component';
export interface Searchable<T, O extends Searchable.Options> {
search(options: O): Promise<T[]>;
@@ -31,3 +32,31 @@ export namespace Searchable {
}
}
}
// IDE2 must keep the library search order from the CLI but do additional boosting
// https://github.com/arduino/arduino-ide/issues/1106
// This additional search result boosting considers the following groups: 'Arduino', '', 'Arduino-Retired', and 'Retired'.
// If two libraries fall into the same group, the original index is the tiebreaker.
export type SortGroup = 'Arduino' | '' | 'Arduino-Retired' | 'Retired';
const sortGroupOrder: Record<SortGroup, number> = {
Arduino: 0,
'': 1,
'Arduino-Retired': 2,
Retired: 3,
};
export function sortComponents<T extends ArduinoComponent>(
components: T[],
group: (component: T) => SortGroup
): T[] {
return components
.map((component, index) => ({ ...component, index }))
.sort((left, right) => {
const leftGroup = group(left);
const rightGroup = group(right);
if (leftGroup === rightGroup) {
return left.index - right.index;
}
return sortGroupOrder[leftGroup] - sortGroupOrder[rightGroup];
});
}

Some files were not shown because too many files have changed in this diff Show More