diff --git a/.gitattributes b/.gitattributes index c12c72b..1821eff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,14 @@ *.sh text eol=lf Dockerfile text eol=lf entrypoint.sh text eol=lf + +# Source files are LF in the repository, whatever the committer's platform. +# A CRLF blob passes Prettier on one runner and fails on another. +*.ts text eol=lf +*.tsx text eol=lf +*.css text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.rs text eol=lf +*.toml text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8eee046..34b51b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,121 +1,131 @@ -name: Build Electron App - -# Pushes to master and develop only run the test suite. Artifacts are published -# from version tags alone (v5.1.0, v5.2.0-beta.1, ...), so merging a PR never -# republishes a release, and the tag is the single trigger for a build. +name: CoreTrace Desktop +# Every push and pull request is checked and built on the three desktops. +# A tag v additionally builds the installers on each and publishes +# them as a GitHub Release, whose notes are docs/releases/v.md. A +# version with a pre-release suffix (6.0.0-beta.1) is published as a +# pre-release; a plain one becomes the latest release. on: push: - branches: - - master - - develop - tags: - - "v*" + branches: [master, develop, feat/tauri-react-desktop] + tags: ["v*"] pull_request: - branches: - - master - + branches: [master] + workflow_dispatch: permissions: contents: read - jobs: - test: - runs-on: ubuntu-24.04 + check: + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-24.04, macos-latest] + runs-on: ${{ matrix.os }} steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 22.15.0 - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Build renderer bundle - run: npm run build:renderer - - - name: Run tests - run: npm test + node-version: "22.19.0" + cache: npm + - uses: dtolnay/rust-toolchain@1.95.0 + with: + components: rustfmt, clippy + - name: Linux desktop dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libdbus-1-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + - run: npm ci + - name: Verify release versions + run: node scripts/check-desktop-version.cjs + - name: Verify bundle icons + run: node scripts/check-bundle-icons.cjs + - run: npm run check + - run: npm run format:check + - name: Show what Prettier would change + # --check only names files; when it fails, the diff says why. + if: failure() + shell: bash + run: | + npx prettier --write desktop vite.config.ts vitest.config.ts >/dev/null + git --no-pager diff --stat + git --no-pager diff | head -120 | cat -A | head -120 + - run: npm test + - run: cargo fmt --manifest-path src-tauri/Cargo.toml --check + - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + - run: cargo test --manifest-path src-tauri/Cargo.toml --locked + - name: Build desktop + run: npm run tauri -- build --no-bundle + - uses: actions/upload-artifact@v4 + with: + name: coretrace-desktop-${{ runner.os }}-${{ runner.arch }} + path: | + src-tauri/target/release/coretrace-desktop.exe + src-tauri/target/release/coretrace-desktop + if-no-files-found: error release: + # Only a tag publishes, and only after every desktop passed its checks. if: startsWith(github.ref, 'refs/tags/v') - needs: test - # Only this job publishes, so only this job gets a write-capable token. + needs: check permissions: contents: write - runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - # windows-2022 pinned: windows-latest (now windows-2025, build 26100) - # breaks node-gyp VS detection during the node-pty native rebuild. - # - # macOS ships two jobs rather than one universal build: node-pty and - # node-llama-cpp are native modules, so each architecture is compiled on - # a runner of that architecture. macos-latest is Apple Silicon; - # macos-15-intel is the x64 runner that replaced the retired macos-13 - # (a macos-13 job sits queued forever instead of failing). - # The builds are unsigned — see the note below. - os: [windows-2022, ubuntu-24.04, macos-latest, macos-15-intel] - + include: + - os: windows-2022 + args: --bundles nsis + - os: macos-latest + args: --target universal-apple-darwin --bundles dmg + - os: ubuntu-24.04 + args: --bundles deb,appimage + runs-on: ${{ matrix.os }} steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: 22.15.0 - cache: "npm" - - # The tag is what users see; package.json is what electron-builder writes - # into the artifacts and the update manifests. If they disagree, the - # updater serves a version nobody can find, so stop before publishing. - - name: Verify the tag matches package.json + node-version: "22.19.0" + cache: npm + - uses: dtolnay/rust-toolchain@1.95.0 + with: + # A universal macOS binary is built from both architectures. + targets: ${{ runner.os == 'macOS' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + - name: Linux desktop dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libdbus-1-dev + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + - run: npm ci + - name: Verify release versions + run: node scripts/check-desktop-version.cjs + - name: Verify bundle icons + run: node scripts/check-bundle-icons.cjs + - name: Release notes + id: notes shell: bash run: | - set -euo pipefail - tag="${GITHUB_REF_NAME#v}" - pkg="$(node -p "require('./package.json').version")" - if [ "$tag" != "$pkg" ]; then - echo "::error::Tag $GITHUB_REF_NAME does not match package.json version $pkg." + file="docs/releases/${GITHUB_REF_NAME}.md" + if [ ! -f "$file" ]; then + echo "No release notes at $file; write them before tagging." >&2 exit 1 fi - echo "Releasing $pkg from tag $GITHUB_REF_NAME." - - # Only `-beta.N` prereleases are recognised: electron-builder names the - # update manifest after the prerelease identifier, and `beta` is the one - # the in-app channel selector reads (see updaterHandlers.js). - - name: Determine release channel - id: channel - shell: bash - run: | - set -euo pipefail - version="${GITHUB_REF_NAME#v}" - if [[ "$version" == *-* ]]; then - if [[ ! "$version" =~ -beta\.[0-9]+$ ]]; then - echo "::error::Prerelease versions must end in -beta.N (got $version)." - exit 1 - fi - echo "prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "prerelease=false" >> "$GITHUB_OUTPUT" - fi - - - name: Install dependencies - run: npm ci - - # macOS artifacts are deliberately unsigned and un-notarized: that needs a - # paid Apple Developer account, and we would rather ship a build testers - # can run today. Gatekeeper blocks the first launch, so users open the app - # once with right-click > Open, or run `xattr -cr /Applications/CtraceGUI.app`. - # To sign later, restore an Apple certificate here and set `identity`, - # `hardenedRuntime` and `notarize` in the `mac` block of package.json. - - name: Publish + { + echo "body<> "$GITHUB_OUTPUT" + - name: Build installers and publish + uses: tauri-apps/tauri-action@v0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_IDENTITY_AUTO_DISCOVERY: false - EP_PRE_RELEASE: ${{ steps.channel.outputs.prerelease }} - run: npm run dist:publish + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ github.ref_name }} + releaseName: CoreTrace Desktop ${{ github.ref_name }} + releaseBody: ${{ steps.notes.outputs.body }} + releaseDraft: false + prerelease: ${{ contains(github.ref_name, '-') }} + includeUpdaterJson: false + args: ${{ matrix.args }} diff --git a/.gitignore b/.gitignore index 9bec827..149d58c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,17 @@ -dist -.env -node_modules -.codex -.claude -.claw -.claw-cache -.claw-logs -ctrace-report.txt -bundle.js \ No newline at end of file +dist +.env +node_modules +.codex +.claw +.claw-cache +.claw-logs +ctrace-report.txt +bundle.js +src-tauri/target/ +src-tauri/gen/ +coverage/ +*.tsbuildinfo +# The native spikes: exploratory Rust/Floem work that has never been committed here, and 12 GB of +# rebuildable build output beside it. Ignored whole, so `git add -A` cannot sweep the tree into a +# commit about something else. Committing the spikes is a decision to take deliberately. +native/ diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 38dd25f..d7f7a83 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1,173 +1,173 @@ -# CTrace GUI - Developer Guide - -## 📋 Documentation Overview - -This project uses **JSDoc** for comprehensive API documentation. The documentation is automatically generated from comments in the source code and provides detailed information about all classes, methods, and functions. - -## 🚀 Generating Documentation - -### Install Dependencies -```bash -npm install -``` - -### Generate Documentation -```bash -npm run docs -``` - -### Watch Mode (Auto-regenerate) -```bash -npm run docs:watch -``` - -## 📁 Documentation Structure - -The generated documentation is organized as follows: - -- **`docs/overview.html`** - Project overview and navigation -- **`docs/index.html`** - Main JSDoc documentation index -- **`docs/UIController.html`** - Main UI controller documentation -- **`docs/FileOperationsManager.html`** - File operations documentation -- **`docs/TabManager.html`** - Tab management documentation -- **`docs/SearchManager.html`** - Search functionality documentation -- **`docs/EditorManager.html`** - Editor management documentation -- **`docs/NotificationManager.html`** - Notification system documentation - -## 🏗️ Architecture Documentation - -### Main Process (`src/main/`) -- **`main.js`** - Application entry point -- **`ipc/fileHandlers.js`** - File system IPC handlers -- **`ipc/ctraceHandlers.js`** - CTrace execution handlers -- **`ipc/editorHandlers.js`** - Editor-related handlers -- **`utils/fileUtils.js`** - File system utilities - -### Renderer Process (`src/renderer/`) -- **`UIController.js`** - Main UI coordinator -- **`managers/`** - Specialized manager classes -- **`utils/`** - Renderer utilities - -## 📝 JSDoc Standards - -### Class Documentation -```javascript -/** - * Brief description of the class. - * - * Detailed description explaining the purpose and functionality. - * - * @class ClassName - * @author CTrace GUI Team - * @version 1.0.0 - * - * @example - * const instance = new ClassName(param1, param2); - */ -class ClassName { - // ... -} -``` - -### Method Documentation -```javascript -/** - * Brief description of the method. - * - * Detailed description of what the method does. - * - * @async - * @memberof ClassName - * @param {string} param1 - Description of parameter 1 - * @param {Object} param2 - Description of parameter 2 - * @returns {Promise} Description of return value - * - * @example - * const result = await instance.methodName('value', {option: true}); - */ -async methodName(param1, param2) { - // ... -} -``` - -### Property Documentation -```javascript -/** - * Description of the property - * @type {string} - * @private - */ -this.propertyName = 'value'; -``` - -## 🔧 Configuration - -### JSDoc Configuration (`jsdoc.conf.json`) -```json -{ - "source": { - "include": ["./src/", "./README.md"], - "includePattern": "\\.(js)$", - "exclude": ["./node_modules/", "./dist/", "./build/"] - }, - "opts": { - "destination": "./docs/", - "recurse": true, - "readme": "./README.md" - }, - "plugins": ["plugins/markdown"], - "metadata": { - "title": "CTrace GUI Documentation", - "description": "Complete documentation for the CTrace GUI Electron application" - } -} -``` - -## 📊 Coverage Guidelines - -Aim for documentation coverage of: -- ✅ **All public classes** - Complete with examples -- ✅ **All public methods** - Parameters, return values, examples -- ✅ **Key private methods** - Internal functionality -- ✅ **Complex algorithms** - Detailed explanations -- ✅ **Configuration objects** - Property descriptions - -## 🎯 Best Practices - -1. **Use descriptive summaries** - First line should clearly explain the purpose -2. **Include examples** - Show how to use classes and methods -3. **Document parameters thoroughly** - Include types and descriptions -4. **Explain return values** - What does the method return? -5. **Use @memberof** - Clearly associate methods with classes -6. **Mark async methods** - Use @async for Promise-returning methods -7. **Private vs Public** - Use @private for internal methods - -## 🔗 Useful Links - -- [JSDoc Official Documentation](https://jsdoc.app/) -- [JSDoc Tags Reference](https://jsdoc.app/index.html#block-tags) -- [Electron Documentation](https://www.electronjs.org/docs) - -## 📈 Viewing Documentation - -1. Generate docs: `npm run docs` -2. Open `docs/overview.html` for project overview -3. Open `docs/index.html` for complete API documentation -4. Navigate using the sidebar or search functionality - -The documentation is fully self-contained HTML and can be: -- Viewed locally in any web browser -- Hosted on a web server -- Included in project releases -- Shared with team members - -## 🤝 Contributing - -When adding new code: -1. Add comprehensive JSDoc comments -2. Include usage examples where appropriate -3. Regenerate documentation: `npm run docs` -4. Verify documentation renders correctly -5. Update this guide if needed - +# CTrace GUI - Developer Guide + +## 📋 Documentation Overview + +This project uses **JSDoc** for comprehensive API documentation. The documentation is automatically generated from comments in the source code and provides detailed information about all classes, methods, and functions. + +## 🚀 Generating Documentation + +### Install Dependencies +```bash +npm install +``` + +### Generate Documentation +```bash +npm run docs +``` + +### Watch Mode (Auto-regenerate) +```bash +npm run docs:watch +``` + +## 📁 Documentation Structure + +The generated documentation is organized as follows: + +- **`docs/overview.html`** - Project overview and navigation +- **`docs/index.html`** - Main JSDoc documentation index +- **`docs/UIController.html`** - Main UI controller documentation +- **`docs/FileOperationsManager.html`** - File operations documentation +- **`docs/TabManager.html`** - Tab management documentation +- **`docs/SearchManager.html`** - Search functionality documentation +- **`docs/EditorManager.html`** - Editor management documentation +- **`docs/NotificationManager.html`** - Notification system documentation + +## 🏗️ Architecture Documentation + +### Main Process (`src/main/`) +- **`main.js`** - Application entry point +- **`ipc/fileHandlers.js`** - File system IPC handlers +- **`ipc/ctraceHandlers.js`** - CTrace execution handlers +- **`ipc/editorHandlers.js`** - Editor-related handlers +- **`utils/fileUtils.js`** - File system utilities + +### Renderer Process (`src/renderer/`) +- **`UIController.js`** - Main UI coordinator +- **`managers/`** - Specialized manager classes +- **`utils/`** - Renderer utilities + +## 📝 JSDoc Standards + +### Class Documentation +```javascript +/** + * Brief description of the class. + * + * Detailed description explaining the purpose and functionality. + * + * @class ClassName + * @author CTrace GUI Team + * @version 1.0.0 + * + * @example + * const instance = new ClassName(param1, param2); + */ +class ClassName { + // ... +} +``` + +### Method Documentation +```javascript +/** + * Brief description of the method. + * + * Detailed description of what the method does. + * + * @async + * @memberof ClassName + * @param {string} param1 - Description of parameter 1 + * @param {Object} param2 - Description of parameter 2 + * @returns {Promise} Description of return value + * + * @example + * const result = await instance.methodName('value', {option: true}); + */ +async methodName(param1, param2) { + // ... +} +``` + +### Property Documentation +```javascript +/** + * Description of the property + * @type {string} + * @private + */ +this.propertyName = 'value'; +``` + +## 🔧 Configuration + +### JSDoc Configuration (`jsdoc.conf.json`) +```json +{ + "source": { + "include": ["./src/", "./README.md"], + "includePattern": "\\.(js)$", + "exclude": ["./node_modules/", "./dist/", "./build/"] + }, + "opts": { + "destination": "./docs/", + "recurse": true, + "readme": "./README.md" + }, + "plugins": ["plugins/markdown"], + "metadata": { + "title": "CTrace GUI Documentation", + "description": "Complete documentation for the CTrace GUI Electron application" + } +} +``` + +## 📊 Coverage Guidelines + +Aim for documentation coverage of: +- ✅ **All public classes** - Complete with examples +- ✅ **All public methods** - Parameters, return values, examples +- ✅ **Key private methods** - Internal functionality +- ✅ **Complex algorithms** - Detailed explanations +- ✅ **Configuration objects** - Property descriptions + +## 🎯 Best Practices + +1. **Use descriptive summaries** - First line should clearly explain the purpose +2. **Include examples** - Show how to use classes and methods +3. **Document parameters thoroughly** - Include types and descriptions +4. **Explain return values** - What does the method return? +5. **Use @memberof** - Clearly associate methods with classes +6. **Mark async methods** - Use @async for Promise-returning methods +7. **Private vs Public** - Use @private for internal methods + +## 🔗 Useful Links + +- [JSDoc Official Documentation](https://jsdoc.app/) +- [JSDoc Tags Reference](https://jsdoc.app/index.html#block-tags) +- [Electron Documentation](https://www.electronjs.org/docs) + +## 📈 Viewing Documentation + +1. Generate docs: `npm run docs` +2. Open `docs/overview.html` for project overview +3. Open `docs/index.html` for complete API documentation +4. Navigate using the sidebar or search functionality + +The documentation is fully self-contained HTML and can be: +- Viewed locally in any web browser +- Hosted on a web server +- Included in project releases +- Shared with team members + +## 🤝 Contributing + +When adding new code: +1. Add comprehensive JSDoc comments +2. Include usage examples where appropriate +3. Regenerate documentation: `npm run docs` +4. Verify documentation renders correctly +5. Update this guide if needed + Happy documenting! 📚 \ No newline at end of file diff --git a/LICENSE b/LICENSE index 29f81d8..261eeb9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,201 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE index 67527cf..6a39fe5 100644 --- a/NOTICE +++ b/NOTICE @@ -1,8 +1,8 @@ -coretrace-gui -Copyright 2026 Hugo Payet - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 +coretrace-gui +Copyright 2026 Hugo Payet + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/QUICK_START_LLM.md b/QUICK_START_LLM.md index 600476c..1016732 100644 --- a/QUICK_START_LLM.md +++ b/QUICK_START_LLM.md @@ -1,141 +1,141 @@ -# Quick Start: Using External LLM Providers - -## 🚀 Get Started in 3 Steps - -### 1. Open Assistant Settings -- Click the **Assistant** icon in the activity bar (left side) -- Click the **Settings** button in the assistant panel - -### 2. Choose Your Provider -Select "External API" and pick from: - -| Provider | Best For | API Key From | -|----------|----------|--------------| -| **OpenAI** | General purpose, highest quality | [platform.openai.com](https://platform.openai.com/api-keys) | -| **Deepseek** | Coding, cost-effective | [platform.deepseek.com](https://platform.deepseek.com/api-keys) | -| **Claude** | Advanced reasoning | [console.anthropic.com](https://console.anthropic.com/settings/keys) | -| **Groq** | Ultra-fast responses | [console.groq.com](https://console.groq.com/keys) | -| **Perplexity** | Current info + search | [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) | -| **Generic** | Any OpenAI-compatible API | Varies by service | - -### 3. Enter Your API Key & Save -- Paste your API key -- Select your preferred model -- Click **Save** - -## 💡 Quick Tips - -### Free Tiers & Trials -- **Groq**: Free tier with generous limits -- **OpenAI**: $5 free credit for new accounts -- **Perplexity**: Limited free requests -- **Deepseek**: Very affordable pricing - -### Best Models for Coding -``` -OpenAI: gpt-4 or gpt-4-turbo -Deepseek: deepseek-coder -Claude: claude-3-5-sonnet-20241022 -Groq: mixtral-8x7b-32768 -``` - -### Customize Behavior -Add a system prompt like: -``` -You are an expert C++ developer. -Provide concise, secure code examples. -Always explain your reasoning. -``` - -### Temperature Guide -- **0.0-0.3**: Precise, deterministic (for code generation) -- **0.7**: Balanced (default, recommended) -- **1.0-2.0**: Creative (for brainstorming) - -## 🔧 Using Local/Private Models - -### LM Studio (Local) -``` -Provider: Custom Provider -Name: LM Studio -Endpoint: http://localhost:1234/v1/chat/completions -API Key: (leave blank) -Model: (whatever you loaded in LM Studio) -``` - -### Together AI -``` -Provider: Custom Provider -Name: Together AI -Endpoint: https://api.together.xyz/v1/chat/completions -API Key: (your Together AI key) -Model: mistralai/Mixtral-8x7B-Instruct-v0.1 -``` - -### Ollama (with OpenAI compatibility) -``` -Provider: Custom Provider -Name: Ollama -Endpoint: http://localhost:11434/v1/chat/completions -API Key: (leave blank) -Model: llama2 -``` - -## ❓ Common Issues - -**"API key is required"** -→ Make sure you entered your API key and saved - -**"Invalid API response"** -→ Check your API key is valid and has credits -→ Verify endpoint URL is correct - -**"Connection timeout"** -→ Check internet connection -→ For local models, ensure server is running - -## 💰 Cost Comparison (Approximate) - -| Provider | Input | Output | Notes | -|----------|-------|--------|-------| -| OpenAI GPT-4 | $30/1M | $60/1M | Highest quality | -| Claude Sonnet 3.5 | $3/1M | $15/1M | Great balance | -| Deepseek | $0.14/1M | $0.28/1M | Very affordable | -| Groq | Free tier | Free tier | Limited free usage | - -*Prices in USD per million tokens (approximate, check provider websites)* - -## 🎯 Feature Support - -| Feature | All Providers | Notes | -|---------|---------------|-------| -| Chat | ✅ | Core functionality | -| Code context | ✅ | Select code → ask question | -| System prompts | ✅ | Customize behavior | -| Temperature | ✅ | Control creativity | -| Streaming | ❌ | Coming soon | -| Function calling | ❌ | Provider-specific | - -## 🔐 Privacy & Security - -✅ API keys stored locally only -✅ No data sent to CTrace servers -✅ Direct provider communication -✅ You control your data - -## 📚 Learn More - -- Full documentation: `EXTERNAL_LLM_PROVIDERS.md` -- Implementation details: `LLM_INTEGRATION_SUMMARY.md` -- Add your own provider: See documentation - -## 🆘 Need Help? - -1. Check provider documentation for API key setup -2. Verify your API key has credits/quota -3. Try the "Generic" provider for maximum flexibility -4. Open an issue on GitHub with error details - ---- - -**Tip**: Start with Groq's free tier to test the assistant features, then upgrade to your preferred provider for production use! +# Quick Start: Using External LLM Providers + +## 🚀 Get Started in 3 Steps + +### 1. Open Assistant Settings +- Click the **Assistant** icon in the activity bar (left side) +- Click the **Settings** button in the assistant panel + +### 2. Choose Your Provider +Select "External API" and pick from: + +| Provider | Best For | API Key From | +|----------|----------|--------------| +| **OpenAI** | General purpose, highest quality | [platform.openai.com](https://platform.openai.com/api-keys) | +| **Deepseek** | Coding, cost-effective | [platform.deepseek.com](https://platform.deepseek.com/api-keys) | +| **Claude** | Advanced reasoning | [console.anthropic.com](https://console.anthropic.com/settings/keys) | +| **Groq** | Ultra-fast responses | [console.groq.com](https://console.groq.com/keys) | +| **Perplexity** | Current info + search | [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api) | +| **Generic** | Any OpenAI-compatible API | Varies by service | + +### 3. Enter Your API Key & Save +- Paste your API key +- Select your preferred model +- Click **Save** + +## 💡 Quick Tips + +### Free Tiers & Trials +- **Groq**: Free tier with generous limits +- **OpenAI**: $5 free credit for new accounts +- **Perplexity**: Limited free requests +- **Deepseek**: Very affordable pricing + +### Best Models for Coding +``` +OpenAI: gpt-4 or gpt-4-turbo +Deepseek: deepseek-coder +Claude: claude-3-5-sonnet-20241022 +Groq: mixtral-8x7b-32768 +``` + +### Customize Behavior +Add a system prompt like: +``` +You are an expert C++ developer. +Provide concise, secure code examples. +Always explain your reasoning. +``` + +### Temperature Guide +- **0.0-0.3**: Precise, deterministic (for code generation) +- **0.7**: Balanced (default, recommended) +- **1.0-2.0**: Creative (for brainstorming) + +## 🔧 Using Local/Private Models + +### LM Studio (Local) +``` +Provider: Custom Provider +Name: LM Studio +Endpoint: http://localhost:1234/v1/chat/completions +API Key: (leave blank) +Model: (whatever you loaded in LM Studio) +``` + +### Together AI +``` +Provider: Custom Provider +Name: Together AI +Endpoint: https://api.together.xyz/v1/chat/completions +API Key: (your Together AI key) +Model: mistralai/Mixtral-8x7B-Instruct-v0.1 +``` + +### Ollama (with OpenAI compatibility) +``` +Provider: Custom Provider +Name: Ollama +Endpoint: http://localhost:11434/v1/chat/completions +API Key: (leave blank) +Model: llama2 +``` + +## ❓ Common Issues + +**"API key is required"** +→ Make sure you entered your API key and saved + +**"Invalid API response"** +→ Check your API key is valid and has credits +→ Verify endpoint URL is correct + +**"Connection timeout"** +→ Check internet connection +→ For local models, ensure server is running + +## 💰 Cost Comparison (Approximate) + +| Provider | Input | Output | Notes | +|----------|-------|--------|-------| +| OpenAI GPT-4 | $30/1M | $60/1M | Highest quality | +| Claude Sonnet 3.5 | $3/1M | $15/1M | Great balance | +| Deepseek | $0.14/1M | $0.28/1M | Very affordable | +| Groq | Free tier | Free tier | Limited free usage | + +*Prices in USD per million tokens (approximate, check provider websites)* + +## 🎯 Feature Support + +| Feature | All Providers | Notes | +|---------|---------------|-------| +| Chat | ✅ | Core functionality | +| Code context | ✅ | Select code → ask question | +| System prompts | ✅ | Customize behavior | +| Temperature | ✅ | Control creativity | +| Streaming | ❌ | Coming soon | +| Function calling | ❌ | Provider-specific | + +## 🔐 Privacy & Security + +✅ API keys stored locally only +✅ No data sent to CTrace servers +✅ Direct provider communication +✅ You control your data + +## 📚 Learn More + +- Full documentation: `EXTERNAL_LLM_PROVIDERS.md` +- Implementation details: `LLM_INTEGRATION_SUMMARY.md` +- Add your own provider: See documentation + +## 🆘 Need Help? + +1. Check provider documentation for API key setup +2. Verify your API key has credits/quota +3. Try the "Generic" provider for maximum flexibility +4. Open an issue on GitHub with error details + +--- + +**Tip**: Start with Groq's free tier to test the assistant features, then upgrade to your preferred provider for production use! diff --git a/README.md b/README.md index 7857a33..933b814 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,103 @@ -# CTrace GUI - -A modern Electron-based GUI application for running CTrace analysis on C/C++ code. - -## Overview - -CTrace GUI provides an intuitive interface for analyzing C/C++ source code using the CTrace static analysis tool. The application features a VS Code-like interface with file management, syntax highlighting, and integrated analysis results. - -## Features - -- **File Management**: Open individual files or entire workspaces -- **File Tree Explorer**: Navigate project structure with refresh and auto-watch capabilities -- **Code Editor**: Syntax-highlighted editor with line numbers and search functionality -- **Tab Management**: Multi-file editing with tab interface -- **CTrace Integration**: Run static analysis directly from the GUI -- **Search**: Global search across workspace files -- **AI Assistant**: Chat with local or cloud LLM models about your code -- **Notifications**: User-friendly notification system -- **Work Loss Prevention**: Automatic session saving and restoration - -## Architecture - -The application follows a modular architecture with separate managers for different concerns: - -- **UIController**: Main coordinator for all UI components -- **FileOperationsManager**: Handles file I/O operations via IPC -- **TabManager**: Manages editor tabs and file switching -- **EditorManager**: Controls the Monaco code editor -- **SearchManager**: Handles search operations (widget and sidebar) -- **NotificationManager**: Manages user notifications -- **StateManager**: Handles session persistence (work loss prevention) -- **DiagnosticsManager**: Manages CTrace analysis results and visualization - -The renderer process communicates with the main process exclusively through a typed IPC bridge defined in `src/preload.js`. All IPC channels are whitelisted — the renderer cannot call anything not on the list. - ---- - -## Getting Started - -### Prerequisites - -| Requirement | Version | Notes | -|---|---|---| -| Node.js | v18 or later | Required for the build toolchain | -| npm | bundled with Node.js | Used to install dependencies | -| CTrace binary | any | Must be placed at `bin/ctrace` (see below) | - -> **Windows users:** CTrace is a Linux binary. It runs inside WSL (Windows Subsystem for Linux). The application will detect WSL on startup and guide you through installation if it is missing. - -### Installation - -```bash -npm install -``` - -This installs all Node.js dependencies including Electron and Monaco Editor. - -### CTrace Binary Setup - -The CTrace binary must be present before you can run analyses. Place it at: - -``` -bin/ctrace # Linux -bin/ctrace # Windows (the binary itself runs inside WSL) -bin/ctrace-darwin-arm64 # macOS, Apple Silicon -bin/ctrace-darwin-x64 # macOS, Intel -``` - -The `bin/` directory is at the root of the repository (same level as `package.json`). Create it if it doesn't exist. - -If the binary is missing, the application will still launch, but the **Run Analysis** button will return an error. - -> **macOS users:** the `ctrace` shipped in the release artifacts is a Linux ELF and cannot run on macOS. The app detects this and tells you so instead of failing with an opaque spawn error. Supply a native Mach-O build either by naming it `ctrace-darwin-` next to the bundled one, or by selecting it from **File → Backend Settings**. Everything else — editor, explorer, search, terminal, assistant — works without it. - -### Running in Development - -```bash -npm start -``` - -This automatically rebuilds the renderer bundle (`src/renderer/bundle.js`) before launching Electron. You must rerun `npm start` (or `npm run build:renderer`) any time you edit files under `src/renderer/`. - -### Security notes - -- The renderer runs sandboxed (`sandbox: true`, `contextIsolation: true`) behind a strict Content-Security-Policy: no inline script, no remote scripts, and no navigation away from `index.html`. New UI code must attach listeners with `addEventListener` (or the `data-action` attributes in `index.html`) and pass every external string (file names, tool output, error messages) through `escapeHtml()` before inserting it as HTML. -- DevTools shortcuts (F12, Ctrl/Cmd+Shift+I) are only active in development. Set `CTRACE_DEVTOOLS=1` to enable them in a packaged build. -- File-system IPC only accepts paths that came from a native dialog, the restored session, or the currently opened workspace. -- The assistant API key is encrypted with the OS credential store and never leaves the main process; keyed requests only go to `https:` endpoints (or `http://localhost`). -- App updates download automatically but are only installed after the user chooses "restart to apply". Builds are not code-signed yet, so keep it that way until signing is in place. - -### Building for Distribution - -| Platform | Command | Output | -|---|---|---| -| Current platform | `npm run dist` | `dist/` | -| Linux (AppImage) | `npm run dist:linux` | `dist/*.AppImage` | -| Windows (NSIS installer) | `npm run dist:win` | `dist/*.exe` | -| macOS (DMG + ZIP) | `npm run dist:mac` | `dist/*.dmg`, `dist/*.zip` | - -macOS builds are **unsigned and un-notarized** (signing requires a paid Apple Developer account). Gatekeeper blocks the first launch, so open the app once with right-click → **Open**, or clear the quarantine attribute: - -```bash -xattr -cr /Applications/CtraceGUI.app -``` - -CI builds macOS twice — `macos-latest` for arm64 and `macos-13` for x64 — because `node-pty` and `node-llama-cpp` are native modules and each architecture is compiled on a runner of that architecture. To sign later, restore an Apple certificate step in `.github/workflows/release.yml` and set `identity`, `hardenedRuntime` and `notarize` in the `mac` block of `package.json` (`build/entitlements.mac.plist` is kept for that purpose). - -### Releases and versioning - -Versions are semver, and the git tag is always `v`: - -| Channel | Version shape | Update manifest | Example | -|---|---|---|---| -| stable (`main` in the app) | `X.Y.Z` | `latest*.yml` | `5.1.0` | -| beta | `X.Y.Z-beta.N` | `beta*.yml` | `5.2.0-beta.1` | - -`-beta.N` is the **only** accepted prerelease form. electron-builder names the update manifest after the prerelease identifier, so a version like `5.0.1-a` produces an `a.yml` that neither channel reads — the release becomes invisible to the updater. `scripts/release.sh` and the release workflow both reject it, and `tests/version.test.js` pins the rule. - -Cut a release from `master` with a clean tree: - -```bash -./scripts/release.sh minor # 5.1.0 -> 5.2.0 -./scripts/release.sh 5.2.0-beta.1 # beta channel -./scripts/release.sh prerelease # 5.2.0-beta.1 -> 5.2.0-beta.2 -``` - -The script bumps `package.json`, tags, and pushes. **Only the tag triggers a build**: pushes to `master` and pull requests run the test suite and nothing else, so merging a PR never republishes a release. CI verifies the tag matches `package.json` before publishing, and marks the GitHub release as a prerelease when the version carries `-beta.N`. - -### Running Tests - -```bash -npm test -``` - -Uses Node.js's built-in test runner (`node --test`). No extra test framework is required. - -### Generating Documentation - -```bash -npm run docs -``` - -Generates JSDoc API docs. Hosted version: https://coretrace.github.io/coretrace-gui/ - ---- - -## Documentation - -Complete API documentation is available [here](https://coretrace.github.io/coretrace-gui/) - -IPC channel reference (all renderer ↔ main channels): [docs/ipc-channels.md](docs/ipc-channels.md) - - ---- - -## License - -Licensed under the Apache License, Version 2.0. - -You may obtain a copy of the License in this repository at [LICENSE](LICENSE) or at: - -http://www.apache.org/licenses/LICENSE-2.0 +# CoreTrace Desktop + +CoreTrace 6 uses **Tauri 2 / Rust**, **React / TypeScript** and an embedded Monaco editor. +A desktop sidebar connects the personal home, organisation dashboard, repositories, +analyses and code workspace. + +## Start + +Install Node.js 22.19+, Rust 1.95+ and the platform's +[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/). +Windows requires Microsoft C++ Build Tools and WebView2 Runtime. + +```sh +npm ci +npm start +``` + +`npm start` launches the native application. `npm run dev` is a browser preview; +filesystem, Git and account actions deliberately require Tauri. No demo data or +production credentials are embedded in the preview. + +## Use + +- **Local code:** Open a folder. Browse the tree, edit files, search with Ctrl/Cmd+F, + save with Ctrl/Cmd+S. Tabs retain drafts while navigating. Closing dirty tabs, + switching folders and closing the desktop asks before discarding. Save conflicts + preserve external edits and your in-memory draft; copy the draft before reopening. +- **GitHub:** Enter `owner/repository` or its HTTPS GitHub URL and choose a parent + folder. Git clones into a new child directory; existing destinations are never + overwritten. Private repositories use Git Credential Manager or `gh auth setup-git`. + CoreTrace does not launch builds or install dependencies while opening or cloning; + Git uses your existing local configuration. +- **Account:** Approve the device code in your browser. Rust keeps access tokens in + memory and refresh tokens in the OS credential manager, separated by API URL. + Credentials never enter React, localStorage or config files. Select the organisation + in the top bar. Dashboards show its quotas, reported period spend, plan and jobs. + Missing figures remain unknown. Account settings list members subject to permissions. +- **Cloud analysis:** Select an enabled connected GitHub repository and branch, tag or + commit, then confirm CTU spending. The repository configuration determines the tools. + Follow progress, cancel, inspect verified reports and open findings in the matching + workspace. Retrying an uncertain submission keeps its idempotency key until inputs + change or submission succeeds. Local edits are not uploaded by this workflow. +- **Local analysis:** Select your installed native `ctrace` executable in Settings, + with its analysis tools installed. Open a source file, save it and run static analysis. + SARIF findings, stdout/stderr and cancellation are available. The existing Linux + `bin/ctrace` cannot run directly on Windows. + Settings also accepts a tool configuration (`--config`) and the project's + `compile_commands.json` (`--compile-commands`). The packaged configuration is + selected automatically next to a CoreTrace distribution. With a configuration, its tool + selection is respected; without one, `--static` requests all static tools. + These choices last for the session. For the Windows CoreTrace distribution, + select `dist/windows/config/tool-config.json` to use its packaged tool selection, + and the matching build's `compile_commands.json` for compiler options. When none is + selected, the desktop searches for a database containing the active file and otherwise + generates a temporary entry with clang and the workspace's `include` directory. + Missing tools must be installed or excluded in the chosen configuration. + +The API defaults to `https://api.coretrace.fr/v1`. Set `CORETRACE_BASE_URL` before launch +for another deployment; `/v1` is added if absent. HTTPS is required except for loopback +API development addresses. Device-browser and signed report URLs require HTTPS. +Web account management links target app.coretrace.fr. + +## Build and verify + +```sh +npm run check +npm test +npm run format:check +cargo fmt --manifest-path src-tauri/Cargo.toml --check +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +npm run test:native +npm run tauri -- build --no-bundle +npm run dist +``` + +Windows executable: `src-tauri/target/release/coretrace-desktop.exe`. +`npm run dist` builds installers. Builds are unsigned; the CI validates Windows, +macOS and Linux and retains tag installers as artifacts. No automatic publication, +signing or replacement update service is configured by this migration. + +## Architecture and current boundaries + +- `desktop/`: React features, typed native bridge, platform projections and tests. +- `src-tauri/src/`: workspace, GitHub, cloud authentication/API and local process services. +- `desktop/platform-schema.d.ts`: generated contract snapshot from + Coretrace-Entreprise/coretrace-web, matching the control plane OpenAPI schema. +- Editing is confined to the chosen workspace, excludes Git metadata and path escapes, + and accepts UTF-8 text up to 4 MiB. Atomic saves check the on-disk revision. Directory + listing omits links and dependency/build folders, with a 5,000-entry limit per folder. +- Reports are capped at 10 MiB, output at 1 MiB per stream, local runs at 15 minutes, + clones at five minutes and cloud history at 200 jobs. Inspect incomplete clone folders + before retrying. Workspace tabs and the analyser choice last for the session. +- The IDE includes exploration, syntax colouring, tabs, search, editing and findings + navigation. Terminal, debugger, LSP, extensions, Git commit/push UI, WSL bridging and + local LLM integrations are not included. Local analysis targets the active source + file; cloud analysis targets connected GitHub commits. Uploading arbitrary local + folders to the cloud is not implemented. + +See [migration/rollback](docs/tauri-migration.md) and +[validation evidence](docs/desktop-validation.md). The old Electron implementation stays +in `src/` as reference, with its [README](docs/electron-readme.md), +[manifest](docs/electron-package.json) and `tests/`. It is not the new runtime; recover it +in a separate clean worktree at `807e2c9` when needed. Existing user changes are preserved. diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 947a2ce..6bc22e9 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -1,14 +1,14 @@ - - - - - com.apple.security.cs.allow-jit - - com.apple.security.cs.allow-unsigned-executable-memory - - com.apple.security.cs.allow-dyld-environment-variables - - com.apple.security.cs.disable-library-validation - - - + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/desktop/App.test.tsx b/desktop/App.test.tsx new file mode 100644 index 0000000..2cac2d3 --- /dev/null +++ b/desktop/App.test.tsx @@ -0,0 +1,282 @@ +import { forwardRef, useImperativeHandle } from "react"; +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, beforeEach, expect, it, test, vi } from "vitest"; +import App from "./App"; +import { ConfirmProvider } from "./components/Dialog"; +import { desktop } from "./bridge"; +import type { Workspace } from "./types"; + +const workspaceA: Workspace = { id: "w1", name: "alpha", path: "/work/alpha" }; +const workspaceB: Workspace = { id: "w2", name: "beta", path: "/work/beta" }; + +type CloseHandler = (event: { + preventDefault: () => void; +}) => void | Promise; +let closeHandler: CloseHandler | undefined; +const destroy = vi.fn(() => Promise.resolve()); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ + onCloseRequested: (handler: CloseHandler) => { + closeHandler = handler; + return Promise.resolve(() => {}); + }, + destroy, + }), +})); +// A stand-in for the real, Monaco-backed editor: only what these tests drive +// — dirtying the buffer, and asking to run a file — matters here. +vi.mock("./features/WorkspaceEditor", () => ({ + WorkspaceEditor: forwardRef(function FakeEditor( + props: { + dirtyChanged: (dirty: boolean) => void; + run: (path: string) => void; + }, + ref: React.Ref<{ open: () => Promise }>, + ) { + useImperativeHandle(ref, () => ({ open: vi.fn() })); + return ( + <> + + + + ); + }), +})); +vi.mock("./features/Dashboard", () => ({ + Dashboard: (props: { openFolder: () => void }) => ( + + ), +})); +vi.mock("./features/Repositories", () => ({ Repositories: () => null })); +vi.mock("./features/Analyses", () => ({ Analyses: () => null })); +vi.mock("./features/Settings", () => ({ Settings: () => null })); +vi.mock("./bridge", () => ({ + native: true, + desktop: { + status: vi.fn(), + restoreSession: vi.fn(), + cloudRunStatus: vi.fn(), + chooseWorkspace: vi.fn(), + closeWorkspace: vi.fn(), + cancelLocal: vi.fn(), + localHistory: vi.fn(), + }, + errorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})); + +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(desktop.status).mockResolvedValue({ + signedIn: false, + baseUrl: "https://api.coretrace.fr/v1", + }); + // A session that cannot be restored is a first run, not a failure. + vi.mocked(desktop.restoreSession).mockRejectedValue(new Error("no session")); + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ phase: "idle" }); + vi.mocked(desktop.localHistory).mockResolvedValue([]); + vi.mocked(desktop.closeWorkspace).mockResolvedValue([]); + vi.mocked(desktop.cancelLocal).mockResolvedValue(); + closeHandler = undefined; +}); +afterEach(cleanup); + +async function openWorkspaceA(user: ReturnType) { + vi.mocked(desktop.chooseWorkspace).mockResolvedValueOnce(workspaceA); + await user.click(await screen.findByText("Ouvrir depuis le tableau de bord")); + await screen.findByText("Modifier le fichier"); +} + +it("declines to switch folders until unsaved changes are confirmed away", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + await user.click(screen.getByText("Modifier le fichier")); + + await user.click( + screen.getByTitle("Ouvrir un autre dossier dans cet espace de travail"), + ); + const dialog = await screen.findByRole("dialog", { + name: "Changer de dossier ?", + }); + await user.click(within(dialog).getByText("Annuler")); + + expect(screen.queryByRole("dialog")).toBeNull(); + // Only the first, original open — the declined switch never asked again. + expect(desktop.chooseWorkspace).toHaveBeenCalledTimes(1); +}); + +it("switches folders once discarding unsaved changes is confirmed", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + await user.click(screen.getByText("Modifier le fichier")); + + vi.mocked(desktop.chooseWorkspace).mockResolvedValueOnce(workspaceB); + await user.click( + screen.getByTitle("Ouvrir un autre dossier dans cet espace de travail"), + ); + const dialog = await screen.findByRole("dialog", { + name: "Changer de dossier ?", + }); + await user.click(within(dialog).getByText("Changer de dossier")); + + await waitFor(() => + expect( + ( + screen.getByRole("combobox", { + name: "Dossier actif", + }) as HTMLSelectElement + ).value, + ).toBe("w2"), + ); + expect(desktop.chooseWorkspace).toHaveBeenCalledTimes(2); +}); + +it("blocks a native close request while work would be lost, and closes once confirmed", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + await user.click(screen.getByText("Modifier le fichier")); + await waitFor(() => expect(closeHandler).toBeDefined()); + + const event = { preventDefault: vi.fn() }; + void closeHandler!(event); + expect(event.preventDefault).toHaveBeenCalled(); + const dialog = await screen.findByRole("dialog", { + name: "Fermer CoreTrace ?", + }); + await user.click(within(dialog).getByText("Quitter")); + + await waitFor(() => expect(destroy).toHaveBeenCalled()); +}); + +it("lets a native close request through when nothing would be lost", async () => { + render( + + + , + ); + await waitFor(() => expect(closeHandler).toBeDefined()); + + const event = { preventDefault: vi.fn() }; + await closeHandler!(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(destroy).not.toHaveBeenCalled(); +}); + +it("offers to open a different folder instead of analysing, from the sidebar's confirm dialog", async () => { + // "Nouvelle analyse" from the sidebar names no folder on screen, so it asks + // first — and, since the reader may not have meant the open folder, offers + // opening a different one as the other thing "new" can mean. + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + + await user.click(screen.getByRole("button", { name: /Nouvelle analyse/ })); + const dialog = await screen.findByRole("dialog", { + name: "Analyser alpha ?", + }); + + vi.mocked(desktop.chooseWorkspace).mockResolvedValueOnce(workspaceB); + await user.click(within(dialog).getByText("Ouvrir un autre dossier")); + + await waitFor(() => + expect( + ( + screen.getByRole("combobox", { + name: "Dossier actif", + }) as HTMLSelectElement + ).value, + ).toBe("w2"), + ); +}); + +it("blocks a folder switch while an operation is already running, without asking to discard anything", async () => { + // Starting a second operation mid-flight would race the first; the busy + // guard has to be checked before the dirty one, or it would ask to discard + // work instead of simply saying to wait. + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + + vi.mocked(desktop.chooseWorkspace).mockReturnValueOnce(new Promise(() => {})); + await user.click( + screen.getByTitle("Ouvrir un autre dossier dans cet espace de travail"), + ); + await user.click( + screen.getByTitle("Ouvrir un autre dossier dans cet espace de travail"), + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + expect( + await screen.findByText( + "Attendez la fin de l’opération en cours avant de changer de dossier.", + ), + ).toBeDefined(); + // openWorkspaceA already made one call; the pending one here is the only + // other — the second click never reached the bridge at all. + expect(desktop.chooseWorkspace).toHaveBeenCalledTimes(2); +}); + +it("sends a local run to Settings, with a reason, when no ctrace executable is chosen", async () => { + const user = userEvent.setup(); + render( + + + , + ); + await openWorkspaceA(user); + + await user.click(screen.getByText("Lancer l’analyse sur ce fichier")); + + expect( + await screen.findByText( + "Sélectionnez le programme ctrace avant de lancer une analyse locale.", + ), + ).toBeDefined(); + expect(screen.getByText("Paramètres").closest("button")?.className).toBe( + "active", + ); +}); diff --git a/desktop/App.tsx b/desktop/App.tsx new file mode 100644 index 0000000..cc7ef32 --- /dev/null +++ b/desktop/App.tsx @@ -0,0 +1,909 @@ +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { + Activity, + ArrowUpRight, + Building2, + Code2, + FolderGit2, + FolderOpen, + FolderPlus, + Home, + LoaderCircle, + PanelLeftClose, + PanelLeftOpen, + Plus, + RefreshCw, + Settings2, + ShieldCheck, + X, +} from "lucide-react"; +import { desktop, errorMessage, native } from "./bridge"; +import { Dialog, useConfirm } from "./components/Dialog"; +import { launchAnalysis } from "./launch"; +import { describe, running, useCloudRun } from "./useCloudRun"; +import { Login } from "./components/Login"; +import { Dashboard } from "./features/Dashboard"; +import { Repositories } from "./features/Repositories"; +import { Analyses } from "./features/Analyses"; +import { Settings } from "./features/Settings"; +import type { EditorHandle } from "./features/WorkspaceEditor"; +import { useCloud } from "./useCloud"; +import { typicalSeconds, workspaceRelativePath } from "./model"; +import type { + Job, + LocalResult, + LocalRun, + Page, + Repository, + Workspace, +} from "./types"; +const WorkspaceEditor = lazy(() => + import("./features/WorkspaceEditor").then((module) => ({ + default: module.WorkspaceEditor, + })), +); + +const pages = { + home: "Accueil", + analyses: "Analyses", + repositories: "Dépôts", + workspace: "Espace de code", + settings: "Paramètres", +}; +const navigation = [ + { id: "home", icon: Home }, + { id: "analyses", icon: Activity }, + { id: "repositories", icon: FolderGit2 }, + { id: "workspace", icon: Code2 }, +] as const; +export default function App() { + const cloud = useCloud(); + const [page, setPage] = useState("home"); + const [collapsed, setCollapsed] = useState(false); + // Several folders can be open at once; `workspace` is the one being edited. + const [workspaces, setWorkspaces] = useState([]); + // Which files were open in each folder. A ref, not state: remembering must + // not re-render the editor that is reporting it. + const openTabs = useRef>( + {}, + ); + // The folder in front right now. A closure captures the folder as it was when + // it started, and an analysis outlives that. + const activeWorkspace = useRef(undefined); + // What the previous session had open. Choosing the executable and reopening + // the same folders every time is work the reader already did once. + useEffect(() => { + if (!native) return; + void desktop + .restoreSession() + .then((session) => { + if (session.analyser) setAnalyser(session.analyser); + if (session.workspaces.length) { + setWorkspaces(session.workspaces); + const first = session.workspaces[0]; + setWorkspace(first); + activeWorkspace.current = first.id; + } + }) + .catch(() => { + // A session that cannot be restored is a first run, not a failure. + }); + }, []); + const [workspace, setWorkspace] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(""); + // Nothing can spin when the system disallows animation, so the banner counts + // real seconds instead: a number that changes is proof the work is alive. + const [waited, setWaited] = useState(0); + const [message, setMessage] = useState(""); + // A message is information; it leaves on its own unless the reader is + // reading it. Decisions and offers are separate notices and stay. + const [holdMessage, setHoldMessage] = useState(false); + useEffect(() => { + if (!message || holdMessage) return; + const timer = setTimeout(() => setMessage(""), 8000); + return () => clearTimeout(timer); + }, [message, holdMessage]); + const [login, setLogin] = useState(false); + const [clone, setClone] = useState(null); + const [selectedJob, setSelectedJob] = useState(null); + const [draftRepo, setDraftRepo] = useState(null); + const [analyser, setAnalyser] = useState(""); + const [local, setLocal] = useState(null); + const [localRunning, setLocalRunning] = useState(false); + // What ran on this machine in the open folder, newest first. Reloaded when + // the folder changes and when a run finishes, which are the times it moves. + const [localHistory, setLocalHistory] = useState([]); + // Runs reported this session, on top of what the history remembers. + const [reportedNow, setReportedNow] = useState>(new Set()); + const reportedRuns = new Set([ + ...localHistory.filter((r) => r.reported).map((r) => r.id), + ...reportedNow, + ]); + useEffect(() => { + if (!native || !workspace) { + setLocalHistory([]); + return; + } + let live = true; + void desktop + .localHistory(workspace.id) + .then((runs) => { + if (live) setLocalHistory(runs); + }) + .catch(() => { + if (live) setLocalHistory([]); + }); + return () => { + live = false; + }; + }, [workspace, localRunning]); + useEffect(() => { + if (!busy && !localRunning) { + setWaited(0); + return; + } + const started = Date.now(); + const tick = setInterval( + () => setWaited(Math.floor((Date.now() - started) / 1000)), + 1000, + ); + return () => clearInterval(tick); + }, [busy, localRunning]); + // A cloud run started from the editor is still the reader's run when they + // move to another tab, so it is owned here and reports itself in notices. + const [finishedJob, setFinishedJob] = useState(""); + const cloudRun = useCloudRun({ + notify: setMessage, + onFinished: (job) => { + setFinishedJob(job); + void cloud.refresh(); + }, + }); + const editor = useRef(null); + const confirm = useConfirm(); + const orgRef = useRef(cloud.org); + orgRef.current = cloud.org; + useEffect(() => { + setSelectedJob(null); + setDraftRepo(null); + }, [cloud.org]); + // Native close requests also respect unsaved buffers; beforeunload alone is not sufficient in a WebView. + useEffect(() => { + if (!native) return; + let disposed = false; + let unlisten: (() => void) | undefined; + void import("@tauri-apps/api/window") + .then(async ({ getCurrentWindow }) => { + const window = getCurrentWindow(); + const off = await window.onCloseRequested(async (event) => { + if (!dirty && !localRunning) return; + event.preventDefault(); + if ( + await confirm( + "Fermer CoreTrace ?", + `${dirty ? "Des modifications ne sont pas enregistrées. " : ""}${localRunning ? "Une analyse locale est en cours. " : ""}Voulez-vous quitter ?`, + "Quitter", + ) + ) { + try { + if (localRunning) await desktop.cancelLocal(); + await window.destroy(); + } catch (e) { + setMessage(errorMessage(e)); + } + } + }); + if (disposed) off(); + else unlisten = off; + }) + .catch((e) => setMessage(errorMessage(e))); + return () => { + disposed = true; + unlisten?.(); + }; + }, [dirty, localRunning, confirm]); + const canSwitch = async () => { + if (busy || localRunning) { + setMessage( + "Attendez la fin de l’opération en cours avant de changer de dossier.", + ); + return false; + } + return ( + !dirty || + (await confirm( + "Changer de dossier ?", + "Les modifications non enregistrées des fichiers ouverts seront abandonnées.", + "Changer de dossier", + )) + ); + }; + const activate = (next: Workspace | null) => { + if (!next) return; + setWorkspaces((open) => + open.some((w) => w.id === next.id) ? open : [...open, next], + ); + setWorkspace(next); + activeWorkspace.current = next.id; + setDirty(false); + setLocal(null); + setPage("workspace"); + }; + /** Switches to a folder already open, guarding unsaved work as a change does. */ + const switchTo = async (id: string) => { + const next = workspaces.find((w) => w.id === id); + if (!next || next.id === workspace?.id) return; + if (!(await canSwitch())) return; + setWorkspace(next); + activeWorkspace.current = next.id; + setDirty(false); + }; + const closeFolder = async (id: string) => { + if (id === workspace?.id && !(await canSwitch())) return; + try { + const open = await desktop.closeWorkspace(id); + delete openTabs.current[id]; + setWorkspaces(open); + if (id === workspace?.id) { + const next = open[open.length - 1] ?? null; + setWorkspace(next); + activeWorkspace.current = next?.id; + setDirty(false); + setLocal(null); + if (open.length === 0) setPage("home"); + } + } catch (e) { + setMessage(errorMessage(e)); + } + }; + const openFolder = async () => { + if (!(await canSwitch())) return; + setBusy("Ouverture du dossier…"); + try { + activate(await desktop.chooseWorkspace()); + } catch (e) { + setMessage(errorMessage(e)); + } finally { + setBusy(""); + } + }; + const cloneRepo = async () => { + if (clone === null || !(await canSwitch())) return; + setBusy("Clonage du dépôt GitHub…"); + try { + const next = await desktop.cloneRepository(clone); + activate(next); + if (next) setClone(null); + } catch (e) { + setMessage(errorMessage(e)); + } finally { + setBusy(""); + } + }; + const runLocal = async (path: string) => { + if (!workspace || localRunning) return; + if (!analyser) { + setPage("settings"); + setMessage( + "Sélectionnez le programme ctrace avant de lancer une analyse locale.", + ); + return; + } + if ( + !(await confirm( + "Analyser ce fichier local ?", + `${path} sera analysé avec le programme ctrace sélectionné et ses outils installés.`, + "Lancer ctrace", + )) + ) + return; + setLocalRunning(true); + setLocal(null); + setPage("analyses"); + const ran = workspace.id; + try { + const result = await desktop.analyseLocal(ran, path); + // An analysis takes seconds; the reader may have moved to another folder + // since, and its result is not theirs. + if (activeWorkspace.current === ran) setLocal(result); + } catch (e) { + setMessage(errorMessage(e)); + } finally { + setLocalRunning(false); + } + }; + /** Analyses the whole open folder locally: what a cloud run does to a + workspace, done on this machine when the cloud is not available. */ + const runLocalFolder = async () => { + if (!workspace || localRunning) return; + if (!analyser) { + setPage("settings"); + setMessage( + "Sélectionnez le programme ctrace avant de lancer une analyse locale.", + ); + return; + } + setLocalRunning(true); + setLocal(null); + setPage("analyses"); + const ran = workspace.id; + try { + const result = await desktop.analyseLocalFolder(ran); + if (activeWorkspace.current === ran) setLocal(result); + } catch (e) { + setMessage(errorMessage(e)); + } finally { + setLocalRunning(false); + } + }; + // One entry point, owned here so the sidebar, the home page and the + // analyses page all do the same thing when they say "Nouvelle analyse". + const [starting, setStarting] = useState(false); + const newAnalysis = async (askFirst = false) => { + if (!workspace) { + await openFolder(); + return; + } + // From the sidebar nothing on screen names the folder, so the button + // would act on one the reader may not have in mind. Ask, naming it, and + // offer the other thing "new" can mean. + if (askFirst) { + const answer = await confirm( + `Analyser ${workspace.name} ?`, + `Le dossier ouvert (${workspace.path}) sera analysé avec vos CTU si possible, sinon sur cette machine.`, + `Analyser ${workspace.name}`, + "Ouvrir un autre dossier", + ); + if (answer === "alternative") { + await openFolder(); + return; + } + if (!answer) return; + } + setStarting(true); + try { + await launchAnalysis({ + workspace: workspace.path, + org: cloud.me ? cloud.org : "", + startCloud: cloudRun.start, + runLocal: runLocalFolder, + notify: setMessage, + }); + } finally { + setStarting(false); + } + }; + const openFinding = (path: string, line: number) => { + if (!workspace || !editor.current) { + setMessage( + "Ouvrez le dossier correspondant dans l’IDE pour accéder à ce fichier.", + ); + return; + } + setPage("workspace"); + try { + const relative = workspaceRelativePath(path, workspace.path); + void editor.current + .open(relative, line) + .catch((e) => setMessage(errorMessage(e))); + } catch (e) { + setMessage(errorMessage(e)); + } + }; + const selectJob = (job: Job | null) => { + if (orgRef.current !== cloud.org) return; + setSelectedJob(job); + setPage("analyses"); + }; + const analyseRepo = (repo: Repository) => { + setDraftRepo(repo); + setSelectedJob(null); + setPage("analyses"); + }; + return ( +
+ +
+
+
+ {page === "workspace" && workspace && ( + <> + {/* Several folders can be open; this is the one being edited. */} + + + {workspaces.length > 1 && ( + + )} + + )} +
+
+ {cloud.me && ( + + )} + + +
+
+ {!native && ( +
+ Aperçu navigateur · Ouvrez l’application desktop pour accéder aux + dossiers, à GitHub et à votre compte. +
+ )} + {cloud.error && ( +
+ {cloud.error} + +
+ )} +
+ {page === "home" && ( + void openFolder()} + clone={() => setClone("")} + analyse={() => void newAnalysis()} + login={() => setLogin(true)} + selectJob={selectJob} + /> + )} + {page === "repositories" && ( + setClone(name ?? "")} + analyse={analyseRepo} + notify={setMessage} + /> + )} + + {page === "settings" && ( + setLogin(true)} + notify={setMessage} + /> + )} + } + > + { + // Opening a file from another folder brings that folder to + // the front with the file already open, which is what + // clicking it means. + const remembered = openTabs.current[id]; + const paths = remembered?.paths.includes(path) + ? remembered.paths + : [...(remembered?.paths ?? []), path]; + openTabs.current[id] = { paths, active: path }; + void switchTo(id); + }} + restore={openTabs.current[workspace.id]} + tabsChanged={(paths, active) => { + openTabs.current[workspace.id] = { paths, active }; + }} + dirtyChanged={setDirty} + run={(path) => void runLocal(path)} + runInCloud={ + cloud.org && workspace + ? () => void cloudRun.start(workspace.path, cloud.org) + : undefined + } + busy={localRunning || running(cloudRun.phase)} + notify={setMessage} + /> + + ) : ( +
+ +

Un espace pour votre code.

+

+ Ouvrez un dossier local ou clonez un dépôt GitHub pour + commencer. +

+
+ + +
+
+ )} +
+ + {/* The status bar is the one place a run reports itself. From any + page, a glance at the bottom edge says what is happening, for how + long, and how to stop it. */} +
+ + {running(cloudRun.phase) ? ( + <> + + {describe( + cloudRun.phase, + cloudRun.seconds, + typicalSeconds(cloud.jobs), + )} + + + ) : localRunning ? ( + <> + + Analyse locale en cours · {waited} s + + + ) : busy ? ( + <> + + {busy} + {waited > 0 && ` · ${waited} s`} + + ) : !native ? ( + "Aperçu de l’interface" + ) : null} + + {workspace ? workspace.name : "Aucun dossier ouvert"} +
+
+ {/* A quote is a decision, not news: nothing is spent until it is taken, + so it is offered wherever the reader happens to be. */} + {cloudRun.phase.phase === "quoted" && ( +
+ + + {cloudRun.phase.ctu.toLocaleString("fr-FR")} CTU seront débités + + Rien n’a encore été débité pour cette analyse. + + + +
+ )} + {finishedJob && ( +
+ + Analyse terminée + Ses résultats sont prêts. + + + +
+ )} + {message && ( +
setHoldMessage(true)} + onMouseLeave={() => setHoldMessage(false)} + > + {message} + +
+ )} + {login && ( + setLogin(false)} connected={cloud.reconnect} /> + )} + {clone !== null && ( + { + if (!busy) setClone(null); + }} + > +

+ Choisissez un dépôt connecté ou saisissez{" "} + propriétaire/dépôt. CoreTrace le clone et le range avec + les autres ; le dossier est indiqué dans les paramètres. +

+ + {busy ? ( +

+ + {busy} + {waited > 0 && · {waited} s} +

+ ) : ( +

+ Les dépôts privés utilisent votre connexion Git locale. Si + nécessaire, connectez Git Credential Manager ou exécutez gh auth + setup-git. +

+ )} +
+ + +
+
+ )} + + ); +} diff --git a/desktop/bridge.test.ts b/desktop/bridge.test.ts new file mode 100644 index 0000000..6a34bd6 --- /dev/null +++ b/desktop/bridge.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const { invoke, isTauri } = vi.hoisted(() => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => true), +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke, isTauri })); + +// `native` is read once, at import time, so each test re-imports the module +// after setting isTauri's answer for that test. +beforeEach(() => { + vi.resetModules(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +it("calls the native command by name, with its arguments, when running in Tauri", async () => { + isTauri.mockReturnValue(true); + invoke.mockResolvedValue([{ id: "w1", name: "alpha", path: "/work/alpha" }]); + const { desktop } = await import("./bridge"); + + const result = await desktop.workspaces(); + + expect(invoke).toHaveBeenCalledWith("workspaces", undefined); + expect(result).toEqual([{ id: "w1", name: "alpha", path: "/work/alpha" }]); +}); + +it("carries every argument through to the command untouched", async () => { + isTauri.mockReturnValue(true); + invoke.mockResolvedValue({ content: "int main(){}", revision: "abc" }); + const { desktop } = await import("./bridge"); + + await desktop.save("w1", "src/main.c", "int main(){}", "abc"); + + expect(invoke).toHaveBeenCalledWith("save_file", { + workspaceId: "w1", + path: "src/main.c", + content: "int main(){}", + revision: "abc", + }); +}); + +it("refuses every action outside the desktop app, without reaching the native bridge", async () => { + isTauri.mockReturnValue(false); + const { desktop } = await import("./bridge"); + + await expect(desktop.workspaces()).rejects.toThrow( + "Cette action nécessite l’application desktop. Lancez npm start.", + ); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("answers signed-out instead of refusing, for the one call the web build still needs", async () => { + isTauri.mockReturnValue(false); + const { desktop } = await import("./bridge"); + + await expect(desktop.status()).resolves.toEqual({ + signedIn: false, + baseUrl: "https://api.coretrace.fr/v1", + }); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("still asks the native bridge for status when running in Tauri", async () => { + isTauri.mockReturnValue(true); + invoke.mockResolvedValue({ + signedIn: true, + baseUrl: "https://api.coretrace.fr/v1", + }); + const { desktop } = await import("./bridge"); + + await expect(desktop.status()).resolves.toEqual({ + signedIn: true, + baseUrl: "https://api.coretrace.fr/v1", + }); + expect(invoke).toHaveBeenCalledWith("cloud_status", undefined); +}); + +it("runs every rejection through the same French translation table as the rest of the app", async () => { + isTauri.mockReturnValue(true); + const { errorMessage } = await import("./bridge"); + + expect(errorMessage(new Error("An analysis is already running"))).toBe( + "Une analyse est déjà en cours.", + ); + expect(errorMessage("Something new happened")).toBe("Something new happened"); +}); diff --git a/desktop/bridge.ts b/desktop/bridge.ts new file mode 100644 index 0000000..a0745d8 --- /dev/null +++ b/desktop/bridge.ts @@ -0,0 +1,135 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { translateError } from "./errors"; +import type { + CloudPhase, + DeviceCode, + Document, + FileEntry, + Job, + LocalResult, + LocalRun, + ReportBody, + ToolStatus, + Workspace, +} from "./types"; + +export const native = isTauri(); +export interface AnalysisOptions { + config: string | null; + compileCommands: string | null; +} +function call(command: string, args?: Record): Promise { + if (!native) + return Promise.reject( + new Error( + "Cette action nécessite l’application desktop. Lancez npm start.", + ), + ); + return invoke(command, args); +} +export const desktop = { + chooseWorkspace: () => call("choose_workspace"), + /** Starts a cloud run on the open folder; it stops at the quote. */ + startCloudRun: (root: string, org: string, tools: string[] = []) => + call("cloud_run_start", { root, org, tools }), + cloudRunStatus: () => call("cloud_run_status"), + /** Approves the quote. This is the call that spends CTU. */ + confirmCloudRun: (org: string) => call("cloud_run_confirm", { org }), + /** Stops a run, or refuses its quote; the organisation names the job. */ + cancelCloudRun: (org?: string) => call("cloud_run_cancel", { org }), + /** The folders and ctrace executable the previous session left behind. */ + restoreSession: () => + call<{ workspaces: Workspace[]; analyser: string | null }>( + "restore_session", + ), + /** Every folder open now, in the order they were opened. */ + workspaces: () => call("workspaces"), + /** Closes one folder; the files on disk are untouched. */ + closeWorkspace: (id: string) => call("close_workspace", { id }), + /** Where clones are kept, for Settings to show. */ + cloneLocation: () => call("clone_location"), + cloneRepository: (repository: string) => + call("clone_repository", { repository }), + files: (workspaceId: string, path = "") => + call("list_files", { workspaceId, path }), + read: (workspaceId: string, path: string) => + call("read_file", { workspaceId, path }), + save: ( + workspaceId: string, + path: string, + content: string, + revision: string, + ) => call("save_file", { workspaceId, path, content, revision }), + chooseAnalyser: () => call("choose_analyser"), + analysisOptions: () => call("analysis_options"), + chooseAnalysisFile: (kind: keyof AnalysisOptions, clear = false) => + call("choose_analysis_file", { kind, clear }), + /** Recent local runs of the open folder, newest first. */ + localHistory: (workspaceId: string) => + call("local_history", { workspaceId }), + /** Analyses every source file in the open folder, one after another. */ + analyseLocalFolder: (workspaceId: string) => + call("analyse_local_folder", { workspaceId }), + analyseLocal: (workspaceId: string, path: string) => + call("analyse_local", { workspaceId, path }), + /** Every file of the folder, relative, for finding one by name. */ + allFiles: (workspaceId: string) => + call("list_all_files", { workspaceId }), + /** Which of ctrace's external tools this machine has. */ + probeTools: () => call("probe_tools"), + /** Repositories already cloned here, as owner/name. */ + clonedRepositories: () => call("cloned_repositories"), + /** Build files at the folder's root, for a tool-failure report. */ + supportCandidates: (workspaceId: string) => + call<{ name: string; bytes: number }[]>("support_candidates", { + workspaceId, + }), + /** A text file the user chose to attach. */ + supportReadFile: (workspaceId: string, relative: string) => + call("support_read_file", { workspaceId, relative }), + /** Sends the report; resolves to its id, rejects with the platform's sentence. */ + supportSend: (report: ReportBody) => call("support_send", { report }), + supportMarkReported: (workspaceId: string, runId: string) => + call("support_mark_reported", { workspaceId, runId }), + cancelLocal: () => call("cancel_local"), + status: () => + native + ? call<{ signedIn: boolean; baseUrl: string }>("cloud_status") + : Promise.resolve({ + signedIn: false, + baseUrl: "https://api.coretrace.fr/v1", + }), + login: () => call("login_start"), + pollLogin: () => call("login_poll"), + cancelLogin: () => call("login_cancel"), + logout: () => call("logout"), + readCloud: (resource: string, org?: string, id?: string, run?: string) => + call("cloud_read", { resource, org, id, run }), + analyseCloud: ( + org: string, + installation: string, + repository: string, + reference: string, + rerun: boolean, + requestId: string, + ) => + call("cloud_analyse", { + org, + installation, + repository, + reference, + rerun, + requestId, + }), + cancelCloud: (org: string, id: string) => + call("cloud_cancel", { org, id }), + report: (org: string, id: string, run: string) => + call("cloud_report", { org, id, run }), + /** Opens GitHub's authorisation in the system browser; resolves with the URL. */ + connectGitHub: () => call("connect_github"), + openAccount: (page: "device" | "dashboard" | "repositories" | "settings") => + call("open_account", { page }), +}; +export function errorMessage(error: unknown): string { + return translateError(error instanceof Error ? error.message : String(error)); +} diff --git a/desktop/components/Dialog.test.tsx b/desktop/components/Dialog.test.tsx new file mode 100644 index 0000000..6051a31 --- /dev/null +++ b/desktop/components/Dialog.test.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { ConfirmProvider, Dialog, useConfirm } from "./Dialog"; + +afterEach(cleanup); +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); + +it("opens as a native modal under its title", () => { + render( + +

contenu

+
, + ); + const dialog = screen.getByRole("dialog", { name: "Titre" }); + expect(dialog.hasAttribute("open")).toBe(true); +}); + +it("treats a native Escape cancel the same as the close button", () => { + const close = vi.fn(); + render( + +

contenu

+
, + ); + const dialog = screen.getByRole("dialog", { name: "Titre" }); + const event = new Event("cancel", { cancelable: true }); + dialog.dispatchEvent(event); + // The browser's default for an unhandled cancel is to close the dialog + // itself, bypassing whatever close() was meant to do first. + expect(event.defaultPrevented).toBe(true); + expect(close).toHaveBeenCalledTimes(1); +}); + +it("closes from the header button", async () => { + const close = vi.fn(); + render( + +

contenu

+
, + ); + await userEvent.click(screen.getByRole("button", { name: "Fermer" })); + expect(close).toHaveBeenCalledTimes(1); +}); + +/** Drives useConfirm with an optional third way, and shows what it resolved. */ +function Ask({ alternative }: { alternative?: string }) { + const ask = useConfirm(); + const [answer, setAnswer] = useState(""); + return ( + <> + +

réponse : {answer || "(en attente)"}

+ + ); +} +function show(alternative?: string) { + render( + + + , + ); +} + +it("resolves true when the reader confirms the action", async () => { + show(); + await userEvent.click(screen.getByText("Demander")); + screen.getByRole("dialog", { name: "Changer de dossier ?" }); + await userEvent.click( + screen.getByRole("button", { name: "Changer de dossier" }), + ); + await screen.findByText("réponse : true"); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("resolves false when the reader cancels, and asks nothing until asked again", async () => { + show(); + await userEvent.click(screen.getByText("Demander")); + await userEvent.click(screen.getByRole("button", { name: "Annuler" })); + await screen.findByText("réponse : false"); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("offers a third way only when one was given", async () => { + show(); + await userEvent.click(screen.getByText("Demander")); + expect(screen.queryByText("Enregistrer")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Annuler" })); +}); + +it("resolves 'alternative' when the third way is chosen", async () => { + show("Enregistrer"); + await userEvent.click(screen.getByText("Demander")); + await userEvent.click(screen.getByRole("button", { name: "Enregistrer" })); + await screen.findByText("réponse : alternative"); +}); diff --git a/desktop/components/Dialog.tsx b/desktop/components/Dialog.tsx new file mode 100644 index 0000000..189a47d --- /dev/null +++ b/desktop/components/Dialog.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { X } from "lucide-react"; + +export function Dialog({ + title, + children, + close, +}: { + title: string; + children: ReactNode; + close: () => void; +}) { + const ref = useRef(null); + useEffect(() => { + const dialog = ref.current!; + dialog.showModal(); + return () => dialog.close(); + }, []); + return ( + { + event.preventDefault(); + close(); + }} + aria-label={title} + > +
+

{title}

+ +
+ {children} +
+ ); +} +/** Resolves true for the action, "alternative" for the second way when one + is offered, false for Annuler. */ +type Ask = ( + title: string, + detail: string, + action?: string, + alternative?: string, +) => Promise; +const ConfirmContext = createContext(async () => false); +export const useConfirm = () => useContext(ConfirmContext); +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [request, setRequest] = useState<{ + title: string; + detail: string; + action: string; + alternative?: string; + resolve: (answer: boolean | "alternative") => void; + } | null>(null); + const ask: Ask = useCallback( + (title, detail, action = "Continuer", alternative) => + new Promise((resolve) => + setRequest({ title, detail, action, alternative, resolve }), + ), + [], + ); + const finish = (answer: boolean | "alternative") => { + request?.resolve(answer); + setRequest(null); + }; + return ( + + {children} + {request && ( + finish(false)}> +

{request.detail}

+
+ + {request.alternative && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/desktop/components/Login.test.tsx b/desktop/components/Login.test.tsx new file mode 100644 index 0000000..cf49b05 --- /dev/null +++ b/desktop/components/Login.test.tsx @@ -0,0 +1,133 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { Login } from "./Login"; +import { desktop } from "../bridge"; + +vi.mock("../bridge", () => ({ + native: true, + desktop: { + login: vi.fn(), + pollLogin: vi.fn(), + cancelLogin: vi.fn(() => Promise.resolve()), + openAccount: vi.fn(() => Promise.resolve()), + }, + errorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); + +// A short interval keeps these tests fast; only its unit (seconds) matters. +const code = { + userCode: "BCDF-GHJK", + verificationUri: + "https://app.coretrace.fr/v1/auth/device/verify?user_code=BCDF-GHJK", + interval: 0.01, + expiresIn: 300, +}; + +it("shows the device code once the platform has issued one", async () => { + vi.mocked(desktop.login).mockResolvedValue(code); + vi.mocked(desktop.pollLogin).mockResolvedValue(false); + render(); + expect(screen.getByText("Préparation de la connexion…")).toBeDefined(); + + await screen.findByText("BCDF-GHJK"); + expect(screen.getByText("Code valable 5 minutes.")).toBeDefined(); + expect(screen.getByText(code.verificationUri)).toBeDefined(); + expect(screen.getByText(/En attente de validation/)).toBeDefined(); +}); + +it("opens the browser to the link the code was actually issued for", async () => { + vi.mocked(desktop.login).mockResolvedValue(code); + vi.mocked(desktop.pollLogin).mockResolvedValue(false); + render(); + await userEvent.click( + await screen.findByRole("button", { name: /Ouvrir la connexion/ }), + ); + expect(desktop.openAccount).toHaveBeenCalledWith("device"); +}); + +it("keeps polling until the platform confirms, then hands off and closes", async () => { + // Every poll after the first is a flat second apart, regardless of the + // interval the platform issued — fake time so the test does not spend + // two real seconds proving that. + vi.useFakeTimers(); + try { + vi.mocked(desktop.login).mockResolvedValue(code); + vi.mocked(desktop.pollLogin) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const connected = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + render(); + + await vi.advanceTimersByTimeAsync(10); // the first poll, at the issued interval + expect(desktop.pollLogin).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(desktop.pollLogin).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1000); + expect(desktop.pollLogin).toHaveBeenCalledTimes(3); + + expect(connected).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } +}); + +it("shows the platform's refusal instead of waiting on a code that will never arrive", async () => { + vi.mocked(desktop.login).mockRejectedValue(new Error("Platform unavailable")); + render(); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Platform unavailable"); + expect(screen.queryByText(/En attente de validation/)).toBeNull(); +}); + +it("surfaces a polling failure without pretending sign-in is still happening", async () => { + vi.mocked(desktop.login).mockResolvedValue(code); + vi.mocked(desktop.pollLogin).mockRejectedValue( + new Error("Sign-in code expired; start again"), + ); + render(); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("Sign-in code expired; start again"); +}); + +it("gives up the native device flow as soon as the dialog goes away", async () => { + // Nothing has confirmed the code by the time the reader closes the dialog; + // leaving the device flow running would let a stale code sign them in later. + vi.mocked(desktop.login).mockResolvedValue(code); + vi.mocked(desktop.pollLogin).mockResolvedValue(false); + const { unmount } = render(); + await screen.findByText("BCDF-GHJK"); + + unmount(); + + expect(desktop.cancelLogin).toHaveBeenCalledTimes(1); +}); + +it("still cancels the device flow if closed before the platform even issues a code", async () => { + // login() is still pending, so no code and no poll timer exist yet; the + // cleanup must not assume either has happened. + vi.mocked(desktop.login).mockReturnValue(new Promise(() => {})); + const { unmount } = render(); + await screen.findByText("Préparation de la connexion…"); + + unmount(); + + expect(desktop.cancelLogin).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/components/Login.tsx b/desktop/components/Login.tsx new file mode 100644 index 0000000..b181fc9 --- /dev/null +++ b/desktop/components/Login.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { ExternalLink, LoaderCircle } from "lucide-react"; +import { desktop, errorMessage } from "../bridge"; +import type { DeviceCode } from "../types"; +import { Dialog } from "./Dialog"; + +export function Login({ + close, + connected, +}: { + close: () => void; + connected: () => Promise; +}) { + const [code, setCode] = useState(null); + const [error, setError] = useState(""); + useEffect(() => { + let stopped = false; + let timer: ReturnType; + const poll = async () => { + try { + const signedIn = await desktop.pollLogin(); + if (stopped) return; + if (signedIn) { + await connected(); + if (!stopped) close(); + } else timer = setTimeout(poll, 1000); + } catch (e) { + if (!stopped) setError(errorMessage(e)); + } + }; + void desktop + .login() + .then((value) => { + if (!stopped) { + setCode(value); + timer = setTimeout(poll, value.interval * 1000); + } + }) + .catch((e) => { + if (!stopped) setError(errorMessage(e)); + }); + return () => { + stopped = true; + clearTimeout(timer); + void desktop.cancelLogin().catch(() => {}); + }; + }, []); // One native device flow per dialog; callbacks are read for this attempt. + return ( + +

+ Validez ce code dans votre navigateur. Votre session sera conservée dans + le coffre-fort du système. +

+ {code ? ( + <> +
{code.userCode}
+

+ Code valable {Math.ceil(code.expiresIn / 60)} minutes. +

+ +

{code.verificationUri}

+ {!error && ( +

+ En attente de + validation… +

+ )} + + ) : ( + !error &&

Préparation de la connexion…

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/desktop/errors.test.ts b/desktop/errors.test.ts new file mode 100644 index 0000000..35cfbfe --- /dev/null +++ b/desktop/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { translateError } from "./errors"; + +it("says the desktop's English answers in French", () => { + expect(translateError("An analysis is already running")).toBe( + "Une analyse est déjà en cours.", + ); + expect( + translateError("Choose the installed ctrace executable in Settings first"), + ).toBe("Choisissez d’abord le programme ctrace dans les paramètres."); +}); + +it("keeps what a tool itself said as a detail", () => { + expect( + translateError( + "Git clone failed. For private repositories, sign in first. fatal: repository not found", + ), + ).toBe( + "Le clonage Git a échoué — For private repositories, sign in first. fatal: repository not found", + ); + expect(translateError("Cannot start ctrace: os error 2")).toBe( + "Impossible de lancer ctrace — os error 2", + ); +}); + +it("passes through what it does not know rather than hiding it", () => { + expect(translateError("Something new happened")).toBe( + "Something new happened", + ); +}); diff --git a/desktop/errors.ts b/desktop/errors.ts new file mode 100644 index 0000000..7c9959f --- /dev/null +++ b/desktop/errors.ts @@ -0,0 +1,125 @@ +/** + * The desktop's Rust side answers in English, and those answers are stable + * enough to serve as keys. This is the one place they become French, so the + * interface does not change language at the exact moment the reader most + * needs to understand it. + * + * Whatever a tool itself printed — git, ctrace, the platform — stays as a + * detail after the translated line: it is evidence, not interface. + */ +const EXACT: Record = { + // analysis + "Choose the installed ctrace executable in Settings first": + "Choisissez d’abord le programme ctrace dans les paramètres.", + "An analysis is already running": "Une analyse est déjà en cours.", + "This folder holds no C or C++ source files to analyse": + "Ce dossier ne contient aucun fichier source C ou C++ à analyser.", + "Choose one source file without a comma in its path": + "Choisissez un seul fichier source, sans virgule dans son chemin.", + "Analysis exceeded 15 minutes and was stopped": + "L’analyse a dépassé 15 minutes et a été arrêtée.", + "Analysis has not stopped yet; keep the window open and try again": + "L’analyse ne s’est pas encore arrêtée ; gardez la fenêtre ouverte et réessayez.", + "Choose an installed ctrace executable": + "Choisissez un programme ctrace installé.", + "Choose a JSON file": "Choisissez un fichier JSON.", + "Unknown analysis setting": "Paramètre d’analyse inconnu.", + // workspace + "Choose a folder": "Choisissez un dossier.", + "Only workspace-relative paths are allowed": + "Seuls les chemins relatifs au dossier ouvert sont autorisés.", + "Git metadata and alternate streams cannot be edited": + "Les métadonnées Git ne peuvent pas être modifiées.", + "Path leaves the selected workspace": "Ce chemin sort du dossier ouvert.", + "Git metadata cannot be edited through an alias": + "Les métadonnées Git ne peuvent pas être modifiées.", + "Choose a text file smaller than 4 MiB": + "Choisissez un fichier texte de moins de 4 Mio.", + "File exceeds 4 MiB": "Le fichier dépasse 4 Mio.", + "Binary files cannot be edited": + "Les fichiers binaires ne peuvent pas être modifiés.", + "Folder contains more than 5,000 entries; open a smaller folder": + "Ce dossier contient plus de 5 000 entrées ; ouvrez un dossier plus petit.", + "Invalid or oversized text": "Texte invalide ou trop volumineux.", + "File is read-only": "Le fichier est en lecture seule.", + "Missing parent folder": "Le dossier parent n’existe plus.", + "File changed on disk. Reopen it before saving; your draft has been kept.": + "Le fichier a changé sur le disque. Rouvrez-le avant d’enregistrer ; votre brouillon est conservé.", + "File changed while saving; your draft has been kept": + "Le fichier a changé pendant l’enregistrement ; votre brouillon est conservé.", + "Workspace changed; reopen the file": + "Le dossier a changé ; rouvrez le fichier.", + // github + "Use owner/repository or https://github.com/owner/repository": + "Indiquez propriétaire/dépôt ou https://github.com/propriétaire/dépôt.", + "Clone timed out. An incomplete folder may remain; inspect it before retrying.": + "Le clonage a expiré. Un dossier incomplet peut subsister ; vérifiez-le avant de réessayer.", + // cloud run + Cancelled: "Annulée.", + "The platform has not priced this analysis yet.": + "La plateforme n’a pas encore chiffré cette analyse.", + "No run is waiting for approval": "Aucune analyse n’attend votre accord.", + "The platform created no job": "La plateforme n’a créé aucune analyse.", + "The upload expired before it was verified.": + "L’envoi a expiré avant d’être vérifié.", + "The platform did not finish verifying the upload.": + "La plateforme n’a pas fini de vérifier l’envoi.", + "The upload is verified but names no input.": + "L’envoi est vérifié mais ne désigne aucune entrée.", + "The platform returned no upload address": + "La plateforme n’a renvoyé aucune adresse d’envoi.", + "The platform authorised no upload": + "La plateforme n’a autorisé aucun envoi.", + "The analysis is taking longer than expected; it is still running in the cloud.": + "L’analyse prend plus de temps que prévu ; elle continue dans le cloud.", + // cloud / sign-in + "Sign in to CoreTrace first": "Connectez-vous d’abord à CoreTrace.", + "Choose an organisation": "Choisissez une organisation.", + "Choose a branch, tag or commit": + "Choisissez une branche, un tag ou un commit.", + "Sign-in code expired; start again": + "Le code de connexion a expiré ; recommencez.", + "No sign-in in progress": "Aucune connexion en cours.", + "Only HTTPS links may be opened": + "Seuls les liens HTTPS peuvent être ouverts.", + "Report checksum mismatch": + "Le rapport reçu ne correspond pas à son empreinte.", + "Report exceeds 10 MiB": "Le rapport dépasse 10 Mio.", + "Report exceeds the desktop limit of 10 MiB": "Le rapport dépasse 10 Mio.", + "Report is not UTF-8": "Le rapport n’est pas en UTF-8.", + "Could not download report": "Impossible de télécharger le rapport.", + "Platform response exceeds 8 MiB": + "La réponse de la plateforme dépasse 8 Mio.", +}; + +/** Messages that carry a variable tail, matched on their head. */ +const PREFIX: [string, string][] = [ + ["Cannot start ctrace: ", "Impossible de lancer ctrace"], + ["Git clone failed.", "Le clonage Git a échoué"], + ["Git is required: ", "Git est requis"], + ["Report download failed (HTTP ", "Le téléchargement du rapport a échoué"], + ["Cannot write the archive: ", "Impossible d’écrire l’archive"], + ["Cannot add ", "Impossible d’ajouter un fichier à l’archive"], + [ + "Could not create the clone folder: ", + "Impossible de créer le dossier de clonage", + ], + [ + "No application data directory: ", + "Aucun dossier de données pour l’application", + ], + ["No configuration directory: ", "Aucun dossier de configuration"], +]; + +export function translateError(text: string): string { + const trimmed = text.trim(); + const exact = EXACT[trimmed]; + if (exact) return exact; + for (const [head, french] of PREFIX) { + if (trimmed.startsWith(head)) { + const detail = trimmed.slice(head.length).trim(); + return detail ? `${french} — ${detail}` : `${french}.`; + } + } + return trimmed; +} diff --git a/desktop/features/Analyses.test.tsx b/desktop/features/Analyses.test.tsx new file mode 100644 index 0000000..b4e3f12 --- /dev/null +++ b/desktop/features/Analyses.test.tsx @@ -0,0 +1,334 @@ +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { Analyses, Findings } from "./Analyses"; +import { ConfirmProvider } from "../components/Dialog"; +import { desktop } from "../bridge"; +import type { CloudModel } from "../useCloud"; +import type { CloudRunModel } from "../useCloudRun"; +import type { LocalResult } from "../types"; +vi.mock("../bridge", () => ({ + desktop: { + analyseCloud: vi.fn(), + readCloud: vi.fn(), + startCloudRun: vi.fn(), + cloudRunStatus: vi.fn().mockResolvedValue({ phase: "idle" }), + }, + errorMessage: (e: unknown) => String(e), + // CloudRun only polls the platform when it is running natively. + native: false, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); +const repo = { + id: "repo", + installation: "installation", + full_name: "CoreTrace/project", + default_branch: "main", + enabled: true, + external_repo_id: "1", + summary_comments: false, +}; +/** A cloud run with nothing happening, which is most of the time. */ +const idleRun = { + phase: { phase: "idle" }, + seconds: 0, + busy: false, + start: vi.fn(), + approve: vi.fn(), + cancel: vi.fn(), +} as unknown as CloudRunModel; +const cloud = { + org: "alpha", + me: { principal: { kind: "user" } }, + repositories: [repo], + jobs: [], + refresh: vi.fn(), +} as unknown as CloudModel; +function form(local: LocalResult | null = null) { + const select = vi.fn(); + const notify = vi.fn(); + render( + + ()} + markReported={vi.fn()} + login={vi.fn()} + /> + , + ); + return { select, notify }; +} +it("shows incomplete execution even when ctrace exits successfully", () => { + form({ + runId: "run-1", + exitCode: 0, + stdout: "Failed to create process", + stderr: "", + report: null, + cancelled: false, + warnings: ["Outil indisponible"], + }); + expect(screen.getByText("Terminée avec avertissements")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain("Outil indisponible"); + expect( + screen.getByText("Sortie de ctrace").parentElement?.hasAttribute("open"), + ).toBe(true); +}); +it("does not submit a paid cloud run when confirmation is dismissed", async () => { + form(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Lancer l’analyse" })); + const dialog = screen.getByRole("dialog"); + expect(dialog.textContent).toContain("CoreTrace/project · main"); + await user.click(within(dialog).getByRole("button", { name: "Annuler" })); + expect(desktop.analyseCloud).not.toHaveBeenCalled(); +}); +it("reuses the idempotency key on retry after an uncertain network failure", async () => { + vi.mocked(desktop.analyseCloud).mockRejectedValue( + new Error("Network interrupted"), + ); + const { notify } = form(); + const user = userEvent.setup(); + for (let count = 1; count <= 2; count++) { + await user.click(screen.getByRole("button", { name: "Lancer l’analyse" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Lancer l’analyse", + }), + ); + await waitFor(() => expect(notify).toHaveBeenCalledTimes(count)); + } + const calls = vi.mocked(desktop.analyseCloud).mock.calls; + expect(calls[0].slice(0, 5)).toEqual([ + "alpha", + "installation", + "repo", + "main", + false, + ]); + expect(calls[0][5]).toBe(calls[1][5]); +}); +it("renders report messages as text and sends a location to the editor", async () => { + const open = vi.fn(); + render( + ", + }, + ]} + open={open} + />, + ); + expect(document.querySelector("img")).toBeNull(); + // The whole row is the target now; its name carries the location first. + await userEvent.click( + screen.getByRole("button", { name: /src\/main\.c:14/ }), + ); + expect(open).toHaveBeenCalledWith("src/main.c", 14); +}); + +it("opens the analysis that already covers the commit", async () => { + // The platform answers 409 naming the job that already exists. Reporting only + // "Conflict (HTTP 409)" threw that away and left the reader with nowhere to go. + const existing = { + id: "job-9", + status: "succeeded", + created_at: "2026-01-01T00:00:00Z", + runs: [], + }; + vi.mocked(desktop.analyseCloud).mockResolvedValue({ + existing_job: "job-9", + } as never); + vi.mocked(desktop.readCloud).mockResolvedValue(existing as never); + const { select, notify } = form(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Lancer l’analyse" })); + await user.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Lancer l’analyse", + }), + ); + + await waitFor(() => expect(select).toHaveBeenCalledWith(existing)); + expect(notify).toHaveBeenCalledWith( + expect.stringContaining("déjà été analysé"), + ); +}); + +it("counts what the search actually shows", async () => { + // The heading counted every finding while the list showed the filtered ones, + // so searching left a number that described nothing on screen. + render( + , + ); + expect(screen.getByRole("heading", { name: /Résultats 3/ })).toBeDefined(); + await userEvent.type( + screen.getByLabelText("Filtrer les résultats"), + "overflow", + ); + expect(screen.getByRole("heading", { name: /1 sur 3/ })).toBeDefined(); +}); + +it("marks a level with its letter, not its name", () => { + render( + , + ); + const mark = screen.getByLabelText("Erreur"); + expect(mark.textContent).toBe("E"); +}); + +it("loads the reports of a job opened from the history", async () => { + // The listing carries no runs, so a job picked from the history arrives with + // an empty run list and the detail follows a moment later. Fetching reports + // only on the first render left every past analysis reading "Résultats 0". + const listed = { + id: "job-1", + status: "completed", + conclusion: "findings", + created_at: "2026-09-01T10:00:00Z", + runs: [], + }; + const detailed = { + ...listed, + runs: [{ id: "run-1", tool: "ctrace", execution_status: "finished" }], + }; + vi.mocked(desktop.readCloud).mockResolvedValue(detailed as never); + const report = vi.fn().mockResolvedValue( + JSON.stringify({ + findings: [ + { + rule_id: "uninitvar", + level: "warning", + message: "Uninitialized variable: name", + location: { path: "src/main.c", line: 11 }, + }, + ], + }), + ); + (desktop as unknown as { report: unknown }).report = report; + + render( + + ()} + markReported={vi.fn()} + login={vi.fn()} + /> + , + ); + + await waitFor(() => + expect(report).toHaveBeenCalledWith("alpha", "job-1", "run-1"), + ); + await screen.findByText("Uninitialized variable: name"); +}); + +it("the one button starts whatever analysis the application decides", async () => { + const newAnalysis = vi.fn(); + render( + + ()} + markReported={vi.fn()} + login={vi.fn()} + /> + , + ); + await userEvent.click( + screen.getByRole("button", { name: /Nouvelle analyse/ }), + ); + expect(newAnalysis).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/features/Analyses.tsx b/desktop/features/Analyses.tsx new file mode 100644 index 0000000..10fce41 --- /dev/null +++ b/desktop/features/Analyses.tsx @@ -0,0 +1,820 @@ +import { useEffect, useMemo, useState } from "react"; +import { CloudRun } from "./CloudRun"; +import { + ArrowLeft, + FileCode2, + LoaderCircle, + Play, + Search, + Square, + Send, +} from "lucide-react"; +import { desktop, errorMessage } from "../bridge"; +import { useConfirm } from "../components/Dialog"; +import { + billed, + date, + duration, + number, + outcome, + parseFindings, + terminal, + typicalSeconds, +} from "../model"; +import type { CloudModel } from "../useCloud"; +import type { CloudRunModel } from "../useCloudRun"; +import type { Finding, Job, LocalResult, LocalRun, Repository } from "../types"; +import { JobRows } from "./Dashboard"; +import { ReportDialog } from "./ReportDialog"; + +/** The first line of a tool's output that looks like the reason it stopped. */ +function firstProblem(local: LocalResult): string | undefined { + const lines = [local.stderr ?? "", local.stdout ?? ""] + .join("\n") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return lines.find((line) => + /error|fatal|not found|cannot|failed/i.test(line), + ); +} + +/** Section titles when the findings are grouped by severity. */ +const GROUP: Record = { + error: "Erreurs", + warning: "Avertissements", + note: "Notes", + info: "Informations", + other: "Autres", +}; + +/** Reports of finished runs, which are immutable once written. */ +const reportCache = new Map(); + +/** The letter and colour a level gets, as the web application shows them. */ +const MARK: Record = { + error: { letter: "E", label: "Erreur" }, + warning: { letter: "W", label: "Avertissement" }, + note: { letter: "N", label: "Note" }, + info: { letter: "N", label: "Information" }, +}; + +export function Findings({ + findings, + open, + loading = false, +}: { + findings: Finding[]; + open: (path: string, line: number) => void; + /** Reports are fetched per run; placeholders stand in until they arrive. */ + loading?: boolean; +}) { + const [search, setSearch] = useState(""); + const filtered = findings.filter((f) => + `${f.message} ${f.rule} ${f.path}` + .toLowerCase() + .includes(search.toLowerCase()), + ); + return ( +
+
+

+ Résultats{" "} + + {loading + ? "…" + : search.trim() + ? `${filtered.length} sur ${findings.length}` + : findings.length} + +

+ +
+ {loading && + [0, 1, 2].map((i) => ( + + ))} + {!loading && + // Grouped by severity: a reader scanning a long list wants the errors + // first, and the level was otherwise only a small mark on each row. + ( + [ + ...["error", "warning", "note", "info"].map( + (level): [string, Finding[]] => [ + level, + filtered.filter((f) => f.level === level), + ], + ), + [ + "other", + filtered.filter( + (f) => !["error", "warning", "note", "info"].includes(f.level), + ), + ] as [string, Finding[]], + ] as [string, Finding[]][] + ) + .filter(([, group]) => group.length > 0) + .map(([level, group]) => ( +
+

+ {GROUP[level] ?? level} · {group.length} +

+ {group.map((f, i) => ( + + ))} +
+ ))} + {!filtered.length && ( +

+ {findings.length + ? "Aucun résultat ne correspond au filtre." + : "Aucun problème signalé dans les rapports chargés."} +

+ )} +
+ ); +} + +function JobDetail({ + initial, + cloud, + close, + notify, + open, + rerun, +}: { + initial: Job; + cloud: CloudModel; + close: () => void; + notify: (message: string) => void; + open: (path: string, line: number) => void; + rerun: (repo: Repository, ref: string) => void; +}) { + const [job, setJob] = useState(initial); + const [findings, setFindings] = useState([]); + const [reportErrors, setReportErrors] = useState([]); + const [loading, setLoading] = useState(false); + const [pollError, setPollError] = useState(""); + const confirm = useConfirm(); + useEffect(() => { + let active = true; + let timer: ReturnType; + const poll = async () => { + try { + const next = await desktop.readCloud("job", cloud.org, initial.id); + if (!active) return; + setJob(next); + setPollError(""); + if (!terminal(next)) timer = setTimeout(poll, 2500); + else void cloud.refresh(); + } catch (e) { + if (active) { + setPollError(errorMessage(e)); + timer = setTimeout(poll, 5000); + } + } + }; + void poll(); + return () => { + active = false; + clearTimeout(timer); + }; + }, [initial.id, cloud.org]); + const finished = terminal(job); + const runKey = job.runs.map((run) => run.id).join(","); + useEffect(() => { + if (!finished) return; + let active = true; + setLoading(true); + setReportErrors([]); + void Promise.allSettled( + job.runs.map(async (run) => { + // A finished run's report never changes, so fetching it again on every + // visit only adds two round trips to the platform and its storage. + const key = `${job.id}/${run.id}`; + let text = reportCache.get(key); + if (text === undefined) { + text = await desktop.report(cloud.org, job.id, run.id); + reportCache.set(key, text); + } + return parseFindings(text, run.tool, "cloud"); + }), + ).then((results) => { + if (!active) return; + setFindings( + results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])), + ); + setReportErrors( + results.flatMap((r, i) => + r.status === "rejected" + ? [`${job.runs[i].tool} : ${errorMessage(r.reason)}`] + : [], + ), + ); + setLoading(false); + }); + return () => { + active = false; + }; + // The runs matter, not just the job: the listing carries none, so a job + // opened from the history starts with an empty run list and its reports + // would never be fetched once the detail arrived. + }, [finished, job.id, cloud.org, runKey]); + const repository = cloud.repositories.find( + (r) => r.full_name === job.source?.repo_full_name, + ); + const cancel = async () => { + if ( + !(await confirm( + "Annuler cette analyse ?", + "Les CTU déjà consommés restent facturés.", + "Arrêter l’analyse", + )) + ) + return; + try { + await desktop.cancelCloud(cloud.org, job.id); + setJob(await desktop.readCloud("job", cloud.org, job.id)); + } catch (e) { + notify(errorMessage(e)); + } + }; + return ( + <> + +
+
+

{job.source?.repo_full_name ?? job.label ?? "Analyse cloud"}

+

+ {job.source?.ref ?? job.source?.commit_sha} · {date(job.created_at)} +

+
+ + {outcome(job)} + +
+
+ {number(billed(job))} CTU facturés + {duration(job)} + + {job.runs.filter((r) => r.finished_at).length} / {job.runs.length}{" "} + outils terminés + + {!finished && ( + + )} + {finished && repository?.enabled && ( + + )} +
+ {pollError && ( +

+ {pollError} · Nouvelle tentative automatique. +

+ )} + {job.rejection_reason && ( +

+ La plateforme a refusé l’analyse : {job.rejection_reason} +

+ )} + {job.conclusion === "capped" && ( +

+ Le plafond CTU a été atteint. Les résultats peuvent être incomplets. +

+ )} + {!finished && ( +
+ + L’analyse est en cours. Les résultats seront chargés à sa fin. +
+ )} + + {reportErrors.map((error) => ( +

+ {error} +

+ ))} + {finished && + (loading || + findings.length > 0 || + (!reportErrors.length && job.runs.length > 0)) && ( + + )} +
+ Exécution des outils · {job.runs.length} + {job.runs.map((run) => ( +
+ {run.tool} + {run.execution_outcome ?? run.execution_status} + {number(run.billed_ctu)} CTU + {run.execution_outcome === "tool_error" && ( +

+ L’outil a échoué. Consultez son rapport lorsqu’il est + disponible. +

+ )} +
+ ))} +
+ + ); +} + +export function Analyses({ + cloud, + selected, + select, + initialRepository, + initialRef, + clearDraft, + notify, + openFinding, + local, + localRunning, + openWorkspace, + workspaceRoot, + workspaceName, + newAnalysis, + cloudRun, + localHistory, + showLocalRun, + workspaceId, + reportedRuns, + markReported, + login, +}: { + cloud: CloudModel; + selected: Job | null; + select: (job: Job | null) => void; + initialRepository: Repository | null; + initialRef: string; + clearDraft: () => void; + notify: (message: string) => void; + openFinding: (path: string, line: number) => void; + local: LocalResult | null; + localRunning: boolean; + openWorkspace: () => void; + /** The folder open in the editor, when there is one: what a cloud run sends. */ + workspaceRoot?: string; + workspaceName?: string; + /** Starts an analysis of the open folder: cloud when possible, this machine + otherwise. Owned by the application, so every button does the same. */ + newAnalysis: () => void; + /** Local runs of the open folder, newest first. */ + localHistory: LocalRun[]; + /** Opens one of them where the last local result is shown. */ + showLocalRun: (run: LocalRun) => void; + workspaceId?: string; + /** Runs whose failure has already been reported to the team. */ + reportedRuns: Set; + markReported: (runId: string) => void; + /** Opens the sign-in; a report needs a signed-in person. */ + login: () => void; + /** The one cloud run, owned above the pages. */ + cloudRun: CloudRunModel; +}) { + const [repositoryId, setRepositoryId] = useState(initialRepository?.id ?? ""); + const [reference, setReference] = useState( + initialRef || initialRepository?.default_branch || "", + ); + const [rerun, setRerun] = useState(!!initialRef); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [running, setRunning] = useState(false); + const [filter, setFilter] = useState(""); + const [status, setStatus] = useState(""); + const confirm = useConfirm(); + const [reporting, setReporting] = useState(false); + const localReport = useMemo(() => { + try { + return { + findings: local?.report + ? parseFindings(local.report, undefined, "local") + : [], + error: "", + }; + } catch (e) { + return { findings: [], error: errorMessage(e) }; + } + }, [local]); + useEffect(() => { + if (initialRepository) { + setRepositoryId(initialRepository.id); + setReference(initialRef || initialRepository.default_branch); + setRerun(!!initialRef); + setRequestId(crypto.randomUUID()); + } + }, [initialRepository, initialRef]); + const run = async () => { + const repo = cloud.repositories.find((r) => r.id === repositoryId); + if (!repo || !reference.trim()) return; + if ( + !(await confirm( + rerun + ? "Relancer cette analyse cloud ?" + : "Lancer cette analyse cloud ?", + `${repo.full_name} · ${reference}. La plateforme analysera le commit distant et débitera les CTU de ${cloud.org}, selon sa configuration et ses plafonds. Les modifications locales ne seront pas envoyées.`, + "Lancer l’analyse", + )) + ) + return; + setRunning(true); + try { + const job = await desktop.analyseCloud( + cloud.org, + repo.installation, + repo.id, + reference.trim(), + rerun, + requestId, + ); + // The commit already had an analysis and the platform named it. Opening it + // is what the reader asked for; a bare conflict left them stuck. + const existing = (job as { existing_job?: string }).existing_job; + if (existing) { + notify( + "Ce commit a déjà été analysé. Voici le résultat ; utilisez « Relancer » pour l’analyser à nouveau.", + ); + select(await desktop.readCloud("job", cloud.org, existing)); + } else { + select(job); + } + setRequestId(crypto.randomUUID()); + clearDraft(); + void cloud.refresh(); + } catch (e) { + notify(errorMessage(e)); + } finally { + setRunning(false); + } + }; + const prepareRerun = (repo: Repository, ref: string) => { + setRepositoryId(repo.id); + setReference(ref); + setRerun(true); + setRequestId(crypto.randomUUID()); + select(null); + }; + return ( +
+ {selected ? ( + select(null)} + notify={notify} + open={openFinding} + rerun={prepareRerun} + /> + ) : ( + <> +
+
+
COMPRENDRE & CORRIGER
+

Analyses

+

Exécutez les outils CoreTrace et retrouvez leurs résultats.

+
+ +
+ {workspaceRoot && cloud.org && ( + + )} + {(initialRepository || rerun) && ( +
+
+

Analyser un dépôt

+ + {cloud.org || "Connexion requise"} + +
+
+ + + +
+ +

+ Les outils sont définis par la configuration du dépôt sur la + plateforme. Chaque exécution peut consommer des CTU. +

+ {!cloud.repositories.some((r) => r.enabled) && ( +

+ Connectez et activez un dépôt depuis la page Dépôts pour + lancer une analyse cloud. +

+ )} +
+ )} + {local && ( +
+
+

Dernière analyse locale

+ + {local.cancelled + ? "Annulée" + : local.warnings?.length + ? "Terminée avec avertissements" + : local.exitCode === 0 + ? "Terminée" + : "Échouée"} + + {(local.warnings?.length || local.exitCode !== 0) && + (cloud.me ? ( + + ) : ( + + ))} +
+ {local.warnings?.map((warning) => ( +

+ {warning} +

+ ))} + {local.report && !localReport.error ? ( + + ) : ( +

+ ctrace n’a produit aucun rapport + {local.exitCode !== 0 && local.exitCode !== undefined + ? ` (code de sortie ${local.exitCode})` + : ""} + .{" "} + {firstProblem(local) ?? + "Vérifiez le programme ctrace et la base de compilation dans les paramètres."} +

+ )} + {localReport.error && ( +

+ {localReport.error} +

+ )} +
+ Sortie de ctrace +
+                  {local.stdout}
+                  {"\n"}
+                  {local.stderr}
+                
+
+ {reporting && workspaceId && ( + setReporting(false)} + onSent={() => { + setReporting(false); + markReported(local.runId); + notify("Rapport envoyé, merci."); + }} + /> + )} +
+ )} + {localHistory.length > 0 && ( + <> +
+

Sur cette machine

+ + {localHistory.length} analyse + {localHistory.length > 1 ? "s" : ""} locale + {localHistory.length > 1 ? "s" : ""} + +
+
+ {localHistory.map((run) => ( + + ))} +
+ + )} +
+

Historique de l’organisation

+
+ setFilter(e.target.value)} + /> + +
+
+ + (job.source?.repo_full_name ?? job.label ?? "") + .toLowerCase() + .includes(filter.toLowerCase()) && + (!status || + (status === "running" + ? !terminal(job) + : job.status === status)), + )} + select={select} + /> + {cloud.jobs.length >= 200 && ( +

+ Affichage des 200 analyses les plus récentes. +

+ )} + + )} +
+ ); +} diff --git a/desktop/features/CloudRun.test.tsx b/desktop/features/CloudRun.test.tsx new file mode 100644 index 0000000..8fa75dd --- /dev/null +++ b/desktop/features/CloudRun.test.tsx @@ -0,0 +1,120 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { desktop } from "../bridge"; +import { CloudRun } from "./CloudRun"; +import { useCloudRun } from "../useCloudRun"; + +vi.mock("../bridge", () => ({ + native: true, + desktop: { + cloudRunStatus: vi.fn(), + startCloudRun: vi.fn(() => Promise.resolve()), + confirmCloudRun: vi.fn(() => Promise.resolve()), + cancelCloudRun: vi.fn(() => Promise.resolve()), + }, + errorMessage: (e: unknown) => String(e), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +/** The panel with the run that drives it, as the application assembles them. */ +function Harness({ + onFinished, + workspace = "C:/work/app", +}: { + onFinished: (job: string) => void; + workspace?: string; +}) { + const run = useCloudRun({ notify: vi.fn(), onFinished }); + return ; +} + +function show(onFinished = vi.fn()) { + render(); + return onFinished; +} + +it("shows the cost and leaves the decision to the notice", async () => { + // The platform quotes the run and parks it. The panel says what it costs; + // the one place to accept or refuse is the notice, so nothing here spends. + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "quoted", + job: "job-1", + ctu: 4000, + deadline: "2030-01-01T00:00:00Z", + }); + show(); + const price = await screen.findByText(/4\s?000 CTU/); + expect(price.textContent).toContain("notification"); + expect(screen.queryByRole("button", { name: /Lancer/ })).toBeNull(); + expect(desktop.confirmCloudRun).not.toHaveBeenCalled(); +}); + +it("can be cancelled while it is packing", async () => { + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "packing", + files: 12, + bytes: 4096, + }); + show(); + await userEvent.click(await screen.findByRole("button", { name: /Annuler/ })); + expect(desktop.cancelCloudRun).toHaveBeenCalled(); +}); + +it("says what the platform said when a run fails", async () => { + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "failed", + reason: "The archive is too large: 900 bytes, and this plan allows 10.", + }); + show(); + expect(await screen.findByText(/this plan allows 10/)).toBeDefined(); +}); + +it("says whether a cancelled run had already spent anything", async () => { + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "cancelled", + spent: false, + }); + show(); + expect(await screen.findByText(/Aucun CTU/)).toBeDefined(); +}); + +it("offers the run when nothing is happening", async () => { + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ phase: "idle" }); + show(); + await userEvent.click( + await screen.findByRole("button", { name: /Analyser dans le cloud/ }), + ); + expect(desktop.startCloudRun).toHaveBeenCalledWith("C:/work/app", "alpha"); +}); + +it("opens the results once when the run finishes", async () => { + // The panel said "results below" and did nothing, so a finished analysis + // showed an empty page. Opening it is what that sentence promised. + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "done", + job: "job-7", + }); + const onFinished = show(); + await waitFor(() => expect(onFinished).toHaveBeenCalledWith("job-7")); + await new Promise((r) => setTimeout(r, 1100)); + expect(onFinished).toHaveBeenCalledTimes(1); +}); + +it("announces a finished run once however long it keeps reporting itself", async () => { + // The status stays "done" and is polled every second. Announcing it each time + // would reopen the analysis under the reader as they worked. + vi.mocked(desktop.cloudRunStatus).mockResolvedValue({ + phase: "done", + job: "job-42", + }); + const onFinished = vi.fn(); + render(); + await waitFor(() => expect(onFinished).toHaveBeenCalledTimes(1)); + await new Promise((r) => setTimeout(r, 2200)); + expect(onFinished).toHaveBeenCalledTimes(1); +}); diff --git a/desktop/features/CloudRun.tsx b/desktop/features/CloudRun.tsx new file mode 100644 index 0000000..74cc18f --- /dev/null +++ b/desktop/features/CloudRun.tsx @@ -0,0 +1,123 @@ +import { Cloud, CloudUpload, Loader2, XCircle } from "lucide-react"; +import { + type CloudRunModel, + describe, + elapsed, + running as active, +} from "../useCloudRun"; + +/** + * Analyses the open folder in the cloud. The run stops at its price: the + * platform quotes it and nothing is spent until the reader approves, which is + * why the confirmation is a separate click and not a spinner. + */ +/** + * Reports the cloud run and offers its decisions. The run itself is owned above + * the pages, so leaving this tab does not abandon it. + * + * The run stops at its price: the platform quotes it and nothing is spent until + * the reader approves, which is why the confirmation is a separate click and + * not a spinner. + */ +export function CloudRun({ + run, + workspace, + org, + typical, + showStart = true, +}: { + run: CloudRunModel; + workspace: string; + org: string; + /** Seconds a cloud analysis usually takes here, measured from finished jobs. + Undefined when too few have run to say anything. */ + typical?: number; + /** Whether this panel offers its own way to start a run. When the page has + one button for every kind of analysis, this panel only reports progress + and stays out of sight until there is some. */ + showStart?: boolean; +}) { + const { phase, seconds, busy } = run; + const startRun = () => void run.start(workspace, org); + const stop = () => void run.cancel(org); + + // Nothing has happened and this panel cannot start anything: showing an + // empty box headed "Analyser dans le cloud" only competes with the button + // that does. + if (!showStart && phase.phase === "idle") return null; + + return ( +
+

Analyser dans le cloud

+ {showStart && ( +

+ Le dossier ouvert est envoyé à la plateforme et analysé avec vos CTU. + Rien n’est débité tant que vous n’avez pas accepté le devis. +

+ )} + + {showStart && phase.phase === "idle" && ( + + )} + + {active(phase) && phase.phase !== "running" && ( +

+ {describe(phase, seconds)} +

+ )} + + {phase.phase === "quoted" && ( +

+ {phase.ctu.toLocaleString("fr-FR")} CTU pour cette + analyse. Rien n’a encore été débité : acceptez ou refusez depuis la + notification. +

+ )} + + {phase.phase === "running" && ( +

+ {" "} + {describe(phase, seconds, typical)} + {typical !== undefined && ( + + {" "} + (d’après vos analyses précédentes, ~{elapsed(typical)}) + + )} +

+ )} + {phase.phase === "done" && ( +

+ Analyse terminée. Ouvrez les résultats depuis la notification. +

+ )} + {phase.phase === "failed" && ( +

+ {phase.reason} +

+ )} + {phase.phase === "cancelled" && ( +

+ Analyse annulée.{" "} + {phase.spent + ? "Elle avait déjà été lancée : les CTU réservés suivent les règles de la plateforme." + : "Aucun CTU n’a été débité."} +

+ )} + + {(active(phase) || phase.phase === "quoted") && ( + + )} +
+ ); +} diff --git a/desktop/features/Dashboard.test.tsx b/desktop/features/Dashboard.test.tsx new file mode 100644 index 0000000..b5d6947 --- /dev/null +++ b/desktop/features/Dashboard.test.tsx @@ -0,0 +1,250 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import { Dashboard } from "./Dashboard"; +import type { CloudModel } from "../useCloud"; +import type { Job, Workspace } from "../types"; + +afterEach(cleanup); + +const workspace: Workspace = { id: "w1", name: "engine", path: "/work/engine" }; + +function cloud(overrides: Partial = {}): CloudModel { + return { + me: null, + org: "", + jobs: [], + limits: null, + ...overrides, + } as unknown as CloudModel; +} + +function handlers() { + return { + navigate: vi.fn(), + openFolder: vi.fn(), + clone: vi.fn(), + analyse: vi.fn(), + login: vi.fn(), + selectJob: vi.fn(), + }; +} + +it("invites a signed-out reader to connect, rather than showing a quota it cannot know", () => { + const fns = handlers(); + render(); + expect(screen.getByText("Retrouvez votre espace CoreTrace")).toBeDefined(); + expect(screen.queryByText("CTU disponibles")).toBeNull(); +}); + +it("asks to connect, and nothing else, when signed out", async () => { + const fns = handlers(); + render(); + await userEvent.click(screen.getByRole("button", { name: /Se connecter/ })); + expect(fns.login).toHaveBeenCalledTimes(1); +}); + +it("reads the organisation's usage off the limits the platform sent", () => { + const fns = handlers(); + render( + , + ); + expect(screen.getByText("Bonjour, Ada.", { exact: false })).toBeDefined(); + expect(screen.getByText("4 000")).toBeDefined(); // remaining CTU + expect(screen.getByText("sur 10 000 CTU pour cette période")).toBeDefined(); + expect(screen.getByText("Team")).toBeDefined(); +}); + +it("shows placeholders rather than a stale or invented figure while limits have not loaded", () => { + const fns = handlers(); + render( + , + ); + // Every figure falls back to a dash: getNodeText only reads a node's own + // text children, so "— CTU" (with the nested ) collapses to "—" too. + expect(screen.getAllByText("—", { selector: "strong" })).toHaveLength(3); + expect( + screen.getByText("Allocation de période non communiquée"), + ).toBeDefined(); + expect( + screen.getByText("Connexion à la plateforme nécessaire"), + ).toBeDefined(); +}); + +it("warns only once the remaining budget drops under a tenth of the allowance", () => { + const fns = handlers(); + const notice = "Votre quota est presque épuisé"; + const { rerender } = render( + , + ); + expect(screen.queryByText(notice, { exact: false })).toBeNull(); + + rerender( + , + ); + expect(screen.getByText(notice, { exact: false })).toBeDefined(); +}); + +it("offers to open, clone or analyse when no folder is open, and nothing to resume", () => { + const fns = handlers(); + render(); + expect(screen.queryByText(/Reprendre/)).toBeNull(); + expect(screen.getByText("Ouvrir un dossier")).toBeDefined(); + expect(screen.getByText("Cloner un dépôt GitHub")).toBeDefined(); +}); + +it("lets an open folder be resumed or analysed directly, hiding the picker", async () => { + const fns = handlers(); + render(); + expect(screen.queryByText("Ouvrir un dossier")).toBeNull(); + + await userEvent.click( + screen.getByRole("button", { name: /Analyser engine/ }), + ); + expect(fns.analyse).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole("button", { name: /Reprendre/ })); + expect(fns.navigate).toHaveBeenCalledWith("workspace"); +}); + +it("shows an empty state until an analysis exists", () => { + const fns = handlers(); + render(); + expect(screen.getByText("Aucune analyse à afficher.")).toBeDefined(); +}); + +it("names a job by its repository, falling back to its label, then to imported sources", () => { + const fns = handlers(); + const jobs = [ + { + id: "1", + status: "completed", + conclusion: "findings", + created_at: "2026-09-01T10:00:00Z", + source: { kind: "scm", repo_full_name: "CoreTrace/gui" }, + runs: [], + }, + { + id: "2", + status: "completed", + conclusion: "clean", + created_at: "2026-09-01T10:00:00Z", + label: "uploaded.zip", + runs: [], + }, + { + id: "3", + status: "completed", + conclusion: "clean", + created_at: "2026-09-01T10:00:00Z", + runs: [], + }, + { + // Carries both a repository and a label: the repository must win, since + // it is the more specific of the two. + id: "4", + status: "completed", + conclusion: "clean", + created_at: "2026-09-01T10:00:00Z", + source: { kind: "scm", repo_full_name: "Both/repo" }, + label: "should-be-ignored", + runs: [], + }, + ] as unknown as Job[]; + render(); + expect(screen.getByText("CoreTrace/gui")).toBeDefined(); + expect(screen.getByText("uploaded.zip")).toBeDefined(); + expect(screen.getByText("Sources importées")).toBeDefined(); + expect(screen.getByText("Both/repo")).toBeDefined(); + expect(screen.queryByText("should-be-ignored")).toBeNull(); +}); + +it("hands the selected job back, and only that one, when a row is clicked", async () => { + const fns = handlers(); + const jobs = [ + { + id: "1", + status: "completed", + conclusion: "clean", + created_at: "2026-09-01T10:00:00Z", + label: "first", + runs: [], + }, + { + id: "2", + status: "completed", + conclusion: "clean", + created_at: "2026-09-02T10:00:00Z", + label: "second", + runs: [], + }, + ] as unknown as Job[]; + render(); + await userEvent.click(screen.getByText("second")); + expect(fns.selectJob).toHaveBeenCalledWith(jobs[1]); + expect(fns.selectJob).toHaveBeenCalledTimes(1); +}); + +it("only ever shows the ten most recent jobs", () => { + const fns = handlers(); + const jobs = Array.from({ length: 12 }, (_, i) => ({ + id: String(i), + status: "completed", + conclusion: "clean", + created_at: "2026-09-01T10:00:00Z", + label: `job-${i}`, + runs: [], + })) as unknown as Job[]; + render(); + expect(screen.getAllByRole("button", { name: /job-/ })).toHaveLength(10); +}); diff --git a/desktop/features/Dashboard.tsx b/desktop/features/Dashboard.tsx new file mode 100644 index 0000000..0822360 --- /dev/null +++ b/desktop/features/Dashboard.tsx @@ -0,0 +1,247 @@ +import { + ArrowRight, + Building2, + FolderOpen, + GitBranch, + ShieldCheck, + Play, + Sparkles, +} from "lucide-react"; +import type { CloudModel } from "../useCloud"; +import type { Job, Page, Workspace } from "../types"; +import { + accessLabel, + billed, + date, + number, + outcome, + roleLabel, + usage, +} from "../model"; + +export function JobRows({ + jobs, + select, +}: { + jobs: Job[]; + select: (job: Job) => void; +}) { + if (!jobs.length) + return ( +
+ +

Aucune analyse à afficher.

+ + Vos prochains résultats apparaîtront ici. + +
+ ); + return ( +
+ {jobs.map((job) => ( + + ))} +
+ ); +} +export function Dashboard({ + cloud, + workspace, + navigate, + openFolder, + clone, + analyse, + login, + selectJob, +}: { + cloud: CloudModel; + workspace: Workspace | null; + navigate: (page: Page) => void; + openFolder: () => void; + clone: () => void; + /** Starts an analysis of the open folder, or opens one when none is. */ + analyse: () => void; + login: () => void; + selectJob: (job: Job) => void; +}) { + const summary = cloud.limits ? usage(cloud.limits) : null; + const allowance = summary?.allowance; + const fraction = + allowance && summary + ? Math.min(100, (summary.remaining / allowance) * 100) + : 0; + const member = cloud.me?.orgs.find((o) => o.slug === cloud.org); + return ( +
+
+ + VOTRE ESPACE DE TRAVAIL +
+
+
+

+ {`Bonjour${cloud.me?.principal.name ? `, ${cloud.me.principal.name}` : ""}.`} +
+ Que souhaitez-vous analyser ? +

+

+ Ouvrez votre code. Lancez une analyse. Comprenez chaque résultat. +

+
+ {workspace && ( + + )} +
+ {!cloud.me && ( +
+
+ Retrouvez votre espace CoreTrace +

+ Connectez-vous pour accéder aux organisations, aux quotas et aux + analyses cloud. +

+
+ +
+ )} + {cloud.me && ( + <> +
+

Votre organisation

+ +
+
+
+ CTU disponibles + {summary ? number(summary.remaining) : "—"} +
+ +
+ + {allowance != null + ? `sur ${number(allowance)} CTU pour cette période` + : "Allocation de période non communiquée"} + +
+
+ Consommation de la période + + {summary?.used != null ? number(summary.used) : "—"}{" "} + CTU + + + {cloud.limits + ? `Renouvellement le ${new Date(cloud.limits.period_ends_at).toLocaleDateString("fr-FR")}` + : "Connexion à la plateforme nécessaire"} + +
+
+ Plan actuel + {cloud.limits?.plan ?? "—"} + + {" "} + {member + ? `${roleLabel(member.role)} · ${accessLabel(member.access_state)}` + : "Aucune organisation sélectionnée"} + +
+
+ {summary && + allowance != null && + summary.remaining < allowance / 10 && ( +
+ Votre quota est presque épuisé. Consultez les limites de votre + organisation avant de relancer une analyse. +
+ )} + + )} + {!workspace && ( + <> +
+ + + +
+ + )} + {workspace && ( + + )} +
+
+

Analyses récentes

+

+ {cloud.me + ? "Les derniers résultats de l’organisation sélectionnée." + : "Connectez-vous pour retrouver votre historique."} +

+
+ +
+ +
+ ); +} diff --git a/desktop/features/ReportDialog.test.tsx b/desktop/features/ReportDialog.test.tsx new file mode 100644 index 0000000..65afdc3 --- /dev/null +++ b/desktop/features/ReportDialog.test.tsx @@ -0,0 +1,92 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, expect, it, vi } from "vitest"; +import { desktop } from "../bridge"; +import { ReportDialog } from "./ReportDialog"; + +vi.mock("../bridge", () => ({ + native: true, + desktop: { + supportCandidates: vi.fn(() => + Promise.resolve([ + { name: "CMakeLists.txt", bytes: 120 }, + { name: "Makefile", bytes: 80 }, + ]), + ), + supportReadFile: vi.fn((_: string, name: string) => + Promise.resolve(`content of ${name}`), + ), + supportSend: vi.fn(() => Promise.resolve("report-1")), + supportMarkReported: vi.fn(() => Promise.resolve()), + }, + errorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)), +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; +}); +const local = { + runId: "run-1", + exitCode: 0, + cancelled: false, + report: null, + stdout: + "|1| == CoreTrace == [WARN] (flawfinder) Failed, so this file is unanalysed by it.\nError: python: can't open file\n", + stderr: "", + warnings: ["Un ou plusieurs outils n’ont pas pu terminer l’analyse."], +}; + +it("lists the build files ticked, states the privacy promise, and sends", async () => { + const onSent = vi.fn(); + render( + , + ); + const cmake = (await screen.findByLabelText( + /CMakeLists\.txt/, + )) as HTMLInputElement; + expect(cmake.checked).toBe(true); + expect(screen.getByText(/Ces données restent privées/)).toBeDefined(); + await userEvent.type(screen.getByLabelText(/Quelles librairies/), "SDL2"); + await userEvent.click(screen.getByLabelText(/Makefile/)); // untick + await userEvent.click(screen.getByRole("button", { name: /Envoyer/ })); + await waitFor(() => expect(onSent).toHaveBeenCalledWith("report-1")); + const sent = vi.mocked(desktop.supportSend).mock.calls[0][0]; + expect(sent.tools).toEqual(["flawfinder"]); + expect(sent.libraries).toBe("SDL2"); + expect(sent.files.map((f) => f.name)).toEqual(["CMakeLists.txt"]); + expect(sent.log).toContain("can't open file"); + expect(desktop.supportMarkReported).toHaveBeenCalledWith("w1", "run-1"); +}); + +it("shows the platform's refusal in place", async () => { + vi.mocked(desktop.supportSend).mockRejectedValueOnce( + new Error( + "Vous avez atteint la limite de rapports. Réessayez dans 3 heures.", + ), + ); + render( + , + ); + await screen.findByLabelText(/CMakeLists\.txt/); + await userEvent.click(screen.getByRole("button", { name: /Envoyer/ })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("3 heures"); +}); diff --git a/desktop/features/ReportDialog.tsx b/desktop/features/ReportDialog.tsx new file mode 100644 index 0000000..a120b27 --- /dev/null +++ b/desktop/features/ReportDialog.tsx @@ -0,0 +1,226 @@ +import { Send } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { desktop, errorMessage } from "../bridge"; +import { Dialog } from "../components/Dialog"; +import type { LocalResult } from "../types"; + +/** What the platform accepts: log plus files. */ +const CAP = 2 * 1024 * 1024; +const LOG_LIMIT = 512 * 1024; +const DESKTOP_VERSION = "6.0.0-beta.1"; + +/** The tools ctrace said did not complete, from its own summary lines. */ +export function failedTools(output: string): string[] { + const tools: string[] = []; + for (const line of output.split("\n")) { + const m = /== CoreTrace == \[WARN\] \(([^)]+)\)/.exec(line); + if (m && !tools.includes(m[1])) tools.push(m[1]); + } + return tools.length ? tools : ["ctrace"]; +} + +/** The first line that reads like the reason; tells one failure from another. */ +function signature(output: string): string { + const line = + output + .split("\n") + .map((l) => l.trim()) + .find((l) => /error|failed|not found|cannot/i.test(l)) ?? ""; + return line.slice(0, 200); +} + +/** Head and tail past the limit: the start says what ran, the end how it ended. */ +function truncate(log: string): string { + if (log.length <= LOG_LIMIT) return log; + const half = LOG_LIMIT / 2; + return `${log.slice(0, half)}\n[… journal tronqué : ${log.length - LOG_LIMIT} caractères omis …]\n${log.slice(-half)}`; +} + +function kilobytes(n: number): string { + return n < 1024 ? `${n} o` : `${Math.round(n / 1024)} Ko`; +} + +/** + * Asks what the team needs to reproduce a failed tool, says plainly what will + * be sent and that it stays private, and sends it. One report per run: the + * caller disables the way in once `onSent` has fired. + */ +export function ReportDialog({ + workspaceId, + local, + onClose, + onSent, +}: { + workspaceId: string; + local: LocalResult; + onClose: () => void; + onSent: (id: string) => void; +}) { + const output = `${local.stdout}\n${local.stderr}`; + const log = useMemo(() => truncate(output), [output]); + const tools = useMemo(() => failedTools(output), [output]); + const [candidates, setCandidates] = useState< + { name: string; bytes: number }[] + >([]); + const [ticked, setTicked] = useState>({}); + const [extra, setExtra] = useState<{ name: string; content: string }[]>([]); + const [libraries, setLibraries] = useState(""); + const [sending, setSending] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + let live = true; + void desktop + .supportCandidates(workspaceId) + .then((found) => { + if (!live) return; + setCandidates(found); + // Ticked by default: these are the files the question asks for. + setTicked(Object.fromEntries(found.map((f) => [f.name, true]))); + }) + .catch((e) => { + if (live) setError(errorMessage(e)); + }); + return () => { + live = false; + }; + }, [workspaceId]); + + const attachedBytes = + candidates.filter((c) => ticked[c.name]).reduce((n, c) => n + c.bytes, 0) + + extra.reduce((n, f) => n + f.content.length, 0); + const total = log.length + attachedBytes; + const lines = log.split("\n").length; + + const addFile = () => { + const name = window.prompt( + "Chemin du fichier, relatif au dossier ouvert :", + ); + if (!name) return; + void desktop + .supportReadFile(workspaceId, name) + .then((content) => + setExtra((list) => [ + ...list.filter((f) => f.name !== name), + { name: name.split(/[\\/]/).pop() ?? name, content }, + ]), + ) + .catch((e) => setError(errorMessage(e))); + }; + + const send = async () => { + setSending(true); + setError(""); + try { + const files = [...extra]; + for (const c of candidates) { + if (ticked[c.name]) { + files.push({ + name: c.name, + content: await desktop.supportReadFile(workspaceId, c.name), + }); + } + } + const id = await desktop.supportSend({ + tools, + signature: signature(output), + ctrace_version: + /ctrace\D{0,12}(\d+\.\d+\.\d+)/.exec(local.stdout)?.[1] ?? "", + desktop_version: DESKTOP_VERSION, + os: "", + libraries, + log, + files, + }); + await desktop.supportMarkReported(workspaceId, local.runId); + onSent(id); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSending(false); + } + }; + + return ( + +

+ Sera envoyé : le journal complet de ctrace ({lines} lignes,{" "} + {kilobytes(log.length)}), la version de ctrace, votre système et la + version de CoreTrace Desktop. Outils concernés :{" "} + {tools.join(", ")}. +

+