diff --git a/.github/workflows/ksai.yml b/.github/workflows/ksai.yml new file mode 100644 index 0000000000..4fb8ebe231 --- /dev/null +++ b/.github/workflows/ksai.yml @@ -0,0 +1,118 @@ +--- +name: KSAI + +permissions: {} + +on: # yamllint disable-line rule:truthy + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request: + types: [review_requested, opened, ready_for_review] + pull_request_review: + types: [submitted] + workflow_dispatch: + inputs: + pr: + description: Pull request number to review + required: false + actor: + description: GitHub username to authorize against CODEOWNERS, matching comment authorization + required: false + issue_number: + description: Issue number for plan continuation; empty when work_ref names the work + required: false + work_ref: + description: The work to do when there is no GitHub issue, as jira/ + required: false + attempt: + description: Runs already spent by this continuation chain + required: false + stall: + description: Consecutive runs that completed no step + required: false + prev_remaining: + description: Unchecked boxes seen by the predecessor, with empty distinct from zero + required: false + comment_id: + description: Comment a dispatched request answers, read back from GitHub rather than believed + required: false + comment_kind: + description: Which space comment_id came from, issue, review or submitted_review + required: false + record_id: + description: Dispatch record written for this run, read back over the run's own OIDC token + required: false + +jobs: + ksai: + name: run + if: >- + (github.event_name == 'workflow_dispatch' && + (inputs.pr != '' || inputs.issue_number != '' || inputs.work_ref != '' || + inputs.comment_id != '' || inputs.comment_kind != '')) || + ((github.event_name == 'issue_comment' || + github.event_name == 'pull_request_review_comment') && + github.event.comment.user.type != 'Bot' && + (contains(github.event.comment.body, '/ksai') || + (vars.KSAI_TRIGGER_PHRASE != '' && + contains(github.event.comment.body, vars.KSAI_TRIGGER_PHRASE)))) || + (github.event_name == 'issue_comment' && + github.event.comment.user.type != 'Bot' && + github.event.issue.pull_request && + github.event.issue.user.type == 'Bot') || + (github.event_name == 'pull_request_review_comment' && + github.event.comment.user.type != 'Bot' && + github.event.comment.in_reply_to_id && + github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'pull_request_review' && + github.event.action == 'submitted' && + github.event.review.user.type != 'Bot' && + github.event.pull_request.draft == true && + github.event.pull_request.user.type == 'Bot' && + github.event.pull_request.head.repo.full_name == github.repository) + uses: Kong/ksai-public/.github/workflows/ksai.yml@15f45eae5f0794b4e3f63875e9a8305fc9e60215 # v3.106.0 + permissions: + contents: write # checked out by every command, and pushed by one that changes code + issues: write # every command answers with a comment, and reacts to the one that asked + pull-requests: write # post reviews and maintain draft pull request plans + id-token: write # mint the GitHub OIDC token for federation + checks: read # read what CI said about the head + statuses: read # the older API a build may report through + actions: write # old caller review dispatch, successor plan runs and test publication + with: + trigger_phrase: ${{ vars.KSAI_TRIGGER_PHRASE }} + anthropic_base_url: ${{ vars.KSAI_ANTHROPIC_BASE_URL }} + federation_rule_id: ${{ vars.KSAI_FEDERATION_RULE_ID }} + organization_id: ${{ vars.KSAI_ORGANIZATION_ID }} + service_account_id: ${{ vars.KSAI_SERVICE_ACCOUNT_ID }} + workspace_id: ${{ vars.KSAI_WORKSPACE_ID }} + push_as_app: false + pr: ${{ inputs.pr }} + actor: ${{ inputs.actor }} + issue_number: ${{ inputs.issue_number }} + work_ref: ${{ inputs.work_ref }} + attempt: ${{ inputs.attempt }} + stall: ${{ inputs.stall }} + prev_remaining: ${{ inputs.prev_remaining }} + comment_id: ${{ inputs.comment_id }} + comment_kind: ${{ inputs.comment_kind }} + record_id: ${{ inputs.record_id }} + + request: + if: >- + (github.event_name == 'pull_request' && + github.event.action == 'review_requested' && + github.event.requested_team.slug == (vars.KSAI_REVIEW_TEAM || 'ksai') && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.sender.type != 'Bot') || + (github.event_name == 'pull_request' && + contains(fromJSON('["opened", "ready_for_review"]'), github.event.action) && + github.event.pull_request.head.repo.full_name == github.repository) + uses: Kong/ksai-public/.github/workflows/ksai-request.yml@15f45eae5f0794b4e3f63875e9a8305fc9e60215 # v3.106.0 + permissions: + actions: write # dispatch this workflow onto the default branch + contents: read # download the actions this call runs + with: + push_as_app: false diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index e50521fbc9..230e8da329 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -392,6 +392,8 @@ jobs: - name: Checkout branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Get release version id: release_version diff --git a/.github/workflows/release-recurring.yml b/.github/workflows/release-recurring.yml index 4ee012c500..45352f7fdd 100644 --- a/.github/workflows/release-recurring.yml +++ b/.github/workflows/release-recurring.yml @@ -48,6 +48,8 @@ jobs: - name: Checkout branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml index 666262bc0c..5136ad0006 100644 --- a/.github/workflows/release-start.yml +++ b/.github/workflows/release-start.yml @@ -36,6 +36,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} ref: develop fetch-depth: 0 + persist-credentials: false - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index 762f298d78..f5d42d14e4 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -27,4 +27,6 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: Kong/public-shared-actions/security-actions/semgrep@a18abf762d6e2444bcbfd20de70451ea1e3bc1b1 # 4.0.1 diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index c9c6630711..80c4930855 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -32,6 +32,8 @@ jobs: - name: Checkout branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 4174c33782..c43f75d047 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -78,6 +78,8 @@ jobs: - name: Checkout branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -100,8 +102,82 @@ jobs: name: app-build path: packages/insomnia/build/ + - name: Download previous Playwright last run state + if: ${{ github.run_attempt > 1 }} + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: playwright-last-run-shard-${{ matrix.shardIndex }}-attempt-* + path: .playwright-last-run-history + + - name: Restore previous Playwright last run state + if: ${{ github.run_attempt > 1 }} + env: + SHARD_INDEX: ${{ matrix.shardIndex }} + SHARD_TOTAL: ${{ matrix.shardTotal }} + run: | + set -euo pipefail + CURRENT_ATTEMPT="${GITHUB_RUN_ATTEMPT}" + PREVIOUS_ATTEMPT=$((CURRENT_ATTEMPT - 1)) + mkdir -p packages/insomnia-smoke-test/traces + + if [ ! -d .playwright-last-run-history ]; then + echo "::notice::No last-run history downloaded for shard $SHARD_INDEX/$SHARD_TOTAL. Falling back to full shard run." + exit 0 + fi + + CANDIDATE_FILE="$( + find .playwright-last-run-history -type f \( -name '.last-run.json' -o -path '*/traces/.last-run.json' \) \ + | while IFS= read -r file; do + if [[ "$file" =~ attempt-([0-9]+) ]]; then + attempt="${BASH_REMATCH[1]}" + else + attempt="$PREVIOUS_ATTEMPT" + fi + + if [ "$attempt" -lt "$CURRENT_ATTEMPT" ]; then + printf '%s\t%s\n' "$attempt" "$file" + fi + done \ + | sort -t $'\t' -k1,1nr \ + | head -n1 \ + | cut -f2 + )" + + if [ -z "${CANDIDATE_FILE:-}" ]; then + echo "::notice::No previous last-run state found for shard $SHARD_INDEX/$SHARD_TOTAL. Falling back to full shard run." + exit 0 + fi + + cp "$CANDIDATE_FILE" packages/insomnia-smoke-test/traces/.last-run.json + echo "Restored Playwright last-run state from: $CANDIDATE_FILE" + - name: Smoke test electron app (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + run: | + LAST_FAILED_ARGS=() + + if [ "${GITHUB_RUN_ATTEMPT:-1}" -gt 1 ]; then + if [ ! -f packages/insomnia-smoke-test/traces/.last-run.json ]; then + echo "::notice::Rerun attempt detected but no previous .last-run.json for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}. Running full shard." + elif node -e "const fs=require('node:fs'); const data=JSON.parse(fs.readFileSync('packages/insomnia-smoke-test/traces/.last-run.json','utf8')); process.exit(Array.isArray(data.failedTests) && data.failedTests.length > 0 ? 0 : 1)"; then + echo "Rerun attempt detected. Running only last failed tests for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}." + LAST_FAILED_ARGS+=(--last-failed) + else + echo "::notice::No failed tests recorded in previous .last-run.json for shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}. Running full shard." + fi + fi + + npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} "${LAST_FAILED_ARGS[@]}" + + - name: Upload Playwright last run state + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + if-no-files-found: warn + name: playwright-last-run-shard-${{ matrix.shardIndex }}-attempt-${{ github.run_attempt }} + path: packages/insomnia-smoke-test/traces/.last-run.json + include-hidden-files: true + retention-days: 7 - name: Upload smoke test traces uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1eafd5f4c..8692dc5968 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -103,228 +103,92 @@ jobs: if: github.event_name != 'pull_request' || steps.vendor-changed.outputs.changed == 'true' run: npm run sandbox:vendored:test-regression -w insomnia - - name: Checkout base branch (cycle comparison) - if: github.event_name == 'pull_request' && always() - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.event.pull_request.base.ref }} - path: insomnia-base + - name: Check circular references + id: check-cycles + continue-on-error: true + shell: bash + # Exit code 1 (a new cycle) and 2 (a stale baseline) are reported differently below, so + # capture it rather than letting `-e` collapse both into a generic step failure. + run: | + set +e + npm run check-cycle-references | tee cycle-report.txt + status=${PIPESTATUS[0]} + set -e + echo "status=$status" >> "$GITHUB_OUTPUT" + exit "$status" - - name: Setup Node (base branch tree) - if: github.event_name == 'pull_request' && always() - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version-file: insomnia-base/.nvmrc - cache: npm - cache-dependency-path: insomnia-base/package-lock.json - registry-url: 'https://npm.pkg.github.com' - scope: '@kong' - - - name: Install packages (base branch tree) - if: github.event_name == 'pull_request' && always() - working-directory: insomnia-base - run: npm ci - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' - - - name: Check Circular References - if: github.event_name == 'pull_request' && always() + - name: Post circular references PR comment + if: github.event_name == 'pull_request' && always() && steps.check-cycles.outcome != 'skipped' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const path = require('path'); + const fs = require('fs'); - const workspaceRoot = process.env.GITHUB_WORKSPACE; - const prPackagesDir = path.join(workspaceRoot, 'packages'); - const basePackagesDir = path.join(workspaceRoot, 'insomnia-base', 'packages'); + // 0 = matches baseline, 1 = a new cycle appeared, 2 = baseline is stale. Anything + // else means the check itself blew up, which is not a claim about cycles either way. + const status = '${{ steps.check-cycles.outputs.status }}'; + const passed = status === '0'; + const summaryByStatus = { + '0': { icon: '✅', text: 'No new circular references' }, + '1': { icon: '⚠️', text: 'New circular references detected' }, + '2': { icon: '♻️', text: 'No new circular references, but the baseline is stale — regenerate and commit it' }, + }; + const { icon, text } = summaryByStatus[status] ?? { icon: '💥', text: 'The circular reference check failed to run' }; + const report = fs.readFileSync('cycle-report.txt', 'utf8').trim(); + const markdownContent = `
+ ${icon} Circular References Report - async function analyzeCircularReferences(rootDir) { - const madge = require('madge'); - const res = await madge(rootDir, { - fileExtensions: ['ts', 'tsx'], - }); - return res.circular(); - } - - // Function to generate markdown report - function generateMarkdownReport(prCircularRefs, baseCircularRefs, prCount, baseCount, baseBranch) { - const timestamp = new Date().toISOString(); - const diff = prCount - baseCount; - const diffPercent = baseCount > 0 ? ((diff / baseCount) * 100).toFixed(2) : '0.00'; - - let status = '✅ PASSED'; - let statusEmoji = '✅'; - - if (diff > 0) { - status = '⚠️ WARNING'; - statusEmoji = '⚠️'; - } else if (diff === 0) { - status = '✅ NO CHANGE'; - statusEmoji = '✅'; - } else { - status = '✨ IMPROVED'; - statusEmoji = '✨'; - } - - // Find new and removed circular references - const prCycleStrings = new Set(prCircularRefs.map(cycle => cycle.join(' -> '))); - const baseCycleStrings = new Set(baseCircularRefs.map(cycle => cycle.join(' -> '))); - - const newCycles = Array.from(prCycleStrings).filter(cycle => !baseCycleStrings.has(cycle)).sort(); - const removedCycles = Array.from(baseCycleStrings).filter(cycle => !prCycleStrings.has(cycle)).sort(); - - return `# ${statusEmoji} Circular References Report - - **Generated at:** ${timestamp} - **Status:** ${status} - - ## Summary - | Metric | Base (\`${baseBranch}\`) | PR | Change | - |--------|----------------|-----|---------| - | Total Circular References | ${baseCount} | ${prCount} | ${diff > 0 ? '+' : ''}${diff} (${diffPercent > 0 ? '+' : ''}${diffPercent}%) | - - ${newCycles.length > 0 ? ` - ## ⚠️ New Circular References Added (${newCycles.length}) - -
- Click to expand/collapse + **Status:** ${text} \`\`\` - ${newCycles.join('\n')} + ${report} \`\`\` -
- ` : ''} - - ${removedCycles.length > 0 ? ` - ## ✨ Circular References Removed (${removedCycles.length}) - -
- Click to expand/collapse - - \`\`\` - ${removedCycles.join('\n')} - \`\`\` - -
- ` : ''} - -
- Click to view all circular references in PR (${prCount}) - - \`\`\` - ${prCircularRefs.length > 0 ? prCircularRefs.sort().map(cycle => cycle.join(' -> ')).join('\n') : 'No circular references found'} - \`\`\` - -
- -
- Click to view all circular references in base branch (${baseCount}) - - \`\`\` - ${baseCircularRefs.length > 0 ? baseCircularRefs.sort().map(cycle => cycle.join(' -> ')).join('\n') : 'No circular references found'} - \`\`\` - -
- - ## Analysis - - ${ - diff > 0 - ? `⚠️ **Warning:** This PR introduces ${diff} new circular ${diff === 1 ? 'reference' : 'references'}. Consider refactoring to avoid adding circular dependencies.` - : diff < 0 - ? `✨ **Great Job!** This PR removes ${Math.abs(diff)} circular ${Math.abs(diff) === 1 ? 'reference' : 'references'}. Keep up the good work!` - : `✅ **No Change:** This PR does not introduce or remove any circular references.` - } - - --- - *This report was generated automatically by comparing against the \`${baseBranch}\` branch.* `; - } - try { - const baseBranch = context.payload.pull_request.base.ref; - console.log(`Base branch (PR target): ${baseBranch}`); - console.log(`PR packages dir: ${prPackagesDir}`); - console.log(`Base packages dir: ${basePackagesDir}`); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); - console.log('Analyzing circular references in PR (merge) tree...'); - const prCircularRefs = await analyzeCircularReferences(prPackagesDir); - const prCount = prCircularRefs.length; - console.log(`PR: Found ${prCount} circular references`); + const botComment = comments.find(comment => + comment.user?.type === 'Bot' && + comment.body?.includes('Circular References Report') + ); - console.log(`Analyzing circular references in ${baseBranch} tree...`); - const baseCircularRefs = await analyzeCircularReferences(basePackagesDir); - const baseCount = baseCircularRefs.length; - console.log(`Base: Found ${baseCount} circular references`); - - // Generate report - const diff = prCount - baseCount; - const markdownContent = generateMarkdownReport(prCircularRefs, baseCircularRefs, prCount, baseCount, baseBranch); - - // Post PR comment (skip creating/updating if there's no change in circular references, - // but clean up a stale comment from a previous run where there was a change) - if (context.eventName === 'pull_request') { - try { - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const botComment = comments.find(comment => - comment.user?.type === 'Bot' && - comment.body?.includes('# ') && - comment.body?.includes('Circular References Report') - ); - - if (diff === 0) { - if (botComment) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - }); - console.log('Deleted stale PR comment (no change)'); - } else { - console.log('No change in circular references, skipping PR comment'); - } - } else if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: markdownContent - }); - console.log('Updated existing PR comment'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: markdownContent - }); - console.log('Created new PR comment'); - } - } catch (error) { - console.error('Error posting PR comment:', error); - } - } - - // Log results but don't fail the build - if (diff > 0) { - console.log(`⚠️ Warning: This PR introduces ${diff} new circular ${diff === 1 ? 'reference' : 'references'}. Base: ${baseCount}, PR: ${prCount}`); - } else if (diff < 0) { - console.log(`✨ Great! This PR removes ${Math.abs(diff)} circular ${Math.abs(diff) === 1 ? 'reference' : 'references'}!`); + if (passed) { + if (botComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + }); + console.log('Deleted stale PR comment (no new circular references)'); } else { - console.log(`✅ No change in circular references (${prCount})`); + console.log('No new circular references, skipping PR comment'); } - - } catch (error) { - console.error('Error analyzing circular references:', error); - core.setFailed('Failed to analyze circular references'); + } else if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: markdownContent + }); + console.log('Updated existing PR comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: markdownContent + }); + console.log('Created new PR comment'); } + + # Fails on a stale baseline too, not just on new cycles: the baseline is a ratchet, so a + # cycle left in it after being fixed would silently keep permitting its reintroduction. + - name: Fail if the circular reference check did not pass + if: steps.check-cycles.outcome == 'failure' + run: exit 1 diff --git a/package-lock.json b/package-lock.json index afecb22c28..b75d87d59b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@types/node": "^24.2.1", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", + "dependency-cruiser": "^18.2.0", "esbuild": "^0.25.10", "esbuild-runner": "^2.2.2", "eslint": "^9.39.1", @@ -43,7 +44,6 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unicorn": "^62.0.0", "globals": "^16.5.0", - "madge": "^8.0.0", "patch-package": "^8.0.0", "prettier": "3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", @@ -1330,20 +1330,6 @@ "integrity": "sha512-k5xhfgG1Sg9VRAzIsO/kuliM0hwTxg/4Fj2fmA83GtwKcihOTo71DkVXKN7ckh1GwWfHhf6htkpiBI2RjX/8Ag==", "license": "MIT" }, - "node_modules/@dependents/detective-less": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@dependents/detective-less/-/detective-less-5.0.1.tgz", - "integrity": "sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@develohpanda/fluent-builder": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@develohpanda/fluent-builder/-/fluent-builder-2.1.2.tgz", @@ -3157,9 +3143,9 @@ } }, "node_modules/@kong/insomnia-plugin-external-vault": { - "version": "0.1.5-dev.20260806050036", - "resolved": "https://npm.pkg.github.com/download/@kong/insomnia-plugin-external-vault/0.1.5-dev.20260806050036/764507d31c7a92146b75d00e871059bc255ed28a", - "integrity": "sha512-7JVgIPeMgRLpcu/56A+KBhXdpLJj8m9ZivFsF1PTl5M6h02ctvC4g5h8gaMvL7LxJrCfCkfpB+PAytVrVf0suw==", + "version": "0.1.5", + "resolved": "https://npm.pkg.github.com/download/@kong/insomnia-plugin-external-vault/0.1.5/8c65150279fbca4ffc20ef2fb292397e81b0b8a4", + "integrity": "sha512-Z10Jqz8bcnLncOhPFXIKuZRP4N/wDEv9Qx6mZ0TOBUcCjQVq9iMoWYGx9Yl2WEQKfb8asztfgACCby6IyCN3Vg==", "license": "MIT", "optional": true, "dependencies": { @@ -6736,6 +6722,80 @@ "integrity": "sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==", "license": "MIT" }, + "node_modules/@rjsf/core": { + "version": "6.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.0.0-beta.19.tgz", + "integrity": "sha512-w6sEQ0acFIS+DPMqf6stRRDFlv4NKd9/Ha1Ur5LckPDfG4++J79KMIZq2G8jFra+vzU8dHXd6sHO5BMElGe/VQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "markdown-to-jsx": "^7.7.13", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.0.0-beta.19", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.0.0-beta.19.tgz", + "integrity": "sha512-PXU07obCAOu5mMYoPfNkKZ6nyMXTfYhtYgKBU/wcu1Xryp5jI7ynsu5ifcfYcWDsEajznOLYBV+chpq7zjDpeg==", + "license": "Apache-2.0", + "dependencies": { + "fast-uri": "^3.1.0", + "json-schema-merge-allof": "^0.8.1", + "jsonpointer": "^5.0.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.0.0-beta.19.tgz", + "integrity": "sha512-Ps1iyaL4ldd5rHU843R7GVo5S7hH2E98fBeGNZ/GvlSqyband6sPfFp3qXnEdG2xmQOJNs64W7B6GhtN45V0hA==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^2.1.1", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.0.0-beta.19" + } + }, + "node_modules/@rjsf/validator-ajv8/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -8595,96 +8655,6 @@ "url": "https://github.com/tinyhttp/tinyhttp?sponsor=1" } }, - "node_modules/@ts-graphviz/adapter": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", - "integrity": "sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ts-graphviz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/ts-graphviz" - } - ], - "license": "MIT", - "dependencies": { - "@ts-graphviz/common": "^2.1.5" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ts-graphviz/ast": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@ts-graphviz/ast/-/ast-2.0.7.tgz", - "integrity": "sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ts-graphviz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/ts-graphviz" - } - ], - "license": "MIT", - "dependencies": { - "@ts-graphviz/common": "^2.1.5" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ts-graphviz/common": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@ts-graphviz/common/-/common-2.1.5.tgz", - "integrity": "sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ts-graphviz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/ts-graphviz" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@ts-graphviz/core": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@ts-graphviz/core/-/core-2.0.7.tgz", - "integrity": "sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ts-graphviz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/ts-graphviz" - } - ], - "license": "MIT", - "dependencies": { - "@ts-graphviz/ast": "^2.0.7", - "@ts-graphviz/common": "^2.1.5" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -9914,90 +9884,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@vue/compiler-core": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", - "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/shared": "3.5.31", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-core/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", - "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.31", - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", - "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.2", - "@vue/compiler-core": "3.5.31", - "@vue/compiler-dom": "3.5.31", - "@vue/compiler-ssr": "3.5.31", - "@vue/shared": "3.5.31", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-sfc/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", - "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.31", - "@vue/shared": "3.5.31" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.31", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", - "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", - "dev": true, - "license": "MIT" - }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -10071,9 +9957,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -10101,6 +9987,26 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-jsx-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/acorn-jsx-walk/-/acorn-jsx-walk-2.0.0.tgz", + "integrity": "sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn-loose": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/acorn-loose/-/acorn-loose-8.5.2.tgz", + "integrity": "sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -10225,13 +10131,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, "node_modules/apiconnect-wsdl": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/apiconnect-wsdl/-/apiconnect-wsdl-2.0.36.tgz", @@ -10475,13 +10374,6 @@ "dev": true, "license": "ISC" }, - "node_modules/app-module-path": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz", - "integrity": "sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -10680,16 +10572,6 @@ "node": "*" } }, - "node_modules/ast-module-types": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.1.tgz", - "integrity": "sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/ast-types": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", @@ -10889,43 +10771,6 @@ "node": "*" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/blakejs": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", @@ -11672,25 +11517,12 @@ "node": ">=0.2.5" } }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=6" }, @@ -12693,8 +12525,8 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=4.0.0" } @@ -12718,29 +12550,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/defaults/node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -12810,6 +12619,67 @@ "node": ">= 0.8" } }, + "node_modules/dependency-cruiser": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/dependency-cruiser/-/dependency-cruiser-18.2.0.tgz", + "integrity": "sha512-xMDoLD0no6pDInR8/4rIIqZ4mERDnsjezk8PkNORYSfBLvjCOogUxaruepmi1uQtZQlYUgdT2u7G3jTlgKqNjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "8.18.0", + "acorn-jsx": "5.3.2", + "acorn-jsx-walk": "2.0.0", + "acorn-loose": "8.5.2", + "acorn-walk": "8.3.5", + "commander": "15.0.0", + "enhanced-resolve": "5.24.5", + "ignore": "7.0.6", + "interpret": "3.1.1", + "is-installed-globally": "1.0.0", + "json5": "2.2.3", + "picomatch": "4.0.5", + "prompts": "2.4.2", + "rechoir": "0.8.0", + "safe-regex": "2.1.1", + "semver": "7.8.5", + "tsconfig-paths-webpack-plugin": "4.2.0", + "watskeburt": "6.0.0" + }, + "bin": { + "depcruise": "bin/dependency-cruise.mjs", + "depcruise-baseline": "bin/depcruise-baseline.mjs", + "depcruise-fmt": "bin/depcruise-fmt.mjs", + "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs", + "dependency-cruise": "bin/dependency-cruise.mjs", + "dependency-cruiser": "bin/dependency-cruise.mjs" + }, + "engines": { + "node": "^22||^24||>=26" + } + }, + "node_modules/dependency-cruiser/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/dependency-cruiser/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/dependency-graph": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", @@ -12819,39 +12689,6 @@ "node": ">= 0.6.0" } }, - "node_modules/dependency-tree": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/dependency-tree/-/dependency-tree-11.4.0.tgz", - "integrity": "sha512-r4wZ1pfv8eQrnoWbIGdrJTVmlb0dkXdwBjKsotKO4gmfqrOsAMG+0+cfA5EZ3NO8umc85twXOl1eO27E5pjTzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "filing-cabinet": "^5.2.0", - "precinct": "^12.2.0", - "typescript": "^5.9.3" - }, - "bin": { - "dependency-tree": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/dependency-tree/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -12879,147 +12716,6 @@ "license": "MIT", "optional": true }, - "node_modules/detective-amd": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-amd/-/detective-amd-6.0.1.tgz", - "integrity": "sha512-TtyZ3OhwUoEEIhTFoc1C9IyJIud3y+xYkSRjmvCt65+ycQuc3VcBrPRTMWoO/AnuCyOB8T5gky+xf7Igxtjd3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "escodegen": "^2.1.0", - "get-amd-module-type": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "bin": { - "detective-amd": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-cjs": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detective-cjs/-/detective-cjs-6.1.0.tgz", - "integrity": "sha512-Qt3S4IddVNDb+71lm+jmt5NznIsgcKlibTnrw9Zr91rT9vRwKp+73+ImqLTNrQj4YuOxnzrC7GwIAVwF7136XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-es6": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-es6/-/detective-es6-5.0.1.tgz", - "integrity": "sha512-XusTPuewnSUdoxRSx8OOI6xIA/uld/wMQwYsouvFN2LAg7HgP06NF1lHRV3x6BZxyL2Kkoih4ewcq8hcbGtwew==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-postcss": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/detective-postcss/-/detective-postcss-7.0.1.tgz", - "integrity": "sha512-bEOVpHU9picRZux5XnwGsmCN4+8oZo7vSW0O0/Enq/TO5R2pIAP2279NsszpJR7ocnQt4WXU0+nnh/0JuK4KHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-url": "^1.2.4", - "postcss-values-parser": "^6.0.2" - }, - "engines": { - "node": "^14.0.0 || >=16.0.0" - }, - "peerDependencies": { - "postcss": "^8.4.47" - } - }, - "node_modules/detective-sass": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/detective-sass/-/detective-sass-6.0.1.tgz", - "integrity": "sha512-jSGPO8QDy7K7pztUmGC6aiHkexBQT4GIH+mBAL9ZyBmnUIOFbkfZnO8wPRRJFP/QP83irObgsZHCoDHZ173tRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-scss": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-scss/-/detective-scss-5.0.1.tgz", - "integrity": "sha512-MAyPYRgS6DCiS6n6AoSBJXLGVOydsr9huwXORUlJ37K3YLyiN0vYHpzs3AdJOgHobBfispokoqrEon9rbmKacg==", - "dev": true, - "license": "MIT", - "dependencies": { - "gonzales-pe": "^4.3.0", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-stylus": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/detective-stylus/-/detective-stylus-5.0.1.tgz", - "integrity": "sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/detective-typescript": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", - "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "^8.23.0", - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, - "node_modules/detective-vue2": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/detective-vue2/-/detective-vue2-2.2.0.tgz", - "integrity": "sha512-sVg/t6O2z1zna8a/UIV6xL5KUa2cMTQbdTIIvqNM0NIPswp52fe43Nwmbahzj3ww4D844u/vC2PYfiGLvD3zFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dependents/detective-less": "^5.0.1", - "@vue/compiler-sfc": "^3.5.13", - "detective-es6": "^5.0.1", - "detective-sass": "^6.0.1", - "detective-scss": "^5.0.1", - "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "typescript": "^5.4.4" - } - }, "node_modules/diff": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", @@ -13677,14 +13373,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -14105,28 +13801,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -14511,20 +14185,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -15133,67 +14793,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/filing-cabinet": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/filing-cabinet/-/filing-cabinet-5.2.0.tgz", - "integrity": "sha512-eNrCJGdYQY0tV+ACNesQ7vb2aMxD76NM7THayMn0Z5XBt1Tonr4vbVN+FbhHfekKGQG9O5UaciDDR7+dw8P9ZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "app-module-path": "^2.2.0", - "commander": "^12.1.0", - "enhanced-resolve": "^5.20.0", - "module-definition": "^6.0.1", - "module-lookup-amd": "^9.1.1", - "resolve": "^1.22.11", - "resolve-dependency-path": "^4.0.1", - "sass-lookup": "^6.1.0", - "stylus-lookup": "^6.1.0", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.3" - }, - "bin": { - "filing-cabinet": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/filing-cabinet/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/filing-cabinet/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -15574,20 +15173,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-amd-module-type": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz", - "integrity": "sha512-MtjsmYiCXcYDDrGqtNbeIYdAl85n+5mSv2r3FbzER/YV3ZILw4HNNIw34HuV5pyl0jzs6GFYU1VHVEefhgcNHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -15803,6 +15388,32 @@ "node": ">=10.0" } }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory/node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/globals": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", @@ -15832,22 +15443,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gonzales-pe": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", - "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "gonzales": "bin/gonzales.js" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/google-auth-library": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", @@ -16640,9 +16235,9 @@ "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "devOptional": true, "license": "MIT", "engines": { @@ -16737,8 +16332,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/inline-style-prefixer": { "version": "7.0.1", @@ -16799,6 +16394,16 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -17285,14 +16890,34 @@ "node": ">=0.10.0" } }, - "node_modules/is-interactive": { + "node_modules/is-installed-globally": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-map": { @@ -17538,26 +17163,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-url-superb": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-url-superb/-/is-url-superb-4.0.0.tgz", - "integrity": "sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -18443,6 +18048,16 @@ "graceful-fs": "^4.1.11" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/koa": { "version": "2.16.4", "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", @@ -19151,55 +18766,6 @@ "yallist": "^3.0.2" } }, - "node_modules/madge": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/madge/-/madge-8.0.0.tgz", - "integrity": "sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "commander": "^7.2.0", - "commondir": "^1.0.1", - "debug": "^4.3.4", - "dependency-tree": "^11.0.0", - "ora": "^5.4.1", - "pluralize": "^8.0.0", - "pretty-ms": "^7.0.1", - "rc": "^1.2.8", - "stream-to-array": "^2.3.0", - "ts-graphviz": "^2.1.2", - "walkdir": "^0.4.1" - }, - "bin": { - "madge": "bin/cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "individual", - "url": "https://www.paypal.me/pahen" - }, - "peerDependencies": { - "typescript": "^5.4.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/madge/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -19259,6 +18825,23 @@ "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", "license": "MIT" }, + "node_modules/markdown-to-jsx": { + "version": "7.7.17", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", + "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/marked": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", @@ -19734,47 +19317,12 @@ "node": ">=0.10.0" } }, - "node_modules/module-definition": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/module-definition/-/module-definition-6.0.1.tgz", - "integrity": "sha512-FeVc50FTfVVQnolk/WQT8MX+2WVcDnTGiq6Wo+/+lJ2ET1bRVi3HG3YlJUfqagNMc/kUlFSoR96AJkxGpKz13g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-module-types": "^6.0.1", - "node-source-walk": "^7.0.1" - }, - "bin": { - "module-definition": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, - "node_modules/module-lookup-amd": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/module-lookup-amd/-/module-lookup-amd-9.1.1.tgz", - "integrity": "sha512-JzXhQvud8K3yT9l24XTDMXMQ4/LD9a9oXBcbLP0ubdvBpVrGFsybm5+2PDIl0negUYP1l88fCgjQzoMMg247+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "requirejs": "^2.3.8", - "requirejs-config-file": "^4.0.0" - }, - "bin": { - "lookup-amd": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -20604,19 +20152,6 @@ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, - "node_modules/node-source-walk": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.1.tgz", - "integrity": "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.26.7" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/nopt": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", @@ -21271,30 +20806,6 @@ "node": ">= 0.8.0" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -21426,16 +20937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", - "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -21688,9 +21189,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -21904,24 +21405,6 @@ "node": ">=4" } }, - "node_modules/postcss-values-parser": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-values-parser/-/postcss-values-parser-6.0.2.tgz", - "integrity": "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "color-name": "^1.1.4", - "is-url-superb": "^4.0.0", - "quote-unquote": "^1.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "postcss": "^8.2.9" - } - }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -22007,36 +21490,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/precinct": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz", - "integrity": "sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@dependents/detective-less": "^5.0.1", - "commander": "^12.1.0", - "detective-amd": "^6.0.1", - "detective-cjs": "^6.0.1", - "detective-es6": "^5.0.1", - "detective-postcss": "^7.0.1", - "detective-sass": "^6.0.1", - "detective-scss": "^5.0.1", - "detective-stylus": "^5.0.1", - "detective-typescript": "^14.0.0", - "detective-vue2": "^2.2.0", - "module-definition": "^6.0.1", - "node-source-walk": "^7.0.1", - "postcss": "^8.5.1", - "typescript": "^5.7.3" - }, - "bin": { - "precinct": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -22154,22 +21607,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-ms": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", - "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse-ms": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -22219,6 +21656,20 @@ "node": ">= 4" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -22437,13 +21888,6 @@ "@jitl/quickjs-ffi-types": "0.32.0" } }, - "node_modules/quote-unquote": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/quote-unquote/-/quote-unquote-1.0.0.tgz", - "integrity": "sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==", - "dev": true, - "license": "MIT" - }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -22506,8 +21950,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "devOptional": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -22522,8 +21966,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "devOptional": true, "license": "MIT", + "optional": true, "engines": { "node": ">=0.10.0" } @@ -22918,6 +22362,41 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/rechoir/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -23044,34 +22523,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/requirejs": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.3.8.tgz", - "integrity": "sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw==", - "dev": true, - "license": "MIT", - "bin": { - "r_js": "bin/r.js", - "r.js": "bin/r.js" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/requirejs-config-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/requirejs-config-file/-/requirejs-config-file-4.0.0.tgz", - "integrity": "sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esprima": "^4.0.0", - "stringify-object": "^3.2.1" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -23141,16 +22592,6 @@ "dev": true, "license": "MIT" }, - "node_modules/resolve-dependency-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz", - "integrity": "sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -23173,20 +22614,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -23367,6 +22794,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -23406,23 +22843,6 @@ "truncate-utf8-bytes": "^1.0.0" } }, - "node_modules/sass-lookup": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/sass-lookup/-/sass-lookup-6.1.1.tgz", - "integrity": "sha512-12dvZdQYTeKZ1ypjuiijZYuMZ1m0F+4+BkRX5yJi2WA9W3DBUrcdCt7bVuKlagHl11n8eYtalWDle+m98Ol2DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0", - "enhanced-resolve": "^5.20.0" - }, - "bin": { - "sass-lookup": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -24033,6 +23453,13 @@ "node": ">=10" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", @@ -24508,16 +23935,6 @@ "license": "MIT", "optional": true }, - "node_modules/stream-to-array": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/stream-to-array/-/stream-to-array-2.3.0.tgz", - "integrity": "sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.1.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -24785,22 +24202,6 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, - "node_modules/stylus-lookup": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/stylus-lookup/-/stylus-lookup-6.1.0.tgz", - "integrity": "sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^12.1.0" - }, - "bin": { - "stylus-lookup": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -24968,9 +24369,9 @@ "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -25396,32 +24797,6 @@ "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==", "license": "Unlicense" }, - "node_modules/ts-graphviz": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/ts-graphviz/-/ts-graphviz-2.1.6.tgz", - "integrity": "sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ts-graphviz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/ts-graphviz" - } - ], - "license": "MIT", - "dependencies": { - "@ts-graphviz/adapter": "^2.0.6", - "@ts-graphviz/ast": "^2.0.7", - "@ts-graphviz/common": "^2.1.5", - "@ts-graphviz/core": "^2.0.7" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -25437,6 +24812,22 @@ "node": ">=6" } }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/tslib": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", @@ -26830,24 +26221,17 @@ "node": ">=18" } }, - "node_modules/walkdir": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", - "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "node_modules/watskeburt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/watskeburt/-/watskeburt-6.0.0.tgz", + "integrity": "sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==", "dev": true, "license": "MIT", + "bin": { + "watskeburt": "dist/run-cli.js" + }, "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" + "node": "^22.13||^24||>=26" } }, "node_modules/web-streams-polyfill": { @@ -27434,7 +26818,7 @@ } }, "packages/insomnia": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@apideck/better-ajv-errors": "^0.3.6", @@ -27456,9 +26840,9 @@ "@react-router/fs-routes": "7.18.2", "@react-router/node": "7.18.2", "@react-router/serve": "7.18.2", - "@rjsf/core": "6.0.0-beta.15", - "@rjsf/utils": "6.0.0-beta.15", - "@rjsf/validator-ajv8": "6.0.0-beta.15", + "@rjsf/core": "6.0.0-beta.19", + "@rjsf/utils": "6.0.0-beta.19", + "@rjsf/validator-ajv8": "6.0.0-beta.19", "@sentry/electron": "^6.5.0", "@stoplight/spectral-core": "^1.22.0", "@stoplight/spectral-formats": "^1.8.2", @@ -27611,25 +26995,25 @@ }, "optionalDependencies": { "@kong/insomnia-plugin-ai": "^1.0.11", - "@kong/insomnia-plugin-external-vault": "0.1.5-dev.20260806050036" + "@kong/insomnia-plugin-external-vault": "0.1.5" } }, "packages/insomnia-analytics": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@segment/analytics-node": "2.2.1" } }, "packages/insomnia-api": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@getinsomnia/insomnia-v3-fetch": "^1.0.27" } }, "packages/insomnia-data": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@seald-io/nedb": "^4.1.1", @@ -27637,7 +27021,8 @@ "deep-equal": "2.2.3", "fuzzysort": "^1.9.0", "graphql": "^16.10.0", - "uuid": "^11.1.1" + "uuid": "^11.1.1", + "zod": "^3.25.75" }, "devDependencies": { "@getinsomnia/node-libcurl": "3.3.0", @@ -27647,8 +27032,17 @@ "type-fest": "^4.40.0" } }, + "packages/insomnia-data/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/insomnia-inso": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@seald-io/nedb": "^4.1.1", @@ -27663,6 +27057,7 @@ "enquirer": "^2.4.1", "fast-string-width": "^3.0.2", "is-unicode-supported": "^2.1.0", + "papaparse": "^5.5.2", "picocolors": "^1.1.1", "string-argv": "^0.3.2", "yaml": "^2.7.1" @@ -27671,13 +27066,14 @@ "inso": "bin/inso" }, "devDependencies": { + "@types/papaparse": "^5.3.15", "cross-env": "^10.1.0", "esbuild": "^0.25.10", "esbuild-runner": "^2.2.2", "shellwords": "^1.0.1" }, "optionalDependencies": { - "@kong/insomnia-plugin-external-vault": "0.1.5-dev.20260806050036" + "@kong/insomnia-plugin-external-vault": "0.1.5" } }, "packages/insomnia-inso/node_modules/@stoplight/types": { @@ -27706,7 +27102,7 @@ } }, "packages/insomnia-scripting-environment": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "@types/deep-equal": "^1.0.4", @@ -27727,7 +27123,7 @@ } }, "packages/insomnia-smoke-test": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "devDependencies": { "@playwright/test": "1.59.1", @@ -28043,14 +27439,14 @@ } }, "packages/insomnia-testing": { - "version": "13.2.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "chai-json-schema": "1.5.1" } }, "packages/insomnia-vcs": { - "version": "13.1.0", + "version": "13.3.0", "license": "Apache-2.0", "dependencies": { "clone": "^2.1.2", @@ -28060,80 +27456,6 @@ "@types/node-forge": "^1.3.14" } }, - "packages/insomnia/node_modules/@rjsf/core": { - "version": "6.0.0-beta.15", - "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.0.0-beta.15.tgz", - "integrity": "sha512-VF6o/tetnYxgdv19qDgTdElkLQlPHH/2yn0Tyz7J8lUVucmYuPEctV6qo88NJUAtvts9EFLr7SYKaMQpfNJonQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "markdown-to-jsx": "^7.7.13", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@rjsf/utils": "^6.0.0-beta.15", - "react": ">=18" - } - }, - "packages/insomnia/node_modules/@rjsf/core/node_modules/markdown-to-jsx": { - "version": "7.7.17", - "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.7.17.tgz", - "integrity": "sha512-7mG/1feQ0TX5I7YyMZVDgCC/y2I3CiEhIRQIhyov9nGBP5eoVrOXXHuL5ZP8GRfxVZKRiXWJgwXkb9It+nQZfQ==", - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "peerDependencies": { - "react": ">= 0.14.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "packages/insomnia/node_modules/@rjsf/utils": { - "version": "6.0.0-beta.15", - "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.0.0-beta.15.tgz", - "integrity": "sha512-AWF0/OVRpdkxNrp1JIrTL+ryzGGffm/IXSICq+c0Ju3HIdcV0kvRDrlQtmdgbIbjdcWogS1Gg97cFhk/DzgjhQ==", - "license": "Apache-2.0", - "dependencies": { - "fast-uri": "^3.0.6", - "json-schema-merge-allof": "^0.8.1", - "jsonpointer": "^5.0.1", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-is": "^18.3.1" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "react": ">=18" - } - }, - "packages/insomnia/node_modules/@rjsf/validator-ajv8": { - "version": "6.0.0-beta.15", - "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.0.0-beta.15.tgz", - "integrity": "sha512-M40hmygBfR8NlaR3JEiBJIqs03Qli7F5DRS7ypFDBDsZLcSm1Kd6BWbjC3EM2IutYyTgxg6lvi1lQw95PqqcYw==", - "license": "Apache-2.0", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^2.1.1", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@rjsf/utils": "^6.0.0-beta.15" - } - }, "packages/insomnia/node_modules/@testing-library/dom": { "version": "10.4.1", "dev": true, @@ -28187,23 +27509,6 @@ "node": ">=14.6" } }, - "packages/insomnia/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "packages/insomnia/node_modules/aria-query": { "version": "5.3.0", "dev": true, diff --git a/package.json b/package.json index 1c12696ae0..1422e27771 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "test:crit:package": "npm run test:package -w insomnia-smoke-test -- --project=Critical", "test:crit:dev": "npm run test:dev -w insomnia-smoke-test -- --project=Critical", "postinstall": "patch-package && npm run verify-bundle-plugins -w insomnia && npm run install-libcurl-electron", - "check-cycle-references": "madge --circular --extensions ts,tsx packages" + "check-cycle-references": "node scripts/circular-references/check-cycle-references.mjs", + "check-cycle-references:baseline": "node scripts/circular-references/check-cycle-references.mjs --update-baseline" }, "devDependencies": { "@develohpanda/fluent-builder": "^2.1.2", @@ -56,6 +57,7 @@ "@types/node": "^24.2.1", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", + "dependency-cruiser": "^18.2.0", "esbuild": "^0.25.10", "esbuild-runner": "^2.2.2", "eslint": "^9.39.1", @@ -66,7 +68,6 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unicorn": "^62.0.0", "globals": "^16.5.0", - "madge": "^8.0.0", "patch-package": "^8.0.0", "prettier": "3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", diff --git a/packages/insomnia-analytics/package.json b/packages/insomnia-analytics/package.json index df548057fd..9f20bffb00 100644 --- a/packages/insomnia-analytics/package.json +++ b/packages/insomnia-analytics/package.json @@ -2,7 +2,7 @@ "private": true, "name": "insomnia-analytics", "license": "Apache-2.0", - "version": "13.2.0", + "version": "13.3.0", "author": "Kong ", "description": "Shared analytics client for the Insomnia desktop app and CLI", "repository": { diff --git a/packages/insomnia-api/package.json b/packages/insomnia-api/package.json index ad30056d26..67f198d445 100644 --- a/packages/insomnia-api/package.json +++ b/packages/insomnia-api/package.json @@ -2,7 +2,7 @@ "private": true, "name": "insomnia-api", "license": "Apache-2.0", - "version": "13.2.0", + "version": "13.3.0", "author": "Kong ", "description": "Insomnia API functionalities", "repository": { diff --git a/packages/insomnia-api/src/organizations.ts b/packages/insomnia-api/src/organizations.ts index 117d6e4989..b533e19478 100644 --- a/packages/insomnia-api/src/organizations.ts +++ b/packages/insomnia-api/src/organizations.ts @@ -59,7 +59,6 @@ export interface FeatureList { aiMockServers: FeatureStatus; aiCommitMessages: FeatureStatus; aiMcpClient: FeatureStatus; - konnectSync: FeatureStatus; } export interface Billing { @@ -182,4 +181,3 @@ export const getOrganizationMemberRoles = ({ sessionId, }); }; - diff --git a/packages/insomnia-api/src/user.ts b/packages/insomnia-api/src/user.ts index b9715f7330..2d7ea3ae9b 100644 --- a/packages/insomnia-api/src/user.ts +++ b/packages/insomnia-api/src/user.ts @@ -1,5 +1,8 @@ import type { CurrentUserActionCreate, User, UserEncryptionKeys, UserOnboarding } from '@getinsomnia/insomnia-v3-fetch'; -import { CurrentUserActionCreateActionTypeEnum, UserOnboardingFirstRequestTreatmentEnum } from '@getinsomnia/insomnia-v3-fetch'; +import { + CurrentUserActionCreateActionTypeEnum, + UserOnboardingFirstRequestTreatmentEnum, +} from '@getinsomnia/insomnia-v3-fetch'; import { fetch } from './fetch'; @@ -102,6 +105,29 @@ export const getUserFiles = async ({ sessionId }: { sessionId: string }) => { }); }; +// GET /v1/user/entitlements +export interface Entitlement { + /** `${featureName}_${entitlementKey}`, e.g. `konnectControlPlanes_default`. */ + featureKey: string; + type: 'metered'; + hasAccess: boolean; + /** `null` means unlimited. */ + allowance: number | null; +} + +export const KONNECT_CONTROL_PLANES_FEATURE = 'konnectControlPlanes'; +export const KONNECT_CONTROL_PLANES_FEATURE_KEY = `${KONNECT_CONTROL_PLANES_FEATURE}_default`; + +/** An account without the feature gets `{ entitlements: [] }` rather than an entry with `hasAccess: false`. */ +export const getUserEntitlements = async ({ sessionId, feature }: { sessionId: string; feature?: string }) => { + const query = feature ? `?feature=${encodeURIComponent(feature)}` : ''; + return fetch<{ entitlements: Entitlement[] }>({ + method: 'GET', + path: `/v1/user/entitlements${query}`, + sessionId, + }); +}; + // GET learning feature export interface LearningFeature { active: boolean; diff --git a/packages/insomnia-data/common-src/hotkeys.ts b/packages/insomnia-data/common-src/hotkeys.ts index d05ab0a94f..aeabd2cc2a 100644 --- a/packages/insomnia-data/common-src/hotkeys.ts +++ b/packages/insomnia-data/common-src/hotkeys.ts @@ -8,7 +8,7 @@ import { strings } from './strings'; * @IMPORTANT Not using dot, because NeDB prohibits field names to contain dots. */ export const keyboardShortcutDescriptions: Record = { - workspace_showSettings: `Show ${strings.document.singular} / ${strings.collection.singular} Settings`, + workspace_showSettings: `Show ${strings.collection.singular} Settings`, request_showSettings: 'Show Request Settings', preferences_showKeyboardShortcuts: 'Show Keyboard Shortcuts', preferences_showGeneral: 'Show App Preferences', @@ -18,7 +18,8 @@ export const keyboardShortcutDescriptions: Record = { request_send: 'Send Request', request_showOptions: 'Send Request (Options)', environment_showEditor: 'Show Environment Editor', - environment_showSwitchMenu: 'Switch Environments', + environment_showSwitchMenu: 'Switch Collection Environments', + environment_showSwitchProjectMenu: 'Switch Project Environments', request_toggleHttpMethodMenu: 'Change HTTP Method', request_toggleHistory: 'Show Request History', request_focusUrl: 'Focus URL', @@ -99,6 +100,10 @@ const defaultRegistry: HotKeyRegistry = { macKeys: [{ shift: true, meta: true, keyCode: keyboardKeys.e.keyCode }], winLinuxKeys: [{ ctrl: true, shift: true, keyCode: keyboardKeys.e.keyCode }], }, + environment_showSwitchProjectMenu: { + macKeys: [{ alt: true, meta: true, keyCode: keyboardKeys.e.keyCode }], + winLinuxKeys: [{ ctrl: true, alt: true, keyCode: keyboardKeys.e.keyCode }], + }, request_toggleHttpMethodMenu: { macKeys: [{ shift: true, meta: true, keyCode: keyboardKeys.l.keyCode }], winLinuxKeys: [{ ctrl: true, shift: true, keyCode: keyboardKeys.l.keyCode }], diff --git a/packages/insomnia-data/common-src/settings.ts b/packages/insomnia-data/common-src/settings.ts index f48fc4a74c..0785a281ee 100644 --- a/packages/insomnia-data/common-src/settings.ts +++ b/packages/insomnia-data/common-src/settings.ts @@ -40,6 +40,7 @@ export type KeyboardShortcut = | 'request_showOptions' | 'environment_showEditor' | 'environment_showSwitchMenu' + | 'environment_showSwitchProjectMenu' | 'request_toggleHttpMethodMenu' | 'request_toggleHistory' | 'request_focusUrl' @@ -204,4 +205,6 @@ export interface Settings { disabledBlockedRoots: string[]; /** Custom npm registry URL for plugin installation (e.g., corporate mirror). Empty string uses the default https://registry.npmjs.org/. */ npmRegistryUrl: string; + // Forces the test tab to show. Otherwise, only show it if the collection already has legacy tests. + enableLegacyUnitTests: boolean; } diff --git a/packages/insomnia-data/common-src/strings.ts b/packages/insomnia-data/common-src/strings.ts index 9a976500bb..b6b3a2a326 100644 --- a/packages/insomnia-data/common-src/strings.ts +++ b/packages/insomnia-data/common-src/strings.ts @@ -18,16 +18,16 @@ type StringId = export const strings: Record = { collection: { - singular: 'Collection', - plural: 'Collections', + singular: 'API Collection', + plural: 'API Collections', }, mock: { singular: 'Mock', plural: 'Mocks', }, document: { - singular: 'Document', - plural: 'Documents', + singular: 'API Collection', + plural: 'API Collections', }, organization: { singular: 'Organization', diff --git a/packages/insomnia-data/node-src/database/database.test.ts b/packages/insomnia-data/node-src/database/database.test.ts index 1180e10bdd..6fb483d437 100644 --- a/packages/insomnia-data/node-src/database/database.test.ts +++ b/packages/insomnia-data/node-src/database/database.test.ts @@ -441,11 +441,13 @@ describe('_repairDatabase()', async () => { id: '1', key: 'foo', value: '1', + source: 'manual', }, { id: 'j1_1', key: 'j1', value: '1', + source: 'manual', }, ], }, @@ -457,11 +459,13 @@ describe('_repairDatabase()', async () => { id: '1', key: 'foo', value: '2', + source: 'manual', }, { id: 'j2_1', key: 'j2', value: '2', + source: 'manual', }, ], }, @@ -488,16 +492,19 @@ describe('_repairDatabase()', async () => { id: '1', key: 'foo', value: '1', + source: 'manual', }, { id: 'j1_1', key: 'j1', value: '1', + source: 'manual', }, { id: 'j2_1', key: 'j2', value: '2', + source: 'manual', }, ], }, @@ -529,7 +536,7 @@ describe('_repairDatabase()', async () => { }); // Make sure we have everything expect((await services.apiSpec.getByParentId(w1._id))?.fileName).toBe(''); - expect((await services.apiSpec.getByParentId(w2._id))?.fileName).toBe('New Document'); + expect((await services.apiSpec.getByParentId(w2._id))?.fileName).toBe('New API Collection'); expect((await services.apiSpec.getByParentId(w3._id))?.fileName).toBe('Unique name'); // Run the fix algorithm await repairDatabase(); diff --git a/packages/insomnia-data/node-src/database/init-model/cookie-jar.test.ts b/packages/insomnia-data/node-src/database/init-model/cookie-jar.test.ts new file mode 100644 index 0000000000..3198244f6c --- /dev/null +++ b/packages/insomnia-data/node-src/database/init-model/cookie-jar.test.ts @@ -0,0 +1,48 @@ +import type { CookieJar } from 'insomnia-data'; +import { describe, expect, it } from 'vitest'; + +import { migrate } from './cookie-jar'; + +function makeCookieJar(cookies: Partial[]): CookieJar { + return { cookies } as CookieJar; +} + +describe('migrateCookieSource()', () => { + it('grandfathers a pre-existing cookie with no source field in as manual', () => { + const jar = makeCookieJar([{ id: 'c1', key: 'legacy', value: '{{ _.base_url }}' }]); + const result = migrate(jar); + + expect(result.cookies[0].source).toBe('manual'); + }); + + it('leaves a cookie that already has a source untouched', () => { + const jar = makeCookieJar([{ id: 'c1', key: 'fromServer', source: 'response' }]); + const result = migrate(jar); + + expect(result.cookies[0].source).toBe('response'); + }); + + it('is idempotent across repeated migrations', () => { + const jar = makeCookieJar([{ id: 'c1', key: 'legacy' }]); + const once = migrate(jar); + const twice = migrate(once); + + expect(twice.cookies[0].source).toBe('manual'); + }); +}); + +describe('migrateCookieId()', () => { + it('assigns an id to a cookie missing one', () => { + const jar = makeCookieJar([{ key: 'noId' }]); + const result = migrate(jar); + + expect(result.cookies[0].id).toBeTruthy(); + }); + + it('keeps an existing id unchanged', () => { + const jar = makeCookieJar([{ id: 'keep-me', key: 'hasId' }]); + const result = migrate(jar); + + expect(result.cookies[0].id).toBe('keep-me'); + }); +}); diff --git a/packages/insomnia-data/node-src/database/init-model/cookie-jar.ts b/packages/insomnia-data/node-src/database/init-model/cookie-jar.ts index 3b30ebce7a..5c8525e42f 100644 --- a/packages/insomnia-data/node-src/database/init-model/cookie-jar.ts +++ b/packages/insomnia-data/node-src/database/init-model/cookie-jar.ts @@ -12,9 +12,26 @@ function migrateCookieId(cookieJar: CookieJar) { return cookieJar; } +/** + * Grandfather in cookies that predate the `source` field as 'manual', so cookies a user + * already relied on for template rendering keep working. Every write path introduced + * alongside `source` (network responses, imports, scripts) always sets it explicitly, so once + * a cookie has passed through this migration once, `source` should never go missing again. + */ +function migrateCookieSource(cookieJar: CookieJar) { + for (const cookie of cookieJar.cookies) { + if (!cookie.source) { + cookie.source = 'manual'; + } + } + + return cookieJar; +} + export function migrate(doc: CookieJar) { try { doc = migrateCookieId(doc); + doc = migrateCookieSource(doc); return doc; } catch (e) { console.log('[db] Error during cookie jar migration', e); diff --git a/packages/insomnia-data/node-src/services/project.ts b/packages/insomnia-data/node-src/services/project.ts index db9cae9cd7..3a0f268aee 100644 --- a/packages/insomnia-data/node-src/services/project.ts +++ b/packages/insomnia-data/node-src/services/project.ts @@ -150,10 +150,14 @@ export async function syncTeamProjects({ } export async function syncProjects(organizationId: string) { + // Local-only organizations have no team projects to fetch, so bail out before the request. + if (models.organization.isLocalOrganizationId(organizationId)) { + return; + } const user = await userSessionService.get(); const teamProjects = await getAllTeamProjects(organizationId); // ensure we don't sync projects in the wrong place - if (Array.isArray(teamProjects) && user.id && !models.organization.isScratchpadOrganizationId(organizationId)) { + if (Array.isArray(teamProjects) && user.id) { await syncTeamProjects({ teamProjects, organizationId }); } } diff --git a/packages/insomnia-data/node-src/services/unit-test-suite.ts b/packages/insomnia-data/node-src/services/unit-test-suite.ts index b9d97c0d3d..28b22871e3 100644 --- a/packages/insomnia-data/node-src/services/unit-test-suite.ts +++ b/packages/insomnia-data/node-src/services/unit-test-suite.ts @@ -1,4 +1,4 @@ -import type { UnitTestSuite } from 'insomnia-data'; +import type { Query, UnitTestSuite } from 'insomnia-data'; import { database as db, models } from 'insomnia-data'; const { type } = models.unitTestSuite; @@ -32,3 +32,7 @@ export const getById = (_id: string) => db.findOne(type, { _id }) export function all() { return db.find(type); } + +export function count(query?: Query) { + return db.count(type, query); +} diff --git a/packages/insomnia-data/package.json b/packages/insomnia-data/package.json index 813cc985a4..7f2e1b4e18 100644 --- a/packages/insomnia-data/package.json +++ b/packages/insomnia-data/package.json @@ -2,7 +2,7 @@ "private": true, "name": "insomnia-data", "license": "Apache-2.0", - "version": "13.2.0", + "version": "13.3.0", "author": "Kong ", "description": "Insomnia data functionalities", "repository": { @@ -39,7 +39,8 @@ "deep-equal": "2.2.3", "fuzzysort": "^1.9.0", "graphql": "^16.10.0", - "uuid": "^11.1.1" + "uuid": "^11.1.1", + "zod": "^3.25.75" }, "devDependencies": { "@getinsomnia/node-libcurl": "3.3.0", diff --git a/packages/insomnia-data/src/database/types.ts b/packages/insomnia-data/src/database/types.ts index c509e71578..11afca4879 100644 --- a/packages/insomnia-data/src/database/types.ts +++ b/packages/insomnia-data/src/database/types.ts @@ -27,6 +27,8 @@ export interface SpecificQuery { $in?: (string | null)[]; $nin?: string[]; $ne?: string | null; + // NeDB's `$ne` also matches documents where the field is absent, so optional keys need this too. + $exists?: boolean; } export type Query = { diff --git a/packages/insomnia-data/src/models/base-schemas.ts b/packages/insomnia-data/src/models/base-schemas.ts new file mode 100644 index 0000000000..b1331ba8ab --- /dev/null +++ b/packages/insomnia-data/src/models/base-schemas.ts @@ -0,0 +1,23 @@ +import { z } from 'zod/v4'; + +import { type AllTypes, allTypesSchema, baseModelSchema } from './base-types'; + +export function createModelSchema(type: TType, _prefix: TPrefix) { + return baseModelSchema.extend({ + type: allTypesSchema.extract([type]), + isPrivate: baseModelSchema.shape.isPrivate.optional().default(false), + name: baseModelSchema.shape.name.optional().default(''), + }); +} + +// Basic literal types that can appear in JSON data +export const LiteralSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +export const KeyLiteralSchema = z.union([z.string(), z.number()]); + +type Literal = z.infer; +type Json = Literal | { [key: string]: Json } | Json[]; + +// Recursive JSON schema that can handle nested objects and arrays +export const JsonSchema: z.ZodType = z.lazy(() => + z.union([LiteralSchema, z.array(JsonSchema), z.record(KeyLiteralSchema, JsonSchema)]), +); diff --git a/packages/insomnia-data/src/models/base-types.ts b/packages/insomnia-data/src/models/base-types.ts index 732b194715..7d92e37fab 100644 --- a/packages/insomnia-data/src/models/base-types.ts +++ b/packages/insomnia-data/src/models/base-types.ts @@ -1,58 +1,65 @@ -export type AllTypes = - | 'ApiSpec' - | 'CaCertificate' - | 'ClientCertificate' - | 'CloudCredential' - | 'CookieJar' - | 'Environment' - | 'GitCredentials' - | 'GitRepository' - | 'GrpcRequest' - | 'GrpcRequestMeta' - | 'MockRoute' - | 'MockServer' - | 'OAuth2Token' - | 'PluginData' - | 'Project' - | 'ProjectLintRuleset' - | 'ProtoDirectory' - | 'ProtoFile' - | 'Request' - | 'RequestGroup' - | 'RequestGroupMeta' - | 'RequestMeta' - | 'RequestVersion' - | 'Response' - | 'RunnerTestResult' - | 'Settings' - | 'SocketIOPayload' - | 'SocketIORequest' - | 'SocketIOResponse' - | 'SocketIORequestMeta' - | 'Stats' - | 'UnitTest' - | 'UnitTestResult' - | 'UnitTestSuite' - | 'UserSession' - | 'WebSocketPayload' - | 'WebSocketRequest' - | 'WebSocketResponse' - | 'WebSocketRequestMeta' - | 'McpRequest' - | 'McpResponse' - | 'McpPayload' - | 'Workspace' - | 'WorkspaceMeta'; +import { z } from 'zod/v4'; -export interface BaseModel { - _id: string; - type: AllTypes; +export const allTypesSchema = z.enum([ + 'ApiSpec', + 'CaCertificate', + 'ClientCertificate', + 'CloudCredential', + 'CookieJar', + 'Environment', + 'GitCredentials', + 'GitRepository', + 'GrpcRequest', + 'GrpcRequestMeta', + 'MockRoute', + 'MockServer', + 'OAuth2Token', + 'PluginData', + 'Project', + 'ProjectLintRuleset', + 'ProtoDirectory', + 'ProtoFile', + 'Request', + 'RequestGroup', + 'RequestGroupMeta', + 'RequestMeta', + 'RequestVersion', + 'Response', + 'RunnerTestResult', + 'Settings', + 'SocketIOPayload', + 'SocketIORequest', + 'SocketIOResponse', + 'SocketIORequestMeta', + 'Stats', + 'UnitTest', + 'UnitTestResult', + 'UnitTestSuite', + 'UserSession', + 'WebSocketPayload', + 'WebSocketRequest', + 'WebSocketResponse', + 'WebSocketRequestMeta', + 'McpRequest', + 'McpResponse', + 'McpPayload', + 'Workspace', + 'WorkspaceMeta', +]); + +export type AllTypes = z.infer; + +export const baseModelSchema = z.object({ + _id: z.string(), + type: allTypesSchema, // TSCONVERSION -- parentId is always required for all models, except 4: // - Stats, Settings, and Project, which never have a parentId // - Workspace optionally has a parentId (which will be the id of a Project) - parentId: string; // or null - modified: number; - created: number; - isPrivate: boolean; - name: string; -} + parentId: z.string(), + modified: z.number(), + created: z.number(), + isPrivate: z.boolean(), + name: z.string(), +}); + +export type BaseModel = z.infer; diff --git a/packages/insomnia-data/src/models/cookie-jar.ts b/packages/insomnia-data/src/models/cookie-jar.ts index a19c68d03e..12811242f4 100644 --- a/packages/insomnia-data/src/models/cookie-jar.ts +++ b/packages/insomnia-data/src/models/cookie-jar.ts @@ -25,6 +25,10 @@ export interface Cookie { hostOnly?: boolean; pathIsDefault?: boolean; lastAccessed?: Date; + // 'manual' cookies (added/edited via the Cookie Jar UI, or written by a script) may contain + // template syntax that gets rendered; 'response' cookies came from a server's Set-Cookie + // header (or an import) and are always rendered as plain literal text. + source?: 'manual' | 'response'; } export interface BaseCookieJar { diff --git a/packages/insomnia-data/src/models/environment.ts b/packages/insomnia-data/src/models/environment.ts index 1e6638dc10..6d0901812a 100644 --- a/packages/insomnia-data/src/models/environment.ts +++ b/packages/insomnia-data/src/models/environment.ts @@ -1,3 +1,6 @@ +import { z } from 'zod/v4'; + +import { createModelSchema } from './base-schemas'; import type { BaseModel } from './base-types'; export const name = 'Environment'; @@ -14,18 +17,6 @@ export const canSync = true; // for those keys do not need to add in model init method export const optionalKeys: (keyof BaseEnvironment)[] = ['kvPairData', 'environmentType', 'dataPropertyOrder']; -export interface BaseEnvironment { - name: string; - data: Record; - dataPropertyOrder?: Record | null; - kvPairData?: EnvironmentKvPairData[]; - color: string | null; - metaSortKey: number; - // For sync control - isPrivate: boolean; - environmentType?: EnvironmentType; -} - export enum EnvironmentType { JSON = 'json', KVPAIR = 'kv', @@ -37,15 +28,32 @@ export enum EnvironmentKvPairDataType { SECRET = 'secret', } -export interface EnvironmentKvPairData { - id: string; - name: string; - value: string; - type: EnvironmentKvPairDataType; - enabled?: boolean; -} +export const EnvironmentKvPairDataSchema = z.object({ + id: z.string(), + name: z.string().optional().default(''), + value: z.string().optional().default(''), + type: z.enum([EnvironmentKvPairDataType.JSON, EnvironmentKvPairDataType.STRING, EnvironmentKvPairDataType.SECRET]), + enabled: z.boolean().optional(), +}); +export type EnvironmentKvPairData = z.infer; -export type Environment = BaseModel & BaseEnvironment; +export const environmentTypeSchema = z.enum([EnvironmentType.JSON, EnvironmentType.KVPAIR]); +export const baseEnvironmentSchema = z.object({ + name: z.string().optional().default(''), + data: z.record(z.string(), z.any()).optional().default({}), + color: z.string().optional().nullable(), + dataPropertyOrder: z.record(z.string(), z.any()).nullable().optional(), + kvPairData: z.array(EnvironmentKvPairDataSchema).optional(), + metaSortKey: z + .number() + .optional() + .default(() => Date.now()), + environmentType: environmentTypeSchema.optional(), +}); +export type BaseEnvironment = z.infer; + +export const schema = createModelSchema(type, prefix).extend(baseEnvironmentSchema.shape); +export type Environment = z.infer; // This is a representation of the data taken from a csv or json file AKA iterationData export type UserUploadEnvironment = Pick; diff --git a/packages/insomnia-data/src/models/organization.ts b/packages/insomnia-data/src/models/organization.ts index 332002b96e..202a4b4c89 100644 --- a/packages/insomnia-data/src/models/organization.ts +++ b/packages/insomnia-data/src/models/organization.ts @@ -1,8 +1,35 @@ -import type { PersonalPlanType } from 'insomnia-api'; +import type { Organization, PersonalPlanType } from 'insomnia-api'; export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad'; export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID; +export const KONNECT_ORGANIZATION_ID_PREFIX = 'org_konnect_'; +export const KONNECT_ORGANIZATION_NAME = 'Control Planes'; + +// The Konnect organization is local-only, but the database is not partitioned per user, so the +// account id is baked into the id to keep one account's Konnect data out of another's. +export const getKonnectOrganizationId = (accountId: string) => `${KONNECT_ORGANIZATION_ID_PREFIX}${accountId}`; +export const isKonnectOrganizationId = (organizationId: string) => + organizationId.startsWith(KONNECT_ORGANIZATION_ID_PREFIX); + +/** Organizations that exist only on this machine and must never be used for organization-scoped API calls. */ +export const isLocalOrganizationId = (organizationId: string) => + isScratchpadOrganizationId(organizationId) || isKonnectOrganizationId(organizationId); + +export const buildKonnectOrganization = (accountId: string): Organization => ({ + id: getKonnectOrganizationId(accountId), + name: KONNECT_ORGANIZATION_NAME, + picture: null, + owner_first_name: null, + owner_last_name: null, + owner_email: null, + total_members: 1, + total_invites: 0, + total_admins: 1, + is_owner: true, + can_leave: false, +}); + export const formatCurrentPlanType = (type: PersonalPlanType) => { switch (type) { case 'free': { diff --git a/packages/insomnia-data/src/models/request-meta.ts b/packages/insomnia-data/src/models/request-meta.ts index c5ba0e60c6..b5e17534d6 100644 --- a/packages/insomnia-data/src/models/request-meta.ts +++ b/packages/insomnia-data/src/models/request-meta.ts @@ -22,6 +22,8 @@ export interface BaseRequestMeta { downloadPath: string | null; expandedAccordionKeys: Partial>; activeMcpPrimitive?: string | null; + streamSummaryPath: string | null; + streamSummaryRenderMarkdown: boolean; } export type RequestMeta = BaseModel & BaseRequestMeta; @@ -41,5 +43,7 @@ export function init() { downloadPath: null, expandedAccordionKeys: {}, activeMcpPrimitive: null, + streamSummaryPath: null, + streamSummaryRenderMarkdown: false, }; } diff --git a/packages/insomnia-data/src/models/settings.ts b/packages/insomnia-data/src/models/settings.ts index 0e845a2076..7b23cf5c41 100644 --- a/packages/insomnia-data/src/models/settings.ts +++ b/packages/insomnia-data/src/models/settings.ts @@ -94,5 +94,6 @@ export function init(): BaseSettings { disabledBlockedProperties: [], disabledBlockedRoots: [], npmRegistryUrl: '', + enableLegacyUnitTests: false, }; } diff --git a/packages/insomnia-data/src/models/workspace.ts b/packages/insomnia-data/src/models/workspace.ts index 0223f374a5..5c8fd2a2f0 100644 --- a/packages/insomnia-data/src/models/workspace.ts +++ b/packages/insomnia-data/src/models/workspace.ts @@ -62,7 +62,8 @@ export const scopeToActivity = (scope: WorkspaceScope) => { return 'debug'; } case WorkspaceScopeKeys.design: { - return 'spec'; + // Legacy spec workspace route has been merged into the debug route, so we return debug here as well. + return 'debug'; } case WorkspaceScopeKeys.mockServer: { return 'mock-server'; diff --git a/packages/insomnia-inso/package.json b/packages/insomnia-inso/package.json index c525a408ff..f66248d75c 100644 --- a/packages/insomnia-inso/package.json +++ b/packages/insomnia-inso/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "insomnia-inso", - "version": "13.2.0", + "version": "13.3.0", "homepage": "https://insomnia.rest", "description": "A CLI for Insomnia - The Collaborative API Design Tool", "author": "Kong ", @@ -45,6 +45,7 @@ "artifacts": "esr src/scripts/artifacts.ts" }, "devDependencies": { + "@types/papaparse": "^5.3.15", "cross-env": "^10.1.0", "esbuild": "^0.25.10", "esbuild-runner": "^2.2.2", @@ -59,15 +60,16 @@ "@stoplight/types": "^14.1.1", "commander": "^12.1.0", "consola": "^3.4.2", - "fast-string-width": "^3.0.2", - "is-unicode-supported": "^2.1.0", "cosmiconfig": "^9.0.0", "enquirer": "^2.4.1", + "fast-string-width": "^3.0.2", + "is-unicode-supported": "^2.1.0", + "papaparse": "^5.5.2", "picocolors": "^1.1.1", "string-argv": "^0.3.2", "yaml": "^2.7.1" }, "optionalDependencies": { - "@kong/insomnia-plugin-external-vault": "0.1.5-dev.20260806050036" + "@kong/insomnia-plugin-external-vault": "0.1.5" } } diff --git a/packages/insomnia-inso/src/cli.ts b/packages/insomnia-inso/src/cli.ts index b6804bdf4c..095bc0b8b1 100644 --- a/packages/insomnia-inso/src/cli.ts +++ b/packages/insomnia-inso/src/cli.ts @@ -29,6 +29,7 @@ import { servicesNodeImpl } from 'insomnia-data/node'; import { generate } from 'insomnia-testing/src/generate/generate'; import { runTestsCli } from 'insomnia-testing/src/run/run'; import orderedJSON from 'json-order'; +import Papa from 'papaparse'; import { parseArgsStringToArgv } from 'string-argv'; import { v4 as uuidv4 } from 'uuid'; @@ -215,7 +216,7 @@ const pathToIterationData = async (pathOrUrl: string, env: string[]): Promise ({ ...data, ...envAsObject })); return transformIterationDataToEnvironmentList(list); }; -const getListFromFileOrUrl = (content: string, fileType?: string): Record[] => { +export const getListFromFileOrUrl = (content: string, fileType?: string): Record[] => { if (fileType === 'json') { try { const jsonDataContent = JSON.parse(content); @@ -229,12 +230,8 @@ const getListFromFileOrUrl = (content: string, fileType?: string): Record row.split(',')); - // at least 2 rows required for csv + const csvRows = Papa.parse(content, { skipEmptyLines: true, delimiter: ',' }).data; + // at least 2 rows required for csv, first row as variable names if (csvRows.length > 1) { const csvHeaders = csvRows[0]; const csvContentRows = csvRows.slice(1); diff --git a/packages/insomnia-inso/src/db/adapters/git-adapter.test.ts b/packages/insomnia-inso/src/db/adapters/git-adapter.test.ts index a101fdf635..80978380db 100644 --- a/packages/insomnia-inso/src/db/adapters/git-adapter.test.ts +++ b/packages/insomnia-inso/src/db/adapters/git-adapter.test.ts @@ -1,6 +1,9 @@ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; import gitAdapter from './git-adapter'; @@ -36,4 +39,104 @@ describe('gitAdapter()', () => { const db = await gitAdapter(workingDir); expect(db).toBe(null); }); + + describe('type restriction', () => { + let workingDir: string; + + afterEach(() => { + if (workingDir) { + fs.rmSync(workingDir, { recursive: true, force: true }); + } + }); + + it('does not read a Settings folder, since Settings.canSync is false', async () => { + // gitAdapter() only reads folders for types where models.canSync() is + // true, so a 'Settings' folder is never opened even when it sits + // alongside a normal Workspace folder with well-formed content. + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-')); + const insomniaDir = path.join(workingDir, '.insomnia'); + + fs.mkdirSync(path.join(insomniaDir, 'Workspace'), { recursive: true }); + fs.writeFileSync( + path.join(insomniaDir, 'Workspace', 'wrk_new.yml'), + YAML.stringify({ + _id: 'wrk_new', + type: 'Workspace', + name: 'New workspace', + parentId: null, + scope: 'collection', + }), + ); + + fs.mkdirSync(path.join(insomniaDir, 'Settings'), { recursive: true }); + const settingsData = { + _id: 'set_new', + type: 'Settings', + validateSSL: false, + proxyEnabled: true, + httpProxy: 'http://example.com:8080', + httpsProxy: 'http://example.com:8080', + }; + fs.writeFileSync( + path.join(insomniaDir, 'Settings', 'set_new.yml'), + YAML.stringify(settingsData), + ); + + const db = await gitAdapter(workingDir); + + expect(db?.Settings).toHaveLength(0); + expect(db?.Workspace).toHaveLength(1); + }); + + // gitAdapter() only ever reads folders named after one of the Database + // keys (see packages/insomnia-inso/src/db/types.ts), so this is the full + // set of syncable types reachable through the inso CLI import path today. + const syncableTypes = ['ApiSpec', 'Environment', 'Request', 'RequestGroup', 'Workspace', 'UnitTestSuite', 'UnitTest'] as const; + // Non-syncable Database keys: these folders exist in legacy .insomnia + // directories but must never be merged in, since canSync is false. + const nonSyncableTypes = ['WorkspaceMeta', 'ClientCertificate', 'CaCertificate', 'CookieJar', 'CloudCredential', 'Settings'] as const; + + const writeMinimalDoc = (insomniaDir: string, type: string) => { + const typeDir = path.join(insomniaDir, type); + fs.mkdirSync(typeDir, { recursive: true }); + const doc = { _id: `${type.toLowerCase()}_new`, type, name: `New ${type}`, parentId: null }; + fs.writeFileSync(path.join(typeDir, `${doc._id}.yml`), YAML.stringify(doc)); + }; + + it.each(syncableTypes)('reads a %s folder, since canSync is true', async type => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-')); + const insomniaDir = path.join(workingDir, '.insomnia'); + writeMinimalDoc(insomniaDir, type); + + const db = await gitAdapter(workingDir); + + expect(db?.[type as keyof typeof db]).toHaveLength(1); + }); + + it.each(nonSyncableTypes)('does not read a %s folder, since canSync is false', async type => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-')); + const insomniaDir = path.join(workingDir, '.insomnia'); + writeMinimalDoc(insomniaDir, type); + // Include a syncable folder alongside it so we confirm the whole read didn't just fail + writeMinimalDoc(insomniaDir, 'Workspace'); + + const db = await gitAdapter(workingDir); + + expect(db?.[type as keyof typeof db]).toHaveLength(0); + expect(db?.Workspace).toHaveLength(1); + }); + + it.each(nonSyncableTypes)('ignores a document whose own type is %s even when filed under a syncable folder', async otherType => { + workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-adapter-')); + const insomniaDir = path.join(workingDir, '.insomnia'); + const typeDir = path.join(insomniaDir, 'Workspace'); + fs.mkdirSync(typeDir, { recursive: true }); + const doc = { _id: 'wrk_mismatched', type: otherType, name: 'Mismatched doc', parentId: null }; + fs.writeFileSync(path.join(typeDir, `${doc._id}.yml`), YAML.stringify(doc)); + + const db = await gitAdapter(workingDir); + + expect(db?.Workspace).toHaveLength(0); + }); + }); }); diff --git a/packages/insomnia-inso/src/db/adapters/git-adapter.ts b/packages/insomnia-inso/src/db/adapters/git-adapter.ts index f640e96640..985eae9ba4 100644 --- a/packages/insomnia-inso/src/db/adapters/git-adapter.ts +++ b/packages/insomnia-inso/src/db/adapters/git-adapter.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { models } from 'insomnia-data'; import YAML from 'yaml'; import type { Database, DbAdapter } from '../types'; @@ -35,10 +36,20 @@ const gitAdapter: DbAdapter = async (dir, filterTypes) => { return; } const obj = YAML.parse(contents); + + // Document's own type must match the folder it was read from. + if (obj?.type !== type) { + console.error(`Ignoring "${fileName}": document type "${obj?.type}" does not match folder type "${type}"`); + return; + } + (db[type] as {}[]).push(obj); }; - const types = filterTypes?.length ? filterTypes : (Object.keys(db) as (keyof Database)[]); + // Only ever read folders for types explicitly marked as syncable. A folder + // named after a non-syncable/global-singleton type (e.g. Settings) is skipped. + const requestedTypes = filterTypes?.length ? filterTypes : (Object.keys(db) as (keyof Database)[]); + const types = requestedTypes.filter(t => models.getModel(t)?.canSync); await Promise.all( types.map(async t => { // Get all files in type dir diff --git a/packages/insomnia-inso/src/db/adapters/insomnia-adapter.ts b/packages/insomnia-inso/src/db/adapters/insomnia-adapter.ts index 6311e3859c..98a9d49a09 100644 --- a/packages/insomnia-inso/src/db/adapters/insomnia-adapter.ts +++ b/packages/insomnia-inso/src/db/adapters/insomnia-adapter.ts @@ -127,7 +127,7 @@ const insomniaAdapter: DbAdapter = async (filePath, filterTypes) => { const obj = parseRaw(model); // Store it, only if the key value exists - (db[obj.type] as {}[])?.push(obj); + (db[obj.type as keyof typeof db] as {}[])?.push(obj); } }); diff --git a/packages/insomnia-inso/src/db/models/types.ts b/packages/insomnia-inso/src/db/models/types.ts index 56fc7659fb..0737dea8e5 100644 --- a/packages/insomnia-inso/src/db/models/types.ts +++ b/packages/insomnia-inso/src/db/models/types.ts @@ -1,10 +1,10 @@ // Simplified and extracted from insomnia/src/models/* -import type { Database } from '../types'; +import type { AllTypes } from 'insomnia-data'; export interface BaseModel { _id: string; name: string; - type: keyof Database; + type: AllTypes; parentId: string; } diff --git a/packages/insomnia-inso/src/iteration-data.test.ts b/packages/insomnia-inso/src/iteration-data.test.ts new file mode 100644 index 0000000000..568349d44d --- /dev/null +++ b/packages/insomnia-inso/src/iteration-data.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { getListFromFileOrUrl } from './cli'; + +describe('getListFromFileOrUrl()', () => { + it('parses simple csv', () => { + expect(getListFromFileOrUrl('a,b\n1,2\n3,4', 'csv')).toEqual([ + { a: '1', b: '2' }, + { a: '3', b: '4' }, + ]); + }); + + it('keeps commas inside quoted fields', () => { + expect(getListFromFileOrUrl('origin,destination\n"New York, NY","Boston, MA"\n', 'csv')).toEqual([ + { origin: 'New York, NY', destination: 'Boston, MA' }, + ]); + }); + + it('unescapes doubled quotes inside quoted fields', () => { + expect(getListFromFileOrUrl('name,quote\n"John ""JJ"" Smith",hello', 'csv')).toEqual([ + { name: 'John "JJ" Smith', quote: 'hello' }, + ]); + }); + + it('handles CRLF line breaks', () => { + expect(getListFromFileOrUrl('a,b\r\n1,2\r\n', 'csv')).toEqual([{ a: '1', b: '2' }]); + }); + + it('handles newlines inside quoted fields', () => { + expect(getListFromFileOrUrl('a,b\n"line1\nline2",2', 'csv')).toEqual([{ a: 'line1\nline2', b: '2' }]); + }); + + it('defaults missing trailing cells to empty string', () => { + expect(getListFromFileOrUrl('a,b,c\n1,2', 'csv')).toEqual([{ a: '1', b: '2', c: '' }]); + }); + + it('throws when there are fewer than two rows', () => { + expect(() => getListFromFileOrUrl('a,b', 'csv')).toThrow( + 'CSV file must contain at least two rows with first row as variable names', + ); + }); + + it('throws for unsupported file types', () => { + expect(() => getListFromFileOrUrl('anything', 'yaml')).toThrow('Uploaded file is unsupported yaml'); + }); +}); diff --git a/packages/insomnia-scripting-environment/package.json b/packages/insomnia-scripting-environment/package.json index d138d69f8f..43896fd055 100644 --- a/packages/insomnia-scripting-environment/package.json +++ b/packages/insomnia-scripting-environment/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "insomnia-scripting-environment", - "version": "13.2.0", + "version": "13.3.0", "description": "", "main": "src/objects/index.ts", "types": "src/objects/index.ts", diff --git a/packages/insomnia-scripting-environment/src/objects/__tests__/environments.test.ts b/packages/insomnia-scripting-environment/src/objects/__tests__/environments.test.ts index 2bdad304c3..dba7e73efd 100644 --- a/packages/insomnia-scripting-environment/src/objects/__tests__/environments.test.ts +++ b/packages/insomnia-scripting-environment/src/objects/__tests__/environments.test.ts @@ -7,7 +7,7 @@ import { Folder, ParentFolders } from '../folders'; import { Url } from '../urls'; describe('test Variables object', () => { - it('test basic operations', async () => { + it('test basic operations', () => { const variables = new Variables({ baseGlobalVars: new Environment('baseGlobals', { value: 'baseValue' }), globalVars: new Environment('globals', { value: 'xyz' }), @@ -18,17 +18,17 @@ describe('test Variables object', () => { localVars: new Environment('local', {}), }); - const uuidAndXyz = await variables.replaceIn('{{ $randomUUID }}{{value }}'); + const uuidAndXyz = variables.replaceIn('{{ $randomUUID }}{{value }}'); expect(validate(uuidAndXyz.replace('xyz', ''))).toBeTruthy(); - const uuidAndBrackets1 = await variables.replaceIn('{{ $randomUUID }}}}'); + const uuidAndBrackets1 = variables.replaceIn('{{ $randomUUID }}}}'); expect(validate(uuidAndBrackets1.replace('}}', ''))).toBeTruthy(); - const uuidAndBrackets2 = await variables.replaceIn('}}{{ $randomUUID }}'); + const uuidAndBrackets2 = variables.replaceIn('}}{{ $randomUUID }}'); expect(validate(uuidAndBrackets2.replace('}}', ''))).toBeTruthy(); }); - it('test environment overriding', async () => { + it('test environment overriding', () => { const baseGlobalVariables = new Variables({ baseGlobalVars: new Environment('baseGlobals', { scope: 'baseGlobals', value: 'baseGlobals-value' }), globalVars: new Environment('globals', {}), @@ -94,10 +94,10 @@ describe('test Variables object', () => { expect(variablesWithFolderLevelData.get('value')).toEqual('folderLevel2-value'); expect(variablesWithLocalData.get('value')).toEqual('local-value'); - expect(await variablesWithFolderLevelData.replaceIn('{{ value}}')).toEqual('folderLevel2-value'); + expect(variablesWithFolderLevelData.replaceIn('{{ value}}')).toEqual('folderLevel2-value'); const urlObj = new Url('http://x/{{ value }}'); - expect(await variablesWithFolderLevelData.replaceIn(urlObj)).toEqual('http://x/folderLevel2-value'); + expect(variablesWithFolderLevelData.replaceIn(urlObj)).toEqual('http://x/folderLevel2-value'); }); it('variables operations', () => { diff --git a/packages/insomnia-scripting-environment/src/objects/__tests__/properties.test.ts b/packages/insomnia-scripting-environment/src/objects/__tests__/properties.test.ts index ef991f2081..e798ad8448 100644 --- a/packages/insomnia-scripting-environment/src/objects/__tests__/properties.test.ts +++ b/packages/insomnia-scripting-environment/src/objects/__tests__/properties.test.ts @@ -14,7 +14,7 @@ describe('test Property objects', () => { }); }); - it('Property: basic operations', async () => { + it('Property: basic operations', () => { const prop = new Property('id', 'name', false, { id: 'real_id', name: 'real_name' }); expect(prop.toJSON()).toEqual({ @@ -23,9 +23,9 @@ describe('test Property objects', () => { name: 'real_name', }); - expect(await Property.replaceSubstitutions('{{ hehe }}', { hehe: 777 })).toEqual('777'); + expect(Property.replaceSubstitutions('{{ hehe }}', { hehe: 777 })).toEqual('777'); expect( - await Property.replaceSubstitutionsIn( + Property.replaceSubstitutionsIn( { value: '{{ hehe }}', }, diff --git a/packages/insomnia-scripting-environment/src/objects/environments.ts b/packages/insomnia-scripting-environment/src/objects/environments.ts index 8b1572a9c8..9c3dde8b05 100644 --- a/packages/insomnia-scripting-environment/src/objects/environments.ts +++ b/packages/insomnia-scripting-environment/src/objects/environments.ts @@ -124,7 +124,7 @@ export class Environment { * * @throws Will throw an error if template is not a string or object. */ - replaceIn = async (template: string | object) => { + replaceIn = (template: string | object) => { if (typeof template === 'object') { template = template.toString(); } else if (typeof template !== 'string') { @@ -327,7 +327,7 @@ export class Variables { * * @throws Will throw an error if template is not a string or object. */ - replaceIn = async (template: string | object) => { + replaceIn = (template: string | object) => { if (typeof template === 'object') { template = template.toString(); } else if (typeof template !== 'string') { diff --git a/packages/insomnia-scripting-environment/src/objects/interpolator.ts b/packages/insomnia-scripting-environment/src/objects/interpolator.ts index 636cbc3685..7f149d9f88 100644 --- a/packages/insomnia-scripting-environment/src/objects/interpolator.ts +++ b/packages/insomnia-scripting-environment/src/objects/interpolator.ts @@ -17,9 +17,9 @@ class Interpolator { }); } - render = async (template: string, context: object): Promise => { + render = (template: string, context: object): string => { // TODO: support plugins - return this.engine.parseAndRender(this.renderWithFaker(template), context); + return this.engine.parseAndRenderSync(this.renderWithFaker(template), context); }; renderWithFaker = (template: string) => { diff --git a/packages/insomnia-scripting-environment/src/objects/properties.ts b/packages/insomnia-scripting-environment/src/objects/properties.ts index 06c729f311..a6f9bf763c 100644 --- a/packages/insomnia-scripting-environment/src/objects/properties.ts +++ b/packages/insomnia-scripting-environment/src/objects/properties.ts @@ -147,7 +147,7 @@ export class Property extends PropertyBase { static _index = 'id'; - static async replaceSubstitutions(content: string, ...variables: object[]): Promise { + static replaceSubstitutions(content: string, ...variables: object[]): string { if (!Array.isArray(variables) || typeof content !== 'string') { throw new TypeError( "replaceSubstitutions: the first param's type is not string or other parameters are not an array", @@ -161,7 +161,7 @@ export class Property extends PropertyBase { return getInterpolator().render(content, context); } - static async replaceSubstitutionsIn(obj: object, ...variables: object[]): Promise { + static replaceSubstitutionsIn(obj: object, ...variables: object[]): object { if (!Array.isArray(variables) || typeof obj !== 'object') { throw new TypeError( "replaceSubstitutions: the first param's type is not object or other parameters are not an array", @@ -177,7 +177,7 @@ export class Property extends PropertyBase { context = { ...context, ...variable }; }); - const rendered = await getInterpolator().render(content, context); + const rendered = getInterpolator().render(content, context); return JSON.parse(rendered); } catch (e: any) { throw new Error(`replaceSubstitutionsIn: ${e.toString()}`); diff --git a/packages/insomnia-smoke-test/.gitignore b/packages/insomnia-smoke-test/.gitignore index dc0a9c6e15..06f03d4b8d 100644 --- a/packages/insomnia-smoke-test/.gitignore +++ b/packages/insomnia-smoke-test/.gitignore @@ -20,3 +20,6 @@ fixtures/inso-nedb/insomnia.OAuth2Token.db fixtures/inso-nedb/insomnia.PluginData.db fixtures/inso-nedb/insomnia.CaCertificate.db fixtures/inso-nedb/insomnia.CloudCredential.db + +# Playwright junit/xml run artifacts +test-results.xml diff --git a/packages/insomnia-smoke-test/package.json b/packages/insomnia-smoke-test/package.json index a969ab43d6..c12844171e 100644 --- a/packages/insomnia-smoke-test/package.json +++ b/packages/insomnia-smoke-test/package.json @@ -11,7 +11,7 @@ "bugs": { "url": "https://github.com/kong/insomnia/issues" }, - "version": "13.2.0", + "version": "13.3.0", "scripts": { "test:dev": "xvfb-maybe cross-env BUNDLE=dev playwright test", "test:build": "xvfb-maybe cross-env BUNDLE=build playwright test", diff --git a/packages/insomnia-smoke-test/playwright.config.ts b/packages/insomnia-smoke-test/playwright.config.ts index b99920a7a5..c5db75a33b 100644 --- a/packages/insomnia-smoke-test/playwright.config.ts +++ b/packages/insomnia-smoke-test/playwright.config.ts @@ -33,8 +33,11 @@ const viteServer: PlaywrightTestConfig['webServer'] = { stdout: 'pipe', stderr: 'pipe', wait: { - stdout: /VITE\s+ready in/, + stdout: /VITE.+ready in/, }, + // Without this, Playwright SIGKILLs the process group on teardown, which the + // .vite-port cleanup in vite.config.ts can't intercept, leaving a stale port file. + gracefulShutdown: isWindows ? undefined : { signal: 'SIGTERM', timeout: 2000 }, }; const onlyStartWebServerInDev = !process.env.BUNDLE || process.env.BUNDLE === 'dev'; const config: PlaywrightTestConfig = { diff --git a/packages/insomnia-smoke-test/playwright/pages/components/export-modal.ts b/packages/insomnia-smoke-test/playwright/pages/components/export-modal.ts index 961368cf5c..629b0a248f 100644 --- a/packages/insomnia-smoke-test/playwright/pages/components/export-modal.ts +++ b/packages/insomnia-smoke-test/playwright/pages/components/export-modal.ts @@ -11,7 +11,7 @@ export class ExportModal { * Handles the export type selection modal (Insomnia v5 or HAR). * @param format - The format to select ('yaml' for Insomnia v5, 'har' for HAR) */ - async selectExportFormat(format: 'yaml' | 'har'): Promise { + async selectExportFormat(format: 'yaml' | 'har' | 'json'): Promise { await this.page.getByText('Which format would you like to export as?').waitFor({ state: 'visible' }); // The modal uses a -
- -
- - - - - { - setAllExpanded(!allExpanded); - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.requestListExpandCollapseClicked, - }); - toggleExpandAllFetcher.submit({ - organizationId, - projectId, - workspaceId, - toggle: allExpanded ? 'collapse-all' : 'expand-all', - }); - }} - className="flex aspect-square h-full items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset" - > - {({ isSelected }) => ( - - )} - - - {allExpanded ? 'Collapse all' : 'Expand all'} - - - - - - - - createInCollectionActionList - .find(i => i.items.find(a => a.id === key)) - ?.items.find(a => a.id === key) - ?.action() - } - items={createInCollectionActionList} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {section => ( - -
- {section.name} -
- - {item => ( - - - {item.name} - {item.hint && } - - )} - -
- )} -
-
-
- - - item.pinned)} - aria-label="Pinned Requests" - disallowEmptySelection - selectedKeys={requestId ? [requestId] : []} - selectionMode="single" - > - {item => { - return ( - { - if (e.button === 1) { - e.preventDefault(); - tabNavigate( - { - organization: organizationId, - project: activeProject, - workspace: activeWorkspace, - item: item.doc, - }, - { withTab: true, shouldNavigate: true, searchParams }, - ); - } - }} - onPress={e => { - tabNavigate( - { - organization: organizationId, - project: activeProject, - workspace: activeWorkspace, - item: item.doc, - }, - { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, - ); - }} - > -
- - {isRequest(item.doc) && ( - - {getMethodShortHand(item.doc)} - - )} - {models.webSocketRequest.isWebSocketRequest(item.doc) && ( - - WS - - )} - {models.socketIORequest.isSocketIORequest(item.doc) && ( - - IO - - )} - {models.grpcRequest.isGrpcRequest(item.doc) && ( - - gRPC - - )} - { - if (isRequestGroup(item.doc)) { - patchGroup(item.doc._id, { name: newName }); - } else { - patchRequest(item.doc._id, { name: newName }); - } - }} - /> - {item.pinned && ( - patchRequestMeta(item.doc._id, { pinned: !item.pinned })} - /> - )} -
-
- ); - }} -
- -
- - {virtualItem => { - const item = visibleCollection[virtualItem.index]; - let label = item.doc.name; - if (isRequest(item.doc)) { - label = `${getMethodShortHand(item.doc)} ${label}`; - } else if (models.webSocketRequest.isWebSocketRequest(item.doc)) { - label = `WS ${label}`; - } else if (models.grpcRequest.isGrpcRequest(item.doc)) { - label = `gRPC ${label}`; - } - - return ( - - ); - }} - -
- - - - - - )} {isRunner ? ( @@ -1163,7 +528,32 @@ const Debug = () => { }} /> )} - {Boolean(!requestId && !requestGroupId) && } + {Boolean(!requestId && !requestGroupId) && ( +
+ +
+ +
+
+ )} {isRequestSettingsModalOpen && activeRequest && ( { }; export default DebugEntry; - -const CollectionGridListItem = ({ - label, - item, - style, - organizationId, - projectId, - workspaceId, - searchParams, - patchGroup, - patchRequest, - activeProject, - activeWorkspace, -}: { - label: string; - item: Child; - style: React.CSSProperties; - organizationId: string; - projectId: string; - workspaceId: string; - searchParams: URLSearchParams; - patchGroup: (requestGroupId: string, patch: Partial) => void; - patchRequest: (requestId: string, patch: Partial | Partial | Partial) => void; - activeEnvironment: Environment; - activeProject: Project; - activeWorkspace: Workspace; -}): React.ReactNode => { - const [isEditable, setIsEditable] = useState(false); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const triggerRef = useRef(null); - const patchRequestMeta = useRequestMetaPatcher(); - - const tabNavigate = useTabNavigate(); - const groupMetaPatcher = useRequestGroupMetaPatcher(); - - const action = isRequestGroup(item.doc) - ? `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request-group/${item.doc._id}/update` - : `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${item.doc._id}/update`; - - const patchFetcher = useFetchers().find(f => f.formAction === action); - - const name = - patchFetcher?.json && - typeof patchFetcher.json === 'object' && - 'name' in patchFetcher.json && - typeof patchFetcher.json.name === 'string' - ? patchFetcher.json.name - : item.doc.name; - - const params = useParams() as { requestId?: string; requestGroupId?: string }; - - const isSelected = item.doc._id === params.requestId || item.doc._id === params.requestGroupId; - - const scrollIntoView = useCallback( - (node: HTMLDivElement) => { - if (isSelected && node) { - scrollElementIntoView(node, { behavior: 'instant' }); - } - }, - [isSelected], - ); - - return ( - {}} - onAuxClick={e => { - if (e.button === 1) { - e.preventDefault(); - tabNavigate( - { - organization: organizationId, - project: activeProject, - workspace: activeWorkspace, - item: item.doc, - }, - { withTab: true, shouldNavigate: true, searchParams }, - ); - } - }} - onPress={e => { - const id = item.doc._id; - // Toggle collapse if it's a request group - if (isRequestGroupId(id)) { - groupMetaPatcher(id, { collapsed: !item.collapsed }); - } - - tabNavigate( - { - organization: organizationId, - project: activeProject, - workspace: activeWorkspace, - item: item.doc, - }, - { - withTab: isPrimaryClickModifier(e), - shouldNavigate: true, - searchParams, - }, - ); - }} - ref={triggerRef} - > -
{ - e.preventDefault(); - setIsContextMenuOpen(true); - }} - onDoubleClick={() => setIsEditable(true)} - data-selected={isSelected} - className="relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden pr-2 pl-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) data-[selected=true]:text-(--color-font)" - style={{ - paddingLeft: `${item.level + 1}rem`, - }} - > - -
-
- ); -}; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.tsx index 9eaa68ecb1..8b7adb83fd 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.tsx @@ -13,7 +13,6 @@ import { MenuItem, MenuTrigger, Popover, - Text, ToggleButton, useDragAndDrop, } from 'react-aria-components'; @@ -42,6 +41,7 @@ import { showModal } from '~/ui/components/modals'; import { AlertModal } from '~/ui/components/modals/alert-modal'; import { InputVaultKeyModal } from '~/ui/components/modals/input-vault-key-modal'; import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; +import { Tooltip } from '~/ui/components/tooltip'; import WorkspacePaneHeader from '~/ui/components/workspace/workspace-pane-header'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { useToggleEnvironmentType } from '~/ui/hooks/use-toggle-environment-type'; @@ -154,8 +154,8 @@ const Component = ({ loaderData, params }: Route.ComponentProps) => { }[] = [ { id: 'shared', - name: 'Shared environment', - description: `${isUsingGitSync ? 'Synced with Git Sync and exportable' : isUsingInsomniaCloudSync ? 'Synced with Insomnia Sync and exportable' : 'Exportable'}`, + name: 'Add Sub Environment', + description: `Sub environments are additional nested environments that ${isUsingGitSync ? 'sync with Git Sync' : isUsingInsomniaCloudSync ? 'sync with Insomnia Sync' : 'can be exported'} and are shared with your team.`, icon: isUsingGitSync ? ['fab', 'git-alt'] : isUsingInsomniaCloudSync ? 'globe-americas' : 'file-arrow-down', action: async () => { createEnvironmentFetcher.submit({ @@ -171,8 +171,8 @@ const Component = ({ loaderData, params }: Route.ComponentProps) => { }, { id: 'private', - name: 'Private environment', - description: 'Local and not exportable', + name: 'Add Private Sub Environment', + description: 'Private sub environments stay local to your machine and are never exported, synced, or committed.', icon: 'lock', action: async () => { createEnvironmentFetcher.submit({ @@ -291,6 +291,21 @@ const Component = ({ loaderData, params }: Route.ComponentProps) => { minSize={10} collapsible > +
+ {createEnvironmentActionsList.map(action => ( + + + + ))} +
{ )} - {item.parentId === workspaceId && ( - - - - { - createEnvironmentActionsList.find(({ id }) => key === id)?.action(item); - }} - items={createEnvironmentActionsList} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {item => ( - -
- - {item.name} -
- - {item.description} - -
- )} -
-
-
- )} ); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx index 5cbbf225f4..a1cd7ac5e9 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.checkout.tsx @@ -20,7 +20,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const { syncItems } = await getSyncItems({ workspaceId }); try { - const delta = (await window.main.sync.checkout(syncItems, branch)) as Operation; + const delta = (await window.main.sync.checkout(workspaceId, syncItems, branch)) as Operation; // This is to synchronize the local database with the branch changes await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx index 69d1bbc2ef..8824e85df8 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.create.tsx @@ -19,9 +19,9 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const { syncItems } = await getSyncItems({ workspaceId }); try { - await window.main.sync.fork(branchName); + await window.main.sync.fork(workspaceId, branchName); // Checkout new branch - const delta = (await window.main.sync.checkout(syncItems, branchName)) as Operation; + const delta = (await window.main.sync.checkout(workspaceId, syncItems, branchName)) as Operation; // This is to synchronize the local database with the branch changes await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.delete.tsx index 1ab23676b3..54a24db6ba 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.delete.tsx @@ -15,9 +15,9 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) invariant(typeof branch === 'string', 'Branch is required'); try { - await window.main.sync.removeRemoteBranch(branch); + await window.main.sync.removeRemoteBranch(workspaceId, branch); try { - await window.main.sync.removeBranch(branch); + await window.main.sync.removeBranch(workspaceId, branch); } catch { // Branch doesn't exist locally, ignore } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx index dd36afa63c..09e2ef1a67 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.branch.merge.tsx @@ -18,7 +18,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const { syncItems } = await getSyncItems({ workspaceId }); let delta; try { - delta = await window.main.sync.merge(syncItems, branch); + delta = await window.main.sync.merge(workspaceId, syncItems, branch); } catch (err) { if (err instanceof UserAbortResolveMergeConflictError) { return null; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot.tsx index 707edadc1d..989c97f91e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot.tsx @@ -20,13 +20,13 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) invariant(typeof data.message === 'string', 'Message is required'); try { - await window.main.sync.takeSnapshot(data.message); + await window.main.sync.takeSnapshot(workspaceId, data.message); if (data.push) { const project = await services.project.getById(projectId); invariant(project, 'Project not found'); invariant(project.remoteId, 'Project is not remote'); - await window.main.sync.push({ + await window.main.sync.push(workspaceId, { teamId: project.parentId, teamProjectId: project.remoteId, }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx index 38fcf8ba45..774d256709 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch.tsx @@ -9,7 +9,7 @@ import { createFetcherSubmitHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.fetch'; export async function clientAction({ request, params }: Route.ClientActionArgs) { - const { projectId } = params; + const { projectId, workspaceId } = params; const project = await services.project.getById(projectId); invariant(project, 'Project not found'); @@ -17,12 +17,12 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const formData = await request.formData(); const branch = formData.get('branch'); invariant(typeof branch === 'string', 'Branch is required'); - const currentBranch = await window.main.sync.getCurrentBranchName(); + const currentBranch = await window.main.sync.getCurrentBranchName(workspaceId); try { invariant(project.remoteId, 'Project is not remote'); - await window.main.sync.checkout([], branch); - const delta = await window.main.sync.pull({ + await window.main.sync.checkout(workspaceId, [], branch); + const delta = await window.main.sync.pull(workspaceId, { candidates: [], teamId: project.parentId, teamProjectId: project.remoteId, @@ -32,7 +32,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) // This is to synchronize the local database with the branch changes await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); } catch (err) { - await window.main.sync.checkout([], currentBranch); + await window.main.sync.checkout(workspaceId, [], currentBranch); const errorMessage = err instanceof Error ? err.message : 'Unknown error while fetching remote branch.'; return { error: errorMessage, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx index 2436697a13..7808a33d95 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.pull.tsx @@ -17,7 +17,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) { const { syncItems } = await getSyncItems({ workspaceId }); try { invariant(project.remoteId, 'Project is not remote'); - const delta = await window.main.sync.pull({ + const delta = await window.main.sync.pull(workspaceId, { candidates: syncItems, teamId: project.parentId, teamProjectId: project.remoteId, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.push.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.push.tsx index 0dca7f4b45..72e6954a18 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.push.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.push.tsx @@ -16,7 +16,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) { invariant(project.remoteId, 'Project is not remote'); try { - await window.main.sync.push({ + await window.main.sync.push(workspaceId, { teamId: project.parentId, teamProjectId: project.remoteId, }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx index ce1026bc55..8c3de78348 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.restore.tsx @@ -17,7 +17,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) invariant(typeof id === 'string', 'Id is required'); try { const { syncItems } = await getSyncItems({ workspaceId }); - const delta = (await window.main.sync.rollback(id, syncItems)) as unknown as Operation; + const delta = (await window.main.sync.rollback(workspaceId, id, syncItems)) as unknown as Operation; // This is to synchronize the local database with the branch changes await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx index b2066328b7..069720ea01 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.rollback.tsx @@ -14,7 +14,7 @@ export async function clientAction({ params }: Route.ClientActionArgs) { try { const { syncItems } = await getSyncItems({ workspaceId }); - const delta = (await window.main.sync.rollbackToLatest(syncItems)) as unknown as Operation; + const delta = (await window.main.sync.rollbackToLatest(workspaceId, syncItems)) as unknown as Operation; // This is to synchronize the local database with the branch changes await database.batchModifyDocs(reparentSyncDelta(delta, projectId)); delete remoteCompareCache[workspaceId]; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage.tsx index 1c0614b65d..a9cd53ece5 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage.tsx @@ -14,7 +14,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const keys = data.keys; invariant(Array.isArray(keys), 'Keys are required'); const { syncItems } = await getSyncItems({ workspaceId }); - const status = await window.main.sync.status(syncItems); + const status = await window.main.sync.status(workspaceId, syncItems); // Staging needs to happen since it creates blobs for the files const itemsToStage = keys .map(key => { @@ -27,7 +27,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }) .filter(isNotNullOrUndefined); - await window.main.sync.stage(itemsToStage); + await window.main.sync.stage(workspaceId, itemsToStage); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx index cbd52f5111..25c4b5cfbe 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data.tsx @@ -14,17 +14,19 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { invariant(project, 'Project not found'); invariant(project.remoteId, 'Project is not remote'); const { syncItems } = await getSyncItems({ workspaceId }); - const localBranches = (await window.main.sync.getBranchNames()).sort(); - const currentBranch = await window.main.sync.getCurrentBranchName(); - const history = (await window.main.sync.getHistory()).sort((a, b) => (b.created > a.created ? 1 : -1)); - const historyCount = await window.main.sync.getHistoryCount(); - const status = await window.main.sync.status(syncItems); + const localBranches = (await window.main.sync.getBranchNames(workspaceId)).sort(); + const currentBranch = await window.main.sync.getCurrentBranchName(workspaceId); + const history = (await window.main.sync.getHistory(workspaceId)).sort((a, b) => (b.created > a.created ? 1 : -1)); + const historyCount = await window.main.sync.getHistoryCount(workspaceId); + const status = await window.main.sync.status(workspaceId, syncItems); let remoteBranches: string[] = []; let compare = { ahead: 0, behind: 0 }; try { - remoteBranches = (remoteBranchesCache[workspaceId] || (await window.main.sync.getRemoteBranchNames())).sort(); - compare = remoteCompareCache[workspaceId] || (await window.main.sync.compareRemoteBranch()); + remoteBranches = ( + remoteBranchesCache[workspaceId] || (await window.main.sync.getRemoteBranchNames(workspaceId)) + ).sort(); + compare = remoteCompareCache[workspaceId] || (await window.main.sync.compareRemoteBranch(workspaceId)); const remoteBackendProjects = remoteBackendProjectsCache[project.remoteId] || (await window.main.sync.remoteBackendProjects({ @@ -73,8 +75,8 @@ export async function clientAction({ params }: Route.ClientActionArgs) { invariant(project.remoteId, 'Project is not remote'); try { - const remoteBranches = (await window.main.sync.getRemoteBranchNames()).sort(); - const compare = await window.main.sync.compareRemoteBranch(); + const remoteBranches = (await window.main.sync.getRemoteBranchNames(workspaceId)).sort(); + const compare = await window.main.sync.compareRemoteBranch(workspaceId); const remoteBackendProjects = await window.main.sync.remoteBackendProjects({ teamId: project.parentId, teamProjectId: project.remoteId, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx index a8e25ae01f..108da12f26 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage.tsx @@ -14,7 +14,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const keys = data.keys; invariant(Array.isArray(keys), 'Keys are required'); const { syncItems } = await getSyncItems({ workspaceId }); - const status = await window.main.sync.status(syncItems); + const status = await window.main.sync.status(workspaceId, syncItems); // Staging needs to happen since it creates blobs for the files const itemsToUnstage = keys .map(key => { @@ -27,7 +27,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }) .filter(isNotNullOrUndefined); - await window.main.sync.unstage(itemsToUnstage); + await window.main.sync.unstage(workspaceId, itemsToUnstage); return null; } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx index 463f99b8b0..9bf26fa727 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx @@ -9,9 +9,13 @@ import { createFetcherSubmitHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection'; -export async function clientAction({ params }: Route.ClientActionArgs) { - const { organizationId, projectId, workspaceId } = params; - +export async function generateRequestCollection({ + projectId, + workspaceId, +}: { + projectId: string; + workspaceId: string; +}) { const project = await services.project.getById(projectId); invariant(project, 'Project not found'); @@ -56,6 +60,12 @@ export async function clientAction({ params }: Route.ClientActionArgs) { count_requests: scannedResources.map(r => r.requests?.length ?? 0).reduce((a, b) => a + b, 0), }, }); +} + +export async function clientAction({ params }: Route.ClientActionArgs) { + const { organizationId, projectId, workspaceId } = params; + + await generateRequestCollection({ projectId, workspaceId }); return redirect( href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug', { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 3eb4f0d42d..27db3dd2c1 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -1,1486 +1,17 @@ -import { type IRuleResult } from '@stoplight/spectral-core'; -import CodeMirror from 'codemirror'; -import { models, services } from 'insomnia-data'; -import type { OpenAPIV3 } from 'openapi-types'; -import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { - Button, - Dialog, - GridList, - GridListItem, - Heading, - ListBox, - ListBoxItem, - Menu, - MenuItem, - MenuTrigger, - Modal, - ModalOverlay, - Popover, - ToggleButton, - Tooltip, - TooltipTrigger, -} from 'react-aria-components'; -import { - type ImperativePanelGroupHandle, - type ImperativePanelHandle, - Panel, - PanelGroup, - PanelResizeHandle, -} from 'react-resizable-panels'; -import { href, redirect, useLoaderData } from 'react-router'; -import * as reactUse from 'react-use'; -import { SwaggerUIBundle } from 'swagger-ui-dist'; -import YAML from 'yaml'; - -import { parseApiSpec } from '~/common/api-specs'; -import { DEFAULT_SIDEBAR_SIZE } from '~/common/constants'; -import { debounce } from '~/common/misc'; -import { utf8ByteLength } from '~/common/utils/utf8-bytes'; -import { useRootLoaderData } from '~/root'; -import { useDeleteProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete-ruleset'; -import { useRefreshProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.refresh-ruleset'; -import { useUpdateProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.update-ruleset'; -import { - useWorkspaceLoaderData, - WORKSPACE_CONTENT_WRAPPER, -} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { useSpecGenerateRequestCollectionActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection'; -import { useSpecUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update'; -import { AnalyticsEvent } from '~/ui/analytics'; -import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; -import { Badge } from '~/ui/components/base/badge'; -import { DesignEmptyState } from '~/ui/components/design-empty-state'; -import { DocumentTab } from '~/ui/components/document-tab'; -import { Icon } from '~/ui/components/icon'; -import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; -import { showError, showModal } from '~/ui/components/modals'; -import { AskModal } from '~/ui/components/modals/ask-modal'; -import { CookiesModal } from '~/ui/components/modals/cookies-modal'; -import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; -import { CertificatesModal } from '~/ui/components/modals/workspace-certificates-modal'; -import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal'; -import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; -import { formatMethodName } from '~/ui/components/tags/method-tag'; -import { showResourceNotFoundToast, showToast } from '~/ui/components/toast-notification'; -import WorkspacePaneHeader from '~/ui/components/workspace/workspace-pane-header'; -import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features'; -import { useOrganizationStorageRule } from '~/ui/hooks/use-organization-storage-rule'; -import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version'; -import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path'; -import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder'; +import { href, redirect } from 'react-router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec'; +// The spec route has been merged into the debug route. +// This route only exists to redirect old bookmarks/tabs/synced links to the merged page. export async function clientLoader({ params }: Route.ClientLoaderArgs) { const { organizationId, projectId, workspaceId } = params; - const project = await services.project.getById(projectId); - if (!project) { - showResourceNotFoundToast(`Project not found: ${projectId}`); - throw redirect(href('/organization/:organizationId/project', { organizationId })); - } - - const workspace = await services.workspace.getById(workspaceId); - if (!workspace) { - showResourceNotFoundToast(`Workspace not found: ${workspaceId}`); - throw redirect(href('/organization/:organizationId/project/:projectId', { organizationId, projectId })); - } - - const apiSpec = await services.apiSpec.getByParentId(workspaceId); - if (!apiSpec) { - showResourceNotFoundToast(`API Specification not found for workspace: ${workspaceId}`); - throw redirect(href('/organization/:organizationId/project/:projectId', { organizationId, projectId })); - } - - const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); - const isConnectedGitProject = models.project.isConnectedGitProject(project); - - const gitRepositoryId = isConnectedGitProject - ? models.project.getEffectiveRepoId(project) - : workspaceMeta?.gitRepositoryId; - // we don't run the lint here because it is expensive and slows first render too much - // TODO: add this in once we run this loader outside the renderer - // Honour user-chosen repo locations: the ruleset lives at the repo root, which - // is the GitRepository's `directory` when set, else the managed location. - const gitRepository = gitRepositoryId ? await services.gitRepository.getById(gitRepositoryId) : null; - const gitSyncRulesetPath = gitRepository - ? window.path.join(resolveGitRepoBaseDir(gitRepository), '.spectral.yaml') - : ''; - - // The ProjectLintRuleset record is the source of truth for both git and cloud projects. - // For git, the RepoFileWatcher keeps .spectral.yaml in sync with this record. - const projectLintRuleset = await services.projectLintRuleset.getByParentId(projectId); - const rulesetContent = projectLintRuleset?.rulesetContent || ''; - const rulesetLastCompiledAt = projectLintRuleset?.modified ?? null; - - // For git projects, surface a committed .spectral.yaml that was rejected as - // invalid on import (so the user learns it wasn't applied and why, instead of - // silently falling back to the default ruleset). Refreshed automatically via - // the git.db-synced → revalidate flow in root.tsx. - const rulesetImportIssue = - isConnectedGitProject && gitRepositoryId - ? await window.main.git.getProjectRulesetImportIssue({ projectId, gitRepositoryId }) - : null; - - let parsedSpec: OpenAPIV3.Document | undefined; - - try { - parsedSpec = YAML.parse(apiSpec.contents) as OpenAPIV3.Document; - } catch {} - - return { - apiSpec, - gitSyncRulesetPath, - isConnectedGitProject, - parsedSpec, - rulesetContent, - rulesetLastCompiledAt, - rulesetImportIssue, - }; + throw redirect( + `${href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug', { + organizationId, + projectId, + workspaceId, + })}`, + ); } - -const SwaggerUIDiv = ({ text }: { text: string }) => { - useEffect(() => { - let spec = {}; - try { - spec = parseApiSpec(text).contents || {}; - } catch {} - SwaggerUIBundle({ spec, dom_id: '#swagger-ui' }); - }, [text]); - return ( -
- ); -}; - -interface LintMessage { - type: 'error' | 'warning' | 'info'; - message: string; - line: number; - range: IRuleResult['range']; - code: string; - path: string; -} -interface GroupedLintMessage { - code: string; - type: 'error' | 'warning' | 'info'; - message: string; - occurrences: { line: number; range: IRuleResult['range']; path: string }[]; -} - -interface SpecActionItem { - id: string; - name: string; - icon?: ReactNode; - badge?: ReactNode; - isDisabled?: boolean; - tooltip?: string; - action: () => void; -} - -const getMethodsFromOpenApiPathItem = (pathItem: OpenAPIV3.PathItemObject): string[] => { - const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].filter( - method => - // @ts-expect-error -- shrug I don't care what pathItem has in it - pathItem[method], - ); - - return methods; -}; - -const lintOptions = { - delay: 1000, -}; - -const Component = ({ params }: Route.ComponentProps) => { - const { organizationId, projectId, workspaceId } = params; - const { activeProject, vcsVersion } = useWorkspaceLoaderData()!; - const { settings } = useRootLoaderData()!; - - const [isCookieModalOpen, setIsCookieModalOpen] = useState(false); - const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false); - const [_isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false); - const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false); - const [isNewMockServerModalOpen, setNewMockServerModalOpen] = useState(false); - const [isViewRulesetModalOpen, setIsViewRulesetModalOpen] = useState(false); - - const storageRules = useOrganizationStorageRule(organizationId); - - const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus(); - - const { - apiSpec, - gitSyncRulesetPath, - isConnectedGitProject, - parsedSpec, - rulesetContent, - rulesetLastCompiledAt, - rulesetImportIssue, - } = useLoaderData(); - - const [lintMessages, setLintMessages] = useState([]); - const [expandedLintMessageCodes, setExpandedLintMessageCodes] = useState([]); - - const editor = useRef(null); - const { submit: updateApiSpec } = useSpecUpdateActionFetcher(); - const { submit: updateProjectRuleset } = useUpdateProjectRulesetActionFetcher(); - const { submit: deleteProjectRuleset } = useDeleteProjectRulesetActionFetcher(); - const { submit: refreshProjectRuleset } = useRefreshProjectRulesetActionFetcher(); - const [isRefreshing, setIsRefreshing] = useState(false); - const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher(); - const gitVersion = useGitVCSVersion(); - const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); - const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); - const [selectedRulesetPath, setSelectedRulesetPath] = useState(''); - - // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. - // Cloud/local projects have no RepoFileWatcher, so rulesetContent from NeDB is mirrored - // to this per-project scratch path. Git projects lint against gitSyncRulesetPath, which - // the RepoFileWatcher keeps in sync with the record. - const rulesetWritePath = useMemo( - () => window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), - [projectId], - ); - - const rulesetHasRemoteExtendsEntries = useMemo(() => { - if (!rulesetContent) return false; - - try { - const parsedRuleset = YAML.parse(rulesetContent); - if (!parsedRuleset.extends) return false; - - const formattedRulesetExtendsEntries = Array.isArray(parsedRuleset.extends) - ? parsedRuleset.extends - : [parsedRuleset.extends]; - return formattedRulesetExtendsEntries.some((entry: string) => entry.startsWith('https://')); - } catch { - return false; - } - }, [rulesetContent]); - - const groupedLintMessages = useMemo(() => { - const map = new Map(); - for (const msg of lintMessages) { - const key = msg.code; - const existing = map.get(key); - if (existing) { - existing.occurrences.push({ line: msg.line, range: msg.range, path: msg.path }); - } else { - map.set(key, { - code: msg.code ?? '', - type: msg.type, - message: msg.message, - occurrences: [{ line: msg.line, range: msg.range, path: msg.path }], - }); - } - } - - return Array.from(map.values()); - }, [lintMessages]); - - const { components, info, servers, paths } = parsedSpec || {}; - const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; - - const lintErrors = lintMessages.filter(message => message.type === 'error'); - const lintWarnings = lintMessages.filter(message => message.type === 'warning'); - - const registerCodeMirrorLint = useCallback( - (rulesetContent: string) => { - CodeMirror.registerHelper('lint', 'openapi', async (contents: string) => { - try { - const { diagnostics, error, cancelled } = await window.main.lintSpec({ - documentContent: contents, - projectId, - rulesetContent, - }); - if (cancelled) { - return []; - } - if (error) { - console.log('Handled error detected while linting:', error); - setLintMessages([]); - showError({ - title: 'Linting Error', - message: `An error occurred while linting the OpenAPI specification: ${error}`, - }); - return []; - } - const lintResult = diagnostics?.map(({ severity, code, message, range, path }) => { - return { - from: CodeMirror.Pos(range.start.line, range.start.character), - to: CodeMirror.Pos(range.end.line, range.end.character), - code: String(code), - message, - severity: ['error', 'warning'][severity] ?? 'info', - type: (['error', 'warning'][severity] ?? 'info') as LintMessage['type'], - range, - line: range.start.line, - path: path.join('.'), - }; - }); - setLintMessages?.(lintResult || []); - return lintResult; - } catch (error) { - // return a rejected promise so that codemirror do nothing - console.log('Unhandled error while linting:', error); - setLintMessages([]); - showError({ - title: 'Linting Error', - message: `An error occurred while linting the OpenAPI specification: ${error}`, - }); - return []; - } - }); - }, - [projectId], - ); - - useEffect(() => { - registerCodeMirrorLint(rulesetContent); - // when first time into document editor, the lint helper register later than codemirror init, we need to trigger lint through execute setOption - editor.current?.tryToSetOption('lint', { ...lintOptions }); - }, [rulesetContent, projectId, registerCodeMirrorLint]); - - useEffect(() => { - setSelectedRulesetPath( - isConnectedGitProject && gitSyncRulesetPath && rulesetContent - ? gitSyncRulesetPath - : rulesetContent - ? rulesetWritePath - : '', - ); - }, [gitSyncRulesetPath, isConnectedGitProject, rulesetWritePath, rulesetContent]); - - reactUse.useUnmount(() => { - // delete the helper to avoid it run multiple times when user enter the page next time - CodeMirror.registerHelper('lint', 'openapi', () => {}); - }); - - const onCodeEditorChange = useMemo(() => { - const handler = async (contents: string) => { - return updateApiSpec({ - organizationId, - projectId, - workspaceId, - contents: contents, - }); - }; - - return debounce(handler, 500); - }, [organizationId, projectId, updateApiSpec, workspaceId]); - - const handleScrollToSelection = useCallback( - (chStart: number, chEnd: number, lineStart: number, lineEnd: number) => { - if (!editor.current) { - return; - } - editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd); - }, - [editor], - ); - - const handleScrollToLintMessage = useCallback( - (occurrence: GroupedLintMessage['occurrences'][number]) => { - if (!editor.current) { - return; - } - const { start, end } = occurrence.range; - editor.current.scrollToSelection(start.character, end.character, start.line, end.line); - }, - [editor], - ); - - const [expandedKeys, setExpandedKeys] = useState([]); - - const navigateToPath = (path: string): void => { - const pathSegments = path.split('.'); - const scrollPosition = { - start: { line: 0, col: 0 }, - end: { line: 0, col: 200 }, - }; - - try { - JSON.parse(apiSpec.contents); - // Account for JSON (as string) line number shift - scrollPosition.start.line = 1; - } catch {} - - const lineCounter = new YAML.LineCounter(); - const doc = YAML.parseDocument(apiSpec.contents, { lineCounter }); - const astNode = doc.getIn(pathSegments, true) as YAML.Node; - const nodePosition = astNode.range && lineCounter.linePos(astNode.range[0]); - if (nodePosition) { - scrollPosition.start.line += nodePosition.line; - } - const isServersSection = pathSegments[0] === 'servers'; - if (!isServersSection) { - scrollPosition.start.line -= 1; - } - - scrollPosition.end.line = scrollPosition.start.line; - // NOTE: We're subtracting 1 from everything because YAML CST uses - // 1-based indexing and we use 0-based. - handleScrollToSelection( - scrollPosition.start.col - 1, - scrollPosition.end.col - 1, - scrollPosition.start.line - 1, - scrollPosition.end.line - 1, - ); - }; - - const sidebarPanelRef = useRef(null); - const lintPanelRef = useRef(null); - - useDocBodyKeyboardShortcuts({ - environment_showEditor: () => setEnvironmentModalOpen(true), - environment_showSwitchMenu: () => setIsEnvironmentPickerOpen(true), - showCookiesEditor: () => setIsCookieModalOpen(true), - }); - - const specFormat = useMemo((): 'json' | 'yaml' | null => { - const contents = apiSpec.contents?.trim(); - if (!contents) { - return null; - } - try { - JSON.parse(contents); - return 'json'; - } catch { - return 'yaml'; - } - }, [apiSpec.contents]); - - const switchFormat = (to: 'json' | 'yaml') => { - const editorValue = editor.current?.getValue(); - if (!editorValue) { - return; - } - let parsedSpec: string | undefined; - try { - // yaml parses json correctly - parsedSpec = YAML.parse(editorValue); - } catch { - showToast({ - title: 'Failed to convert spec format', - icon: 'circle-exclamation', - status: 'error', - description: `Spec is not valid, cannot convert to ${to.toUpperCase()}`, - }); - return; - } - const contents = to === 'json' ? JSON.stringify(parsedSpec, null, 2) : YAML.stringify(parsedSpec); - editor.current?.setValue(contents); - updateApiSpec({ organizationId, projectId, workspaceId, contents }); - }; - - const handleSelectSpectralFile = async () => { - const { filePath, canceled } = await selectFileOrFolder({ - itemTypes: ['file'], - extensions: ['yaml', 'yml'], - showHiddenFiles: true, - }); - - if (canceled || !filePath) { - return; - } - - // We bundle the ruleset to resolve any extended rulesets and to validate the content - const { content, error } = await window.main.bundleSpectralRuleset({ sourcePath: filePath }); - if (error || !content) { - showError({ - title: 'Invalid Spectral Ruleset', - message: error ?? 'Failed to bundle ruleset.', - }); - return; - } - - const RULESET_MAX_BYTES = 1 * 1024 * 1024; // 1 MB - if (utf8ByteLength(content) > RULESET_MAX_BYTES) { - showError({ - title: 'Ruleset Too Large', - message: 'The selected ruleset exceeds the maximum allowed size of 1 MB.', - }); - return; - } - - await updateProjectRuleset({ organizationId, projectId, rulesetContent: content }); - - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.uploadLintRulesetClicked, - properties: { - project_type: models.project.isGitProject(activeProject) - ? 'git' - : models.project.isRemoteProject(activeProject) - ? 'remote' - : 'local', - }, - }); - - setSelectedRulesetPath(gitSyncRulesetPath || rulesetWritePath); - }; - - const handleRefreshRuleset = async () => { - if (!rulesetContent) { - return; - } - setIsRefreshing(true); - try { - await window.main.refreshCompiledRuleset({ projectId, rulesetContent }); - refreshProjectRuleset({ organizationId, projectId }); - editor.current?.tryToSetOption('lint', { ...lintOptions }); - } catch (err) { - showError({ - title: 'Refresh Failed', - message: `Failed to refresh ruleset: ${err instanceof Error ? err.message : String(err)}`, - }); - } finally { - setIsRefreshing(false); - } - }; - - const handleUnselectSpectralFile = async () => { - showModal(AskModal, { - title: 'Remove Ruleset File', - message: - 'Are you sure you want to remove this custom ruleset? This will disable all custom linting rules and use the default Spectral ruleset.', - yesText: 'Remove', - color: 'danger', - noText: 'Cancel', - onDone: async (confirmed: boolean) => { - if (confirmed) { - await deleteProjectRuleset({ - organizationId, - projectId, - }); - await window.main.deleteCompiledRuleset({ projectId }); - setSelectedRulesetPath(''); - } - }, - }); - }; - - const generateActionList: SpecActionItem[] = [ - { - id: 'generate-request-collection', - name: 'Collection', - icon: ( - - - - ), - isDisabled: !apiSpec.contents || lintErrors.length > 0 || generateRequestCollectionFetcher.state !== 'idle', - tooltip: - lintErrors.length > 0 - ? 'You cannot generate a collection when spec errors exist. Fix the errors or change the ruleset first.' - : undefined, - action: () => - generateRequestCollectionFetcher.submit({ - organizationId, - projectId, - workspaceId, - }), - }, - { - id: 'generate-mock-server', - name: 'Mock Server', - icon: ( - - - - ), - badge: , - isDisabled: !apiSpec.contents || !isGenerateMockServersWithAIEnabled, - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.designerGenerateMockClicked, - }); - setNewMockServerModalOpen(true); - }, - }, - ]; - - const specFormatActionList: SpecActionItem[] = [ - { - id: 'json', - name: 'JSON', - action: () => switchFormat('json'), - }, - { - id: 'yaml', - name: 'YAML', - action: () => switchFormat('yaml'), - }, - ]; - - const generateDisabledKeys = generateActionList.filter(item => item.isDisabled).map(item => item.id); - - const historyKey = `${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${vcsVersion}`; - - const [direction, setDirection] = useState<'horizontal' | 'vertical'>( - settings.forceVerticalLayout ? 'vertical' : 'horizontal', - ); - useLayoutEffect(() => { - if (settings.forceVerticalLayout) { - setDirection('vertical'); - return () => {}; - } - // Listen on media query changes - const mediaQuery = window.matchMedia('(max-width: 880px)'); - setDirection(mediaQuery.matches ? 'vertical' : 'horizontal'); - - const handleChange = (e: MediaQueryListEvent) => { - setDirection(e.matches ? 'vertical' : 'horizontal'); - }; - - mediaQuery.addEventListener('change', handleChange); - - return () => { - mediaQuery.removeEventListener('change', handleChange); - }; - }, [settings.forceVerticalLayout, direction]); - - const specEditor = ( -
- - {apiSpec.contents ? null : ( - { - updateApiSpec({ - organizationId, - projectId, - workspaceId, - contents: value, - fromTemplate: true, - }); - }} - /> - )} -
- ); - - const specPaneToolbar = apiSpec.contents ? ( -
- - {parsedSpec?.openapi ? `OpenAPI ${parsedSpec.openapi}` : ''} - - - - - - { - const item = generateActionList.find(item => item.id === key); - if (item) { - item.action(); - } - }} - items={generateActionList} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {item => ( - - - {item.icon} - {item.name} - {item.badge && ( - {item.badge} - )} - - - {item.tooltip} - - - )} - - - - - - -
- Spec format - { - const item = specFormatActionList.find(item => item.id === key); - if (item) { - item.action(); - } - }} - items={specFormatActionList} - className="min-w-max focus:outline-hidden" - > - {item => ( - - {item.name} - - )} - -
-
-
- - { - setIsSpecPaneOpen(value); - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.designerPreviewToggled, - properties: { - status: !value ? 'open' : 'collapsed', - }, - }); - }} - > - {({ isSelected }) => ( - - - - )} - - - {isSpecPaneOpen ? 'Hide docs preview' : 'Show docs preview'} - - -
- ) : null; - - const isRulesetInvalid = !!rulesetImportIssue && !rulesetContent; - - const lintToolbar = ( -
-
- - {!isRulesetInvalid && selectedRulesetPath && ( - <> - - - -

Using ruleset from

- {selectedRulesetPath} -
-
- {rulesetHasRemoteExtendsEntries && ( - - - -

Recompile ruleset, including re-fetching any referenced remote entries.

- {rulesetLastCompiledAt && ( -

{`Last updated ${new Date(rulesetLastCompiledAt).toLocaleString()}`}.

- )} -
-
- )} - - - -

Clear custom ruleset and use default OAS ruleset

-
-
- - )} - {!selectedRulesetPath && ( - <> - Default OAS Ruleset - {isRulesetInvalid && ( - - - -

- The custom ruleset in this project contains invalid content: {rulesetImportIssue} -

-

Fix the syntax in your connected git repository. The default OAS ruleset is used until then.

-
-
- )} - - - -

- Upload a custom Spectral ruleset - {isConnectedGitProject && ( - - {' '} - or add a .spectral.yaml file to the root of your connected git - repository - - )} - . Any local files or remote URLs referenced via extends will be bundled - into a single ruleset on upload. -

-
-
- - )} -
- -
- {lintErrors.length > 0 && ( -
- -
- )} - {lintWarnings.length > 0 && ( -
- -
- )} - {apiSpec.contents && ( -
- {lintMessages.length === 0 && } - {lintMessages.length === 0 ? ( - 'No lint problems' - ) : ( - - )} -
- )} -
-
- ); - - const lintMessageList = ( -
- {groupedLintMessages.map(item => ( -
- - {expandedLintMessageCodes.includes(item.code) && ( -
- {item.occurrences.map(occurrence => ( - - ))} -
- )} -
- ))} -
- ); - - return ( -
- - - - -
- -
- {/* Info */} - {info && ( -
- - {/* Info */} - {expandedKeys.includes('info') && ( - navigateToPath(key.toString())}> - - Title: {info.title} - - - Description: {info.description} - - - Version: {info.version} - - - License: {info.license?.name} - - - )} -
- )} - {/* Servers */} - {servers && ( -
-
- -
- {expandedKeys.includes('servers') && ( - ({ - path: index, - ...server, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.url} - - )} - - )} -
- )} - {/* Paths */} - {paths && ( -
-
- -
- {expandedKeys.includes('paths') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - -
- {item.path} - - {getMethodsFromOpenApiPathItem(item).map(method => ( - - ))} -
-
- )} -
- )} -
- )} - {/* RequestBodies */} - {requestBodies && ( -
-
- -
- {expandedKeys.includes('requestBodies') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} - {/* Responses */} - {responses && ( -
-
- -
- {expandedKeys.includes('responses') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} - {/* Parameters */} - {parameters && ( -
-
- -
- {expandedKeys.includes('parameters') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} - {/* Headers */} - {headers && ( -
-
- -
- {expandedKeys.includes('headers') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} - {/* Schemas */} - {schemas && ( -
-
- -
- {expandedKeys.includes('schemas') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} - {/* Security */} - {securitySchemes && ( -
-
- -
- {expandedKeys.includes('security') && ( - ({ - ...item, - id: path, - path, - }))} - onAction={key => navigateToPath(key.toString())} - > - {item => ( - - {item.path} - - )} - - )} -
- )} -
- {isEnvironmentModalOpen && ( - setEnvironmentModalOpen(false)} /> - )} - {isCookieModalOpen && } - {isCertificatesModalOpen && setCertificatesModalOpen(false)} />} - {isNewMockServerModalOpen && ( - - )} - {isViewRulesetModalOpen && ( - - - - {({ close }) => ( - <> -
- - Existing Ruleset Contents - -
- {rulesetContent && ( - - )} - - )} -
-
-
- )} -
-
- - - {specPaneToolbar} - - -
- - - {specEditor} - - {apiSpec.contents && ( - <> - - setIsLintPaneOpen(false)} - onExpand={() => setIsLintPaneOpen(true)} - className="flex flex-col overflow-hidden" - > - {isLintPaneOpen && ( -
- {lintToolbar} - {lintMessageList} -
- )} -
- - )} -
- {apiSpec.contents && !isLintPaneOpen ? lintToolbar : null} -
-
- {isSpecPaneOpen && ( - <> - - - - - - )} -
-
-
-
- ); -}; - -export default Component; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx index c3ff1cfe5f..7f203c1fd3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update.tsx @@ -6,19 +6,21 @@ import { invariant } from '~/common/utils/invariant'; import { createFetcherSubmitHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.update'; +import { generateRequestCollection } from './organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection'; export async function clientAction({ request, params }: Route.ClientActionArgs) { - const { workspaceId } = params; + const { projectId, workspaceId } = params; const formData = await request.formData(); const contents = formData.get('contents'); const fromTemplate = Boolean(formData.get('fromTemplate')); + const shouldGenerateRequestCollection = Boolean(formData.get('generateRequestCollection')); invariant(typeof contents === 'string', 'Contents is required'); - const apiSpec = await services.apiSpec.getByParentId(workspaceId); + // Create the spec if it doesn't exist yet, otherwise update the existing one + const apiSpec = await services.apiSpec.getOrCreateForParentId(workspaceId); - invariant(apiSpec, 'API Spec not found'); await database.update({ ...apiSpec, modified: Date.now(), @@ -26,6 +28,14 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) contents, }); + if (shouldGenerateRequestCollection) { + try { + await generateRequestCollection({ projectId, workspaceId }); + } catch (error) { + console.error('Error generating requests from spec:', error); + } + } + return null; } @@ -37,12 +47,14 @@ export const useSpecUpdateActionFetcher = createFetcherSubmitHook( workspaceId, contents, fromTemplate = false, + generateRequestCollection = false, }: { organizationId: string; projectId: string; workspaceId: string; contents: string; fromTemplate?: boolean; + generateRequestCollection?: boolean; }) => { const url = href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/spec/update', { organizationId, @@ -55,6 +67,9 @@ export const useSpecUpdateActionFetcher = createFetcherSubmitHook( if (fromTemplate) { formData.append('fromTemplate', 'true'); } + if (generateRequestCollection) { + formData.append('generateRequestCollection', 'true'); + } return submit(formData, { action: url, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx index 982436f527..49e06a37f3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.tsx @@ -1,7 +1,7 @@ import type { IconName } from '@fortawesome/fontawesome-svg-core'; import type { UnitTestSuite } from 'insomnia-data'; import { models } from 'insomnia-data'; -import { Suspense, useLayoutEffect, useRef, useState } from 'react'; +import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Button, DropIndicator, @@ -26,7 +26,7 @@ import { useRunAllTestsActionFetcher } from '~/routes/organization.$organization import { TestRunStatus } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.test-result.$testResultId'; import { useTestSuiteUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.$testSuiteId.update'; import { useTestSuiteNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.test.test-suite.new'; -import { DocumentTab } from '~/ui/components/document-tab'; +import { CollectionTab } from '~/ui/components/collection-tab'; import { EditableInput } from '~/ui/components/editable-input'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { Icon } from '~/ui/components/icon'; @@ -256,10 +256,35 @@ const Component = () => { }; }, [settings.forceVerticalLayout, direction]); + const hasLegacyUnitTests = unitTestSuites.length > 0; + + useEffect(() => { + if (!settings.enableLegacyUnitTests && !hasLegacyUnitTests) { + // If legacy unit tests are disabled and the collection has none, redirect to the spec route for the workspace + tabNavigate( + { + organization: organizationId, + project: activeProject, + workspace: activeWorkspace, + item: activeWorkspace, + }, + { shouldNavigate: true }, + ); + } + }, [activeProject, activeWorkspace, hasLegacyUnitTests, organizationId, settings.enableLegacyUnitTests, tabNavigate]); + return (
+ { >
-
- -
+
+

{learningFeature.message}

+ + {learningFeature.cta} + + +
+ )} + +
+ + + + + + + {isNewProjectModalOpen && ( + + )} + + ); +}; + +export default Component; diff --git a/packages/insomnia/src/routes/organization._index.tsx b/packages/insomnia/src/routes/organization._index.tsx index 5ea17f5ce1..312af5afd2 100644 --- a/packages/insomnia/src/routes/organization._index.tsx +++ b/packages/insomnia/src/routes/organization._index.tsx @@ -4,8 +4,13 @@ import { href, redirect } from 'react-router'; import { syncOrganizations } from '~/common/organization'; import { invariant } from '~/common/utils/invariant'; +import { migrateKonnectProjectsIfUnambiguous } from '~/konnect/migrate-konnect-organization'; import * as session from '~/ui/account/session'; -import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization } from '~/ui/organization-utils'; +import { + findMigrationTargetSpaceId, + migrateProjectsUnderOrganization, + refreshKonnectAccess, +} from '~/ui/organization-utils'; import type { Route } from './+types/organization._index'; @@ -13,6 +18,13 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const { id: sessionId, accountId } = await services.userSession.get(); if (sessionId) { await syncOrganizations(sessionId, accountId); + // Signing in does not reload the renderer, so the startup resolution in `entry.client.tsx` ran + // against the previous (logged-out) session — it had no account to migrate Konnect projects or + // resolve entitlements for. Run the unambiguous Konnect migration here instead, now that + // `${accountId}:spaces` is freshly synced above; a genuine conflict is still left for the modal + // in `organization.tsx` to surface after hydration. + await migrateKonnectProjectsIfUnambiguous(accountId); + await refreshKonnectAccess(sessionId, accountId); const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; invariant(organizations.length, 'Failed to fetch organizations. Check your network connection and try again.'); diff --git a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx index 9aed14d40c..19ed7cf8cd 100644 --- a/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx +++ b/packages/insomnia/src/routes/organization.sync-organizations-and-projects.tsx @@ -2,7 +2,6 @@ import type { Organization } from 'insomnia-api'; import { services } from 'insomnia-data'; import { href, redirect } from 'react-router'; -import { syncOrganizations } from '~/common/organization'; import { syncProjects } from '~/common/project'; import { invariant } from '~/common/utils/invariant'; import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization } from '~/ui/organization-utils'; @@ -27,12 +26,6 @@ export async function clientAction({ request }: Route.ClientActionArgs) { const { id: sessionId, accountId } = await services.userSession.get(); const taskPromiseList = []; - if (asyncTaskList.includes(AsyncTask.SyncOrganization)) { - invariant(sessionId, 'sessionId is required'); - invariant(accountId, 'accountId is required'); - taskPromiseList.push(syncOrganizations(sessionId, accountId)); - } - if (asyncTaskList.includes(AsyncTask.MigrateProjects)) { const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; invariant(organizations.length, 'Failed to fetch organizations.'); diff --git a/packages/insomnia/src/routes/organization.sync.tsx b/packages/insomnia/src/routes/organization.sync.tsx deleted file mode 100644 index 4700a51a81..0000000000 --- a/packages/insomnia/src/routes/organization.sync.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { services } from 'insomnia-data'; - -import { syncOrganizations } from '~/common/organization'; -import { createFetcherSubmitHook } from '~/ui/utils/router'; - -import type { Route } from './+types/organization.sync'; - -export async function clientAction(_args: Route.ClientActionArgs) { - const { id: sessionId, accountId } = await services.userSession.get(); - - if (sessionId) { - await syncOrganizations(sessionId, accountId); - } - - return null; -} - -export const useOrganizationSyncActionFetcher = createFetcherSubmitHook( - submit => () => { - return submit( - {}, - { - method: 'POST', - action: '/organization/sync', - }, - ); - }, - clientAction, -); diff --git a/packages/insomnia/src/routes/organization.tsx b/packages/insomnia/src/routes/organization.tsx index 822ce9d579..e525e0f183 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,11 +1,13 @@ -import { type Billing, type CurrentPlan, type FeatureList, type Organization, type User } from 'insomnia-api'; +import { type CurrentPlan, type User } from 'insomnia-api'; import type { Settings } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; +import { models } from 'insomnia-data'; import React, { useCallback, useEffect, useState } from 'react'; import { Button, Link, ToggleButton, Tooltip, TooltipTrigger } from 'react-aria-components'; -import { href, NavLink, Outlet, useLocation, useNavigate, useParams, useRouteLoaderData } from 'react-router'; +import { href, NavLink, Outlet, useLocation, useNavigate, useParams } from 'react-router'; import * as reactUse from 'react-use'; +import type { KonnectMigrationGroup } from '~/konnect/migrate-konnect-organization'; +import { detectKonnectOrgMigration } from '~/konnect/migrate-konnect-organization'; import { useRootLoaderData } from '~/root'; import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useSyncOrganizationsAndProjectsActionFetcher } from '~/routes/organization.sync-organizations-and-projects'; @@ -22,53 +24,21 @@ import { Icon } from '~/ui/components/icon'; import { InsomniaLogo } from '~/ui/components/insomnia-icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { showModal } from '~/ui/components/modals'; +import { KonnectOrgMigrationModal } from '~/ui/components/modals/konnect-org-migration-modal'; import { SettingsModal, showSettingsModal } from '~/ui/components/modals/settings-modal'; import { PresentUsers } from '~/ui/components/present-users'; +import { KonnectMovedOnboarding } from '~/ui/components/project/konnect-moved-onboarding'; import { OrganizationSelect } from '~/ui/components/project/organization-select'; import { AppDataCacheProvider } from '~/ui/context/app/insomnia-app-data-context'; import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-stream-context'; import { SidebarContext } from '~/ui/context/app/insomnia-sidebar-context'; import { InsomniaTabProvider } from '~/ui/context/app/insomnia-tab-context'; import { RunnerProvider } from '~/ui/context/app/runner-context'; +import { useCurrentPlan, useCurrentUser, useOrganizations } from '~/ui/hooks/use-account-server-data'; import { useCloseConnection } from '~/ui/hooks/use-close-connection'; +import { refreshKonnectAccess } from '~/ui/organization-utils'; import type { AsyncTask } from '~/ui/utils/router'; -import type { Route } from './+types/organization'; - -export interface OrganizationLoaderData { - organizations: Organization[]; - user?: User; - currentPlan?: CurrentPlan; -} - -export async function clientLoader(_args: Route.ClientLoaderArgs) { - const { id, accountId } = await services.userSession.get(); - if (id) { - const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; - const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as User; - const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan; - return { - organizations, - user, - currentPlan, - }; - } - return { - organizations: [], - user: undefined, - currentPlan: undefined, - }; -} - -export interface OrganizationFeatureLoaderData { - featuresPromise: Promise; - billingPromise: Promise; -} - -export const useOrganizationLoaderData = () => { - return useRouteLoaderData('routes/organization'); -}; - interface IndicatorProps { asyncTaskStatus: 'error' | 'idle' | 'loading' | 'submitting'; settings: Settings; @@ -177,26 +147,39 @@ const LoginUserActions = ({ user: User; currentPlan?: CurrentPlan; }) => { + // Collaboration is meaningless in an organization that only exists on this machine. + const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId); return ( <> - - + {!isLocalOrganization && ( + <> + + + + )} ); }; -const Component = ({ loaderData }: Route.ComponentProps) => { - const { organizations, user, currentPlan } = loaderData; - const { settings } = useRootLoaderData()!; +const Component = () => { + const organizations = useOrganizations(); + const user = useCurrentUser(); + const currentPlan = useCurrentPlan(); + const { settings, userSession } = useRootLoaderData()!; + const [konnectMigrationGroups, setKonnectMigrationGroups] = useState([]); + // `konnectMigrationGroups` starts empty before `detectKonnectOrgMigration` below has resolved too, + // so its length alone can't distinguish "not checked yet" from "checked, no conflict" — needed to + // keep the moved-notice popover (below) from racing the migration modal it must wait for. + const [hasCheckedMigrationConflict, setHasCheckedMigrationConflict] = useState(false); const workspaceData = useWorkspaceLoaderData(); @@ -249,10 +232,44 @@ const Component = ({ loaderData }: Route.ComponentProps) => { return () => window.main.setCurrentOrganizationId(undefined); }, [organizationId]); + useEffect(() => { + const accountId = userSession.accountId; + if (!accountId) { + return; + } + // The unambiguous case already migrated during startup; only a genuine conflict reaches the UI. + detectKonnectOrgMigration({ accountId }).then(plan => { + setKonnectMigrationGroups(plan.status === 'conflict' ? plan.groups : []); + setHasCheckedMigrationConflict(true); + }); + }, [userSession.accountId]); + const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || []; const untrackedWorkspaces = untrackedProjectsFetcher.data?.untrackedWorkspaces || []; const hasUntrackedData = untrackedProjects.length > 0 || untrackedWorkspaces.length > 0; const isScratchPad = organizationId === models.organization.SCRATCHPAD_ORGANIZATION_ID; + const isLocalOrganization = models.organization.isLocalOrganizationId(organizationId); + const isKonnectOrganization = models.organization.isKonnectOrganizationId(organizationId); + + // One-time nudge pointing users who already sync Konnect to the organization dropdown, where + // their control planes now live under "Control Planes" instead of the removed sidebar tab. + // Must wait for the migration-conflict check so it never races or overlaps the migration modal + // (z-50 vs. the modal's z-10 backdrop — it would render on top of an unresolved choice otherwise). + const [orgSelectNode, setOrgSelectNode] = useState(null); + const [hasSeenKonnectMovedNotice, setHasSeenKonnectMovedNotice] = reactUse.useLocalStorage( + 'hasSeenKonnectMovedNotice', + false, + ); + const showKonnectMovedNotice = + !isScratchPad && + !isKonnectOrganization && + settings.hasKonnectPat && + !hasSeenKonnectMovedNotice && + hasCheckedMigrationConflict && + konnectMigrationGroups.length === 0; + const dismissKonnectMovedNotice = useCallback(() => { + setHasSeenKonnectMovedNotice(true); + }, [setHasSeenKonnectMovedNotice]); useCloseConnection({ organizationId, @@ -292,16 +309,18 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
{!isScratchPad && ( - { - window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched }); - navigate(`/organization/${id}`); - }} - currentPlan={currentPlan} - isScratchpadWorkspace={!!isScratchpadWorkspace} - /> +
+ { + window.main.trackAnalyticsEvent({ event: AnalyticsEvent.organizationSwitched }); + navigate(`/organization/${id}`); + }} + currentPlan={currentPlan} + isScratchpadWorkspace={!!isScratchpadWorkspace} + /> +
)} {!user ? : null} @@ -395,7 +414,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { - {!isScratchpadWorkspace && hasUntrackedData && ( + {!isScratchpadWorkspace && !isLocalOrganization && hasUntrackedData && ( + + { + createActionsList.find(({ id }) => key === id)?.action?.(); + }} + items={createActionsList} + className="min-w-max cursor-pointer overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-sm shadow-lg select-none focus:outline-hidden" + > + {item => + item.id.startsWith('separator') ? ( + + ) : ( + + {item.icon && } + {item.name} + + ) + } + + + + + + + { + importActionsList.find(({ id }) => key === id)?.action?.(); + }} + items={importActionsList} + className="min-w-max cursor-pointer overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-sm shadow-lg select-none focus:outline-hidden" + > + {item => ( + + {item.icon && } + {item.name} + + )} + + + @@ -144,24 +228,6 @@ export const DesignEmptyState: FC = ({ onImport }) => { - - diff --git a/packages/insomnia/src/ui/components/document-tab.tsx b/packages/insomnia/src/ui/components/document-tab.tsx deleted file mode 100644 index 3562f68456..0000000000 --- a/packages/insomnia/src/ui/components/document-tab.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import classnames from 'classnames'; -import { NavLink } from 'react-router'; - -interface Props { - organizationId: string; - projectId: string; - workspaceId: string; - className?: string; -} - -export const DocumentTab = ({ organizationId, projectId, workspaceId, className }: Props) => { - return ( - - ); -}; diff --git a/packages/insomnia/src/ui/components/dropdowns/actions/create-actions.ts b/packages/insomnia/src/ui/components/dropdowns/actions/create-actions.ts index 6f674ddc5c..b2b1ca6333 100644 --- a/packages/insomnia/src/ui/components/dropdowns/actions/create-actions.ts +++ b/packages/insomnia/src/ui/components/dropdowns/actions/create-actions.ts @@ -13,14 +13,12 @@ export interface ActionItem { export const createWorkspaceActionItems = ({ createCollection, - createDocument, createMcpClient, createMockServer, createEnvironment, canCreateMockServer, }: { createCollection: () => void; - createDocument: () => void; createMcpClient: () => void; createMockServer: () => void; createEnvironment: () => void; @@ -29,18 +27,11 @@ export const createWorkspaceActionItems = ({ return [ { id: 'new-collection', - name: 'Collection', + name: 'API Collection', scope: 'collection', icon: 'bars', action: createCollection, }, - { - id: 'new-document', - name: 'Document', - scope: 'design', - icon: 'file', - action: createDocument, - }, { id: 'new-mcp-client', name: 'MCP Client', diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 527600c209..8419d5e533 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -89,12 +89,12 @@ export const GitProjectSyncDropdown: FC = ({ gitRepository, activeProject }, [providerName]); useEffect(() => { - if (gitRepository?.uri && gitRepository?._id && gitRepoDataFetcher.state === 'idle' && !gitRepoDataFetcher.data) { + if (gitRepository?._id && gitRepoDataFetcher.state === 'idle' && !gitRepoDataFetcher.data) { gitRepoDataFetcher.load({ projectId, }); } - }, [gitRepoDataFetcher, gitRepository?.uri, gitRepository?._id, organizationId, projectId]); + }, [gitRepoDataFetcher, gitRepository?._id, organizationId, projectId]); const legacyInsomniaWorkspace = gitRepoDataFetcher.data && diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx index 15f53e373d..8c8380bf09 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx @@ -95,8 +95,6 @@ export const ProjectDropdown: FC = ({ const createNewCollection = () => setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source: 'sidebar-dropdown' }); - const createNewDocument = () => - setNewWorkspaceModalState({ scope: 'design', isOpen: true, source: 'sidebar-dropdown' }); const canCreateMockServer = project?._id; const createNewMockServer = () => canCreateMockServer && @@ -189,7 +187,6 @@ export const ProjectDropdown: FC = ({ const createInProjectActionList: ProjectActionItem[] = createWorkspaceActionItems({ createCollection: createNewCollection, - createDocument: createNewDocument, createMcpClient: createNewMcpClient, createMockServer: createNewMockServer, createEnvironment: createNewGlobalEnvironment, diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-shortcut-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-shortcut-actions-dropdown.tsx index fe9993fbc5..efc3decb74 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-shortcut-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-shortcut-actions-dropdown.tsx @@ -99,10 +99,6 @@ export const SidebarShortcutActionsDropdown = ({ setNewWorkspaceModalState({ scope: 'collection', isOpen: true }); onOpenChange(false); }, - createDocument: () => { - setNewWorkspaceModalState({ scope: 'design', isOpen: true }); - onOpenChange(false); - }, createMcpClient: () => { setNewWorkspaceModalState({ scope: 'mcp', isOpen: true }); onOpenChange(false); diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx index 82eb48a85e..28bbda862a 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -1,8 +1,10 @@ import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; +import classNames from 'classnames'; import { exportGlobalEnvironmentToFile, exportMcpClientToFile, exportMockServerToFile, + exportSpecificationToFile, } from 'insomnia/src/ui/components/settings/import-export'; import type { MockServer, Project, Workspace } from 'insomnia-data'; import { models, services } from 'insomnia-data'; @@ -59,6 +61,7 @@ interface Props { sortOrder?: SortOrder; onSortOrderChange: (newSortOrder: SortOrder) => void; isOpen: boolean; + forceShowTrigger?: boolean; onOpenChange: (isOpen: boolean) => void; } @@ -88,6 +91,7 @@ export const SidebarWorkspaceDropdown = ({ onSortOrderChange, isOpen, onOpenChange, + forceShowTrigger = false, }: Props) => { const projectId = project._id; const workspaceId = workspace._id; @@ -109,8 +113,8 @@ export const SidebarWorkspaceDropdown = ({ const workspaceName = workspace.name; const projectName = project.name || getProductName(); - const isCollection = models.workspace.isCollection(workspace); - const isDesign = models.workspace.isDesign(workspace); + // Legacy design workspace is treated as collection for now. + const isCollectionLike = models.workspace.isCollection(workspace) || models.workspace.isDesign(workspace); const isScratchpadWorkspace = models.workspace.isScratchpad(workspace); const createRequest = (requestType: CreateRequestType) => { @@ -220,7 +224,7 @@ export const SidebarWorkspaceDropdown = ({ items: [ { id: 'RunCollection', - name: 'Run Collection', + name: 'Run API Collection', icon: 'circle-play', action: () => openInNewTab(true), }, @@ -228,8 +232,8 @@ export const SidebarWorkspaceDropdown = ({ }; const createSections: ActionSection[] = [ - ...(isCollection ? [createSection, importSection] : []), - ...(isCollection || isDesign ? [runSection] : []), + ...(isCollectionLike ? [createSection, importSection] : []), + ...(isCollectionLike ? [runSection] : []), ]; const actionSection: ActionSection = { @@ -309,6 +313,16 @@ export const SidebarWorkspaceDropdown = ({ return setIsExportModalOpen(true); }, }, + ...(isCollectionLike + ? [ + { + id: 'ExportOpenApiSpec', + name: 'Export OpenAPI Spec', + icon: 'file-code' as IconName, + action: () => exportSpecificationToFile(workspace), + }, + ] + : []), { id: 'Settings', name: 'Settings', @@ -379,7 +393,14 @@ export const SidebarWorkspaceDropdown = ({ diff --git a/packages/insomnia/src/ui/components/editable-input.tsx b/packages/insomnia/src/ui/components/editable-input.tsx index 721e0b9cab..e645f837e4 100644 --- a/packages/insomnia/src/ui/components/editable-input.tsx +++ b/packages/insomnia/src/ui/components/editable-input.tsx @@ -23,6 +23,10 @@ export const EditableInput = ({ // This state is used to keep track of the value while submitting when parent component value is not updated const [pendingValue, setPendingValue] = useState(null); const editableRef = useRef(null); + // Removing the focused Input from the DOM (on Enter/Escape) fires a native blur + // synchronously, which would otherwise run onSubmit a second time (or commit after an + // Escape cancel) and race with the next double-click's FocusScope mount. + const settledRef = useRef(false); useEffect(() => { // set pending value to null if parent value is changed @@ -33,6 +37,12 @@ export const EditableInput = ({ setIsEditable(editable); }, [editable]); + useEffect(() => { + if (isEditable) { + settledRef.current = false; + } + }, [isEditable]); + useEffect(() => { if (!isEditable) { return; @@ -76,7 +86,14 @@ export const EditableInput = ({ {pendingValue ?? value} {isEditable && ( - + // No `restoreFocus`: on commit the input unmounts while the Enter keypress is + // still in flight, and restoring focus here races the RAC collection correction, + // landing DOM focus on a draggable row (e.g. the KV editor's trailing row) before + // the keyup fires. RAC then reads that keyup as a keyboard drag release and + // starts a drag session that aria-hides the whole page (frozen UI until Escape). + // Letting focus settle on the body keeps the collection unfocused, so the drag + // can never be armed. + el?.select()} className={`truncate ${className || 'px-2'}`} @@ -84,9 +101,13 @@ export const EditableInput = ({ aria-label={ariaLabel} defaultValue={value} onKeyDown={e => { - const value = e.currentTarget.value; if (e.key === 'Enter') { e.stopPropagation(); + if (settledRef.current) { + return; + } + settledRef.current = true; + const value = e.currentTarget.value; setPendingValue(value); onSubmit(value); setIsEditable(false); @@ -95,11 +116,16 @@ export const EditableInput = ({ if (e.key === 'Escape') { e.stopPropagation(); + settledRef.current = true; setIsEditable(false); onEditableChange?.(false); } }} onBlur={e => { + if (settledRef.current) { + return; + } + settledRef.current = true; const value = e.currentTarget.value; setPendingValue(value); onSubmit(value); diff --git a/packages/insomnia/src/ui/components/editors/environment-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-editor.tsx index 5d84ab690a..815698f531 100644 --- a/packages/insomnia/src/ui/components/editors/environment-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-editor.tsx @@ -1,6 +1,6 @@ import { isWindows } from 'insomnia-data/common'; import orderedJSON from 'json-order'; -import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { checkNestedKeys } from '~/common/utils/environment-utils'; import { CodeEditor, type CodeEditorHandle } from '~/ui/components/.client/codemirror/code-editor'; @@ -22,20 +22,22 @@ interface Props { export interface EnvironmentEditorHandle { isValid: () => boolean; + /** Throws on invalid content (empty included) - guard with isValid(). */ getValue: () => EnvironmentInfo | null; } export const EnvironmentEditor = forwardRef( ({ environmentInfo, onBlur, onChange, historyKey }, ref) => { const editorRef = useRef(null); - const editorErrorRef = useRef(''); const [error, setError] = useState(''); const getValue = useCallback(() => { - // @ts-expect-error -- current can be null - let value = editorRef.current.getValue(); - if (!editorRef.current || !value) { + const editor = editorRef.current; + if (!editor) { return null; } + /* Empty content is invalid JSON, not "no value": let orderedJSON.parse throw so it follows + the same error path as any other malformed input. */ + let value = editor.getValue(); // On Windows, backslashes are used as directory separators. // The file tag inserted by Nunjucks in JSON uses double backslashes in its path parameter, but in the logic below, orderedJSON.parse unescapes those double backslashes into a single backslash. This causes the file tag to fail when the corresponding environment variable is referenced in a request. @@ -52,20 +54,33 @@ export const EnvironmentEditor = forwardRef( }; return environmentInfo; }, []); + + /** Parses the current document: the value to commit, or the error to show. */ + const validate = useCallback(() => { + try { + const value = getValue(); + if (!value?.object) { + return { value: null, error: '' }; + } + // Check root and nested properties + const err = checkNestedKeys(value.object); + return err ? { value: null, error: err } : { value, error: '' }; + } catch (err) { + return { value: null, error: err.message }; + } + }, [getValue]); + useImperativeHandle( ref, () => ({ - isValid: () => !editorErrorRef.current, + /* Derived from the document, not from the last edit event: onChange is debounced and the + cached content is restored on mount without firing it. */ + isValid: () => !validate().error, getValue, }), - [getValue], + [getValue, validate], ); - const updateEditorError = (message: string) => { - editorErrorRef.current = message; - setError(message); - }; - let defaultValue = orderedJSON.stringify( environmentInfo.object, environmentInfo.propertyOrder || null, @@ -77,6 +92,12 @@ export const EnvironmentEditor = forwardRef( defaultValue = unescapeFileTag(defaultValue); } + /* The editor can hold content that never passed through onChange (the cached document is + restored on mount), so re-validate to keep the notice and isValid() in sync with it. */ + useEffect(() => { + setError(validate().error); + }, [validate, defaultValue, historyKey]); + return (
( autoPrettify enableNunjucks onChange={() => { - updateEditorError(''); - try { - const value = getValue(); - // Check for invalid key names - if (value?.object) { - // Check root and nested properties - const err = checkNestedKeys(value.object); - if (err) { - updateEditorError(err); - } else { - onChange?.(value); - } - } - } catch (err) { - updateEditorError(err.message); + const { value, error: validationError } = validate(); + setError(validationError); + if (value) { + onChange?.(value); } }} defaultValue={defaultValue} diff --git a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx index 497856cfc8..b05b29f4c8 100644 --- a/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/environment-key-value-editor/key-value-editor.tsx @@ -82,6 +82,7 @@ export const EnvironmentKVEditor = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(data)]); const blankNameEditorRef = useRef(null); + const [autoFocusBlankRow, setAutoFocusBlankRow] = useState(!disabled); // The id for the trailing blank row is derived from the persisted pairs (rather than // held in state) so it only changes when the data actually changes. This keeps it in // sync with the async data updates - if it flipped eagerly the row the user just typed @@ -106,11 +107,7 @@ export const EnvironmentKVEditor = ({ // diffs) until the user starts typing in it. const kvPairs: EnvironmentKvPairData[] = useMemo(() => [...persistedPairs, blankPair], [persistedPairs, blankPair]); const codeModalRef = useRef(null); - // Refs to each row's Name editor, keyed by pair id. React Aria's ListBox (with drag-and-drop) owns - // roving focus and lands it on the row element; we hand that focus into the row's CodeMirror editor - // rather than fighting React Aria with an imperative focus loop (which corrupts the modal's - // ariaHideOutside/inert management on the sibling environments list). - const nameEditorRefs = useRef>(new Map()); + const [kvPairError, setKvPairError] = useState<{ id: string; error: string }[]>([]); const [decryptedValues, setDecryptedValues] = useState>({}); const symmetricKey = useMemo(() => (vaultKey === '' ? {} : base64decode(vaultKey, true)), [vaultKey]); @@ -341,11 +338,6 @@ export const EnvironmentKVEditor = ({
{ - if (el) { - nameEditorRefs.current.set(id, el); - } else { - nameEditorRefs.current.delete(id); - } if (isBlank) { blankNameEditorRef.current = el; } @@ -355,6 +347,8 @@ export const EnvironmentKVEditor = ({ placeholder={'Input Name'} defaultValue={name} readOnly={!enabled || disabled} + autoFocus={isBlank && autoFocusBlankRow} + onAutoFocus={isBlank ? () => setAutoFocusBlankRow(false) : undefined} onChange={newName => { // check filed names for invalid '$' for '.' sign const error = ensureKeyIsValid(newName, true); @@ -560,17 +554,14 @@ export const EnvironmentKVEditor = ({ 0 && kvPairs[kvPairs.length - 1].name === '' ? 'last' : undefined} > {kvPair => { const { id, name, enabled } = kvPair; - const isTrailingBlankRow = name === '' && kvPair === kvPairs[kvPairs.length - 1]; return ( { - // Forward focus from the row element into its Name editor, but only for the trailing - // blank row and only when the row itself (not an inner field) received focus. - if (!disabled && isTrailingBlankRow && e.target === e.currentTarget) { - nameEditorRefs.current.get(id)?.focusEnd(); - } - }} > {renderPairItem(kvPair)} diff --git a/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx b/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx index 45b138ceb2..6ffc370988 100644 --- a/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx +++ b/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx @@ -3,12 +3,12 @@ import React, { useState } from 'react'; import { Button } from 'react-aria-components'; import { useNavigate, useParams } from 'react-router'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { useRequestLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; import { isInMockContentTypeList, useMockRoutePatcher, } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId'; +import { useCurrentPlan } from '~/ui/hooks/use-account-server-data'; import { getContentTypeName, getMimeTypeFromContentType } from '../../../common/constants'; import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -23,8 +23,8 @@ export const MockResponseExtractor = () => { const { activeProject, activeWorkspace } = useWorkspaceLoaderData()!; const isLocalProject = !activeProject?.remoteId; - const { currentPlan } = useOrganizationLoaderData()!; - const isEnterprise = currentPlan?.type.includes('enterprise'); + const currentPlan = useCurrentPlan(); + const isEnterprise = currentPlan?.type?.includes('enterprise'); // In a local project, users are not allowed to create a cloud mock server, only enterprise users can create a self-hosted mock server. // In a local project, users without enterprise plan can't create cloud mock server route from a request response diff --git a/packages/insomnia/src/ui/components/environment-picker.tsx b/packages/insomnia/src/ui/components/environment-picker.tsx index 946cd7a4f4..8f924d9336 100644 --- a/packages/insomnia/src/ui/components/environment-picker.tsx +++ b/packages/insomnia/src/ui/components/environment-picker.tsx @@ -1,37 +1,54 @@ -import type { IconName } from '@fortawesome/fontawesome-svg-core'; +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import { models } from 'insomnia-data'; -import { fuzzyMatch } from 'insomnia-data/common'; -import { Fragment } from 'react'; -import { - Button, - ComboBox, - Dialog, - DialogTrigger, - Heading, - Input, - ListBox, - ListBoxItem, - Popover, - Text, -} from 'react-aria-components'; +import { Fragment, useState } from 'react'; +import { Button, Dialog, DialogTrigger, Heading, ListBox, ListBoxItem, Popover, Text } from 'react-aria-components'; import { useNavigate, useParams } from 'react-router'; import { useSetActiveEnvironmentFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.set-active'; import { useEnvironmentSetActiveGlobalActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.set-active-global'; +import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal'; import { Tooltip } from '~/ui/components/tooltip'; +import { useOrganizationStorageRule } from '~/ui/hooks/use-organization-storage-rule'; import { useWorkspaceLoaderData } from '../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import uiEventBus from '../event-bus'; import { useOrganizationPermissions } from '../hooks/use-organization-features'; import { Icon } from './icon'; +const triggerButtonClassName = + 'flex max-w-48 shrink-0 items-center gap-1.5 truncate rounded-xs px-2 py-1 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-open:bg-(--hl-sm)'; + +// Capped so a long environment name truncates with an ellipsis instead of stretching the popover off-screen. +const popoverClassName = 'z-10! flex max-h-[90vh] w-64 max-w-xs flex-col'; +const headingClassName = + 'flex h-(--line-height-sm) shrink-0 items-center justify-between gap-2 px-3 py-1 text-sm font-bold text-(--hl)'; +const listBoxClassName = + 'flex max-h-fit min-w-0 flex-1 flex-col overflow-y-auto p-2 text-sm select-none empty:p-0 focus:outline-hidden'; +const listBoxItemClassName = + 'group flex h-(--line-height-xs) w-full min-w-0 flex-none items-center gap-2 rounded-sm bg-transparent pr-1 text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed'; +// Sub-environments indent by exactly one icon's width so they line up under the parent row's name, not a stray gap. +const itemIndentClassName = (isBase: boolean) => (isBase ? 'pl-(--padding-sm)' : 'pl-[calc(var(--padding-sm)+1rem)]'); + +const InheritanceTooltip = () => ( + + + +); + export const EnvironmentPicker = ({ isOpen, onOpenChange, + isProjectPickerOpen, + onProjectPickerOpenChange, onOpenEnvironmentSettingsModal, }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + isProjectPickerOpen: boolean; + onProjectPickerOpenChange: (isOpen: boolean) => void; onOpenEnvironmentSettingsModal: () => void; }) => { const { @@ -54,10 +71,12 @@ export const EnvironmentPicker = ({ }; const { features } = useOrganizationPermissions(); + const storageRules = useOrganizationStorageRule(organizationId); const isUsingInsomniaCloudSync = Boolean( models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId, ); const isUsingGitSync = Boolean(features.gitSync.enabled && activeWorkspaceMeta?.gitRepositoryId); + const isScratchpadProject = models.project.isScratchpadProject(activeProject); const setActiveEnvironmentFetcher = useSetActiveEnvironmentFetcher(); const setActiveGlobalEnvironmentFetcher = useEnvironmentSetActiveGlobalActionFetcher(); @@ -73,289 +92,268 @@ export const EnvironmentPicker = ({ : activeGlobalEnvironment?.parentId; const selectedGlobalBaseEnvironment = globalBaseEnvironments.find(e => e._id === selectedGlobalBaseEnvironmentId); - const globalEnvironmentList = selectedGlobalBaseEnvironment - ? [ - selectedGlobalBaseEnvironment, - ...globalSubEnvironments.filter(e => e.parentId === selectedGlobalBaseEnvironment._id), - ].map(({ type, ...subEnvironment }) => ({ - ...subEnvironment, - id: subEnvironment._id, - isBase: subEnvironment._id === selectedGlobalBaseEnvironment._id, - })) - : []; - const activeGlobalBaseEnvironment = selectedGlobalBaseEnvironment; const activeBaseEnvironment = baseEnvironment; const activeSubEnvironment = subEnvironments.find(e => e._id === activeEnvironment._id); + const activeCollectionEnvironmentName = activeSubEnvironment ? activeSubEnvironment.name : activeBaseEnvironment.name; const navigate = useNavigate(); + const [isNewProjectEnvironmentModalOpen, setIsNewProjectEnvironmentModalOpen] = useState(false); + + const getEnvironmentIcon = (isPrivate?: boolean): IconProp => + isPrivate + ? 'lock' + : isUsingGitSync + ? ['fab', 'git-alt'] + : isUsingInsomniaCloudSync + ? 'globe-americas' + : 'file-arrow-down'; + + // Flattened list of every project environment file plus its sub-environments, so picking + // one is a single action instead of choosing a file first and then a sub-environment. + const projectEnvironmentItems: { + id: string; + name: string; + icon: IconProp; + color?: string | null; + isBase: boolean; + workspaceId?: string; + }[] = [ + { id: '', name: 'No Project Environment', icon: 'cancel', isBase: true }, + ...globalBaseEnvironments.flatMap(baseEnv => [ + { + id: baseEnv._id, + name: baseEnv.workspaceName || baseEnv.name, + icon: getEnvironmentIcon(baseEnv.isPrivate), + color: baseEnv.color, + isBase: true, + workspaceId: baseEnv.parentId, + }, + ...globalSubEnvironments + .filter(subEnv => subEnv.parentId === baseEnv._id) + .map(subEnv => ({ + id: subEnv._id, + name: subEnv.name, + icon: getEnvironmentIcon(subEnv.isPrivate), + color: subEnv.color, + isBase: false, + })), + ]), + ]; + return ( - -
-
- )} -
- - - - {activeSubEnvironment ? activeSubEnvironment.name : activeBaseEnvironment.name} - - -
- - - -
- - Project Environments - - -
- { - const match = Boolean(fuzzyMatch(filter, textValue, { splitSpace: false, loose: true })?.indexes); - - return match; - }} - onSelectionChange={key => { - if (key === 'all' || key === null) { - return; - } - - setActiveGlobalEnvironmentFetcher.submit({ - organizationId, - projectId, - workspaceId, - environmentId: key.toString(), - }); - }} - defaultInputValue={ - selectedGlobalBaseEnvironment?.workspaceName || - selectedGlobalBaseEnvironment?.name || - 'No Project Environment' - } - selectedKey={selectedGlobalBaseEnvironmentId || ''} - defaultItems={[ - { id: '', icon: 'cancel', name: 'No Project Environment', textValue: 'No Project Environment' }, - ...globalBaseEnvironments.map(baseEnv => { - return { - id: baseEnv._id, - icon: 'code', - name: baseEnv.workspaceName || baseEnv.name, - textValue: baseEnv.workspaceName || baseEnv.name, - }; - }), - ]} - > -
- - + : getEnvironmentIcon(false) + : 'cancel' + } + style={{ color: activeGlobalEnvironment?.color || '' }} + className="w-4 shrink-0" + /> + + + {activeGlobalEnvironment && activeGlobalBaseEnvironment + ? activeGlobalEnvironment.name + : 'No Project Environment'} + + + + + + + + + Project Environments + +
+ + + +
- - className="flex h-full max-h-full min-w-max flex-col p-2 text-sm select-none focus:outline-hidden"> - {item => ( - - - {item.name} - - )} - - - -
- { - if (keys === 'all' || !keys) { - return; - } - const [environmentId] = keys.values(); - - setActiveGlobalEnvironmentFetcher.submit({ - organizationId, - projectId, - workspaceId, - environmentId: environmentId.toString(), - }); - }} - className="flex max-h-fit min-w-max flex-1 flex-col overflow-y-auto p-2 text-sm select-none empty:p-0 focus:outline-hidden" - > - {item => ( - - {({ isSelected }) => ( - - - - - - {item.name} - - {isSelected && } - - )} - - )} - -
- - Collection Environments - { if (keys === 'all' || !keys) { return; } const [environmentId] = keys.values(); - setActiveEnvironmentFetcher.submit({ + + setActiveGlobalEnvironmentFetcher.submit({ organizationId, projectId, workspaceId, environmentId: environmentId.toString(), }); - uiEventBus.emit('CHANGE_ACTIVE_ENV', workspaceId); }} - className="max-h-fit flex-1 overflow-y-auto p-2 text-sm select-none focus:outline-hidden" + className={listBoxClassName} > {item => ( {({ isSelected }) => ( - - - - + /> + {item.name} - {isSelected && } + {isSelected && ( + + )} + {item.workspaceId && ( + + )} )} )} -
-
-
-
- + + + + )} + + + + + + + Collection Environment + +
+ + +
+
+ { + if (keys === 'all' || !keys) { + return; + } + const [environmentId] = keys.values(); + setActiveEnvironmentFetcher.submit({ + organizationId, + projectId, + workspaceId, + environmentId: environmentId.toString(), + }); + uiEventBus.emit('CHANGE_ACTIVE_ENV', workspaceId); + }} + className={listBoxClassName} + > + {item => ( + + {({ isSelected }) => ( + + + + {item.name} + + {isSelected && ( + + )} + + )} + + )} + +
+
+
+ {!isScratchpadProject && isNewProjectEnvironmentModalOpen && ( + + )} + ); }; diff --git a/packages/insomnia/src/ui/components/first-request-creation.tsx b/packages/insomnia/src/ui/components/first-request-creation.tsx index 6fe92dc7a4..69efb639fa 100644 --- a/packages/insomnia/src/ui/components/first-request-creation.tsx +++ b/packages/insomnia/src/ui/components/first-request-creation.tsx @@ -582,7 +582,7 @@ export const FirstRequestCreation = ({ setSelectOpen(isOpen)} - ariaLabel="Select target collection" + ariaLabel="Select target API collection" placement="bottom end" items={collectionItems} selectedKey={selectedCollectionId} @@ -593,10 +593,10 @@ export const FirstRequestCreation = ({ }); }} title="Where should we put your request?" - emptyState="You have no collections, so a new one will be created for you by default." + emptyState="You have no API collections, so a new one will be created for you by default." footer={ } triggerClassName="h-full rounded-md px-3 text-[12px]/[18px] font-[590] data-[focus-visible=true]:!ring-0" diff --git a/packages/insomnia/src/ui/components/header-invite-button.tsx b/packages/insomnia/src/ui/components/header-invite-button.tsx index a9f2bd1d7f..271fd299ea 100644 --- a/packages/insomnia/src/ui/components/header-invite-button.tsx +++ b/packages/insomnia/src/ui/components/header-invite-button.tsx @@ -1,4 +1,5 @@ import { getOrgUserPermissions, type Permission } from 'insomnia-api'; +import { models } from 'insomnia-data'; import React, { useEffect, useState } from 'react'; import { Button, Heading, Link, Radio, RadioGroup } from 'react-aria-components'; @@ -23,6 +24,9 @@ export const HeaderInviteButton = ({ // TODO: should manage this in the scope of organization context useEffect(() => { + if (models.organization.isLocalOrganizationId(organizationId)) { + return; + } (async () => { getOrgUserPermissions({ organizationId, diff --git a/packages/insomnia/src/ui/components/header-plan-indicator.tsx b/packages/insomnia/src/ui/components/header-plan-indicator.tsx index 2281adfcdf..a499100d6f 100644 --- a/packages/insomnia/src/ui/components/header-plan-indicator.tsx +++ b/packages/insomnia/src/ui/components/header-plan-indicator.tsx @@ -10,6 +10,7 @@ import { useTrialStartActionFetcher } from '~/routes/trial.start'; import { Icon } from '~/ui/components/icon'; import { TrialConfirmationModal } from '~/ui/components/modals/trial-confirmation-modal'; import { Tooltip } from '~/ui/components/tooltip'; +import { useInvalidateAccountData } from '~/ui/hooks/use-account-server-data'; import { usePlanData } from '~/ui/hooks/use-plan'; import { useUserService } from '~/ui/hooks/use-user-service'; import { formatNumber } from '~/ui/utils'; @@ -29,6 +30,7 @@ export const HeaderPlanIndicator = ({ isMinimal }: Props) => { const planName = `${planDisplayName} Plan`; const startFetcher = useTrialStartActionFetcher(); + const invalidateAccountData = useInvalidateAccountData(); const { load: usageLoad, state: usageState, data: usageData } = useResourceUsageFetcher(); function handleStartTrial() { @@ -66,8 +68,11 @@ export const HeaderPlanIndicator = ({ isMinimal }: Props) => { useEffect(() => { if (startFetcher.data?.success) { setCanTrial(false); + // trial.start wrote the new plan to localStorage; refresh the query so every + // plan consumer (this indicator included) reflects the upgraded plan. + invalidateAccountData(); } - }, [startFetcher.data?.success]); + }, [startFetcher.data?.success, invalidateAccountData]); const isUnlimited = isEnterpriseLike; diff --git a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx index eebe6abcd4..ea3dc241e2 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx @@ -265,25 +265,6 @@ export const KeyValueEditor: FC = ({ }, }); - /* When the user presses a letter key and then immediately presses the space bar. - The keydown event for the space key was stopped from propagating during the capture phase by the ListBox component - That is why the inner editor fails to respond to the immediate space press behavior. - Here we add a wrapper to the outer ListBox and add a wrapper to the inner editor and listen to the keydown event in both wrapper - When the user presses the space key, we change the event.key property with non-breakable space in the outer wrapper - and change it back in the inner wrapper. - */ - const onKeyDownOuter = useCallback(event => { - if (event.key === ' ') { - event.key = '\u00A0'; - } - }, []); - - const onKeyDownInner = useCallback(event => { - if (event.key === '\u00A0') { - event.key = ' '; - } - }, []); - return ( @@ -330,6 +311,7 @@ export const KeyValueEditor: FC = ({ = ({ )}
= ({ = ({ >
-
+
= ({ }} />
-
{valueEditor}
+
{valueEditor}
{showDescription && ( -
+
= ({ const { activeRequest, activeRequestMeta, requestPayload } = useRequestLoaderData()! as McpRequestLoaderData; const latestRequestPayloadRef = useLatest(requestPayload); - const { activeProject } = useWorkspaceLoaderData()!; + const { activeProject, activeGlobalEnvironment } = useWorkspaceLoaderData()!; const [mcpParams, setMcpParams] = useState>(getParamsFromPayload(requestPayload?.params)); @@ -123,7 +123,7 @@ export const McpRequestPane: FC = ({ // Reset the response pane state when we switch requests, the environment gets modified // Some of the UI(tokens) depends on the readyState, so we include it here as well - const uniqueKey = `${environment?.modified}::${requestId}::${activeRequestMeta?.activeResponseId}::${readyState}`; + const uniqueKey = `${environment?._id}::${environment?.modified}::${activeGlobalEnvironment?._id}::${activeGlobalEnvironment?.modified}::${requestId}::${activeRequestMeta?.activeResponseId}::${readyState}`; const requestAuth = getAuthObjectOrNull(activeRequest.authentication); const isNoneOrInherited = requestAuth?.type === 'none' || requestAuth === null; const jsonSchema = useMemo(() => { diff --git a/packages/insomnia/src/ui/components/modals/__tests__/upload-runner-data-modal.test.ts b/packages/insomnia/src/ui/components/modals/__tests__/upload-runner-data-modal.test.ts index 7f04e576fd..e419ee2f62 100644 --- a/packages/insomnia/src/ui/components/modals/__tests__/upload-runner-data-modal.test.ts +++ b/packages/insomnia/src/ui/components/modals/__tests__/upload-runner-data-modal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { genPreviewTableData } from '../upload-runner-data-modal'; +import { genPreviewTableData, parseCsvUploadData } from '../upload-runner-data-modal'; describe('test generate table preview data ', () => { it('test normal json input', () => { @@ -66,3 +66,55 @@ describe('test generate table preview data ', () => { expect(data.length).toBe(0); }); }); + +describe('parseCsvUploadData()', () => { + it('parses simple csv', () => { + expect(parseCsvUploadData('a,b\n1,2\n3,4')).toEqual({ + headers: ['a', 'b'], + data: [ + { a: '1', b: '2' }, + { a: '3', b: '4' }, + ], + }); + }); + + it('keeps commas inside quoted fields', () => { + expect(parseCsvUploadData('origin,destination\n"New York, NY","Boston, MA"\n')).toEqual({ + headers: ['origin', 'destination'], + data: [{ origin: 'New York, NY', destination: 'Boston, MA' }], + }); + }); + + it('unescapes doubled quotes inside quoted fields', () => { + expect(parseCsvUploadData('name,quote\n"John ""JJ"" Smith",hello')).toEqual({ + headers: ['name', 'quote'], + data: [{ name: 'John "JJ" Smith', quote: 'hello' }], + }); + }); + + it('handles CRLF line breaks', () => { + expect(parseCsvUploadData('a,b\r\n1,2\r\n')).toEqual({ + headers: ['a', 'b'], + data: [{ a: '1', b: '2' }], + }); + }); + + it('handles newlines inside quoted fields', () => { + expect(parseCsvUploadData('a,b\n"line1\nline2",2')).toEqual({ + headers: ['a', 'b'], + data: [{ a: 'line1\nline2', b: '2' }], + }); + }); + + it('defaults missing trailing cells to empty string', () => { + expect(parseCsvUploadData('a,b,c\n1,2')).toEqual({ + headers: ['a', 'b', 'c'], + data: [{ a: '1', b: '2', c: '' }], + }); + }); + + it('returns null when there are fewer than two rows', () => { + expect(parseCsvUploadData('a,b')).toBeNull(); + expect(parseCsvUploadData('')).toBeNull(); + }); +}); diff --git a/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx index 9f6c541d63..07183a03fa 100644 --- a/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/add-request-to-collection-modal.tsx @@ -100,7 +100,12 @@ export const AddRequestToCollectionModal: FC = ({ onHide }
-
-
-
- - Use QuickJS sandbox for scripts (proof of concept) - -

- Run pre-request and after-response scripts inside the QuickJS-WASM sandbox instead of the hidden browser - window. Only a minimal API is supported so far (console, insomnia.environment/variables get/set, read-only - insomnia.request) — scripts calling insomnia.sendRequest() or insomnia.test()/pm.test() will throw. Leave - this off to keep using the full-featured hidden-window sandbox. -

-
- patchSettings({ useQuickJsScriptSandbox: enabled })} - className="group flex items-center gap-2" - > -
- + {QUICKJS_SANDBOX_ENABLED && ( +
+
+
+ + Use QuickJS sandbox for scripts (proof of concept) + +

+ Run pre-request and after-response scripts inside the QuickJS-WASM sandbox instead of the hidden browser + window. Only a minimal API is supported so far (console, insomnia.environment/variables get/set, read-only + insomnia.request) — scripts calling insomnia.sendRequest() or insomnia.test()/pm.test() will throw. Leave + this off to keep using the full-featured hidden-window sandbox. +

- + patchSettings({ useQuickJsScriptSandbox: enabled })} + className="group flex items-center gap-2" + > +
+ +
+
+
-
+ )}
diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/index.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/index.ts index 107576960d..4e76eeda36 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/index.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/index.ts @@ -1,3 +1,2 @@ export { NewProjectButton } from './new-project-button'; export { SidebarSearchField } from './sidebar-search-field'; -export { SideBarTabList } from './sidebar-tab-list'; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/sidebar-tab-list.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/sidebar-tab-list.tsx deleted file mode 100644 index 636cea874a..0000000000 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/components/sidebar-tab-list.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Tab, TabList } from 'react-aria-components'; - -import { KongLogo } from '~/ui/components/kong-logo'; - -export const SideBarTabList = ({ - konnectSyncEnabled, - isScratchPad, - nonKonnectProjectLength, - konnectProjectsLength, -}: { - konnectSyncEnabled: boolean; - isScratchPad: boolean; - nonKonnectProjectLength: number; - konnectProjectsLength: number; -}) => { - return ( - - - {isScratchPad ? 'Projects' : `Projects (${nonKonnectProjectLength})`} - - {konnectSyncEnabled && !isScratchPad && ( - - - - Konnect ({konnectProjectsLength}) - - - )} - - ); -}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx index 783589de37..e3ecf8101c 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/empty-node.tsx @@ -43,7 +43,6 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp isOpen: false, }); const createNewCollection = () => setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source: 'sidebar' }); - const createNewDocument = () => setNewWorkspaceModalState({ scope: 'design', isOpen: true, source: 'sidebar' }); const createNewMockServer = () => setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true, source: 'sidebar' }); const createNewGlobalEnvironment = () => @@ -52,6 +51,7 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp const newRequestFetcher = useRequestNewActionFetcher(); const newRequestGroupFetcher = useRequestGroupNewActionFetcher(); + const parentId = requestGroup?._id || workspace?._id || project._id; const createRequest = ({ requestType }: { requestType: CreateRequestType }) => { @@ -150,18 +150,11 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp const createInProjectActionList: ActionItem[] = [ { id: 'new-collection', - name: 'Request collection', + name: 'API Collection', scope: 'collection', icon: 'bars', action: createNewCollection, }, - { - id: 'new-document', - name: 'Design document', - scope: 'design', - icon: 'file', - action: createNewDocument, - }, { id: 'new-mcp-client', name: 'MCP Client', @@ -193,7 +186,7 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp return 'Project is empty'; } case 'emptyCollection': { - return 'Collection is empty'; + return 'No requests yet'; } case 'emptyFolder': { return 'Folder is empty'; @@ -210,7 +203,7 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp return 'empty project'; } case 'emptyCollection': { - return 'empty collection'; + return 'empty API collection'; } case 'emptyFolder': { return 'empty folder'; @@ -243,6 +236,7 @@ export const EmptyNode = ({ item, storageRules, depthOffset = 0 }: EmptyNodeProp {getLabel()} + + ) : ( + + + + {konnectSyncEnabled ? ( + + ) : ( + 'Your account does not have access to Konnect control planes.' + )} + + + )} + +
+ ); +} + +/** Sits right after the action row, mirroring where the full sidebar shows sync progress. */ +export function KonnectSyncProgressLine({ syncing, progress }: KonnectSyncBarState) { + if (!syncing) { + return null; + } + return

{progress}

; +} + +/** The last-sync-result panel and the Konnect settings modal (both are self-guarding, so this is + * safe to render unconditionally regardless of where in the layout it lands). */ +export function KonnectSyncResultPanel({ + lastSyncResult, + setLastSyncResult, + showSyncDetails, + setShowSyncDetails, + skippedRoutesByReason, + copiedReason, + setCopiedReason, + showKonnectConfigModal, + setShowKonnectConfigModal, + onDisconnect, + konnectSyncEnabled, +}: KonnectSyncBarState) { + return ( + <> + {lastSyncResult && ( +
0 || lastSyncResult.skippedRegions.length > 0 + ? 'bg-[rgba(250,173,20,0.15)]' + : 'bg-[rgba(82,196,26,0.15)]' + }`} + > +
+ +
+

+ {lastSyncResult.success + ? lastSyncResult.skippedRoutes.length > 0 || lastSyncResult.skippedRegions.length > 0 + ? 'Sync complete, with warnings' + : 'Sync complete' + : 'Sync failed'} +

+

+ {!lastSyncResult.success + ? lastSyncResult.error + : lastSyncResult.routes.created === 0 && + lastSyncResult.routes.updated === 0 && + lastSyncResult.routes.deleted === 0 && + lastSyncResult.routes.skipped === 0 && + lastSyncResult.skippedRegions.length === 0 + ? 'Already up-to-date with Konnect.' + : [ + lastSyncResult.routes.created > 0 && `${lastSyncResult.routes.created} request(s) added`, + lastSyncResult.routes.updated > 0 && `${lastSyncResult.routes.updated} request(s) updated`, + lastSyncResult.routes.deleted > 0 && `${lastSyncResult.routes.deleted} request(s) deleted`, + lastSyncResult.routes.skipped > 0 && `${lastSyncResult.routes.skipped} route(s) skipped`, + lastSyncResult.skippedRegions.length > 0 && + `${lastSyncResult.skippedRegions.length} region(s) skipped`, + ] + .filter(Boolean) + .join(', ') + '.'} +

+ {lastSyncResult.success && + (lastSyncResult.skippedRoutes.length > 0 || lastSyncResult.skippedRegions.length > 0) && ( + <> + + {showSyncDetails && ( +
+ {lastSyncResult.skippedRegions.length > 0 && ( +
+

Failed to fetch control planes for the following regions:

+
    + {lastSyncResult.skippedRegions.map(r => ( +
  • + {r} +
  • + ))} +
+
+ )} + {[...skippedRoutesByReason.entries()].map(([reason, routes]) => { + const MAX_SHOW = 5; + const visible = routes.slice(0, MAX_SHOW); + const extra = routes.length - MAX_SHOW; + return ( +
+

{reason} for the following routes:

+
    + {visible.map(r => ( +
  • + {r} +
  • + ))} +
+ {extra > 0 && ( +
+ + {extra} more + +
+ )} +
+ ); + })} +
+ )} + + )} +
+
+ +
+ )} + + {showKonnectConfigModal && ( + setShowKonnectConfigModal(false)} + onDisconnect={onDisconnect} + konnectSyncEnabled={konnectSyncEnabled} + /> + )} + + ); +} diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 36f2954b39..21aa5069fa 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -4,10 +4,8 @@ import type { CollectionWorkspaceChildren, RequestGroup, Workspace } from 'insom import { models, services } from 'insomnia-data'; import { fuzzyMatchAll } from 'insomnia-data/common'; import { - type Dispatch, type ForwardedRef, forwardRef, - type SetStateAction, useCallback, useEffect, useImperativeHandle, @@ -15,7 +13,7 @@ import { useRef, useState, } from 'react'; -import { Button, GridList, GridListItem, type Selection, Tabs, Tooltip, TooltipTrigger } from 'react-aria-components'; +import { GridList, GridListItem, type Selection, Tooltip, TooltipTrigger } from 'react-aria-components'; import { href, useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; @@ -26,25 +24,16 @@ import type { SortOrder } from '~/common/constants'; import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; import { getUnsyncedRemoteWorkspaces } from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; -import type { SyncResult } from '~/konnect/sync'; import { useRootLoaderData } from '~/root'; import { useProjectDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete'; -import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update'; import { AnalyticsEvent } from '~/ui/analytics'; import type { WorkspaceSortOrder } from '~/ui/components/dropdowns/sidebar-project-dropdown'; import { SidebarShortcutActionsDropdown } from '~/ui/components/dropdowns/sidebar-shortcut-actions-dropdown'; import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown'; import { useKeyboardShortcuts } from '~/ui/components/keydown-binder'; -import { KongLogo } from '~/ui/components/kong-logo'; import { showModal } from '~/ui/components/modals'; import { AlertModal } from '~/ui/components/modals/alert-modal'; -import { AskModal } from '~/ui/components/modals/ask-modal'; -import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal'; -import { - NewProjectButton, - SidebarSearchField, - SideBarTabList, -} from '~/ui/components/sidebar/project-navigation-sidebar/components'; +import { NewProjectButton, SidebarSearchField } from '~/ui/components/sidebar/project-navigation-sidebar/components'; import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; import { KonnectEnvOnboarding } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding'; import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; @@ -53,14 +42,17 @@ import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigatio import { useProjectNavigationSidebarData } from '~/ui/components/sidebar/project-navigation-sidebar/use-navigation-sidebar-data'; import { useDBQueryClient } from '~/ui/context/app/insomnia-app-data-context'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; -import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; -import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { useRemoteFilesByProjectId } from '~/ui/hooks/use-remote-files'; import { useSettingsPatcher } from '~/ui/hooks/use-request'; -import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; import { Icon } from '../../icon'; +import { + KonnectSyncActionsRow, + KonnectSyncProgressLine, + KonnectSyncResultPanel, + useKonnectSyncBar, +} from './konnect-sync-bar'; import { getSidebarGridListItemId } from './project-navigation-sidebar-utils'; import { ProjectNode } from './project-node'; import { PinnedHeaderNode, RequestNode } from './request-node'; @@ -72,39 +64,13 @@ import { WorkspaceNode } from './workspace-node'; interface ProjectNavigationSidebarProps { storageRules: StorageRules; activeNodeId?: string; - activeTab: ProjectNavigationSidebarTabId; - konnectSyncEnabled: boolean; onCreateProject: () => void; - setActiveTab: Dispatch>; } export interface ProjectNavigationSidebarHandle { expandProject: (projectId: string) => void; } -export type ProjectNavigationSidebarTabId = 'projects' | 'konnect'; - -function LastSyncedLabel({ lastSyncedAt }: { lastSyncedAt: number | null }) { - return lastSyncedAt ? `Last synced: ${getRelativeTimeString(lastSyncedAt, Date.now())}` : 'Not yet synced'; -} - -function getRelativeTimeString(timestamp: number, now: number = Date.now()): string { - const seconds = Math.floor((now - timestamp) / 1000); - if (seconds < 60) { - return `${seconds}s ago`; - } - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - return `${minutes}m ${seconds % 60}s ago`; - } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return `${hours}h ${minutes % 60}m ago`; - } - const days = Math.floor(hours / 24); - return `${days}d ${hours % 24}h ago`; -} - const workspaceManualSortMethod = (a: Workspace, b: Workspace, localOrder: string[]) => { const aIndex = localOrder.indexOf(a._id); const bIndex = localOrder.indexOf(b._id); @@ -112,7 +78,7 @@ const workspaceManualSortMethod = (a: Workspace, b: Workspace, localOrder: strin }; const ProjectNavigationSidebarInner = ( - { storageRules, konnectSyncEnabled, onCreateProject, activeTab, setActiveTab }: ProjectNavigationSidebarProps, + { storageRules, onCreateProject }: ProjectNavigationSidebarProps, ref: ForwardedRef, ) => { const navigate = useNavigate(); @@ -139,9 +105,6 @@ const ProjectNavigationSidebarInner = ( const [focusedWorkspaceId, setFocusedWorkspaceId] = useState(null); const [focusTransition, setFocusTransition] = useState<'none' | 'in' | 'out'>('none'); const [isFocusedWorkspaceMenuOpen, setIsFocusedWorkspaceMenuOpen] = useState(false); - const [isRenamingFocusedWorkspace, setIsRenamingFocusedWorkspace] = useState(false); - const [renamingWorkspaceValue, setRenamingWorkspaceValue] = useState(''); - const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher(); const [projectWorkspaceSortOrder, setProjectWorkspaceSortOrder] = useState>({}); // Customized workspace sort orders by projectId const [localWorkspaceOrders, setLocalWorkspaceOrders] = reactUse.useLocalStorage>( @@ -152,39 +115,25 @@ const ProjectNavigationSidebarInner = ( `${organizationId}:project-navigation-sidebar-filter`, '', ); - const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage( - `${organizationId}:project-navigation-konnect-filter`, - '', - ); const [expandedProjectAndWorkspaceIds, setExpandedProjectAndWorkspaceIds] = reactUse.useLocalStorage( `${organizationId}:nav-expanded-projects-and-workspaces`, [], ); const deleteProjectFetcher = useProjectDeleteActionFetcher(); - const isProjectTabActive = activeTab === 'projects'; - const { syncing, progress, startSync, cancelSync } = useKonnectSync(); - const [lastSyncedAt, setLastSyncedAt] = reactUse.useLocalStorage( - `${organizationId}:konnect-last-synced-at`, - null, - ); + const isKonnectOrganization = models.organization.isKonnectOrganizationId(organizationId); const { organizationProjects, organizationWorkspaces, workspaceMetas, - activeProjects, collectionByWorkspaceIds, pendingCollectionWorkspaceIds, - nonKonnectProjects, - konnectProjects, } = useProjectNavigationSidebarData(organizationId, { - isProjectTabActive, projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds, }); const [filterInputValue, setFilterInputValue] = useState(projectNavigationSidebarFilter || ''); - const [konnectFilterInputValue, setKonnectFilterInputValue] = useState(konnectFilter || ''); useEffect(() => { // Keep input state aligned with storage only when organization context switches. @@ -202,122 +151,23 @@ const ProjectNavigationSidebarInner = ( } }; setFilterInputValue(readLocalStorageString(`${organizationId}:project-navigation-sidebar-filter`)); - setKonnectFilterInputValue(readLocalStorageString(`${organizationId}:project-navigation-konnect-filter`)); }, [organizationId]); // Debounce update filter reactUse.useDebounce(() => setProjectNavigationSidebarFilter(filterInputValue), 300, [filterInputValue]); - reactUse.useDebounce(() => setKonnectFilter(konnectFilterInputValue), 300, [konnectFilterInputValue]); - const activeFilter = ((isProjectTabActive ? projectNavigationSidebarFilter : konnectFilter) || '').trim(); - - const syncKonnectProjectsAndNotifyRef = useRef<(konnectOrganizationId?: string | null) => Promise>( - async () => {}, - ); + const activeFilter = (projectNavigationSidebarFilter || '').trim(); const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID; + // The active project can't answer this while no project is selected, so gate the create-project + // affordance on the organization instead — the Scratch Pad organization never accepts new ones. + const isScratchPadOrganization = models.organization.isScratchpadOrganizationId(organizationId); // Unsynced remote files grouped by projectId, sourced from a shared server-data query // (deduped with the project view). Refresh is handled inside the hook via CLOUD_SYNC_FILE_CHANGE. const unsyncedFilesByProjectId = useRemoteFilesByProjectId(organizationId, organizationProjects); - const syncKonnectProjectsAndNotify = async (konnectOrganizationId?: string | null) => { - setLastSyncResult(null); - const isFirstSync = lastSyncedAt == null; - const result = await startSync( - organizationId, - konnectOrganizationId !== undefined ? konnectOrganizationId : settings.konnectOrganizationId, - ); - setLastSyncResult(result ?? null); - setShowSyncDetails(false); - setCopiedReason(null); - if (result?.success) { - setLastSyncedAt(Date.now()); - // Navigate to and expand the first Konnect project after a successful sync - const allProjects = await services.project.listByOrganizationIds(organizationId); - const sortedKonnectProjects = models.project.sortProjects( - allProjects.filter(p => p.konnectControlPlaneId != null), - ); - const firstKonnectProject = sortedKonnectProjects[0]; - if (firstKonnectProject) { - const workspaces = await services.workspace.listByParentId(firstKonnectProject._id); - const envWorkspace = workspaces.find(w => w.scope === 'environment'); - if (envWorkspace) { - // Show environment onboarding after first successful sync - if (isFirstSync) { - setOnboardingEnvWorkspaceId(envWorkspace._id); - } - navigate( - `/organization/${organizationId}/project/${firstKonnectProject._id}/workspace/${envWorkspace._id}/environment`, - ); - } else { - navigate(`/organization/${organizationId}/project/${firstKonnectProject._id}`); - } - setExpandedProjectAndWorkspaceIds(prev => { - const ids = prev || []; - return ids.includes(firstKonnectProject._id) ? ids : [...ids, firstKonnectProject._id]; - }); - } - } - }; - syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify; - - const handleSync = async () => { - if (!konnectSyncEnabled) { - return; - } - - const isResync = konnectProjects.length > 0; - if (isResync) { - showModal(AskModal, { - title: 'Sync updates from Konnect', - message: ( -
-
-
- - Konnect -
- → -
- Insomnia - Insomnia -
-
-

- Sync the latest changes from your Konnect organization into Insomnia. This will: -

-
    -
  • - - Keep your local custom changes (never pushed to Konnect) -
  • -
  • - - Update existing resources to match Konnect -
  • -
  • - - Remove collections or environments tied to control planes, services, or routes deleted in Konnect -
  • -
-
- ), - yesText: 'Sync Now', - noText: 'Cancel', - color: 'surprise', - onDone: async (confirmed: boolean) => { - if (confirmed) { - await syncKonnectProjectsAndNotify(); - } - }, - }); - } else { - await syncKonnectProjectsAndNotify(); - } - }; - useEffect(() => { - if (projectNavigationSidebarFilter || konnectFilter) { + if (projectNavigationSidebarFilter) { window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectListFiltered, properties: { @@ -325,7 +175,7 @@ const ProjectNavigationSidebarInner = ( }, }); } - }, [projectNavigationSidebarFilter, konnectFilter, activeProjectId]); + }, [projectNavigationSidebarFilter, activeProjectId]); const flatItems = useMemo(() => { const buildWorkspaceAndCollectionData = (): FlatItem[] => { @@ -335,7 +185,7 @@ const ProjectNavigationSidebarInner = ( const collectionChildrenAndMetaByWorkspaceIds = collectionByWorkspaceIds; - for (const project of activeProjects) { + for (const project of organizationProjects) { const projectId = project._id; const isProjectCollapsed = !(expandedProjectAndWorkspaceIds ?? []).includes(projectId); items.push({ @@ -397,7 +247,7 @@ const ProjectNavigationSidebarInner = ( }); } else { const { scope, _id: workspaceId } = workspace as Workspace; - const isCollection = scope === 'collection'; + const isCollection = scope === 'collection' || scope === 'design'; // Only collection workspace has nested children const isWorkspaceCollapsed = !( isCollection && (expandedProjectAndWorkspaceIds ?? []).includes(workspaceId) @@ -570,12 +420,12 @@ const ProjectNavigationSidebarInner = ( return buildWorkspaceAndCollectionData(); }, [ activeFilter, - activeProjects, collectionByWorkspaceIds, collectionSortOrders, expandedProjectAndWorkspaceIds, localWorkspaceOrders, organizationId, + organizationProjects, organizationWorkspaces, pendingCollectionWorkspaceIds, projectWorkspaceSortOrder, @@ -691,8 +541,6 @@ const ProjectNavigationSidebarInner = ( const clearFocusState = useCallback(() => { setFocusTransition('out'); setFocusedWorkspaceId(null); - setIsRenamingFocusedWorkspace(false); - setRenamingWorkspaceValue(''); setIsFocusedWorkspaceMenuOpen(false); }, []); @@ -737,7 +585,7 @@ const ProjectNavigationSidebarInner = ( return; } prevActiveWorkspaceIdRef.current = activeWorkspaceId; - if (workspaceItem.doc.scope === 'collection') { + if (workspaceItem.doc.scope === 'collection' || workspaceItem.doc.scope === 'design') { focusWorkspace(activeWorkspaceId, workspaceItem.project._id); } else { // Design docs, mock servers, environments etc. show the full tree. @@ -832,6 +680,7 @@ const ProjectNavigationSidebarInner = ( : undefined, [flatItems, focusedWorkspaceId], ); + const focusedWorkspaceScope = focusedWorkspaceItem?.doc.scope ?? 'collection'; // In focus mode the project + workspace rows are hidden, so strip those two // ancestor indent levels and let the tree indent from its first level. const treeDepthOffset = focusedWorkspaceId ? 2 : 0; @@ -848,9 +697,9 @@ const ProjectNavigationSidebarInner = ( virtualizer, onWorkspaceReorder: handleLocalWorkspaceReorder, expandedProjectAndWorkspaceIds, + depthOffset: treeDepthOffset, }); const { selectedItemId, routeInfo } = useProjectNavigationSidebarNavigation({ - setActiveTab, toggleRequestGroups, expandProjectOrWorkspaces, visibleFlatItems, @@ -879,7 +728,7 @@ const ProjectNavigationSidebarInner = ( useKeyboardShortcuts(() => parentRef.current!, { sidebar_showCreateDropdown: event => { - if (!isProjectTabActive) { + if (isKonnectOrganization) { return; } @@ -933,11 +782,7 @@ const ProjectNavigationSidebarInner = ( }); const { hasKonnectPat } = settings; - const showKonnectSyncIntro = konnectSyncEnabled && !isProjectTabActive && !hasKonnectPat; - const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false); - const [lastSyncResult, setLastSyncResult] = useState(null); - const [showSyncDetails, setShowSyncDetails] = useState(false); - const [copiedReason, setCopiedReason] = useState(null); + const showKonnectSyncIntro = isKonnectOrganization && !hasKonnectPat; const [onboardingEnvWorkspaceId, setOnboardingEnvWorkspaceId] = useState(null); const [envOnboardingNode, setEnvOnboardingNode] = useState(null); @@ -945,6 +790,13 @@ const ProjectNavigationSidebarInner = ( setOnboardingEnvWorkspaceId(null); }, []); + const konnectSyncBar = useKonnectSyncBar({ + organizationId, + hasProjects: organizationProjects.length > 0, + onFirstSyncEnvWorkspace: setOnboardingEnvWorkspaceId, + setExpandedProjectAndWorkspaceIds, + }); + // One-time nudge explaining collection focus mode, anchored to the "back to // all projects" arrow (only rendered while a collection is actually focused). const patchSettings = useSettingsPatcher(); @@ -955,31 +807,10 @@ const ProjectNavigationSidebarInner = ( patchSettings({ hasSeenSidebarFocusOnboarding: true }); }, [patchSettings]); - const skippedRoutesByReason = useMemo(() => { - const map = new Map(); - for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) { - const list = map.get(reason) ?? []; - list.push(`${routeName} — ${serviceName}`); - map.set(reason, list); - } - return map; - }, [lastSyncResult]); - return (
- {/* In focused mode the whole sidebar belongs to the collection, so the project/konnect tabs are hidden too. */} - {!focusedWorkspaceId && ( - setActiveTab(key as ProjectNavigationSidebarTabId)}> - - - )} {showKonnectSyncIntro ? ( - setShowKonnectConfigModal(true)} /> + konnectSyncBar.setShowKonnectConfigModal(true)} /> ) : ( <> {focusedWorkspaceId ? ( @@ -1008,48 +839,28 @@ const ProjectNavigationSidebarInner = (
- +
- {isRenamingFocusedWorkspace ? ( - setRenamingWorkspaceValue(e.target.value)} - onFocus={e => e.currentTarget.select()} - onBlur={() => { - const trimmed = renamingWorkspaceValue.trim(); - if (focusedWorkspaceItem && trimmed && trimmed !== focusedWorkspaceItem.doc.name) { - updateWorkspaceFetcher.submit({ - organizationId, - projectId: focusedWorkspaceItem.project._id, - patch: { name: trimmed, workspaceId: focusedWorkspaceItem.doc._id }, - }); - } - setIsRenamingFocusedWorkspace(false); - }} - onKeyDown={e => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - } else if (e.key === 'Escape') { - setIsRenamingFocusedWorkspace(false); - } - }} - className="min-w-0 flex-1 truncate rounded-xs bg-(--hl-sm) p-1 font-semibold text-(--color-font) focus:ring-1 focus:ring-(--color-surprise) focus:outline-none" - /> - ) : ( - { - setRenamingWorkspaceValue(focusedWorkspaceItem?.doc.name || ''); - setIsRenamingFocusedWorkspace(true); - }} - className="flex-1 truncate rounded-xs p-1 font-semibold text-(--color-font) transition-colors hover:cursor-text hover:bg-(--hl-xs)" - > - {focusedWorkspaceItem?.doc.name || 'Collection'} - - )} + { + if (focusedWorkspaceItem) { + tabNavigate( + { + organization: organizationId, + project: focusedWorkspaceItem.project, + workspace: focusedWorkspaceItem.doc, + item: focusedWorkspaceItem.doc, + }, + { withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams }, + ); + } + }} + className="flex-1 truncate rounded-xs p-1 font-semibold text-(--color-font) transition-colors hover:cursor-pointer hover:bg-(--hl-xs)" + > + {focusedWorkspaceItem?.doc.name || 'API Collection'} +
{focusedWorkspaceItem && ( { setCollectionSortOrders(prev => ({ ...prev, [focusedWorkspaceId]: newOrder })); }} + forceShowTrigger isOpen={isFocusedWorkspaceMenuOpen} onOpenChange={setIsFocusedWorkspaceMenuOpen} /> @@ -1073,58 +885,19 @@ const ProjectNavigationSidebarInner = ( ) : (
- {isProjectTabActive ? ( - !isScratchPad && ( - - ) + {isKonnectOrganization ? ( + ) : ( -
- {syncing ? ( - - ) : ( - - - - - - - )} - -
+ !isScratchPadOrganization && )}
)} - {!isProjectTabActive && syncing && ( -

{progress}

- )} + {isKonnectOrganization && }
)} - {!isProjectTabActive && lastSyncResult && ( -
0 || lastSyncResult.skippedRegions.length > 0 - ? 'bg-[rgba(250,173,20,0.15)]' - : 'bg-[rgba(82,196,26,0.15)]' - }`} - > -
- -
-

- {lastSyncResult.success - ? lastSyncResult.skippedRoutes.length > 0 || lastSyncResult.skippedRegions.length > 0 - ? 'Sync complete, with warnings' - : 'Sync complete' - : 'Sync failed'} -

-

- {!lastSyncResult.success - ? lastSyncResult.error - : lastSyncResult.routes.created === 0 && - lastSyncResult.routes.updated === 0 && - lastSyncResult.routes.deleted === 0 && - lastSyncResult.routes.skipped === 0 && - lastSyncResult.skippedRegions.length === 0 - ? 'Already up-to-date with Konnect.' - : [ - lastSyncResult.routes.created > 0 && `${lastSyncResult.routes.created} request(s) added`, - lastSyncResult.routes.updated > 0 && `${lastSyncResult.routes.updated} request(s) updated`, - lastSyncResult.routes.deleted > 0 && `${lastSyncResult.routes.deleted} request(s) deleted`, - lastSyncResult.routes.skipped > 0 && `${lastSyncResult.routes.skipped} route(s) skipped`, - lastSyncResult.skippedRegions.length > 0 && - `${lastSyncResult.skippedRegions.length} region(s) skipped`, - ] - .filter(Boolean) - .join(', ') + '.'} -

- {lastSyncResult.success && - (lastSyncResult.skippedRoutes.length > 0 || lastSyncResult.skippedRegions.length > 0) && ( - <> - - {showSyncDetails && ( -
- {lastSyncResult.skippedRegions.length > 0 && ( -
-

Failed to fetch control planes for the following regions:

-
    - {lastSyncResult.skippedRegions.map(r => ( -
  • - {r} -
  • - ))} -
-
- )} - {[...skippedRoutesByReason.entries()].map(([reason, routes]) => { - const MAX_SHOW = 5; - const visible = routes.slice(0, MAX_SHOW); - const extra = routes.length - MAX_SHOW; - return ( -
-

{reason} for the following routes:

-
    - {visible.map(r => ( -
  • - {r} -
  • - ))} -
- {extra > 0 && ( -
- + {extra} more - -
- )} -
- ); - })} -
- )} - - )} -
-
- -
- )} )} - {showKonnectConfigModal && ( - setShowKonnectConfigModal(false)} - syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef} - onDisconnect={() => setLastSyncedAt(null)} - /> - )} + {/* Outside the branch above on purpose: this hosts the Konnect settings modal, and the + intro branch's "Configure" button is what opens it. Nesting it in the else branch made + that button set state nothing rendered, which is unrecoverable for an organization that + has Konnect projects but no PAT. */} + {isKonnectOrganization && } {onboardingEnvWorkspaceId && envOnboardingNode && ( @@ -1534,26 +1194,3 @@ const ProjectNavigationSidebarInner = ( export const ProjectNavigationSidebar = forwardRef( ProjectNavigationSidebarInner, ); - -export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreateProject: () => void }) => { - const { organizationId } = useParams() as { organizationId: string }; - const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId); - const { features } = useOrganizationPermissions(); - - return ( -
- - - -
- {}} /> - {!isScratchPad && } -
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-navigation-sidebar-data.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-navigation-sidebar-data.ts index 3b22c1e6c1..68a98531cd 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-navigation-sidebar-data.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-navigation-sidebar-data.ts @@ -1,10 +1,10 @@ +import type { WorkspaceChildrenForScope } from 'insomnia-data'; import { useMemo } from 'react'; import { useOrganizationData } from '~/ui/hooks/use-organization-data'; import { useMultipleWorkspacesData } from '~/ui/hooks/use-workspace-data'; interface UseProjectNavigationSidebarDataOptions { - isProjectTabActive: boolean; projectNavigationSidebarFilter?: string; expandedProjectAndWorkspaceIds?: string[]; } @@ -13,24 +13,19 @@ export function useProjectNavigationSidebarData( organizationId: string, options: UseProjectNavigationSidebarDataOptions, ) { - const { isProjectTabActive, projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds } = options; + const { projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds } = options; const { projects, workspaces, workspaceMetas } = useOrganizationData(organizationId); - // Show konnect or none-konnect projects based on selected tab - const activeProjects = useMemo( - () => projects.filter(isProjectTabActive ? p => !p.konnectControlPlaneId : p => p.konnectControlPlaneId != null), - [projects, isProjectTabActive], - ); - const projectIds = useMemo(() => activeProjects.map(p => p._id), [activeProjects]); - // Get the list of collection workspace ids that should be cached based on the current filter and expanded projects/workspaces. + const projectIds = useMemo(() => projects.map(p => p._id), [projects]); - const collectionWorkspaceIds = useMemo(() => { + // Get the list of collection/design workspace ids that should be cached based on the current filter and expanded projects/workspaces. + const collectionOrDesignWorkspaceIds = useMemo(() => { const ids: string[] = []; projectIds.forEach(projectId => { workspaces .filter(w => w.parentId === projectId) .forEach(workspace => { if ( - workspace.scope === 'collection' && + (workspace.scope === 'collection' || workspace.scope === 'design') && (!!projectNavigationSidebarFilter || (expandedProjectAndWorkspaceIds || []).includes(workspace._id)) ) { ids.push(workspace._id); @@ -41,21 +36,18 @@ export function useProjectNavigationSidebarData( }, [projectIds, workspaces, projectNavigationSidebarFilter, expandedProjectAndWorkspaceIds]); const { dataByWorkspaceId: collectionByWorkspaceIds, pendingWorkspaceIds: pendingCollectionWorkspaceIds } = - useMultipleWorkspacesData(collectionWorkspaceIds, 'collection'); - - const nonKonnectProjects = useMemo(() => projects.filter(p => !p.konnectControlPlaneId), [projects]); - const konnectProjects = useMemo(() => projects.filter(p => p.konnectControlPlaneId != null), [projects]); + useMultipleWorkspacesData(collectionOrDesignWorkspaceIds); return { organizationProjects: projects, organizationWorkspaces: workspaces, workspaceMetas, - activeProjects, projectIds, - collectionWorkspaceIds, - collectionByWorkspaceIds, + collectionOrDesignWorkspaceIds, + collectionByWorkspaceIds: collectionByWorkspaceIds as Map< + string, + WorkspaceChildrenForScope<'collection' | 'design'> + >, pendingCollectionWorkspaceIds, - nonKonnectProjects, - konnectProjects, }; } diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts index 6e31f27745..480f1241c1 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-project-navigation-sidebar-navigation.ts @@ -1,6 +1,5 @@ import type { Virtualizer } from '@tanstack/react-virtual'; import { database, models, type Workspace } from 'insomnia-data'; -import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { NavigationResources } from '~/ui/hooks/use-insomnia-navigation'; @@ -25,13 +24,11 @@ const getSelectedItemId = (resources?: NavigationResources) => { }; export const useProjectNavigationSidebarNavigation = ({ - setActiveTab, toggleRequestGroups, expandProjectOrWorkspaces, visibleFlatItems, virtualizer, }: { - setActiveTab: Dispatch>; toggleRequestGroups: (requestGroupIds: string[], workspace: Workspace, collapsed?: boolean) => Promise; expandProjectOrWorkspaces: (ids: string[]) => void; visibleFlatItems: FlatItem[]; @@ -42,10 +39,8 @@ export const useProjectNavigationSidebarNavigation = ({ const [selectedItemId, setSelectedItemId] = useState(null); const lastHandledScrollKeyRef = useRef(null); - const setActiveTabRef = useRef(setActiveTab); const toggleRequestGroupsRef = useRef(toggleRequestGroups); const expandProjectOrWorkspacesRef = useRef(expandProjectOrWorkspaces); - setActiveTabRef.current = setActiveTab; toggleRequestGroupsRef.current = toggleRequestGroups; expandProjectOrWorkspacesRef.current = expandProjectOrWorkspaces; @@ -69,9 +64,6 @@ export const useProjectNavigationSidebarNavigation = ({ return; } - // update active tab - setActiveTabRef.current(resources.project.konnectControlPlaneId != null ? 'konnect' : 'projects'); - const idsToExpand = [resources.project._id]; if (resources.workspace && models.workspace.isCollection(resources.workspace)) { idsToExpand.push(resources.workspace._id); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.test.ts b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.test.ts new file mode 100644 index 0000000000..0831124d91 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, it } from 'vitest'; + +import type { CollectionChildFlatItem, EmptyNodeFlatItem, FlatItem, ProjectWithGitRepository } from './types'; +import type { BoundaryTargets, DropContext } from './use-sidebar-drag-and-drop'; +import { + canDrop, + getIndentLevel, + levelFromX, + resolveDrop, + SidebarDropTargetDelegate, +} from './use-sidebar-drag-and-drop'; + +// Fixtures mirror the sidebar's flat list: `level` is 0 for a direct child of a +// workspace, and `ancestors` is the root-first folder chain, so +// `ancestors.length === level`. Code under test indexes `ancestors` by level. + +const project = { _id: 'proj_1', remoteId: null, name: 'Local' } as unknown as ProjectWithGitRepository; +const workspaceDoc = { _id: 'wrk_1', scope: 'collection', name: 'Collection' }; + +const workspaceItem = { + kind: 'workspace', + doc: workspaceDoc, + project, + collapsed: false, + hidden: false, + organizationId: 'org_1', +} as unknown as FlatItem; + +interface ChildOptions { + id: string; + level: number; + parentId: string; + ancestors: string[]; + folder?: boolean; + metaSortKey?: number; + name?: string; +} + +function child({ id, level, parentId, ancestors, folder = false, metaSortKey = 0, name }: ChildOptions) { + return { + kind: 'collectionChild', + doc: { + _id: id, + type: folder ? 'RequestGroup' : 'Request', + name: name ?? id, + parentId, + metaSortKey, + }, + project, + workspace: workspaceDoc, + ancestors, + level, + pinned: false, + collapsed: false, + hidden: false, + organizationId: 'org_1', + } as unknown as CollectionChildFlatItem; +} + +function emptyFolderNode(id: string, level: number, requestGroupId: string) { + return { + kind: 'emptyFolder', + doc: { _id: id, name: 'Folder is empty' }, + project, + workspace: workspaceDoc, + requestGroup: { _id: requestGroupId }, + level, + hidden: false, + organizationId: 'org_1', + } as unknown as EmptyNodeFlatItem; +} + +// Rebuilds the lookups the hook derives from its flat list. +function buildIndex(items: FlatItem[]) { + const visibles = items.filter(item => !item.hidden); + const flatItemsById = new Map( + visibles.map((item, index) => [item.doc._id, [item, visibles[index - 1], visibles[index + 1]] as const]), + ); + const lookup = (id: string) => flatItemsById.get(id)?.[0] ?? null; + // Mirrors the hook: a childless expanded folder still shows an interior. + const hasVisibleChildren = (folderId: string) => + visibles.some(candidate => { + if (candidate.hidden) { + return false; + } + if (candidate.kind === 'emptyFolder') { + return candidate.requestGroup?._id === folderId; + } + return candidate.kind === 'collectionChild' && candidate.doc.parentId === folderId; + }); + return { flatItemsById, lookup, hasVisibleChildren }; +} + +type DropPosition = 'before' | 'after' | 'on'; + +function boundaryAt(items: FlatItem[], dropPosition: DropPosition, key: string) { + const { flatItemsById, lookup, hasVisibleChildren } = buildIndex(items); + const entry = flatItemsById.get(key); + if (!entry) { + throw new Error(`fixture has no visible item with id ${key}`); + } + const [targetItem, prevItem, nextItem] = entry; + const boundary: BoundaryTargets = { + dropPosition, + targetItem, + realTargetItem: dropPosition === 'before' ? prevItem : targetItem, + nextItem, + }; + return { boundary, flatItemsById, lookup, hasVisibleChildren }; +} + +function resolveAt(items: FlatItem[], dropPosition: DropPosition, key: string, preferredLevel: number) { + const { boundary, flatItemsById, lookup, hasVisibleChildren } = boundaryAt(items, dropPosition, key); + const ctx: DropContext = { hasVisibleChildren, lookup, flatItemsById, preferredLevel }; + return { boundary, ...resolveDrop(boundary, ctx) }; +} + +// Produces every ambiguous gap in one tree. +// +// folder A level 0 +// request A1 level 1 +// subfolder B level 1 +// request B1 level 2 +// request C level 0 +const A = child({ id: 'A', level: 0, parentId: 'wrk_1', ancestors: [], folder: true, name: 'Alpha' }); +const A1 = child({ id: 'A1', level: 1, parentId: 'A', ancestors: ['A'], metaSortKey: 100 }); +const B = child({ id: 'B', level: 1, parentId: 'A', ancestors: ['A'], folder: true, metaSortKey: 200, name: 'Bravo' }); +const B1 = child({ id: 'B1', level: 2, parentId: 'B', ancestors: ['A', 'B'], metaSortKey: 300 }); +const C = child({ id: 'C', level: 0, parentId: 'wrk_1', ancestors: [], metaSortKey: 400 }); +const nestedTree: FlatItem[] = [workspaceItem, A, A1, B, B1, C]; + +// folder A level 0 +// subfolder E level 1 (empty, expanded) +// "Folder empty" level 1 (placeholder, not a real item) +// request C level 0 +const E = child({ id: 'E', level: 1, parentId: 'A', ancestors: ['A'], folder: true, metaSortKey: 200, name: 'Echo' }); +const emptyPlaceholder = emptyFolderNode('empty_E', 1, 'E'); +const emptyTree: FlatItem[] = [workspaceItem, A, E, emptyPlaceholder, C]; + +describe('levelFromX', () => { + // 1rem is 13px by default here, not the css default of 16. + it.each([ + { level: 0, x: 39 }, + { level: 1, x: 52 }, + { level: 2, x: 65 }, + { level: 3, x: 78 }, + ])('maps a row indent guide to its own level at 13px per rem (level $level)', ({ level, x }) => { + expect(levelFromX(x, 0, 13)).toBe(level); + }); + + it.each([ + { level: 0, x: 48 }, + { level: 1, x: 64 }, + { level: 2, x: 80 }, + ])('maps a row indent guide to its own level at 16px per rem (level $level)', ({ level, x }) => { + expect(levelFromX(x, 0, 16)).toBe(level); + }); + + it('accounts for depthOffset, which strips ancestor levels in focus mode', () => { + // Focus mode renders level 0 at 1rem = 13px. + expect(levelFromX(13, 2, 13)).toBe(0); + expect(levelFromX(39, 2, 13)).toBe(2); + }); + + it('rounds, giving each level a half-step lead-in before its guide', () => { + // Level 1's guide is 52px; the lead-in makes deeper levels easier to hit. + expect(levelFromX(46, 0, 13)).toBe(1); + expect(levelFromX(45, 0, 13)).toBe(0); + }); + + it('returns negative levels left of the shallowest indent', () => { + // Callers clamp; this does not. + expect(levelFromX(0, 0, 13)).toBe(-3); + expect(levelFromX(0, 2, 13)).toBe(-1); + }); +}); + +describe('getIndentLevel', () => { + it('reads the level off a collection child', () => { + expect(getIndentLevel(B1)).toBe(2); + }); + + it('treats a missing item as top level', () => { + expect(getIndentLevel(null)).toBe(0); + }); + + it('reports pinned requests as top level, matching how they render', () => { + const pinned = { ...(B1 as CollectionChildFlatItem), kind: 'pinnedRequest' } as unknown as FlatItem; + expect(getIndentLevel(pinned)).toBe(0); + }); + + it('falls back to top level for a node carrying no level', () => { + expect(getIndentLevel(emptyFolderNode('e', undefined as unknown as number, 'E'))).toBe(0); + }); +}); + +describe('resolveDrop — folder trailing edge', () => { + it('lands inside the folder when the cursor asks to go deeper', () => { + expect(resolveAt(nestedTree, 'after', 'A', 1).normalized).toMatchObject({ dropPosition: 'on', targetItem: A }); + }); + + it('stays beside the folder when the cursor stays at its level', () => { + const { normalized } = resolveAt(nestedTree, 'after', 'A', 0); + expect(normalized).toMatchObject({ dropPosition: 'after', targetItem: A }); + }); + + it('does not merge into a folder with no visible children', () => { + const { normalized } = resolveAt([workspaceItem, A, E, C], 'after', 'E', 5); + expect(normalized).toMatchObject({ dropPosition: 'after', targetItem: E }); + }); +}); + +describe('resolveDrop — first child leading edge', () => { + // The form react-aria actually renders for the gap above. + it('keeps the child target when the cursor asks for the child level', () => { + const { normalized } = resolveAt(nestedTree, 'before', 'A1', 1); + expect(normalized).toMatchObject({ dropPosition: 'before', targetItem: A1 }); + }); + + it('redirects to the parent folder when the cursor goes shallower', () => { + const { normalized } = resolveAt(nestedTree, 'before', 'A1', 0); + expect(normalized).toMatchObject({ dropPosition: 'after', targetItem: A }); + }); + + it('never redirects shallower than the folder that owns the gap', () => { + // The gap spans "inside A" to "beside A" only. + const { normalized } = resolveAt(nestedTree, 'before', 'A1', -5); + expect(normalized).toMatchObject({ dropPosition: 'after', targetItem: A }); + }); +}); + +describe('resolveDrop — subtree tail', () => { + it.each([ + { level: 2, expected: 'B1', description: 'deepest — stays inside the subfolder' }, + { level: 1, expected: 'B', description: 'intermediate — inside the containing folder' }, + { level: 0, expected: 'A', description: 'shallowest — beside the containing folder' }, + ])('walks the ancestor chain to level $level ($description)', ({ level, expected }) => { + const { normalized } = resolveAt(nestedTree, 'after', 'B1', level); + expect(normalized.targetItem.doc._id).toBe(expected); + }); + + it('clamps a cursor left of the shallow row rather than escaping the gap', () => { + expect(resolveAt(nestedTree, 'after', 'B1', -4).normalized.targetItem.doc._id).toBe('A'); + }); + + it('clamps a cursor past the deep row onto that row', () => { + expect(resolveAt(nestedTree, 'after', 'B1', 99).normalized.targetItem.doc._id).toBe('B1'); + }); +}); + +describe('resolveDrop — empty folder placeholder', () => { + it('keeps the placeholder form when landing inside the empty folder', () => { + const { normalized } = resolveAt(emptyTree, 'before', 'empty_E', 2); + expect(normalized).toMatchObject({ dropPosition: 'before', targetItem: emptyPlaceholder }); + }); + + it('rewrites to the folder trailing edge when landing beside the folder', () => { + // Also fixes ordering: the placeholder form carries a first-child sort key. + const { normalized } = resolveAt(emptyTree, 'before', 'empty_E', 1); + expect(normalized).toMatchObject({ dropPosition: 'after', targetItem: E }); + }); +}); + +describe('resolveDrop — destination parent', () => { + it('parents on the workspace root at the top of a collection', () => { + expect(resolveAt(nestedTree, 'before', 'A', 0).parentId).toBe('wrk_1'); + }); + + it('parents inside a folder for an "on" drop', () => { + expect(resolveAt(nestedTree, 'after', 'A', 1).parentId).toBe('A'); + }); + + it('parents on the workspace for a top-level sibling insert', () => { + expect(resolveAt(nestedTree, 'before', 'C', 0).parentId).toBe('wrk_1'); + }); + + it('moves a nested request out to the collection top level', () => { + // Requests may live directly under a collection, with no folder at all. + expect(resolveAt(nestedTree, 'after', 'B1', 0).parentId).toBe('wrk_1'); + expect(resolveAt(nestedTree, 'after', 'B1', 0).folderName).toBeNull(); + }); + + it('parents inside the empty folder when the cursor stays deep', () => { + expect(resolveAt(emptyTree, 'before', 'empty_E', 2).parentId).toBe('E'); + }); + + it('parents outside the empty folder when the cursor goes shallow', () => { + expect(resolveAt(emptyTree, 'before', 'empty_E', 1).parentId).toBe('A'); + }); +}); + +describe('both raw forms of one gap agree', () => { + // The invariant the module exists to uphold: react-stately may hand the commit + // path one form and the render path the other. + it.each([-2, -1, 0, 1, 2, 3, 10])('subtree tail resolves identically at level %i', level => { + const viaAfter = resolveAt(nestedTree, 'after', 'B1', level); + const viaBefore = resolveAt(nestedTree, 'before', 'C', level); + expect(viaAfter.parentId).toBe(viaBefore.parentId); + expect(viaAfter.folderName).toBe(viaBefore.folderName); + }); + + it.each([-2, -1, 0, 1, 2, 3, 10])('folder trailing edge resolves identically at level %i', level => { + const viaAfter = resolveAt(nestedTree, 'after', 'A', level); + const viaBefore = resolveAt(nestedTree, 'before', 'A1', level); + expect(viaAfter.parentId).toBe(viaBefore.parentId); + }); + + it.each([-2, -1, 0, 1, 2, 3, 10])('empty folder boundary resolves identically at level %i', level => { + const viaAfter = resolveAt(emptyTree, 'after', 'E', level); + const viaBefore = resolveAt(emptyTree, 'before', 'empty_E', level); + expect(viaAfter.parentId).toBe(viaBefore.parentId); + }); +}); + +describe('resolveDrop — destination folder name', () => { + it('names the folder a nested insert actually lands in', () => { + expect(resolveAt(nestedTree, 'after', 'B1', 2).folderName).toBe('Bravo'); + }); + + it('names the containing folder once the cursor steps out one level', () => { + expect(resolveAt(nestedTree, 'after', 'B1', 1).folderName).toBe('Alpha'); + }); + + it('names nothing at top level', () => { + expect(resolveAt(nestedTree, 'after', 'B1', 0).folderName).toBeNull(); + }); + + it('stays silent on a folder trailing edge that did not merge inside', () => { + expect(resolveAt(nestedTree, 'after', 'A', 0).folderName).toBeNull(); + }); + + it('names the folder when the trailing edge did merge inside', () => { + expect(resolveAt(nestedTree, 'after', 'A', 1).folderName).toBe('Alpha'); + }); +}); + +describe('canDrop', () => { + const at = (items: FlatItem[], dropPosition: DropPosition, key: string) => + boundaryAt(items, dropPosition, key).boundary; + // canDrop judges containment against the resolved destination. + const dest = (items: FlatItem[], parentId: string | null) => ({ + parentId, + parentItem: parentId ? (buildIndex(items).flatItemsById.get(parentId)?.[0] ?? null) : null, + }); + + it('rejects a drop that lands exactly where the item already is', () => { + expect(canDrop(B1, at(nestedTree, 'after', 'B1'), dest(nestedTree, 'B'))).toBe(false); + }); + + it('allows a drop beside the item when the level changes its parent', () => { + // Same boundary, cursor moved left: a real move out of the subfolder. + expect(canDrop(B1, at(nestedTree, 'after', 'B1'), dest(nestedTree, 'A'))).toBe(true); + }); + + it('rejects moving a folder into its own descendant', () => { + expect(canDrop(B, at(nestedTree, 'on', 'B1'), dest(nestedTree, 'B1'))).toBe(false); + }); + + it('allows moving a request into an unrelated folder', () => { + expect(canDrop(A1, at(nestedTree, 'on', 'B'), dest(nestedTree, 'B'))).toBe(true); + }); + + it('rejects an "on" drop onto a request rather than a folder', () => { + expect(canDrop(A1, at(nestedTree, 'on', 'C'), dest(nestedTree, 'wrk_1'))).toBe(false); + }); + + it('allows inserting after an empty-folder placeholder', () => { + // "after" the placeholder is the gap after the folder it stands in for. + expect(canDrop(C, at(emptyTree, 'after', 'empty_E'), dest(emptyTree, 'A'))).toBe(true); + }); + + // Self-parenting is unrecoverable: the folder and its subtree vanish. + it('never lets a folder become its own parent (leading edge of its first child)', () => { + const resolved = resolveAt(nestedTree, 'before', 'A1', 1); + expect(resolved.parentId).toBe('A'); + expect(canDrop(A, at(nestedTree, 'before', 'A1'), dest(nestedTree, resolved.parentId))).toBe(false); + }); + + it('never lets a folder become its own parent (its own trailing edge)', () => { + expect(canDrop(A, at(nestedTree, 'on', 'A'), dest(nestedTree, 'A'))).toBe(false); + }); + + it('never lets a folder land inside its own descendant', () => { + expect(canDrop(A, at(nestedTree, 'after', 'B1'), dest(nestedTree, 'B'))).toBe(false); + }); + + it('lets a folder escape via the gap below its own subtree', () => { + // The anchor row (B1) is inside B, but the destination is not. + expect(canDrop(B, at(nestedTree, 'after', 'B1'), dest(nestedTree, 'wrk_1'))).toBe(true); + }); + + it('allows moving a nested request out to the collection top level', () => { + // Previous row is a collection child, not the workspace header. + expect(canDrop(B1, at(nestedTree, 'before', 'C'), dest(nestedTree, 'wrk_1'))).toBe(true); + }); +}); + +describe('SidebarDropTargetDelegate', () => { + // Rows 20px tall. `y` arrives already in this space, so scrollOffset must not + // be added — react-aria measures against the GridList inside the scroller. + const rows = [ + { key: 'A', index: 0, start: 0, end: 20, size: 20, lane: 0 }, + { key: 'A1', index: 1, start: 20, end: 40, size: 20, lane: 0 }, + { key: 'B', index: 2, start: 40, end: 60, size: 20, lane: 0 }, + ]; + + const delegateWith = (scrollOffset: number, rendered = rows) => { + const virtualizer = { getVirtualItems: () => rendered, scrollOffset } as never; + const delegate = new SidebarDropTargetDelegate(virtualizer); + delegate.configure(0, () => {}); + return delegate; + }; + + const acceptBeforeAfter = (target: { type: string; dropPosition?: string }) => + target.type === 'item' && target.dropPosition !== 'on'; + + it('hit-tests against y directly when unscrolled', () => { + expect(delegateWith(0).getDropTargetFromPoint(40, 45, acceptBeforeAfter)).toMatchObject({ key: 'B' }); + }); + + it('ignores scrollOffset — y is already in content space', () => { + // y is near the top on purpose: double-counting lands past every row. + for (const scrollOffset of [0, 100, 5000]) { + expect(delegateWith(scrollOffset).getDropTargetFromPoint(40, 5, acceptBeforeAfter)).toMatchObject({ key: 'A' }); + } + }); + + it('resolves the row under the cursor rather than the last rendered row', () => { + const scrolled = [ + { key: 'X', index: 10, start: 200, end: 220, size: 20, lane: 0 }, + { key: 'Y', index: 11, start: 220, end: 240, size: 20, lane: 0 }, + ]; + expect(delegateWith(200, scrolled).getDropTargetFromPoint(40, 205, acceptBeforeAfter)).toMatchObject({ key: 'X' }); + }); + + it('falls back to root when nothing is rendered', () => { + expect(delegateWith(0, []).getDropTargetFromPoint(40, 45, acceptBeforeAfter)).toEqual({ type: 'root' }); + }); +}); + +describe('dropping at the very top of a collection', () => { + // The only boundary anchored on the collection header, so it takes its own + // path through canDrop — including on cloud-synced projects. + const topGap = (remoteId: string | null) => { + const cloudProject = { _id: 'proj_1', remoteId, name: 'P' } as unknown as ProjectWithGitRepository; + const withProject = (item: FlatItem) => ({ ...item, project: cloudProject }) as FlatItem; + const items = [workspaceItem, A, C].map(withProject); + const [wsRow, folderRow, requestRow] = items; + const { flatItemsById, lookup, hasVisibleChildren } = buildIndex(items); + const boundary: BoundaryTargets = { + dropPosition: 'before', + targetItem: folderRow, + realTargetItem: wsRow, + nextItem: requestRow, + }; + const ctx: DropContext = { hasVisibleChildren, lookup, flatItemsById, preferredLevel: 0 }; + return { dragged: requestRow, boundary, resolved: resolveDrop(boundary, ctx) }; + }; + + it.each([ + { kind: 'local', remoteId: null }, + { kind: 'cloud-synced', remoteId: 'remote_1' }, + ])('parents on the collection itself ($kind project)', ({ remoteId }) => { + expect(topGap(remoteId).resolved.parentId).toBe('wrk_1'); + }); + + it.each([ + { kind: 'local', remoteId: null }, + { kind: 'cloud-synced', remoteId: 'remote_1' }, + ])('allows the drop ($kind project)', ({ remoteId }) => { + const { dragged, boundary, resolved } = topGap(remoteId); + expect(canDrop(dragged, boundary, resolved)).toBe(true); + }); +}); + +describe('the region below an empty folder placeholder', () => { + // folder A level 0 + // subfolder E level 1 (expanded, empty) + // "Folder empty" (placeholder) + // subfolder S level 1 <- want to drop above this + // request R level 1 + const S = child({ id: 'S', level: 1, parentId: 'A', ancestors: ['A'], folder: true, metaSortKey: 300, name: 'Sierra' }); + const R = child({ id: 'R', level: 1, parentId: 'A', ancestors: ['A'], metaSortKey: 400 }); + const tree: FlatItem[] = [workspaceItem, A, E, emptyPlaceholder, S, R]; + + const resolveHere = (dropPosition: DropPosition, key: string, preferredLevel: number) => { + const { boundary, flatItemsById, lookup, hasVisibleChildren } = boundaryAt(tree, dropPosition, key); + const ctx: DropContext = { hasVisibleChildren, lookup, flatItemsById, preferredLevel }; + const resolved = resolveDrop(boundary, ctx); + return { ...resolved, valid: canDrop(R, boundary, resolved) }; + }; + + it.each([0, 1])('accepts a drop below the placeholder, shallow cursor (L%i)', level => { + // Both raw forms of that one gap. + for (const [dropPosition, key] of [['after', 'empty_E'], ['before', 'S']] as const) { + const resolved = resolveHere(dropPosition, key, level); + expect({ dropPosition, ...resolved }).toMatchObject({ parentId: 'A', valid: true }); + } + }); + + it('means one thing regardless of cursor depth', () => { + // Not level-aware: the opposite raw form cannot express "inside". + for (const level of [0, 1, 2, 5]) { + expect(resolveHere('after', 'empty_E', level)).toMatchObject({ parentId: 'A', valid: true }); + } + }); + + it('agrees across both raw forms at every level', () => { + for (const level of [-1, 0, 1, 2, 3]) { + expect(resolveHere('after', 'empty_E', level).parentId).toBe(resolveHere('before', 'S', level).parentId); + } + }); + + it('still refuses to insert beside an empty COLLECTION placeholder', () => { + const emptyCollection = { + ...emptyFolderNode('empty_ws', 0, 'wrk_1'), + kind: 'emptyCollection', + requestGroup: undefined, + } as unknown as FlatItem; + const items: FlatItem[] = [workspaceItem, emptyCollection]; + const { boundary, flatItemsById, lookup, hasVisibleChildren } = boundaryAt(items, 'after', 'empty_ws'); + const ctx: DropContext = { hasVisibleChildren, lookup, flatItemsById, preferredLevel: 0 }; + expect(canDrop(R, boundary, resolveDrop(boundary, ctx))).toBe(false); + }); +}); diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx index 028b19d2e1..c3853f6ba5 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/use-sidebar-drag-and-drop.tsx @@ -1,10 +1,11 @@ import type { Virtualizer } from '@tanstack/react-virtual'; import { models, type WorkspaceScope } from 'insomnia-data'; -import { useCallback, useMemo, useRef } from 'react'; -import type { DragAndDropHooks, ItemDropTarget } from 'react-aria-components'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { DragAndDropHooks, DropTarget, ItemDropTarget } from 'react-aria-components'; import { DropIndicator, useDragAndDrop } from 'react-aria-components'; import { useDebugReorderActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.reorder'; +import { Icon } from '~/ui/components/icon'; import type { CollectionChildFlatItem, EmptyNodeFlatItem, FlatItem } from './types'; @@ -31,27 +32,282 @@ function isEmptyNode(item: FlatItem): item is EmptyNodeFlatItem { return emptyNodeKinds.includes(item.kind); } -function canDrop( +export function getIndentLevel(item: FlatItem | null): number { + if (!item || item.kind === 'pinnedRequest') { + return 0; + } + return 'level' in item && typeof item.level === 'number' ? item.level : 0; +} + +type HasVisibleChildren = (folderId: string) => boolean; +type FlatItemById = (id: string) => FlatItem | null; +// Every visible row mapped to itself plus its previous and next visible rows. +type FlatItemsById = Map; +type DropPositionKind = ItemDropTarget['dropPosition']; + +function isCollectionChild(item: FlatItem | null | undefined): item is CollectionChildFlatItem { + return item?.kind === 'collectionChild'; +} + +function isFolderItem(item: FlatItem | null | undefined): item is CollectionChildFlatItem { + return isCollectionChild(item) && models.requestGroup.isRequestGroup(item.doc); +} + +// realTargetItem is the row the drop attaches to: the preceding row for a +// "before" drop, the target itself otherwise. +export interface BoundaryTargets { + dropPosition: DropPositionKind; + targetItem: FlatItem; + realTargetItem?: FlatItem | null; + nextItem?: FlatItem | null; +} + +export interface DropContext { + hasVisibleChildren: HasVisibleChildren; + lookup: FlatItemById; + flatItemsById: FlatItemsById; + // Nesting level the cursor's x currently selects (see levelFromX). + preferredLevel: number; +} + +interface NormalizedBoundary { + dropPosition: DropPositionKind; + targetItem: FlatItem; + redirected: boolean; +} + +// Not 16: the app sets html font-size from the interface fontSize setting +// (13px default) in use-settings-side-effects.ts, so it has to be measured. +const FALLBACK_PX_PER_REM = 16; +function measurePxPerRem(): number { + const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + return Number.isFinite(rootFontSize) && rootFontSize > 0 ? rootFontSize : FALLBACK_PX_PER_REM; +} + +// Mirrors the rows' paddingLeft (request-node.tsx). levelFromX and +// indentRemForLevel must stay exact inverses of each other. +const ROOT_INDENT_REM = 3; +const MIN_INDENT_REM = 1; + +export function levelFromX(x: number, depthOffset: number, pxPerRem: number): number { + return Math.round(x / pxPerRem) - ROOT_INDENT_REM + depthOffset; +} + +function indentRemForLevel(level: number, depthOffset: number): number { + return Math.max(level + ROOT_INDENT_REM - depthOffset, MIN_INDENT_REM); +} + +// Deeper and shallower side of a multi-depth gap, or null for a same-level +// insert. Both raw forms of one gap map to the same pair. +function resolveGapSides( + { dropPosition, targetItem, realTargetItem, nextItem }: BoundaryTargets, +): { deep: CollectionChildFlatItem; shallow: CollectionChildFlatItem } | null { + const otherItem = dropPosition === 'before' ? realTargetItem : nextItem; + if (!isCollectionChild(targetItem) || !isCollectionChild(otherItem)) { + return null; + } + const targetLevel = getIndentLevel(targetItem); + const otherLevel = getIndentLevel(otherItem); + if (targetLevel === otherLevel) { + return null; + } + // A folder's own trailing edge; the merge-inside branch handles it. + if (dropPosition === 'after' && targetLevel < otherLevel) { + return null; + } + return targetLevel > otherLevel + ? { deep: targetItem, shallow: otherItem } + : { deep: otherItem, shallow: targetItem }; +} + +// react-stately treats `after X` and `before nextKey(X)` as one insertion point +// (getOppositeTarget in useDroppableCollectionState). In a tree they must differ +// — beside a folder vs. inside it — so both raw forms are normalized here, and +// every caller resolves through this. Doing it in the delegate instead breaks +// rendering: react-aria only tests candidates tied to specific rows. +// +// A gap can span several depths; preferredLevel picks one by indexing the deeper +// row's `ancestors` (root-first, so index === level). +export function normalizeBoundaryTarget( + boundary: BoundaryTargets, + { hasVisibleChildren, preferredLevel, lookup }: DropContext, +): NormalizedBoundary { + const { dropPosition, targetItem, realTargetItem } = boundary; + // Same gap as "before" its first child: land inside only if the cursor asks + // to go deeper than the folder itself. + if ( + dropPosition === 'after' && + isFolderItem(targetItem) && + hasVisibleChildren(targetItem.doc._id) && + preferredLevel > getIndentLevel(targetItem) + ) { + return { dropPosition: 'on', targetItem, redirected: false }; + } + // A "Folder is empty" placeholder stands in for the folder's interior, so the + // gap above it is the folder's trailing edge. Rewriting also fixes ordering: + // the placeholder form carries a first-child sort key. + if ( + dropPosition === 'before' && + isEmptyNode(targetItem) && + isFolderItem(realTargetItem) && + preferredLevel <= getIndentLevel(realTargetItem) + ) { + return { dropPosition: 'after', targetItem: realTargetItem, redirected: true }; + } + // The placeholder's lower edge: the gap after the folder's whole interior. + // Not level-aware on purpose — the opposite raw form can't express "inside", + // so offering it here would make the two forms disagree. + if (dropPosition === 'after' && isEmptyNode(targetItem)) { + const owningFolder = targetItem.requestGroup ? lookup(targetItem.requestGroup._id) : null; + if (isFolderItem(owningFolder)) { + return { dropPosition: 'after', targetItem: owningFolder, redirected: true }; + } + } + + // Clamp the requested level to the range the gap spans, then walk the deeper + // row's ancestor chain. Three raw forms share this one walk by design. + const gap = resolveGapSides(boundary); + if (gap) { + const deepLevel = getIndentLevel(gap.deep); + const level = Math.min(Math.max(preferredLevel, getIndentLevel(gap.shallow)), deepLevel); + const ancestorId = level < deepLevel ? gap.deep.ancestors?.[level] : undefined; + const ancestor = ancestorId ? lookup(ancestorId) : null; + if (isCollectionChild(ancestor)) { + return { dropPosition: 'after', targetItem: ancestor, redirected: ancestor !== targetItem }; + } + // Deepest landing. If the deep row is the target the caller's form already + // says so; otherwise re-anchor onto it. + return gap.deep === targetItem + ? { dropPosition, targetItem, redirected: false } + : { dropPosition: 'after', targetItem: gap.deep, redirected: true }; + } + + return { dropPosition, targetItem, redirected: false }; +} + +// The dragged item's new parent, sent to the reorder route outright so there is +// one implementation of "where does this land". +function destinationParentIdFor( + { targetItem, dropPosition }: NormalizedBoundary, + { realTargetItem }: BoundaryTargets, +): string | null { + // At the top of a collection the previous row is the workspace header itself. + if (realTargetItem?.kind === 'workspace' && models.workspace.isCollection(realTargetItem.doc)) { + return realTargetItem.doc._id; + } + + if (dropPosition === 'on' && isFolderItem(targetItem)) { + return targetItem.doc._id; + } + + // Only the "inside" case reaches here; normalizeBoundaryTarget rewrites the + // shallower one. + if (isEmptyNode(targetItem) && isFolderItem(realTargetItem)) { + return realTargetItem.doc._id; + } + + if (isCollectionChild(targetItem) && 'parentId' in targetItem.doc) { + return targetItem.doc.parentId; + } + + return null; +} + +// Folder a drop lands inside, or null at top level. Worth naming because the +// folder adjacent to a boundary is often not the destination. +function destinationFolderNameFor(normalized: NormalizedBoundary, parentItem: FlatItem | null): string | null { + // Hovering a folder's own row without merging to 'on': naming it would read + // as "into it" when the drop goes beside it. A redirect is unambiguous. + if (normalized.dropPosition === 'after' && !normalized.redirected && isFolderItem(normalized.targetItem)) { + return null; + } + return isFolderItem(parentItem) ? parentItem.doc.name || 'Untitled folder' : null; +} + +export interface ResolvedDrop { + normalized: NormalizedBoundary; + parentId: string | null; + // The destination as a row, when visible — canDrop needs its ancestry. + parentItem: FlatItem | null; + folderName: string | null; + landingLevel: number; +} + +// One normalization pass, shared by the indicator and the commit so they cannot +// disagree about where a drop lands. +export function resolveDrop(boundary: BoundaryTargets, ctx: DropContext): ResolvedDrop { + const normalized = normalizeBoundaryTarget(boundary, ctx); + const parentId = destinationParentIdFor(normalized, boundary); + const parentItem = parentId ? (ctx.flatItemsById.get(parentId)?.[0] ?? null) : null; + return { + normalized, + parentId, + parentItem, + folderName: destinationFolderNameFor(normalized, parentItem), + landingLevel: getIndentLevel(normalized.targetItem), + }; +} + +export function canDrop( dragItem: FlatItem, - dropItem: FlatItem, - { dropPosition }: ItemDropTarget, - dropPrevItem: FlatItem | null, - dropNextItem: FlatItem | null, + boundary: BoundaryTargets, + // Level selection can redirect the landing far from the row the boundary sits + // on, so containment below is tested against this, not the anchor row. + destination: Pick, expandedProjectAndWorkspaceIds?: string[], ) { - const realDropItem = dropPosition === 'before' ? dropPrevItem : dropItem; + const { dropPosition, targetItem: dropItem, realTargetItem: realDropItem, nextItem } = boundary; + const { parentId: destinationParentId, parentItem: destinationParentItem } = destination; + if (dropPosition === 'on') { + // Dropping directly "on" a row means "move inside this folder" — only valid + // when dragging a request/request group onto another folder. + if ( + dragItem.doc._id === dropItem.doc._id || + dragItem.kind !== 'collectionChild' || + !isAllowDragItem(dragItem) || + !isFolderItem(dropItem) + ) { + return false; + } + // moving a folder into itself or one of its own descendants is not allowed + return !(models.requestGroup.isRequestGroup(dragItem.doc) && dropItem.ancestors?.includes(dragItem.doc._id)); + } + // The item following realDropItem in the list - const itemAfterRealDrop = dropPosition === 'before' ? dropItem : dropNextItem; - // drag and drop items are same. - if ( - !realDropItem || - dragItem.doc._id === dropItem.doc._id || - dragItem.doc._id === realDropItem.doc._id || - !isAllowDropTarget(realDropItem) - ) { + const itemAfterRealDrop = dropPosition === 'before' ? dropItem : nextItem; + if (!realDropItem || !isAllowDropTarget(realDropItem)) { return false; } + // Nothing may land inside itself, and no folder inside its own descendants. + // Self-parenting is unrecoverable: roots come from `parentId === workspaceId`, + // so the folder and its subtree would vanish from the sidebar. + if (isCollectionChild(dragItem)) { + if (destinationParentId === dragItem.doc._id) { + return false; + } + if ( + models.requestGroup.isRequestGroup(dragItem.doc) && + isCollectionChild(destinationParentItem) && + destinationParentItem.ancestors?.includes(dragItem.doc._id) + ) { + return false; + } + } + + // Beside the dragged item is normally a no-op, but level selection can make it + // a real move out of the parent — so only reject it if the parent is unchanged. + const isBesideDragItem = dragItem.doc._id === dropItem.doc._id || dragItem.doc._id === realDropItem.doc._id; + if (isBesideDragItem) { + const changesParent = + dragItem.kind === 'collectionChild' && + destinationParentId != null && + destinationParentId !== dragItem.doc.parentId; + if (!changesParent) { + return false; + } + } + if (!isAllowDragItem(dragItem)) { return false; } @@ -60,6 +316,15 @@ function canDrop( const dragInCloud = models.project.isRemoteProject(dragItem.project); if (dragItem.kind === 'workspace') { const dragWorkspaceScope = dragItem.doc.scope; + // Konnect workspaces are keyed to their project's control plane, so moving one across projects + // would orphan it and make the next sync delete or duplicate it. + const dropProject = realDropItem.kind === 'project' ? realDropItem.doc : realDropItem.project; + const dropToAnotherKonnectProject = + dragItem.project._id !== dropProject._id && + (dragItem.project.konnectControlPlaneId != null || dropProject.konnectControlPlaneId != null); + if (dropToAnotherKonnectProject) { + return false; + } if (realDropItem) { if (realDropItem.kind === 'project') { const dropToAnotherProject = dragItem.project._id !== realDropItem.doc._id; @@ -122,17 +387,187 @@ function canDrop( models.workspace.isCollection(realDropItem.doc) && (models.requestGroup.isRequestGroup(dragItem.doc) || models.request.isRequest(dragItem.doc)) ) { - // not same collection and none are in cloud + // Repositioning inside its own collection is not a cross-collection move, so + // the cloud rule below does not apply. + if (isCollectionChild(dragItem) && dragItem.workspace._id === realDropItem.doc._id) { + return true; + } + // Moving BETWEEN collections cannot involve cloud sync on either side. const dropInCloud = models.project.isRemoteProject(realDropItem.project); return !dragInCloud && !dropInCloud; } - // move other things into workspace is not allowed, or move after empty node is not allowed - if (realDropItem.kind === 'workspace' || isEmptyNode(realDropItem)) { + // An empty PROJECT or COLLECTION placeholder has no sibling list to join. An + // empty FOLDER's does, since it stands in for that folder's interior. + if (realDropItem.kind === 'workspace' || (isEmptyNode(realDropItem) && realDropItem.kind !== 'emptyFolder')) { return false; } - return !(models.requestGroup.isRequestGroup(dragItem.doc) && realDropItem.ancestors?.includes(dragItem.doc._id)); + return true; +} + +// Widens react-aria's 5px before/after band around an "on"-capable row. +const EDGE_PX = 10; + +interface VirtualRow { + key: string | number | bigint; + start: number; + end: number; + size: number; +} + +// Hit-tests from the virtualizer's row geometry, with no DOM measurement. Knows +// only row positions; normalizeBoundaryTarget interprets the result. +export class SidebarDropTargetDelegate { + private depthOffset = 0; + private onHoverLevelChange: ((level: number) => void) | null = null; + private pxPerRem = FALLBACK_PX_PER_REM; + + constructor(private virtualizer: Virtualizer) {} + + // onHoverLevelChange is setState-backed: React bails out on an unchanged + // value, so calling it every dragover only re-renders when the level moves. + configure(depthOffset: number, onHoverLevelChange: (level: number) => void) { + this.depthOffset = depthOffset; + this.onHoverLevelChange = onHoverLevelChange; + } + + // Once per drag: it can't change mid-drag, and reading it per pointer hit + // forces a style recalculation in the drag hot path. + sampleRootFontSize() { + this.pxPerRem = measurePxPerRem(); + } + + private classify( + contentY: number, + virtualItems: VirtualRow[], + isValidDropTarget: (target: DropTarget) => boolean, + ): DropTarget { + let matchIndex = virtualItems.findIndex(v => contentY >= v.start && contentY < v.end); + if (matchIndex === -1) { + matchIndex = contentY < virtualItems[0].start ? 0 : virtualItems.length - 1; + } + const match = virtualItems[matchIndex]; + + // Virtual item keys can technically be bigint; our own keys are always + // doc ids (strings), and react-aria's Key type doesn't include bigint. + const key = String(match.key); + const onTarget: ItemDropTarget = { type: 'item', key, dropPosition: 'on' }; + const { start, end, size } = match; + + if (isValidDropTarget(onTarget)) { + const edge = Math.min(EDGE_PX, size / 3); + if (contentY <= start + edge) { + const before: ItemDropTarget = { ...onTarget, dropPosition: 'before' }; + if (isValidDropTarget(before)) { + return before; + } + } + if (contentY >= end - edge) { + const after: ItemDropTarget = { ...onTarget, dropPosition: 'after' }; + if (isValidDropTarget(after)) { + return after; + } + } + return onTarget; + } + + const mid = start + size / 2; + if (contentY <= mid) { + const before: ItemDropTarget = { ...onTarget, dropPosition: 'before' }; + if (isValidDropTarget(before)) { + return before; + } + } + const after: ItemDropTarget = { ...onTarget, dropPosition: 'after' }; + if (isValidDropTarget(after)) { + return after; + } + return onTarget; + } + + getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { + this.onHoverLevelChange?.(levelFromX(x, this.depthOffset, this.pxPerRem)); + + const virtualItems = this.virtualizer.getVirtualItems(); + if (virtualItems.length === 0) { + return { type: 'root' }; + } + + // Do NOT add scrollOffset: react-aria measures y against the GridList, which + // is inside the scroller at full content height, so the offset cancels out. + return this.classify(y, virtualItems, isValidDropTarget); + } +} + +interface SidebarDropIndicatorProps { + target: ItemDropTarget; + rowStart: number; + rowEnd: number; + rowHeight: number; + isValid: boolean; + // Indent matching the level the item will land at. + indentRem: number; + folderName: string | null; +} + +function SidebarDropIndicator({ + target, + rowStart, + rowEnd, + rowHeight, + isValid, + indentRem, + folderName, +}: SidebarDropIndicatorProps) { + const outlineClass = isValid ? 'outline-(--color-surprise)' : 'outline-(--color-danger)'; + + // "on" means inside: outline the whole row rather than draw a line. + if (target.dropPosition === 'on') { + return ( + + + + ); + } + + return ( + + {folderName ? ( + + + {folderName} + + ) : ( + + )} + + ); } interface UseSidebarDragAndDropOptions { @@ -148,6 +583,8 @@ interface UseSidebarDragAndDropOptions { dropPosition: 'before' | 'after', ) => void; expandedProjectAndWorkspaceIds?: string[]; + // Ancestor indent levels stripped from row rendering (see request-node.tsx). + depthOffset?: number; } export const useSidebarDragAndDrop = ({ @@ -156,6 +593,7 @@ export const useSidebarDragAndDrop = ({ virtualizer, onWorkspaceReorder, expandedProjectAndWorkspaceIds, + depthOffset = 0, }: UseSidebarDragAndDropOptions): DragAndDropHooks => { const reorderFetcher = useDebugReorderActionFetcher(); @@ -166,6 +604,32 @@ export const useSidebarDragAndDrop = ({ visibles.map((item, index) => [item.doc._id, [item, visibles[index - 1], visibles[index + 1]]] as const), ); }, [flatItems]); + + const dropTargetDelegateRef = useRef(null); + if (!dropTargetDelegateRef.current) { + dropTargetDelegateRef.current = new SidebarDropTargetDelegate(virtualizer); + } + + // Nesting level the cursor's x selects, for gaps spanning several depths. + const [hoverLevel, setHoverLevel] = useState(0); + dropTargetDelegateRef.current.configure(depthOffset, setHoverLevel); + + // Whether the folder currently shows an interior to drop into — which an + // expanded but childless folder does, via its placeholder row. + const hasVisibleChildren = useCallback( + folderId => + flatItems.some(candidate => { + if (candidate.hidden) { + return false; + } + if (candidate.kind === 'emptyFolder') { + return candidate.requestGroup?._id === folderId; + } + return candidate.kind === 'collectionChild' && candidate.doc.parentId === folderId; + }), + [flatItems], + ); + const draggingCollectionItemIdRef = useRef(null); const getCollectionItemByKey = useCallback( @@ -179,27 +643,54 @@ export const useSidebarDragAndDrop = ({ [flatItemsById], ); + const dropContext: DropContext = { + hasVisibleChildren, + lookup: getCollectionItemByKey, + flatItemsById, + preferredLevel: hoverLevel, + }; + const collectionDragAndDrop = useDragAndDrop({ getItems: keys => [...keys].map(key => ({ 'text/plain': key.toString() })), + // Without this, the browser allows copy/move/link and picks the cursor icon itself, + // which shows up as a flickering "+" (copy) cursor instead of a stable move cursor. + getAllowedDropOperations: () => ['move'], + // Our own delegate replaces react-aria's default hit-testing (which uses + // a hardcoded, easy-to-jitter-across 5px edge band) with wider zones + // computed from the virtualizer's own row geometry. + dropTargetDelegate: dropTargetDelegateRef.current, onDragStart(event) { const [draggedKey] = event.keys; draggingCollectionItemIdRef.current = draggedKey?.toString() || null; + dropTargetDelegateRef.current?.sampleRootFontSize(); }, onDragEnd() { draggingCollectionItemIdRef.current = null; + // Or the next drag opens against this one's last cursor column. + setHoverLevel(0); }, getDropOperation(target, _types) { - if (target.type !== 'item' || target.dropPosition === 'on') { + if (target.type !== 'item') { + return 'cancel'; + } + if (target.dropPosition === 'on') { + const dropItem = getCollectionItemByKey(target.key); + // Only allow the "on" drop position (move inside) for folders. This lets + // react-aria carve out real before/after edge zones for every other row, + // instead of the whole row being one big "move inside" target. + if (isFolderItem(dropItem)) { + return 'move'; + } return 'cancel'; } return 'move'; }, onMove(event) { - const { type, dropPosition: _dropPosition, key } = event.target; + const { type, dropPosition: rawDropPosition, key } = event.target; if (type !== 'item') { return; } - let dropPosition = _dropPosition; + let dropPosition = rawDropPosition; const isBefore = dropPosition === 'before'; const droppedKey = key.toString(); @@ -207,24 +698,29 @@ export const useSidebarDragAndDrop = ({ const draggedItem = getCollectionItemByKey(draggedKey) as AllowDragItem | null; const targetItem = getCollectionItemByKey(droppedKey) as AllowDropTarget | null; const realTargetItem = isBefore ? flatItemsById.get(droppedKey)?.[1] : targetItem; + const nextTargetItem = flatItemsById.get(droppedKey)?.[2]; + // Resolved from the RAW target, the same way the indicator resolves it. + const boundary: BoundaryTargets | null = targetItem && { + dropPosition: rawDropPosition, + targetItem, + realTargetItem, + nextItem: nextTargetItem, + }; + const resolved = boundary ? resolveDrop(boundary, dropContext) : null; + const destinationParentId = resolved?.parentId ?? null; if ( !draggedItem || !targetItem || - !canDrop( - draggedItem, - targetItem, - event.target, - flatItemsById.get(droppedKey)?.[1] || null, - flatItemsById.get(droppedKey)?.[2] || null, - expandedProjectAndWorkspaceIds, - ) + !boundary || + !resolved || + !canDrop(draggedItem, boundary, resolved, expandedProjectAndWorkspaceIds) ) { return; } // move workspace to another project or reorder within same project if (draggedItem.kind === 'workspace') { - if (_dropPosition === 'on') { + if (rawDropPosition === 'on') { return; } // Dropping after the last child of a collection @@ -277,7 +773,7 @@ export const useSidebarDragAndDrop = ({ targetProjectId, draggedItem.doc._id, targetItem.doc._id, - _dropPosition, + rawDropPosition, ); } } else { @@ -289,7 +785,7 @@ export const useSidebarDragAndDrop = ({ draggedItem.project._id, draggedItem.doc._id, targetItem.doc._id, - _dropPosition, + rawDropPosition, ); } } @@ -298,7 +794,7 @@ export const useSidebarDragAndDrop = ({ // move request or request group into collection if (realTargetItem?.kind === 'workspace' && models.workspace.isCollection(realTargetItem!.doc)) { - const siblingItem = flatItems.find( + const firstChildOfCollection = flatItems.find( (item): item is CollectionChildFlatItem => item.kind === 'collectionChild' && item.doc.parentId === realTargetItem!.doc._id, ); @@ -310,55 +806,57 @@ export const useSidebarDragAndDrop = ({ targetId: realTargetItem!.doc._id, id: draggedItem.doc._id, dropPosition: 'after', // collection only accepts move into, so treat all drops as "after" - metaSortKey: siblingItem?.doc.metaSortKey != null ? siblingItem.doc.metaSortKey - 100 : -1 * Date.now(), + metaSortKey: firstChildOfCollection?.doc.metaSortKey != null ? firstChildOfCollection.doc.metaSortKey - 100 : -1 * Date.now(), }, }); return; } + const normalized = normalizeBoundaryTarget(boundary, dropContext); + dropPosition = normalized.dropPosition; + // normalizeBoundaryTarget only ever swaps in a 'collectionChild'. + const normalizedTargetItem = normalized.targetItem as AllowDropTarget; + const id = draggedItem.doc._id; - let targetId = targetItem.doc._id; - const targetIsEmptyNode = isEmptyNode(targetItem); + let targetId = normalizedTargetItem.doc._id; + const targetIsEmptyNode = isEmptyNode(normalizedTargetItem); const workspaceCollectionItems = flatItems.filter( (item): item is CollectionChildFlatItem => item.kind === 'collectionChild' && item.workspace._id === draggedItem.workspace._id, ); let metaSortKey = 0; - const isMovingItemInsideFolder = - !targetIsEmptyNode && models.requestGroup.isRequestGroup(targetItem.doc) && dropPosition === 'after'; - - const isMovingOnEmptyNode = - realTargetItem && - 'type' in realTargetItem.doc && - models.requestGroup.isRequestGroup(realTargetItem.doc) && - targetIsEmptyNode; + const isMovingItemInsideFolder = dropPosition === 'on' && isFolderItem(normalizedTargetItem); + const isMovingIntoEmptyFolder = targetIsEmptyNode && isFolderItem(realTargetItem); if (isMovingItemInsideFolder) { - // The reorder route interprets "after folder" as moving into that folder. + // Sort ahead of the folder's current first child so it lands at the top. const children = workspaceCollectionItems.filter(item => item.doc.parentId === targetId); metaSortKey = children.length > 0 ? children[0].doc.metaSortKey - 100 : -1 * Date.now(); - } else if (isMovingOnEmptyNode) { + } else if (isMovingIntoEmptyFolder) { + // The placeholder has no doc of its own to sort against, so target the + // folder that owns it; parentId below puts the item inside that folder. targetId = realTargetItem.doc._id; - dropPosition = 'after'; metaSortKey = -1 * Date.now(); } else { // move before or after another request in same or different collection const siblingItems = workspaceCollectionItems.filter( - item => 'parentId' in targetItem.doc && item.doc.parentId === targetItem.doc.parentId, + item => 'parentId' in normalizedTargetItem.doc && item.doc.parentId === normalizedTargetItem.doc.parentId, ); const targetIndex = siblingItems.findIndex(item => item.doc._id === targetId); - if ('metaSortKey' in targetItem.doc && targetItem.doc.metaSortKey != null) { + if ('metaSortKey' in normalizedTargetItem.doc && normalizedTargetItem.doc.metaSortKey != null) { if (dropPosition === 'after') { const afterItem = siblingItems[targetIndex + 1]; metaSortKey = afterItem - ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - afterItem.doc.metaSortKey) / 2 - : targetItem.doc.metaSortKey + 100; + ? normalizedTargetItem.doc.metaSortKey - + (normalizedTargetItem.doc.metaSortKey - afterItem.doc.metaSortKey) / 2 + : normalizedTargetItem.doc.metaSortKey + 100; } else { const beforeItem = siblingItems[targetIndex - 1]; metaSortKey = beforeItem - ? targetItem.doc.metaSortKey - (targetItem.doc.metaSortKey - beforeItem.doc.metaSortKey) / 2 - : targetItem.doc.metaSortKey - 100; + ? normalizedTargetItem.doc.metaSortKey - + (normalizedTargetItem.doc.metaSortKey - beforeItem.doc.metaSortKey) / 2 + : normalizedTargetItem.doc.metaSortKey - 100; } } } @@ -376,53 +874,52 @@ export const useSidebarDragAndDrop = ({ id, dropPosition, metaSortKey, + parentId: destinationParentId ?? undefined, }, }); }, renderDropIndicator(target) { - if (target.type === 'item') { - const item = virtualizer.getVirtualItems().find(virtualItem => virtualItem.key === target.key); - if (item) { - const draggedItem = getCollectionItemByKey(draggingCollectionItemIdRef.current); - const targetItem = getCollectionItemByKey(target.key); - if ( - draggedItem == null || - targetItem == null || - !canDrop( - draggedItem as FlatItem, - targetItem as FlatItem, - target, - flatItemsById.get(target.key.toString())?.[1] || null, - flatItemsById.get(target.key.toString())?.[2] || null, - expandedProjectAndWorkspaceIds, - ) - ) { - return ( - - ); - } - return ( - - ); - } + const row = + target.type === 'item' + ? virtualizer.getVirtualItems().find(virtualItem => virtualItem.key === target.key) + : undefined; + if (target.type !== 'item' || !row) { + return ( + + ); } + const entry = flatItemsById.get(target.key.toString()); + const draggedItem = getCollectionItemByKey(draggingCollectionItemIdRef.current); + const targetItem = getCollectionItemByKey(target.key); + const boundary: BoundaryTargets | null = targetItem && { + dropPosition: target.dropPosition, + targetItem, + realTargetItem: target.dropPosition === 'before' ? entry?.[1] : targetItem, + nextItem: entry?.[2], + }; + + // One pass drives validity, indent and the named destination. + const resolved = boundary ? resolveDrop(boundary, dropContext) : null; + const isValid = + draggedItem != null && + boundary != null && + resolved != null && + canDrop(draggedItem, boundary, resolved, expandedProjectAndWorkspaceIds); + return ( - ); }, diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index 3f199aa149..2867292809 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -29,7 +29,7 @@ export const WorkspaceNode = ({ const { doc, collapsed, project, organizationId, hasUncommittedChanges, hasUnpushedChanges } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); - const isCollection = workspaceScope === 'collection'; + const isCollection = workspaceScope === 'collection' || workspaceScope === 'design'; return (
{ + useEffect(() => { + let spec = {}; + try { + spec = parseApiSpec(text).contents || {}; + } catch {} + SwaggerUIBundle({ spec, dom_id: '#swagger-ui' }); + }, [text]); + return ( +
+ ); +}; + +interface LintMessage { + type: 'error' | 'warning' | 'info'; + message: string; + line: number; + range: IRuleResult['range']; + code: string; + path: string; +} +interface GroupedLintMessage { + code: string; + type: 'error' | 'warning' | 'info'; + message: string; + occurrences: { line: number; range: IRuleResult['range']; path: string }[]; +} + +interface SpecActionItem { + id: string; + name: string; + icon?: ReactNode; + badge?: ReactNode; + isDisabled?: boolean; + tooltip?: string; + action: () => void; +} + +const getMethodsFromOpenApiPathItem = (pathItem: OpenAPIV3.PathItemObject): string[] => { + const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].filter( + method => + // @ts-expect-error -- shrug I don't care what pathItem has in it + pathItem[method], + ); + + return methods; +}; + +const lintOptions = { + delay: 1000, +}; + +export interface SpecViewProps { + organizationId: string; + projectId: string; + workspaceId: string; + gitSyncRulesetPath: string; + isConnectedGitProject: boolean; + rulesetContent: string; + rulesetLastCompiledAt: number | null; + rulesetImportIssue: string | null; + parsedSpec: OpenAPIV3.Document | undefined; + apiSpec?: ApiSpec; +} + +export const SpecView = ({ + organizationId, + projectId, + workspaceId, + gitSyncRulesetPath, + isConnectedGitProject, + rulesetContent, + rulesetLastCompiledAt, + rulesetImportIssue, + parsedSpec, + apiSpec, +}: SpecViewProps) => { + const { activeProject, vcsVersion, activeWorkspace } = useWorkspaceLoaderData()!; + const { settings } = useRootLoaderData()!; + + const [isViewRulesetModalOpen, setIsViewRulesetModalOpen] = useState(false); + const [isNewMockServerModalOpen, setNewMockServerModalOpen] = useState(false); + + const storageRules = useOrganizationStorageRule(organizationId); + + const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus(); + + const [lintMessages, setLintMessages] = useState([]); + const [expandedLintMessageCodes, setExpandedLintMessageCodes] = useState([]); + + const editor = useRef(null); + const { submit: updateApiSpec } = useSpecUpdateActionFetcher(); + const { submit: updateProjectRuleset } = useUpdateProjectRulesetActionFetcher(); + const { submit: deleteProjectRuleset } = useDeleteProjectRulesetActionFetcher(); + const { submit: refreshProjectRuleset } = useRefreshProjectRulesetActionFetcher(); + const [isRefreshing, setIsRefreshing] = useState(false); + const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher(); + const newRequestFetcher = useRequestNewActionFetcher(); + const newRequestGroupFetcher = useRequestGroupNewActionFetcher(); + const gitVersion = useGitVCSVersion(); + const [isLintPaneOpen, setIsLintPaneOpen] = useState(false); + const [isSpecPaneOpen, setIsSpecPaneOpen] = useState(Boolean(parsedSpec)); + const [selectedRulesetPath, setSelectedRulesetPath] = useState(''); + + // Spectral requires a file path on disk to lint with a ruleset. Ref: lint-process.mjs. + // Cloud/local projects have no RepoFileWatcher, so rulesetContent from NeDB is mirrored + // to this per-project scratch path. Git projects lint against gitSyncRulesetPath, which + // the RepoFileWatcher keeps in sync with the record. + const rulesetWritePath = useMemo( + () => window.path.join(window.app.getPath('userData'), `projects/${projectId}/.spectral.yaml`), + [projectId], + ); + + const rulesetHasRemoteExtendsEntries = useMemo(() => { + if (!rulesetContent) return false; + + try { + const parsedRuleset = YAML.parse(rulesetContent); + if (!parsedRuleset.extends) return false; + + const formattedRulesetExtendsEntries = Array.isArray(parsedRuleset.extends) + ? parsedRuleset.extends + : [parsedRuleset.extends]; + return formattedRulesetExtendsEntries.some((entry: string) => entry.startsWith('https://')); + } catch { + return false; + } + }, [rulesetContent]); + + const groupedLintMessages = useMemo(() => { + const map = new Map(); + for (const msg of lintMessages) { + const key = msg.code; + const existing = map.get(key); + if (existing) { + existing.occurrences.push({ line: msg.line, range: msg.range, path: msg.path }); + } else { + map.set(key, { + code: msg.code ?? '', + type: msg.type, + message: msg.message, + occurrences: [{ line: msg.line, range: msg.range, path: msg.path }], + }); + } + } + + return Array.from(map.values()); + }, [lintMessages]); + + const { components, info, servers, paths } = parsedSpec || {}; + const { requestBodies, responses, parameters, headers, schemas, securitySchemes } = components || {}; + const showSpecInfo = Boolean(info || servers || paths || components); + + const lintErrors = lintMessages.filter(message => message.type === 'error'); + const lintWarnings = lintMessages.filter(message => message.type === 'warning'); + + const registerCodeMirrorLint = useCallback( + (rulesetContent: string) => { + CodeMirror.registerHelper('lint', 'openapi', async (contents: string) => { + try { + const { diagnostics, error, cancelled } = await window.main.lintSpec({ + documentContent: contents, + projectId, + rulesetContent, + }); + if (cancelled) { + return []; + } + if (error) { + console.log('Handled error detected while linting:', error); + setLintMessages([]); + showError({ + title: 'Linting Error', + message: `An error occurred while linting the OpenAPI specification: ${error}`, + }); + return []; + } + const lintResult = diagnostics?.map(({ severity, code, message, range, path }) => { + return { + from: CodeMirror.Pos(range.start.line, range.start.character), + to: CodeMirror.Pos(range.end.line, range.end.character), + code: String(code), + message, + severity: ['error', 'warning'][severity] ?? 'info', + type: (['error', 'warning'][severity] ?? 'info') as LintMessage['type'], + range, + line: range.start.line, + path: path.join('.'), + }; + }); + setLintMessages?.(lintResult || []); + return lintResult; + } catch (error) { + // return a rejected promise so that codemirror do nothing + console.log('Unhandled error while linting:', error); + setLintMessages([]); + showError({ + title: 'Linting Error', + message: `An error occurred while linting the OpenAPI specification: ${error}`, + }); + return []; + } + }); + }, + [projectId], + ); + + useEffect(() => { + registerCodeMirrorLint(rulesetContent); + // when first time into document editor, the lint helper register later than codemirror init, we need to trigger lint through execute setOption + editor.current?.tryToSetOption('lint', { ...lintOptions }); + }, [rulesetContent, projectId, registerCodeMirrorLint]); + + useEffect(() => { + setSelectedRulesetPath( + isConnectedGitProject && gitSyncRulesetPath && rulesetContent + ? gitSyncRulesetPath + : rulesetContent + ? rulesetWritePath + : '', + ); + }, [gitSyncRulesetPath, isConnectedGitProject, rulesetWritePath, rulesetContent]); + + useEffect(() => { + if (showSpecInfo) { + specSidebarRef.current?.expand(); + } else { + specSidebarRef.current?.collapse(); + } + }, [showSpecInfo]); + + reactUse.useUnmount(() => { + // delete the helper to avoid it run multiple times when user enter the page next time + CodeMirror.registerHelper('lint', 'openapi', () => {}); + }); + + const onCodeEditorChange = useMemo(() => { + const handler = async (contents: string) => { + return updateApiSpec({ + organizationId, + projectId, + workspaceId, + contents: contents, + }); + }; + + return debounce(handler, 500); + }, [organizationId, projectId, updateApiSpec, workspaceId]); + + const handleScrollToSelection = useCallback( + (chStart: number, chEnd: number, lineStart: number, lineEnd: number) => { + if (!editor.current) { + return; + } + editor.current.scrollToSelection(chStart, chEnd, lineStart, lineEnd); + }, + [editor], + ); + + const handleScrollToLintMessage = useCallback( + (occurrence: GroupedLintMessage['occurrences'][number]) => { + if (!editor.current) { + return; + } + const { start, end } = occurrence.range; + editor.current.scrollToSelection(start.character, end.character, start.line, end.line); + }, + [editor], + ); + + const [expandedKeys, setExpandedKeys] = useState([]); + + const navigateToPath = (path: string): void => { + if (!apiSpec) { + return; + } + const pathSegments = path.split('.'); + const scrollPosition = { + start: { line: 0, col: 0 }, + end: { line: 0, col: 200 }, + }; + + try { + JSON.parse(apiSpec.contents); + // Account for JSON (as string) line number shift + scrollPosition.start.line = 1; + } catch {} + + const lineCounter = new YAML.LineCounter(); + const doc = YAML.parseDocument(apiSpec.contents, { lineCounter }); + const astNode = doc.getIn(pathSegments, true) as YAML.Node; + const nodePosition = astNode.range && lineCounter.linePos(astNode.range[0]); + if (nodePosition) { + scrollPosition.start.line += nodePosition.line; + } + const isServersSection = pathSegments[0] === 'servers'; + if (!isServersSection) { + scrollPosition.start.line -= 1; + } + + scrollPosition.end.line = scrollPosition.start.line; + // NOTE: We're subtracting 1 from everything because YAML CST uses + // 1-based indexing and we use 0-based. + handleScrollToSelection( + scrollPosition.start.col - 1, + scrollPosition.end.col - 1, + scrollPosition.start.line - 1, + scrollPosition.end.line - 1, + ); + }; + + const sidebarPanelRef = useRef(null); + const specSidebarRef = useRef(null); + const lintPanelRef = useRef(null); + + const specFormat = useMemo((): 'json' | 'yaml' | null => { + const contents = apiSpec?.contents?.trim(); + if (!contents) { + return null; + } + return detectApiSpecSyntax(contents); + }, [apiSpec?.contents]); + + const switchFormat = (to: 'json' | 'yaml') => { + const editorValue = editor.current?.getValue(); + if (!editorValue) { + return; + } + let contents: string; + try { + contents = convertApiSpecSyntax(editorValue, to); + } catch { + showToast({ + title: 'Failed to convert spec format', + icon: 'circle-exclamation', + status: 'error', + description: `Spec is not valid, cannot convert to ${to.toUpperCase()}`, + }); + return; + } + editor.current?.setValue(contents); + updateApiSpec({ organizationId, projectId, workspaceId, contents }); + }; + + const handleSelectSpectralFile = async () => { + const { filePath, canceled } = await selectFileOrFolder({ + itemTypes: ['file'], + extensions: ['yaml', 'yml'], + showHiddenFiles: true, + }); + + if (canceled || !filePath) { + return; + } + + // We bundle the ruleset to resolve any extended rulesets and to validate the content + const { content, error } = await window.main.bundleSpectralRuleset({ sourcePath: filePath }); + if (error || !content) { + showError({ + title: 'Invalid Spectral Ruleset', + message: error ?? 'Failed to bundle ruleset.', + }); + return; + } + + const RULESET_MAX_BYTES = 1 * 1024 * 1024; // 1 MB + if (utf8ByteLength(content) > RULESET_MAX_BYTES) { + showError({ + title: 'Ruleset Too Large', + message: 'The selected ruleset exceeds the maximum allowed size of 1 MB.', + }); + return; + } + + await updateProjectRuleset({ organizationId, projectId, rulesetContent: content }); + + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.uploadLintRulesetClicked, + properties: { + project_type: models.project.isGitProject(activeProject) + ? 'git' + : models.project.isRemoteProject(activeProject) + ? 'remote' + : 'local', + }, + }); + + setSelectedRulesetPath(gitSyncRulesetPath || rulesetWritePath); + }; + + const handleRefreshRuleset = async () => { + if (!rulesetContent) { + return; + } + setIsRefreshing(true); + try { + await window.main.refreshCompiledRuleset({ projectId, rulesetContent }); + refreshProjectRuleset({ organizationId, projectId }); + editor.current?.tryToSetOption('lint', { ...lintOptions }); + } catch (err) { + showError({ + title: 'Refresh Failed', + message: `Failed to refresh ruleset: ${err instanceof Error ? err.message : String(err)}`, + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleUnselectSpectralFile = async () => { + showModal(AskModal, { + title: 'Remove Ruleset File', + message: + 'Are you sure you want to remove this custom ruleset? This will disable all custom linting rules and use the default Spectral ruleset.', + yesText: 'Remove', + color: 'danger', + noText: 'Cancel', + onDone: async (confirmed: boolean) => { + if (confirmed) { + await deleteProjectRuleset({ + organizationId, + projectId, + }); + await window.main.deleteCompiledRuleset({ projectId }); + setSelectedRulesetPath(''); + } + }, + }); + }; + + const generateActionList: SpecActionItem[] = [ + { + id: 'generate-request-collection', + name: 'Requests', + icon: ( + + + + ), + isDisabled: !apiSpec?.contents || lintErrors.length > 0 || generateRequestCollectionFetcher.state !== 'idle', + tooltip: + lintErrors.length > 0 + ? 'You cannot generate an API collection when spec errors exist. Fix the errors or change the ruleset first.' + : 'Auto populate this API collection with a request for each endpoint in the spec', + action: () => + generateRequestCollectionFetcher.submit({ + organizationId, + projectId, + workspaceId, + }), + }, + { + id: 'generate-mock-server', + name: 'Mock Server', + icon: ( + + + + ), + badge: , + isDisabled: !apiSpec?.contents || !isGenerateMockServersWithAIEnabled, + action: () => { + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.designerGenerateMockClicked, + }); + setNewMockServerModalOpen(true); + }, + }, + ]; + + const specFormatActionList: SpecActionItem[] = [ + { + id: 'json', + name: 'JSON', + action: () => switchFormat('json'), + }, + { + id: 'yaml', + name: 'YAML', + action: () => switchFormat('yaml'), + }, + ]; + + const generateDisabledKeys = generateActionList.filter(item => item.isDisabled).map(item => item.id); + const historyKey = `${activeWorkspace._id}::${apiSpec?._id}::${apiSpec?.created}::${gitVersion}::${vcsVersion}`; + + const [direction, setDirection] = useState<'horizontal' | 'vertical'>( + settings.forceVerticalLayout ? 'vertical' : 'horizontal', + ); + useLayoutEffect(() => { + if (settings.forceVerticalLayout) { + setDirection('vertical'); + return () => {}; + } + // Listen on media query changes + const mediaQuery = window.matchMedia('(max-width: 880px)'); + setDirection(mediaQuery.matches ? 'vertical' : 'horizontal'); + + const handleChange = (e: MediaQueryListEvent) => { + setDirection(e.matches ? 'vertical' : 'horizontal'); + }; + + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, [settings.forceVerticalLayout, direction]); + + const specEditor = ( +
+ + {apiSpec?.contents ? null : ( + { + updateApiSpec({ + organizationId, + projectId, + workspaceId, + contents: value, + fromTemplate: true, + generateRequestCollection, + }); + }} + onCreateRequest={requestType => { + newRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + requestType, + parentId: workspaceId, + }); + }} + onCreateFolder={() => + showModal(PromptModal, { + title: 'New Folder', + defaultValue: 'My Folder', + submitName: 'Create', + label: 'Name', + selectText: true, + onComplete: (name: string) => + newRequestGroupFetcher.submit({ + organizationId, + projectId, + workspaceId, + parentId: workspaceId, + name, + }), + }) + } + /> + )} +
+ ); + + const specPaneToolbar = apiSpec?.contents ? ( +
+ + {parsedSpec?.openapi ? `OpenAPI ${parsedSpec.openapi}` : ''} + + + + + + { + const item = generateActionList.find(item => item.id === key); + if (item) { + item.action(); + } + }} + items={generateActionList} + className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" + > + {item => ( + + + {item.icon} + {item.name} + {item.badge && ( + {item.badge} + )} + + + {item.tooltip} + + + )} + + + + + + +
+ Spec format + { + const item = specFormatActionList.find(item => item.id === key); + if (item) { + item.action(); + } + }} + items={specFormatActionList} + className="min-w-max focus:outline-hidden" + > + {item => ( + + {item.name} + + )} + +
+
+
+ + { + setIsSpecPaneOpen(value); + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.designerPreviewToggled, + properties: { + status: !value ? 'open' : 'collapsed', + }, + }); + }} + > + {({ isSelected }) => ( + + + + )} + + + {isSpecPaneOpen ? 'Hide docs preview' : 'Show docs preview'} + + +
+ ) : null; + + const isRulesetInvalid = !!rulesetImportIssue && !rulesetContent; + + const lintToolbar = ( +
+
+ + {!isRulesetInvalid && selectedRulesetPath && ( + <> + + + +

Using ruleset from

+ {selectedRulesetPath} +
+
+ {rulesetHasRemoteExtendsEntries && ( + + + +

Recompile ruleset, including re-fetching any referenced remote entries.

+ {rulesetLastCompiledAt && ( +

{`Last updated ${new Date(rulesetLastCompiledAt).toLocaleString()}`}.

+ )} +
+
+ )} + + + +

Clear custom ruleset and use default OAS ruleset

+
+
+ + )} + {!selectedRulesetPath && ( + <> + Default OAS Ruleset + {isRulesetInvalid && ( + + + +

+ The custom ruleset in this project contains invalid content: {rulesetImportIssue} +

+

Fix the syntax in your connected git repository. The default OAS ruleset is used until then.

+
+
+ )} + + + +

+ Upload a custom Spectral ruleset + {isConnectedGitProject && ( + + {' '} + or add a .spectral.yaml file to the root of your connected git + repository + + )} + . Any local files or remote URLs referenced via extends will be bundled + into a single ruleset on upload. +

+
+
+ + )} +
+ +
+ {lintErrors.length > 0 && ( +
+ +
+ )} + {lintWarnings.length > 0 && ( +
+ +
+ )} + {apiSpec?.contents && ( +
+ {lintMessages.length === 0 && } + {lintMessages.length === 0 ? ( + 'No lint problems' + ) : ( + + )} +
+ )} +
+
+ ); + + const lintMessageList = ( +
+ {groupedLintMessages.map(item => ( +
+ + {expandedLintMessageCodes.includes(item.code) && ( +
+ {item.occurrences.map(occurrence => ( + + ))} +
+ )} +
+ ))} +
+ ); + + return ( + + +
+
+ {/* Info */} + {info && ( +
+ + {/* Info */} + {expandedKeys.includes('info') && ( + navigateToPath(key.toString())}> + + Title: {info.title} + + + Description: {info.description} + + + Version: {info.version} + + + License: {info.license?.name} + + + )} +
+ )} + {/* Servers */} + {servers && ( +
+
+ +
+ {expandedKeys.includes('servers') && ( + ({ + path: index, + ...server, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.url} + + )} + + )} +
+ )} + {/* Paths */} + {paths && ( +
+
+ +
+ {expandedKeys.includes('paths') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + +
+ {item.path} + + {getMethodsFromOpenApiPathItem(item).map(method => ( + + ))} +
+
+ )} +
+ )} +
+ )} + {/* RequestBodies */} + {requestBodies && ( +
+
+ +
+ {expandedKeys.includes('requestBodies') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Responses */} + {responses && ( +
+
+ +
+ {expandedKeys.includes('responses') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Parameters */} + {parameters && ( +
+
+ +
+ {expandedKeys.includes('parameters') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Headers */} + {headers && ( +
+
+ +
+ {expandedKeys.includes('headers') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Schemas */} + {schemas && ( +
+
+ +
+ {expandedKeys.includes('schemas') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} + {/* Security */} + {securitySchemes && ( +
+
+ +
+ {expandedKeys.includes('security') && ( + ({ + ...item, + id: path, + path, + }))} + onAction={key => navigateToPath(key.toString())} + > + {item => ( + + {item.path} + + )} + + )} +
+ )} +
+ {isNewMockServerModalOpen && apiSpec && ( + + )} + {isViewRulesetModalOpen && ( + + + + {({ close }) => ( + <> +
+ + Existing Ruleset Contents + +
+ {rulesetContent && ( + + )} + + )} +
+
+
+ )} +
+
+ + + {specPaneToolbar} + + +
+ + + {specEditor} + + {apiSpec?.contents && ( + <> + + setIsLintPaneOpen(false)} + onExpand={() => setIsLintPaneOpen(true)} + className="flex flex-col overflow-hidden" + > + {isLintPaneOpen && ( +
+ {lintToolbar} + {lintMessageList} +
+ )} +
+ + )} +
+ {apiSpec?.contents && !isLintPaneOpen ? lintToolbar : null} +
+
+ {isSpecPaneOpen && !!parsedSpec && ( + <> + + + + + + )} +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/tabs/tab.tsx b/packages/insomnia/src/ui/components/tabs/tab.tsx index 74b6b99763..b7d58c7d2e 100644 --- a/packages/insomnia/src/ui/components/tabs/tab.tsx +++ b/packages/insomnia/src/ui/components/tabs/tab.tsx @@ -4,6 +4,7 @@ import { models } from 'insomnia-data'; import React, { useCallback } from 'react'; import { Button, GridListItem } from 'react-aria-components'; +import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; import type { WorkspaceFileIssue } from '~/main/git-service'; import { scrollElementIntoView } from '~/ui/utils'; @@ -56,24 +57,24 @@ const REQUEST_METHOD_STYLE_MAP: Record = { const WORKSPACE_TAB_UI_MAP: Partial> = { collection: { - icon: 'bars', - bgColor: 'bg-(--color-surprise)', - textColor: 'text-(--color-font-surprise)', + icon: scopeToIconMap.collection, + bgColor: scopeToBgColorMap.collection, + textColor: scopeToTextColorMap.collection, }, environment: { - icon: 'code', - bgColor: 'bg-(--color-font)', - textColor: 'text-(--color-bg)', + icon: scopeToIconMap.environment, + bgColor: scopeToBgColorMap.environment, + textColor: scopeToTextColorMap.environment, }, mockServer: { - icon: 'server', - bgColor: 'bg-(--color-warning)', - textColor: 'text-(--color-font-warning)', + icon: scopeToIconMap['mock-server'], + bgColor: scopeToBgColorMap['mock-server'], + textColor: scopeToTextColorMap['mock-server'], }, document: { - icon: 'file', - bgColor: 'bg-(--color-info)', - textColor: 'text-(--color-font-info)', + icon: scopeToIconMap.design, + bgColor: scopeToBgColorMap.design, + textColor: scopeToTextColorMap.design, }, }; diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index 6d9b4afb9f..091c06a167 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -2,12 +2,11 @@ import classnames from 'classnames'; import clone from 'clone'; import type { BaseModel, CloudProviderCredential, PluginData, Request, RequestGroup, Workspace } from 'insomnia-data'; import { models, services } from 'insomnia-data'; -import React, { type FC, useCallback, useEffect, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useRef, useState } from 'react'; import { Button, Link } from 'react-aria-components'; import * as reactUse from 'react-use'; import { getAppBundlePlugins } from '~/common/constants'; -import { generateId } from '~/common/misc'; import { localTemplateTags } from '~/common/templating/local-template-tags'; import type { NunjucksParsedTag, NunjucksParsedTagArg } from '~/common/templating/types'; import * as templateUtils from '~/common/templating/utils'; @@ -42,6 +41,7 @@ interface Props { workspace: Workspace; editorId?: string; close: () => void; + onRenderingChange: (rendering: boolean) => void; } interface State { @@ -89,6 +89,15 @@ export const TagEditor: FC = props => { }); const { handleRender, handleGetRenderContext } = useNunjucks({ renderContext: { purpose: 'preview' } }); + const isEditorMountedRef = useRef(true); + useEffect(() => { + isEditorMountedRef.current = true; + return () => { + // Mark the editor as unmounted to prevent state updates on an unmounted component. + isEditorMountedRef.current = false; + }; + }, []); + const refreshModels = useCallback(async () => { setState(state => ({ ...state, loadingDocs: true })); const allDocs: Record = {}; @@ -193,7 +202,7 @@ export const TagEditor: FC = props => { // Generate a unique tag id for the vault and link it to the credential in pluginData. // Update the tag arg to store the unique id instead of the raw (per-user) credential id. function convertLegacyCredentialTag(legacyCredentialId: string, argIndex: number) { - const tagUniqueId = generateId('externalVaultTag'); + const tagUniqueId = templateUtils.generateExternalVaultTagId(); updateArg(tagUniqueId, argIndex); return services.pluginData .upsertByKey(vaultPluginName, tagUniqueId, legacyCredentialId) @@ -226,6 +235,7 @@ export const TagEditor: FC = props => { ) { const start = Date.now(); setState(state => ({ ...state, rendering: true })); + props.onRenderingChange(true); let preview = ''; let error = ''; let activeTagData: NunjucksParsedTag | null = tagData; @@ -253,6 +263,11 @@ export const TagEditor: FC = props => { } } + if (!isEditorMountedRef.current) { + // Editor is not mounted, abort the onChange + return; + } + setState(state => ({ ...state, tagDefinitions, @@ -267,6 +282,7 @@ export const TagEditor: FC = props => { // Make rendering take at least this long so we can see a spinner await delay(300 - (Date.now() - start)); setState(state => ({ ...state, rendering: false, preview })); + props.onRenderingChange(false); } const { error, preview, activeTagDefinition, activeTagData, rendering } = state; diff --git a/packages/insomnia/src/ui/components/toast-notification.tsx b/packages/insomnia/src/ui/components/toast-notification.tsx index 6e0d8b4bc2..58a4fdffe5 100644 --- a/packages/insomnia/src/ui/components/toast-notification.tsx +++ b/packages/insomnia/src/ui/components/toast-notification.tsx @@ -34,6 +34,33 @@ const logTransitionError = (error: unknown) => { console.warn('Transition error:', error); }; +type DocumentWithActiveViewTransition = Document & { activeViewTransition?: ViewTransition | null }; + +// While a view transition is running - including the moment one gets skipped by a newer one - +// Chromium retargets hit-testing for the whole viewport to , so a click can arrive with +// document.documentElement as its target. react-aria's interact-outside reads such an event as a +// click outside an open modal and closes it (e.g. the settings modal when pressing Check for +// updates repeatedly). Drop those events: their real target cannot be recovered while the +// transition is running, and the browser was suppressing hit-testing for that click anyway. +// Events that land on real elements are untouched, so dialogs, popovers and menus keep behaving +// as before. This complements the ::view-transition rules in styles.css, which keep clicks +// reaching real elements in the first place. +const isRetargetedByViewTransition = (event: Event) => + event.target === document.documentElement && + Boolean((document as DocumentWithActiveViewTransition).activeViewTransition); + +const dropRetargetedEvents = (event: Event) => { + if (isRetargetedByViewTransition(event)) { + event.stopPropagation(); + } +}; + +// Guarded because this module is also evaluated in Node (dev server, prerender), where `window` does not exist. +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', dropRetargetedEvents, true); + window.addEventListener('click', dropRetargetedEvents, true); +} + // Create a global ToastQueue. export const queue = new ToastQueue({ // Wrap state updates in a CSS view transition. @@ -110,7 +137,7 @@ export const Toaster = () => (
diff --git a/packages/insomnia/src/ui/components/unsynced-file-delete-button.tsx b/packages/insomnia/src/ui/components/unsynced-file-delete-button.tsx new file mode 100644 index 0000000000..8ca6276546 --- /dev/null +++ b/packages/insomnia/src/ui/components/unsynced-file-delete-button.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { Button, Dialog, Heading, Modal, ModalOverlay, Tooltip, TooltipTrigger } from 'react-aria-components'; + +import { useInsomniaSyncDeleteRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.delete-remote-file'; +import { Icon } from '~/ui/components/icon'; +import { showToast } from '~/ui/components/toast-notification'; + +interface Props { + organizationId: string; + backendProjectId: string; + name: string; +} + +export const UnsyncedFileDeleteButton = ({ organizationId, backendProjectId, name }: Props) => { + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + const deleteRemoteFileFetcher = useInsomniaSyncDeleteRemoteFileActionFetcher(); + const isDeleting = deleteRemoteFileFetcher.state !== 'idle'; + const error = deleteRemoteFileFetcher.data?.error; + + useEffect(() => { + if (error && deleteRemoteFileFetcher.state === 'idle') { + showToast({ + title: 'Failed to delete remote file', + icon: 'star', + status: 'error', + description: `Failed to delete ${name}: ${error}`, + }); + } + }, [error, deleteRemoteFileFetcher.state, name]); + + return ( + <> + + + + Delete permanently + + + {isConfirmModalOpen && ( + setIsConfirmModalOpen(false)} + isDismissable + className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" + > + setIsConfirmModalOpen(false)} + className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" + > + + {({ close }) => ( +
+
+ Delete file + +
+

+ This will permanently delete {name} from + the Cloud for everyone. You cannot undo this action. +

+
+ +
+
+ )} +
+
+
+ )} + + ); +}; diff --git a/packages/insomnia/src/ui/components/websockets/event-view.tsx b/packages/insomnia/src/ui/components/websockets/event-view.tsx index 9243fa1c46..062294e024 100644 --- a/packages/insomnia/src/ui/components/websockets/event-view.tsx +++ b/packages/insomnia/src/ui/components/websockets/event-view.tsx @@ -74,7 +74,7 @@ export const MessageEventView: FC
-
+
}> = ({ + requestId, + streamSummary, +}) => { + const showWarning = Boolean(streamSummary.resultPath) && streamSummary.summary.fragmentCount === 0; + return ( +
+
+ loader revalidation + // (same reason code-editor.tsx's filter input uses defaultValue, not value). + key={requestId} + aria-label="Stream summary JSONPath" + defaultValue={streamSummary.resultPath ?? ''} + onChange={value => { + if (value === '') { + streamSummary.setResultPath(''); + } + }} + className="w-full" + > + { + if (e.key === 'Enter') { + streamSummary.setResultPath(e.currentTarget.value); + } + }} + className="w-full rounded-sm border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) transition-colors focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden" + /> + + {showWarning && ( + + + + This JSONPath did not match any incoming message. + + + )} + + {streamSummary.renderMarkdown ? 'Markdown' : 'Plain text'} + + + } + > + + + streamSummary.setRenderMarkdown(false)} + /> + + + streamSummary.setRenderMarkdown(true)} + /> + + + +
+
+ {streamSummary.renderMarkdown ? ( + + ) : ( +
+            {streamSummary.summary.summary}
+          
+ )} +
+
+ ); +}; + export const RealtimeResponsePane: FC<{ requestId?: string }> = () => { const { activeResponse, responses, requestVersions } = useRequestLoaderData()!; @@ -136,6 +238,15 @@ const RealtimeActiveResponsePane: FC { setSelectedEvent((selected: EventType | null) => (selected?._id === event._id ? null : event)); }; @@ -260,7 +371,12 @@ const RealtimeActiveResponsePane: FC - + Events + {showStreamSummaryTab && ( + + Summary + + )} {models.mcpResponse.isMcpResponse(response) && ( + {showStreamSummaryTab && ( + + + + )} {models.mcpResponse.isMcpResponse(response) && ( diff --git a/packages/insomnia/src/ui/components/workspace/resource-icon.tsx b/packages/insomnia/src/ui/components/workspace/resource-icon.tsx index 4769d5803d..2aaa4dddc2 100644 --- a/packages/insomnia/src/ui/components/workspace/resource-icon.tsx +++ b/packages/insomnia/src/ui/components/workspace/resource-icon.tsx @@ -21,7 +21,7 @@ export function ResourceIcon({ resource }: { resource: any }) { if (isWorkspace) { icon = ({ - 'design': 'file', + 'design': 'bars', 'collection': 'bars', 'mock-server': 'server', 'environment': 'code', diff --git a/packages/insomnia/src/ui/components/workspace/workspace-pane-header.tsx b/packages/insomnia/src/ui/components/workspace/workspace-pane-header.tsx index f4fded6512..9ab5eeb15e 100644 --- a/packages/insomnia/src/ui/components/workspace/workspace-pane-header.tsx +++ b/packages/insomnia/src/ui/components/workspace/workspace-pane-header.tsx @@ -40,6 +40,7 @@ export default function WorkspacePaneHeader({ hasSettings }: { hasSettings: bool }, [breadcrumbs]); const [isEnvironmentPickerOpen, setIsEnvironmentPickerOpen] = useState(false); + const [isProjectEnvironmentPickerOpen, setIsProjectEnvironmentPickerOpen] = useState(false); const [isEnvironmentModalOpen, setEnvironmentModalOpen] = useState(false); const [isCookieModalOpen, setIsCookieModalOpen] = useState(false); const [isCertificatesModalOpen, setCertificatesModalOpen] = useState(false); @@ -47,6 +48,7 @@ export default function WorkspacePaneHeader({ hasSettings }: { hasSettings: bool useDocBodyKeyboardShortcuts({ environment_showEditor: () => setEnvironmentModalOpen(true), environment_showSwitchMenu: () => setIsEnvironmentPickerOpen(true), + environment_showSwitchProjectMenu: () => setIsProjectEnvironmentPickerOpen(true), showCookiesEditor: () => setIsCookieModalOpen(true), }); @@ -77,6 +79,15 @@ export default function WorkspacePaneHeader({ hasSettings }: { hasSettings: bool }); } }} + isProjectPickerOpen={isProjectEnvironmentPickerOpen} + onProjectPickerOpenChange={isOpen => { + setIsProjectEnvironmentPickerOpen(isOpen); + if (isOpen) { + window.main.trackAnalyticsEvent({ + event: AnalyticsEvent.requestEnvironmentClicked, + }); + } + }} onOpenEnvironmentSettingsModal={() => setEnvironmentModalOpen(true)} /> {!isMcp && ( diff --git a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx index c3c52dad78..50c32f1175 100644 --- a/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx +++ b/packages/insomnia/src/ui/context/app/insomnia-event-stream-context.tsx @@ -1,4 +1,5 @@ import { getRealTimeCollaborators, type Organization, type UserPresence } from 'insomnia-api'; +import { models } from 'insomnia-data'; import React, { createContext, type FC, type PropsWithChildren, useContext, useEffect, useState } from 'react'; import { useFetchers, useParams, useRevalidator } from 'react-router'; import * as reactUse from 'react-use'; @@ -10,9 +11,9 @@ import { useProjectLoaderData } from '~/routes/organization.$organizationId.proj import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; import { useInsomniaSyncDataActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.sync-data'; import { useOrganizationSyncProjectsActionFetcher } from '~/routes/organization.$organizationId.sync-projects'; -import { useOrganizationSyncActionFetcher } from '~/routes/organization.sync'; import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { avatarImageCache } from '~/ui/hooks/image-cache'; +import { useInvalidateAccountData } from '~/ui/hooks/use-account-server-data'; import { useInvalidateOrganizationStorageRule } from '~/ui/hooks/use-organization-storage-rule'; const InsomniaEventStreamContext = createContext<{ @@ -73,7 +74,7 @@ const isSameWorkspaceWithRemote = async (workspaceId: string | undefined, remote if (!workspaceId || !remoteWorkspaceId) { return false; } - const currentBackendProject = await window.main.sync.getActiveBackendProject(); + const currentBackendProject = await window.main.sync.getActiveBackendProject(workspaceId); if ( currentBackendProject && currentBackendProject?.id === remoteWorkspaceId && @@ -97,7 +98,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) const remoteId = projectData?.activeProject?.remoteId || workspaceData?.activeProject.remoteId; const [presence, setPresence] = useState([]); - const { submit: syncOrganizationsSubmit } = useOrganizationSyncActionFetcher(); + const invalidateAccountData = useInvalidateAccountData(); const invalidateStorageRule = useInvalidateOrganizationStorageRule(); const { submit: syncProjectsSubmit } = useOrganizationSyncProjectsActionFetcher(); const { submit: syncDataSubmit } = useInsomniaSyncDataActionFetcher(); @@ -111,7 +112,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) useEffect(() => { async function updatePresence() { const sessionId = userSession.id; - if (sessionId && remoteId) { + if (sessionId && remoteId && !models.organization.isLocalOrganizationId(organizationId)) { try { const response = await getRealTimeCollaborators({ sessionId, @@ -140,7 +141,8 @@ export const InsomniaEventStreamProvider: FC = ({ children }) useEffect(() => { const sessionId = userSession.id; - if (sessionId) { + // Local-only organizations have no server-side stream to subscribe to. + if (sessionId && !models.organization.isLocalOrganizationId(organizationId)) { try { const source = new EventSource(`insomnia-event-source://v1/teams/${sanitizeTeamId(organizationId)}/streams`); @@ -183,7 +185,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) if (event.avatar) { window.setTimeout(() => avatarImageCache.invalidate(event.avatar), CDN_INVALIDATION_TTL); } - syncOrganizationsSubmit(); + invalidateAccountData(); } else if (event.type === 'StorageRuleChanged' && (event.team.startsWith('org_') || event.team.startsWith('team_'))) { invalidateStorageRule(event.team); } else if (event.type === 'TeamProjectChanged' && event.team === organizationId) { @@ -203,11 +205,14 @@ export const InsomniaEventStreamProvider: FC = ({ children }) } } else if (event.type === 'VaultKeyChanged') { const accountId = userSession.accountId; - const organizations = JSON.parse( - localStorage.getItem(`${accountId}:spaces`) || '[]', - ) as Organization[]; + const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; clearVaultKeySubmit({ - organizations: organizations?.map(org => org.id) || [], + // The Konnect organization is local-only so it is never in the cached list, but its + // workspaces hold secrets like any other. + organizations: [ + ...(organizations?.map(org => org.id) || []), + models.organization.getKonnectOrganizationId(accountId), + ], sessionId: event.sessionId, }); } else if ( @@ -249,7 +254,7 @@ export const InsomniaEventStreamProvider: FC = ({ children }) organizationId, revalidate, syncDataSubmit, - syncOrganizationsSubmit, + invalidateAccountData, syncProjectsSubmit, invalidateStorageRule, userSession.accountId, diff --git a/packages/insomnia/src/ui/css/styles.css b/packages/insomnia/src/ui/css/styles.css index 3bf1f9dab2..dc03de2247 100644 --- a/packages/insomnia/src/ui/css/styles.css +++ b/packages/insomnia/src/ui/css/styles.css @@ -255,25 +255,22 @@ table.json-diff-viewer { margin: 0; } -::view-transition-new(toast):only-child { - animation: slideIn 400ms ease-out; +/* Toasts animate through the View Transition API, and while such a transition is running the + ::view-transition overlay captures every click on top of the document. Together with the + spec rule that elements captured by a transition skip hit-testing, that made clicks + anywhere in the app resolve to , which read as a click outside an open modal and + closed it (e.g. clicking Check for updates in Settings repeatedly). + Both rules are required: `pointer-events: none` alone still leaves the page unclickable + because the captured root's contents skip hit-testing, while excluding the root alone + still leaves the overlay swallowing the clicks. + Note that the root rule opts the root out of every view transition in the app; revisit it + if transitions other than toasts are ever introduced. */ +::view-transition { + pointer-events: none; } -::view-transition-old(toast):only-child { - animation: slideOut 400ms ease-in; -} - -@keyframes slideIn { - from { - translate: 100% 0; - opacity: 0; - } -} -@keyframes slideOut { - to { - translate: 100% 0; - opacity: 0; - } +:root { + view-transition-name: none; } .CodeMirror-merge { diff --git a/packages/insomnia/src/ui/event-bus.ts b/packages/insomnia/src/ui/event-bus.ts index 87e4a464f8..1a205f5d50 100644 --- a/packages/insomnia/src/ui/event-bus.ts +++ b/packages/insomnia/src/ui/event-bus.ts @@ -3,18 +3,23 @@ type EventHandler = (...args: any[]) => void; export const OAUTH2_AUTHORIZATION_STATUS_CHANGE = 'OAUTH2_AUTHORIZATION_STATUS_CHANGE'; // This event is emitted when remote cloud sync file is changed, including project, workspace creation, deletion and update. export const CLOUD_SYNC_FILE_CHANGE = 'CLOUD_SYNC_FILE_CHANGE'; +// Lets the Konnect settings modal start a sync without being handed a callback by whichever +// sidebar variant happens to be rendered (they're mutually exclusive routes). +export const KONNECT_SYNC_TRIGGER = 'KONNECT_SYNC_TRIGGER'; type UIEventType = | 'CLOSE_TAB' | 'CHANGE_ACTIVE_ENV' | typeof CLOUD_SYNC_FILE_CHANGE - | typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE; + | typeof OAUTH2_AUTHORIZATION_STATUS_CHANGE + | typeof KONNECT_SYNC_TRIGGER; class EventBus { private events: Record = { CLOSE_TAB: [], CHANGE_ACTIVE_ENV: [], [CLOUD_SYNC_FILE_CHANGE]: [], [OAUTH2_AUTHORIZATION_STATUS_CHANGE]: [], + [KONNECT_SYNC_TRIGGER]: [], }; // Subscribe to event, returns unsubscribe function diff --git a/packages/insomnia/src/ui/hooks/use-account-server-data.ts b/packages/insomnia/src/ui/hooks/use-account-server-data.ts new file mode 100644 index 0000000000..45f3ce7188 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-account-server-data.ts @@ -0,0 +1,90 @@ +import type { CurrentPlan, Organization, User } from 'insomnia-api'; +import { useCallback } from 'react'; + +import { + fetchOrganizationData, + type OrganizationData, + readOrganizationDataFromLocalStorage, + writeOrganizationDataToLocalStorage, +} from '~/common/organization'; +import { useRootLoaderData } from '~/root'; +import { useServerDataQueryClient } from '~/ui/context/app/server-data-context'; +import { useServerQuery } from '~/ui/hooks/use-query'; +import { useKonnectOrganization } from '~/ui/organization-utils'; + +// Account-scoped server data (organizations / user / current plan). +const accountServerDataKey = (accountId: string) => ['account-server-data', accountId] as const; + +const loggedOutData: OrganizationData = { organizations: [], user: undefined, currentPlan: undefined }; + +// Stable selector references so TanStack doesn't re-run them every render. +const selectOrganizations = (data: OrganizationData) => data.organizations; +const selectUser = (data: OrganizationData) => data.user; +const selectCurrentPlan = (data: OrganizationData) => data.currentPlan; + +/** + * The single source of truth (for React consumers) of the current account's organizations, + * user and current plan. + * Freshness: the server-data client's default `staleTime` refetches on mount, so the value is + * authoritative regardless of what localStorage held. The queryFn writes the result through to + * localStorage — a bridge that keeps the remaining non-React readers (`getInitialEntry`, various + * loaders, event-stream handlers) working while their migration is done in a later phase. + * + * Refresh is event-driven and owned by `useInvalidateAccountData` (SSE `OrganizationChanged`, + * trial start, etc.). + * + * Prefer the slice hooks below (`useOrganizations` / `useCurrentUser` / `useCurrentPlan`) at call + * sites; each subscribes to only its slice. + */ +function useAccountServerData(select: (data: OrganizationData) => TData): TData | undefined { + const { userSession } = useRootLoaderData()!; + const sessionId = userSession.id; + const accountId = userSession.accountId; + const isLoggedIn = !!sessionId; + + const { data } = useServerQuery({ + queryKey: accountServerDataKey(accountId), + queryFn: async () => { + const result = await fetchOrganizationData(sessionId); + writeOrganizationDataToLocalStorage(accountId, result); + return result; + }, + // Seed from localStorage for an instant first paint (matching the old loader, which read + // localStorage synchronously). When logged out we intentionally keep `user`/`currentPlan` + // undefined so consumers can distinguish "no user". + initialData: () => (isLoggedIn ? readOrganizationDataFromLocalStorage(accountId) : loggedOutData), + enabled: isLoggedIn, + select, + }); + + return data; +} + +/** The current account's organizations, with the local-only Konnect organization prepended when visible. */ +export function useOrganizations(): Organization[] { + const { userSession } = useRootLoaderData()!; + const organizations = useAccountServerData(selectOrganizations) ?? []; + const konnectOrganization = useKonnectOrganization(userSession.accountId); + return konnectOrganization ? [konnectOrganization, ...organizations] : organizations; +} + +/** The signed-in user, or undefined when logged out / not yet loaded. */ +export function useCurrentUser(): User | undefined { + return useAccountServerData(selectUser); +} + +/** The account's current plan, or undefined when logged out / not yet loaded. */ +export function useCurrentPlan(): CurrentPlan | undefined { + return useAccountServerData(selectCurrentPlan); +} + +export function useInvalidateAccountData() { + const queryClient = useServerDataQueryClient(); + const { userSession } = useRootLoaderData()!; + const accountId = userSession.accountId; + + return useCallback( + () => queryClient.invalidateQueries({ queryKey: accountServerDataKey(accountId) }), + [queryClient, accountId], + ); +} diff --git a/packages/insomnia/src/ui/hooks/use-command-search.ts b/packages/insomnia/src/ui/hooks/use-command-search.ts index 83cc45e17a..1271f8f241 100644 --- a/packages/insomnia/src/ui/hooks/use-command-search.ts +++ b/packages/insomnia/src/ui/hooks/use-command-search.ts @@ -1,5 +1,5 @@ import type { CommandSearchResult } from 'insomnia-data'; -import { services } from 'insomnia-data'; +import { models, services } from 'insomnia-data'; import { useCallback, useEffect, useRef, useState } from 'react'; interface UseCommandSearchParams { @@ -44,9 +44,18 @@ export function useCommandSearch({ services.helpers.abortCommandSearch(prevRequestId).catch(() => {}); } - const allOrganizations: { id: string; name: string }[] = JSON.parse( + const cachedOrganizations: { id: string; name: string }[] = JSON.parse( localStorage.getItem(`${accountId}:spaces`) || '[]', ); + // The Konnect organization is local-only so it is never in the cached list, and search scopes + // projects by organization id — without it Konnect projects are unreachable. + const allOrganizations = [ + ...cachedOrganizations, + { + id: models.organization.getKonnectOrganizationId(accountId), + name: models.organization.KONNECT_ORGANIZATION_NAME, + }, + ]; try { const result = await services.helpers.commandSearch({ diff --git a/packages/insomnia/src/ui/hooks/use-organization-features.tsx b/packages/insomnia/src/ui/hooks/use-organization-features.tsx index a1c0f8f190..99ce1f0ee6 100644 --- a/packages/insomnia/src/ui/hooks/use-organization-features.tsx +++ b/packages/insomnia/src/ui/hooks/use-organization-features.tsx @@ -12,7 +12,6 @@ export const fallbackFeatures = Object.freeze({ aiMockServers: { enabled: false, reason: 'Insomnia API unreachable' }, aiCommitMessages: { enabled: false, reason: 'Insomnia API unreachable' }, aiMcpClient: { enabled: false, reason: 'Insomnia API unreachable' }, - konnectSync: { enabled: false, reason: 'Insomnia API unreachable' }, }); // If network unreachable assume user has paid for the current period @@ -36,7 +35,7 @@ export function useOrganizationPermissions(organizationIdParam?: string) { const params = useParams() as { organizationId?: string }; const organizationId = organizationIdParam ?? params.organizationId ?? ''; - const isEnabled = !!organizationId && !models.organization.isScratchpadOrganizationId(organizationId); + const isEnabled = !!organizationId && !models.organization.isLocalOrganizationId(organizationId); const { data } = useServerQuery({ queryKey: ['organization-features', organizationId], @@ -47,7 +46,7 @@ export function useOrganizationPermissions(organizationIdParam?: string) { enabled: isEnabled, }); - // Fall back to safe defaults while loading, when disabled (scratchpad), or on error. + // Fall back to safe defaults while loading, when disabled (scratchpad/local-only), or on error. return { features: data?.features ?? fallbackFeatures, billing: data?.billing ?? fallbackBilling, diff --git a/packages/insomnia/src/ui/hooks/use-organization-storage-rule.ts b/packages/insomnia/src/ui/hooks/use-organization-storage-rule.ts index e54b145385..a1167eb472 100644 --- a/packages/insomnia/src/ui/hooks/use-organization-storage-rule.ts +++ b/packages/insomnia/src/ui/hooks/use-organization-storage-rule.ts @@ -1,9 +1,8 @@ -import { getOrganizationStorageRule, type StorageRules } from 'insomnia-api'; -import { models, services } from 'insomnia-data'; +import type { StorageRules } from 'insomnia-api'; import { useCallback } from 'react'; import { useParams } from 'react-router'; -import { DEFAULT_STORAGE_RULES } from '~/common/organization-storage-rules'; +import { DEFAULT_STORAGE_RULES, fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; import { useServerDataQueryClient } from '~/ui/context/app/server-data-context'; import { useServerQuery } from '~/ui/hooks/use-query'; @@ -12,23 +11,24 @@ const organizationStorageRuleKey = (organizationId: string) => ['organization-st /** * @param organizationIdParam optional explicit organization ID; defaults to the * `organizationId` route param. + * + * Delegates to `fetchAndCacheOrganizationStorageRule`, which already short-circuits local-only + * organizations (Scratchpad, Konnect) with the correct hardcoded rules and no network call — do + * not reimplement that check here as an `enabled` gate, since falling back to + * `DEFAULT_STORAGE_RULES` on a disabled query would surface Cloud Sync / Git Sync options inside + * those organizations (the opposite of the local-only rules). */ export function useOrganizationStorageRule(organizationIdParam?: string): StorageRules { const params = useParams() as { organizationId?: string }; const organizationId = organizationIdParam ?? params.organizationId ?? ''; - const isEnabled = !!organizationId && !models.organization.isScratchpadOrganizationId(organizationId); - const { data } = useServerQuery({ queryKey: organizationStorageRuleKey(organizationId), - queryFn: async () => { - const { id: sessionId } = await services.userSession.get(); - return getOrganizationStorageRule({ organizationId, sessionId }); - }, - enabled: isEnabled, + queryFn: () => fetchAndCacheOrganizationStorageRule(organizationId, true), + enabled: !!organizationId, }); - // Fall back to safe defaults while loading, when disabled (scratchpad), or on error. + // Fall back to safe defaults while loading or on error. return data ?? DEFAULT_STORAGE_RULES; } diff --git a/packages/insomnia/src/ui/hooks/use-plan.tsx b/packages/insomnia/src/ui/hooks/use-plan.tsx index 4d4cc5082c..f2b2033213 100644 --- a/packages/insomnia/src/ui/hooks/use-plan.tsx +++ b/packages/insomnia/src/ui/hooks/use-plan.tsx @@ -3,7 +3,7 @@ import { models } from 'insomnia-data'; import { useParams } from 'react-router'; import { useRootLoaderData } from '~/root'; -import { useOrganizationLoaderData } from '~/routes/organization'; +import { useCurrentPlan, useOrganizations } from '~/ui/hooks/use-account-server-data'; export const usePlanData = () => { let isOwner = false; @@ -14,19 +14,15 @@ export const usePlanData = () => { let isEnterprisePlan = false; const { userSession } = useRootLoaderData()!; const { organizationId } = useParams<{ organizationId: string }>(); - const organizationData = useOrganizationLoaderData(); + const organizations = useOrganizations(); + const currentPlan = useCurrentPlan(); // ensure user has logged in with valid organization - if ( - organizationData && - userSession && - Array.isArray(organizationData.organizations) && - organizationData.organizations.length > 0 - ) { - const currentOrg = organizationData.organizations.find(organization => organization.id === organizationId); + if (userSession && Array.isArray(organizations) && organizations.length > 0) { + const currentOrg = organizations.find(organization => organization.id === organizationId); if (currentOrg && userSession.accountId) { isOwner = Boolean(currentOrg.is_owner); } - planType = organizationData.currentPlan?.type || planType; + planType = currentPlan?.type || planType; isFreePlan = planType.includes('free'); isTeamPlan = planType.includes('team'); isEnterprisePlan = planType.includes('enterprise'); @@ -34,7 +30,7 @@ export const usePlanData = () => { } return { isOwner, - currentPlan: organizationData?.currentPlan, + currentPlan, planDisplayName, isFreePlan, isTeamPlan, diff --git a/packages/insomnia/src/ui/hooks/use-remote-files.ts b/packages/insomnia/src/ui/hooks/use-remote-files.ts index b285611584..1068fdee2f 100644 --- a/packages/insomnia/src/ui/hooks/use-remote-files.ts +++ b/packages/insomnia/src/ui/hooks/use-remote-files.ts @@ -1,4 +1,5 @@ import type { Project } from 'insomnia-data'; +import { models } from 'insomnia-data'; import { useEffect, useMemo } from 'react'; import { type InsomniaFile } from '~/common/project'; @@ -40,10 +41,13 @@ export function useRemoteBackendProjectsInvalidation(organizationId: string): vo * CLOUD_SYNC_FILE_CHANGE, which invalidates the query. */ export function useRemoteBackendProjects(organizationId: string) { + // Local-only organizations (Scratchpad, Konnect) never have remote/cloud-backed projects. + const isEnabled = !!organizationId && !models.organization.isLocalOrganizationId(organizationId); + const { data, error } = useServerQuery({ queryKey: remoteBackendProjectsKey(organizationId), queryFn: () => getAllRemoteBackendProjectsOfOrg({ organizationId }), - enabled: !!organizationId, + enabled: isEnabled, }); useEffect(() => { diff --git a/packages/insomnia/src/ui/hooks/use-stream-summary.ts b/packages/insomnia/src/ui/hooks/use-stream-summary.ts new file mode 100644 index 0000000000..b977a0c667 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-stream-summary.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; + +import { + computeStreamSummary, + getCandidatePayloadsFromEvents, + inferStreamSummaryPath, + type StreamMessageEvent, +} from '~/common/stream-summary'; +import type { CurlEvent } from '~/main/network/curl'; +import { + type RequestLoaderData, + useRequestLoaderData, +} from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId'; + +import { useRequestMetaPatcher } from './use-request'; + +const getCandidatePayloads = (events: CurlEvent[]): string[] => { + const streamEvents: StreamMessageEvent[] = events.map(event => ({ + type: event.type, + direction: 'direction' in event ? event.direction : '', + data: 'data' in event ? event.data : '', + })); + return getCandidatePayloadsFromEvents(streamEvents); +}; + +export const useStreamSummary = ({ + requestId, + url, + events, +}: { + requestId: string; + url: string; + events: CurlEvent[]; +}) => { + const { activeRequestMeta } = useRequestLoaderData() as RequestLoaderData; + const patchRequestMeta = useRequestMetaPatcher(); + + const inferredPath = useMemo(() => inferStreamSummaryPath(url), [url]); + const resultPath = useMemo(() => { + const path = activeRequestMeta.streamSummaryPath ?? inferredPath; + return path?.trim() || null; + }, [activeRequestMeta.streamSummaryPath, inferredPath]); + + const summary = useMemo(() => { + if (!resultPath) { + return { fragmentCount: 0, summary: '' }; + } + return computeStreamSummary(getCandidatePayloads(events), resultPath); + }, [events, resultPath]); + + const setResultPath = (path: string) => patchRequestMeta(requestId, { streamSummaryPath: path }); + const renderMarkdown = activeRequestMeta.streamSummaryRenderMarkdown; + const setRenderMarkdown = (value: boolean) => patchRequestMeta(requestId, { streamSummaryRenderMarkdown: value }); + + return { inferredPath, resultPath, summary, setResultPath, renderMarkdown, setRenderMarkdown }; +}; diff --git a/packages/insomnia/src/ui/hooks/use-user-service.ts b/packages/insomnia/src/ui/hooks/use-user-service.ts index a231999811..7102409e07 100644 --- a/packages/insomnia/src/ui/hooks/use-user-service.ts +++ b/packages/insomnia/src/ui/hooks/use-user-service.ts @@ -1,8 +1,9 @@ -import { useOrganizationLoaderData } from '~/routes/organization'; +import { useCurrentPlan, useCurrentUser } from '~/ui/hooks/use-account-server-data'; import { diffInDayCeil } from '~/ui/utils'; export function useUserService() { - const { currentPlan, user } = useOrganizationLoaderData()!; + const currentPlan = useCurrentPlan(); + const user = useCurrentUser(); const isPro = currentPlan?.type === 'individual' || currentPlan?.type === 'team'; const isEnterpriseOwner = currentPlan?.type === 'enterprise'; const isEnterpriseMember = currentPlan?.type === 'enterprise-member'; diff --git a/packages/insomnia/src/ui/images/onboarding/custom_linting.png b/packages/insomnia/src/ui/images/onboarding/custom_linting.png deleted file mode 100644 index 588740c862..0000000000 Binary files a/packages/insomnia/src/ui/images/onboarding/custom_linting.png and /dev/null differ diff --git a/packages/insomnia/src/ui/images/onboarding/first_request.png b/packages/insomnia/src/ui/images/onboarding/first_request.png deleted file mode 100644 index 634e70cbd8..0000000000 Binary files a/packages/insomnia/src/ui/images/onboarding/first_request.png and /dev/null differ diff --git a/packages/insomnia/src/ui/images/onboarding/konnect_integration.png b/packages/insomnia/src/ui/images/onboarding/konnect_integration.png deleted file mode 100644 index ba6f6486ac..0000000000 Binary files a/packages/insomnia/src/ui/images/onboarding/konnect_integration.png and /dev/null differ diff --git a/packages/insomnia/src/ui/images/onboarding/new_api_collection.png b/packages/insomnia/src/ui/images/onboarding/new_api_collection.png new file mode 100644 index 0000000000..7db2580729 Binary files /dev/null and b/packages/insomnia/src/ui/images/onboarding/new_api_collection.png differ diff --git a/packages/insomnia/src/ui/images/onboarding/scripts_for_unit_test.png b/packages/insomnia/src/ui/images/onboarding/scripts_for_unit_test.png new file mode 100644 index 0000000000..6f268e3953 Binary files /dev/null and b/packages/insomnia/src/ui/images/onboarding/scripts_for_unit_test.png differ diff --git a/packages/insomnia/src/ui/images/onboarding/sidebar_navigation.png b/packages/insomnia/src/ui/images/onboarding/sidebar_navigation.png deleted file mode 100644 index b939fdb1f6..0000000000 Binary files a/packages/insomnia/src/ui/images/onboarding/sidebar_navigation.png and /dev/null differ diff --git a/packages/insomnia/src/ui/organization-utils.test.ts b/packages/insomnia/src/ui/organization-utils.test.ts new file mode 100644 index 0000000000..1b5ea8aa4f --- /dev/null +++ b/packages/insomnia/src/ui/organization-utils.test.ts @@ -0,0 +1,69 @@ +/** + * Tests run against the in-memory NeDB initialized by setup-vitest.ts. localStorage is stubbed + * per-test; getUserEntitlements is mocked so calls can be counted without hitting the network. + */ + +import type * as InsomniaApi from 'insomnia-api'; +import { initDatabase } from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { mainDatabase } from '../main/database.main'; +import { refreshKonnectAccess } from './organization-utils'; + +const getUserEntitlementsMock = vi.fn(); + +vi.mock('insomnia-api', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + getUserEntitlements: (...args: unknown[]) => getUserEntitlementsMock(...args), + }; +}); + +const ACCOUNT_ID = 'acct_1'; + +function stubLocalStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + vi.stubGlobal('localStorage', { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }); + return store; +} + +beforeEach(async () => { + await initDatabase(mainDatabase, { inMemoryOnly: true }, true); + stubLocalStorage(); + getUserEntitlementsMock.mockReset(); + getUserEntitlementsMock.mockResolvedValue({ entitlements: [] }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// `konnectAccessResolvedFor` is module-level state that outlives any single call, so every phase +// of the dedup behavior is exercised in one test — splitting it across `it()` blocks would leak +// state between them (the module isn't re-imported per test). +describe('refreshKonnectAccess', () => { + it('dedupes by session, not account, and force always re-fetches', async () => { + await refreshKonnectAccess('session-1', ACCOUNT_ID); + expect(getUserEntitlementsMock).toHaveBeenCalledTimes(1); + + // Re-entering the same session (e.g. re-running the post-login loader on a cold start) must + // not issue a second request. + await refreshKonnectAccess('session-1', ACCOUNT_ID); + expect(getUserEntitlementsMock).toHaveBeenCalledTimes(1); + + // Logging out and back into the *same* account mints a new session token. Signing in never + // reloads the renderer, so this must still trigger a real re-fetch rather than silently + // reusing the stale answer from the previous session. + await refreshKonnectAccess('session-2', ACCOUNT_ID); + expect(getUserEntitlementsMock).toHaveBeenCalledTimes(2); + + // `force` bypasses the dedup guard even for the session already resolved. + await refreshKonnectAccess('session-2', ACCOUNT_ID, { force: true }); + expect(getUserEntitlementsMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/insomnia/src/ui/organization-utils.ts b/packages/insomnia/src/ui/organization-utils.ts index 3c0b5f8370..01b744d516 100644 --- a/packages/insomnia/src/ui/organization-utils.ts +++ b/packages/insomnia/src/ui/organization-utils.ts @@ -1,8 +1,18 @@ -import { createTeamProject, isApiError, type Organization } from 'insomnia-api'; +import { + createTeamProject, + getUserEntitlements, + isApiError, + KONNECT_CONTROL_PLANES_FEATURE, + KONNECT_CONTROL_PLANES_FEATURE_KEY, + type Organization, +} from 'insomnia-api'; import type { Project } from 'insomnia-data'; +import { models } from 'insomnia-data'; import { services } from 'insomnia-data'; +import { useMemo, useSyncExternalStore } from 'react'; import { invariant } from '~/common/utils/invariant'; +import { syncVCSLikeForWorkspace } from '~/ui/sync-utils'; // TODO: move vcs into services so we can remove this file. import { @@ -15,14 +25,156 @@ import { shouldMigrateProjectUnderOrganization, } from '../sync/vcs/migrate-projects-into-organization'; +interface KonnectAccess { + /** The account holds the Konnect control-planes entitlement, so syncing is allowed. */ + hasEntitlement: boolean; + /** The Konnect organization should appear in the organization list. */ + isOrganizationVisible: boolean; +} + +const konnectEntitlementStorageKey = (accountId: string) => `${accountId}:konnectSyncEnabled`; + +let konnectAccess: KonnectAccess = { hasEntitlement: false, isOrganizationVisible: false }; +let konnectAccessResolvedFor: string | null = null; +const konnectAccessListeners = new Set<() => void>(); + +const subscribeToKonnectAccess = (listener: () => void) => { + konnectAccessListeners.add(listener); + return () => { + konnectAccessListeners.delete(listener); + }; +}; + +function setKonnectAccess(next: KonnectAccess) { + if ( + next.hasEntitlement === konnectAccess.hasEntitlement && + next.isOrganizationVisible === konnectAccess.isOrganizationVisible + ) { + return; + } + konnectAccess = next; + konnectAccessListeners.forEach(listener => listener()); +} + +/** + * Combines `hasEntitlement` with a freshly-queried local Konnect project count into + * `isOrganizationVisible`, and writes both into the shared access store. Shared by + * `refreshKonnectAccess` (after a real entitlement fetch) and `getKonnectOrganizationEscapeRoute` + * (reusing the last-resolved entitlement, since only the local count can have changed). + */ +async function reconcileKonnectAccess(hasEntitlement: boolean, konnectOrganizationId: string): Promise { + const localKonnectProjectCount = await services.project.count({ + konnectControlPlaneId: { $exists: true, $ne: null }, + parentId: konnectOrganizationId, + }); + setKonnectAccess({ hasEntitlement, isOrganizationVisible: hasEntitlement || localKonnectProjectCount > 0 }); + return konnectAccess; +} + +/** + * Resolves Konnect access for the account. Awaited before hydration and again after signing in so + * that render-time readers can stay synchronous; the two mutations that can flip it mid-session + * (disconnecting the PAT, resolving the migration conflict) pass `force`. + * + * Visibility is the entitlement OR local Konnect data from a previous version, which stays + * reachable with syncing disabled. Callers must run the startup migration first, otherwise the + * project lookup still sees the pre-migration parents. + * + * Deduped by `sessionId`, not `accountId`: signing in does not reload the renderer, so this + * module-level guard otherwise survives a logout, and a fresh login into the *same* account keeps + * the same accountId — which would silently skip the re-resolution. A new login always mints a new + * session token even for the same account, so keying on `sessionId` still dedupes the normal case + * (the startup call and the post-login loader's call share one session on a cold start) while + * still re-resolving after a genuine logout/login cycle. + */ +export async function refreshKonnectAccess( + sessionId: string, + accountId: string, + { force = false }: { force?: boolean } = {}, +) { + if (!force && konnectAccessResolvedFor === sessionId) { + return; + } + konnectAccessResolvedFor = sessionId; + + if (!sessionId || !accountId) { + setKonnectAccess({ hasEntitlement: false, isOrganizationVisible: false }); + return; + } + + let hasEntitlement = localStorage.getItem(konnectEntitlementStorageKey(accountId)) === 'true'; + try { + const { entitlements } = await getUserEntitlements({ + sessionId, + feature: KONNECT_CONTROL_PLANES_FEATURE, + }); + hasEntitlement = (entitlements ?? []).some( + entitlement => entitlement.featureKey === KONNECT_CONTROL_PLANES_FEATURE_KEY && entitlement.hasAccess, + ); + localStorage.setItem(konnectEntitlementStorageKey(accountId), String(hasEntitlement)); + } catch { + // Offline: keep the last known answer rather than hiding an organization the user owns. + } + + await reconcileKonnectAccess(hasEntitlement, models.organization.getKonnectOrganizationId(accountId)); +} + +/** Whether the account may sync from Konnect. */ +export function useKonnectSyncEnabled(): boolean { + return useSyncExternalStore(subscribeToKonnectAccess, () => konnectAccess.hasEntitlement); +} + +/** + * Re-checks a Konnect organization's visibility using a fresh local project count — no network + * call, since entitlement is read from the last-resolved value (kept current by the explicit + * `force` points: login, disconnect, migration) and the local count is the only half that can + * change from a plain NeDB delete — and updates the shared access store to match, so + * `useOrganizations()` subscribers (the org dropdown) see the change immediately regardless of + * what the caller navigates to next. + * + * Returns the URL to redirect to when the organization just became invisible (its last Konnect + * project was just removed, one at a time or via Disconnect, with no entitlement to fall back on), + * or `null` when the caller should proceed into the organization normally. Every code path that can + * remove the last Konnect project calls this before deciding where to land, so the check — and the + * store update — live in one place instead of being duplicated per delete path. + */ +export async function getKonnectOrganizationEscapeRoute(organizationId: string): Promise { + if (!models.organization.isKonnectOrganizationId(organizationId)) { + return null; + } + + const { accountId } = await services.userSession.get(); + const { isOrganizationVisible } = await reconcileKonnectAccess(konnectAccess.hasEntitlement, organizationId); + if (isOrganizationVisible) { + return null; + } + + const organizations = JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[]; + invariant(organizations.length, 'Failed to fetch organizations. Check your network connection and try again.'); + return `/organization/${organizations[0].id}`; +} + +/** The account's local-only Konnect organization, or null when it should not be shown. */ +export function useKonnectOrganization(accountId: string): Organization | null { + const isOrganizationVisible = useSyncExternalStore( + subscribeToKonnectAccess, + () => konnectAccess.isOrganizationVisible, + ); + + return useMemo( + () => (isOrganizationVisible && accountId ? models.organization.buildKonnectOrganization(accountId) : null), + [isOrganizationVisible, accountId], + ); +} + export async function updateLocalProjectToRemote({ project, - vcs, + getVcsForWorkspace, sessionId, organizationId, }: { project: Project; - vcs: SyncVCSLike; + getVcsForWorkspace: (workspaceId: string) => SyncVCSLike; sessionId: string; organizationId: string; }) { @@ -46,6 +198,7 @@ export async function updateLocalProjectToRemote({ // Initialize Sync on the workspace if it's not using Git sync try { if (!workspaceMeta.gitRepositoryId) { + const vcs = getVcsForWorkspace(workspace._id); invariant(vcs, 'VCS must be initialized'); await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace }); @@ -109,7 +262,7 @@ export async function migrateProjectsUnderOrganization(personalOrganizationId: s project, organizationId: personalOrganizationId, sessionId, - vcs: window.main.sync, + getVcsForWorkspace: syncVCSLikeForWorkspace, }); } } diff --git a/packages/insomnia/src/ui/sync-utils.ts b/packages/insomnia/src/ui/sync-utils.ts index 343935dc2e..e4bedc9ee0 100644 --- a/packages/insomnia/src/ui/sync-utils.ts +++ b/packages/insomnia/src/ui/sync-utils.ts @@ -19,6 +19,7 @@ import type { BackendProject, Compare, StatusCandidate } from 'insomnia-vcs'; import { database, type Operation } from '~/common/database'; import { invariant } from '~/common/utils/invariant'; +import type { SyncVCSLike } from '~/sync/vcs/initialize-backend-project'; type PushPull = 'push' | 'pull'; type VCSAction = @@ -47,6 +48,24 @@ export const remoteBranchesCache: Record = {}; export const remoteCompareCache: Record = {}; export const remoteBackendProjectsCache: Record = {}; +/** + * `window.main.sync` methods each take a leading `workspaceId` so the main process can route to + * that workspace's own VCS instance. `initializeLocalBackendProjectAndMarkForSync` / + * `pushSnapshotOnInitialize` predate that and expect the plain `SyncVCSLike` shape (no + * `workspaceId`) — this binds one workspace's calls into that shape. + */ +export function syncVCSLikeForWorkspace(workspaceId: string): SyncVCSLike { + return { + hasBackendProject: () => window.main.sync.hasBackendProject(workspaceId), + push: options => window.main.sync.push(workspaceId, options), + stage: entries => window.main.sync.stage(workspaceId, entries), + status: candidates => window.main.sync.status(workspaceId, candidates), + switchAndCreateBackendProjectIfNotExist: (rootDocumentId, name) => + window.main.sync.switchAndCreateBackendProjectIfNotExist(workspaceId, rootDocumentId, name), + takeSnapshot: name => window.main.sync.takeSnapshot(workspaceId, name), + }; +} + /** * ProjectLintRuleset is parented to the project, whose _id is not stable across machines, * so its parentId is normalized to null in sync transit. Re-parent any ProjectLintRuleset diff --git a/packages/insomnia/src/ui/utils/render-realtime-connect.test.ts b/packages/insomnia/src/ui/utils/render-realtime-connect.test.ts index 9ba1af253c..e6aa006165 100644 --- a/packages/insomnia/src/ui/utils/render-realtime-connect.test.ts +++ b/packages/insomnia/src/ui/utils/render-realtime-connect.test.ts @@ -184,4 +184,58 @@ describe('renderRealtimeConnectPayload integration', () => { expect(result?.url).toBe('ws://localhost/api/chat/5678'); }); + + it('does not render a response-sourced cookie value when preparing a realtime connect payload', async () => { + const actual = await vi.importActual('./try-interpolate'); + tryToInterpolateRequestOrShowRenderErrorModal.mockImplementation( + actual.tryToInterpolateRequestOrShowRenderErrorModal, + ); + + const project = await services.project.create({ + _id: 'proj_render_connect_cookie', + name: 'Render Connect Cookie Project', + }); + const workspace = await services.workspace.create({ + _id: 'wrk_render_connect_cookie', + name: 'Render Connect Cookie Workspace', + parentId: project._id, + scope: 'collection', + }); + const environment = await services.environment.getOrCreateForParentId(workspace._id); + + const templateCookieValue = "{% uuid 'v4' %}"; + const cookieJar = await services.cookieJar.getOrCreateForParentId(workspace._id); + await services.cookieJar.update(cookieJar, { + cookies: [ + { + id: 'c_render_connect', + key: 'tracking', + value: templateCookieValue, + domain: 'localhost', + path: '/', + secure: false, + httpOnly: false, + expires: null, + source: 'response', + }, + ], + }); + + const request = await services.webSocketRequest.create({ + _id: 'ws-req_render_connect_cookie', + name: 'WS Cookie Test', + parentId: workspace._id, + url: 'ws://localhost/api/chat', + metaSortKey: 0, + }); + + const result = await renderRealtimeConnectPayload({ + request, + environmentId: environment._id, + workspaceId: workspace._id, + }); + const responseCookie = result?.workspaceCookieJar.cookies.find(c => c.key === 'tracking'); + + expect(responseCookie?.value).toBe(templateCookieValue); + }); }); diff --git a/packages/insomnia/src/ui/utils/render-realtime-connect.ts b/packages/insomnia/src/ui/utils/render-realtime-connect.ts index 13a2d16505..c9e23879f2 100644 --- a/packages/insomnia/src/ui/utils/render-realtime-connect.ts +++ b/packages/insomnia/src/ui/utils/render-realtime-connect.ts @@ -37,6 +37,12 @@ export async function renderRealtimeConnectPayload({ const authentication = getOrInheritAuthentication({ request, requestGroups }); const body = 'body' in request ? request.body : undefined; + // Only manually-authored cookies may contain live template syntax. Server-set cookie values + // are excluded from rendering here too, mirroring getRenderedRequestAndContext in render.ts, + // so that a template placed in a cookie by a response is never re-evaluated when connecting. + const manualCookies = workspaceCookieJar.cookies.filter(cookie => cookie.source === 'manual'); + const nonManualCookies = workspaceCookieJar.cookies.filter(cookie => cookie.source !== 'manual'); + const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ request, environmentId, @@ -47,7 +53,7 @@ export async function renderRealtimeConnectPayload({ body, parameters: request.parameters.filter(p => !p.disabled), pathParameters: request.pathParameters, - workspaceCookieJar, + workspaceCookieJar: { ...workspaceCookieJar, cookies: manualCookies }, }, }); @@ -65,7 +71,10 @@ export async function renderRealtimeConnectPayload({ authentication: rendered.authentication, body: rendered.body, parameters: rendered.parameters, - workspaceCookieJar: rendered.workspaceCookieJar, + workspaceCookieJar: { + ...rendered.workspaceCookieJar, + cookies: [...rendered.workspaceCookieJar.cookies, ...nonManualCookies], + }, suppressUserAgent, }; } diff --git a/packages/insomnia/src/ui/utils/router.test.ts b/packages/insomnia/src/ui/utils/router.test.ts new file mode 100644 index 0000000000..c7990b2bae --- /dev/null +++ b/packages/insomnia/src/ui/utils/router.test.ts @@ -0,0 +1,95 @@ +import type * as InsomniaData from 'insomnia-data'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('insomnia-data', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + services: { + ...actual.services, + userSession: { ...actual.services.userSession, get: vi.fn() }, + project: { ...actual.services.project, list: vi.fn(), get: vi.fn() }, + }, + }; +}); + +vi.mock('~/ui/organization-utils', () => ({ + getKonnectOrganizationEscapeRoute: vi.fn(), +})); + +import { models, services } from 'insomnia-data'; + +import { HAS_SEEN_ONBOARDING_KEY } from '~/common/constants'; +import { getKonnectOrganizationEscapeRoute } from '~/ui/organization-utils'; + +import { getInitialEntry } from './router'; + +const ACCOUNT_ID = 'acct_1'; +const SESSION_ID = 'sess_1'; +const REAL_ORGANIZATION_ID = 'org_real_1'; +const KONNECT_ORGANIZATION_ID = models.organization.getKonnectOrganizationId(ACCOUNT_ID); + +function stubLocalStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + const storage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }; + // `getInitialEntry` reads some keys via bare `localStorage` and others via `window.localStorage` — + // both need to resolve to the same store. + vi.stubGlobal('localStorage', storage); + vi.stubGlobal('window', { localStorage: storage }); + return store; +} + +describe('getInitialEntry', () => { + beforeEach(() => { + vi.mocked(services.userSession.get).mockResolvedValue({ id: SESSION_ID, accountId: ACCOUNT_ID } as any); + vi.mocked(services.project.list).mockResolvedValue([]); + vi.mocked(services.project.get).mockResolvedValue(undefined as any); + vi.mocked(getKonnectOrganizationEscapeRoute).mockReset(); + stubLocalStorage({ + [HAS_SEEN_ONBOARDING_KEY]: 'true', + [`${ACCOUNT_ID}:spaces`]: JSON.stringify([{ id: REAL_ORGANIZATION_ID, name: 'Real Org' }]), + lastVisitedOrganizationId: KONNECT_ORGANIZATION_ID, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('escapes to a real organization when the last-visited Konnect organization is no longer visible', async () => { + vi.mocked(getKonnectOrganizationEscapeRoute).mockResolvedValue(`/organization/${REAL_ORGANIZATION_ID}`); + + const entry = await getInitialEntry(); + + expect(getKonnectOrganizationEscapeRoute).toHaveBeenCalledWith(KONNECT_ORGANIZATION_ID); + expect(entry).toMatchObject({ pathname: `/organization/${REAL_ORGANIZATION_ID}` }); + // Must not have tried to resolve a route *inside* the now-invisible organization. + expect(services.project.get).not.toHaveBeenCalled(); + }); + + it('still restores the Konnect organization when it remains visible', async () => { + vi.mocked(getKonnectOrganizationEscapeRoute).mockResolvedValue(null); + + const entry = await getInitialEntry(); + + expect(getKonnectOrganizationEscapeRoute).toHaveBeenCalledWith(KONNECT_ORGANIZATION_ID); + expect(entry).toMatchObject({ pathname: `/organization/${KONNECT_ORGANIZATION_ID}/project` }); + }); + + it('never consults the escape route for a real organization', async () => { + stubLocalStorage({ + [HAS_SEEN_ONBOARDING_KEY]: 'true', + [`${ACCOUNT_ID}:spaces`]: JSON.stringify([{ id: REAL_ORGANIZATION_ID, name: 'Real Org' }]), + lastVisitedOrganizationId: REAL_ORGANIZATION_ID, + }); + + const entry = await getInitialEntry(); + + expect(getKonnectOrganizationEscapeRoute).not.toHaveBeenCalled(); + expect(entry).toMatchObject({ pathname: `/organization/${REAL_ORGANIZATION_ID}/project` }); + }); +}); diff --git a/packages/insomnia/src/ui/utils/router.ts b/packages/insomnia/src/ui/utils/router.ts index 231e24fe0f..37c28ff807 100644 --- a/packages/insomnia/src/ui/utils/router.ts +++ b/packages/insomnia/src/ui/utils/router.ts @@ -4,10 +4,11 @@ import { database, models, services } from 'insomnia-data'; import { useCallback } from 'react'; import { href, matchPath, type PathMatch, useFetcher } from 'react-router'; +import { HAS_SEEN_ONBOARDING_KEY } from '~/common/constants'; import { CURRENT_MIGRATION_VERSION } from '~/sync/git/git-migration-version'; +import { getKonnectOrganizationEscapeRoute } from '~/ui/organization-utils'; export const enum AsyncTask { - SyncOrganization, MigrateProjects, SyncProjects, } @@ -108,9 +109,9 @@ export const getInitialEntry = async () => { } } - const hasSeenOnboardingV13 = Boolean(window.localStorage.getItem('hasSeenOnboardingV13')); + const hasSeenOnboarding = Boolean(window.localStorage.getItem(HAS_SEEN_ONBOARDING_KEY)); - if (!hasSeenOnboardingV13) { + if (!hasSeenOnboarding) { return href('/onboarding/*', { '*': '', }); @@ -120,9 +121,7 @@ export const getInitialEntry = async () => { const user = await services.userSession.get(); if (user.id) { - const organizations = JSON.parse( - localStorage.getItem(`${user.accountId}:spaces`) || '[]', - ) as Organization[]; + const organizations = JSON.parse(localStorage.getItem(`${user.accountId}:spaces`) || '[]') as Organization[]; // If no organizations are in local storage, go fetch from org index loader if (organizations.length === 0) { return href('/organization'); @@ -133,16 +132,31 @@ export const getInitialEntry = async () => { // Check if the user has a last visited organization try { const lastVisitedOrganizationId = localStorage.getItem('lastVisitedOrganizationId'); - if (lastVisitedOrganizationId && organizations.find(o => o.id === lastVisitedOrganizationId)) { + // The Konnect organization is local-only, so it is never in the cached organization list. + const isKnownOrganization = + lastVisitedOrganizationId === + (user.accountId && models.organization.getKonnectOrganizationId(user.accountId)) || + organizations.some(o => o.id === lastVisitedOrganizationId); + if (lastVisitedOrganizationId && isKnownOrganization) { organizationId = lastVisitedOrganizationId; } } catch {} + // The Konnect organization may have gone invisible since the previous session (entitlement + // revoked server-side, or its last local project removed elsewhere) — `refreshKonnectAccess()` + // already re-resolved that immediately before this call, in `entry.client.tsx`. Re-check before + // restoring a route into it, otherwise the app lands on a URL for an organization that no + // longer appears in the dropdown (this path resolves straight to a leaf route such as + // `.../project`, which never runs the org-index loader's own escape-route check). + const escapeRoute = models.organization.isKonnectOrganizationId(organizationId) + ? await getKonnectOrganizationEscapeRoute(organizationId) + : null; + return { - pathname: await getInitialRouteForOrganization({ organizationId, navigateToWorkspace: true }), + pathname: escapeRoute ?? (await getInitialRouteForOrganization({ organizationId, navigateToWorkspace: true })), state: { // async task need to execute when first entry - asyncTaskList: [AsyncTask.SyncOrganization, AsyncTask.MigrateProjects, AsyncTask.SyncProjects], + asyncTaskList: [AsyncTask.MigrateProjects, AsyncTask.SyncProjects], }, }; } diff --git a/packages/insomnia/types/react-aria-components.d.ts b/packages/insomnia/types/react-aria-components.d.ts new file mode 100644 index 0000000000..86301cc943 --- /dev/null +++ b/packages/insomnia/types/react-aria-components.d.ts @@ -0,0 +1,30 @@ +// `disallowTypeAhead` is missing from `ListBoxProps` typings in every published +// react-aria-components version (verified up to 1.21.1) even though the runtime fully +// supports it, and `GridListProps`/`TableProps` do declare it. Augment it here until +// upstream types it. +// +// Runtime evidence chain (adobe/react-spectrum, permalinks pinned to the commits our +// lockfile builds from): +// 1. ListBox spreads all props into the aria hook: +// https://github.com/adobe/react-spectrum/blob/8df187370053aa35f553cb388ad670f65e1ab371/packages/react-aria-components/src/ListBox.tsx#L169 (useListBox({...props, ...})) +// 2. useListBox forwards props and merges the resulting handlers onto the listbox DOM element: +// https://github.com/adobe/react-spectrum/blob/8df187370053aa35f553cb388ad670f65e1ab371/packages/%40react-aria/listbox/src/useListBox.ts#L79 (useSelectableList({...props})), #L118-L122 (listBoxProps mergeProps(..., listProps)) +// 3. useSelectableList forwards props into useSelectableCollection: +// https://github.com/adobe/react-spectrum/blob/8df187370053aa35f553cb388ad670f65e1ab371/packages/%40react-aria/selection/src/useSelectableList.ts#L75-L77 +// 4. useSelectableCollection gates the typeahead handlers on `disallowTypeAhead`: +// https://github.com/adobe/react-spectrum/blob/8df187370053aa35f553cb388ad670f65e1ab371/packages/%40react-aria/selection/src/useSelectableCollection.ts#L71 (#L118 default), #L571-L578 (if (!disallowTypeAhead) handlers = mergeProps(typeSelectProps, handlers)) +// 5. Without it, useTypeSelect swallows the space key in the capture phase while its +// search buffer is non-empty (within 1s of the last printable key): +// https://github.com/adobe/react-spectrum/blob/8df187370053aa35f553cb388ad670f65e1ab371/packages/%40react-aria/selection/src/useTypeSelect.ts#L60-L68 (#L102 onKeyDownCapture) +// +// Even the latest published version at the time of writing (1.21.1, commit 4dd44e0) +// still lacks this prop in ListBoxProps — re-check the link below when upgrading, and +// delete this augmentation once upstream declares it: +// https://github.com/adobe/react-spectrum/blob/4dd44e0f400636a87a9ad4390903e78c5ae6113c/packages/react-aria-components/src/ListBox.tsx +import 'react-aria-components'; + +declare module 'react-aria-components' { + interface ListBoxProps { + disallowTypeAhead?: boolean; + } +} diff --git a/packages/insomnia/vite.config.ts b/packages/insomnia/vite.config.ts index 79b173db05..2895dc239e 100644 --- a/packages/insomnia/vite.config.ts +++ b/packages/insomnia/vite.config.ts @@ -19,6 +19,15 @@ function writePortFile(): Plugin { fs.writeFileSync(portFilePath, String(addr.port)); } }); + const cleanup = () => { + try { fs.unlinkSync(portFilePath); } catch {} + process.exit(0); + }; + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + process.once('exit', () => { + try { fs.unlinkSync(portFilePath); } catch {} + }); }, }; } diff --git a/schemas/insomnia.schema.5.1.json b/schemas/insomnia.schema.5.1.json index 5f039ae9fe..94245e722f 100644 --- a/schemas/insomnia.schema.5.1.json +++ b/schemas/insomnia.schema.5.1.json @@ -141,6 +141,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -167,19 +237,6 @@ "id" ] }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "subEnvironments": { "type": "array", "items": { @@ -188,6 +245,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -213,31 +340,9 @@ "required": [ "id" ] - }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] } - }, - "required": [ - "name" - ] + } } - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" } } }, @@ -280,6 +385,168 @@ } } } + }, + "testSuites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "tests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + }, + "requestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "code": { + "type": "string" + } + } + } + } + } + } + }, + "spec": { + "anyOf": [ + { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + } + }, + "required": [ + "file" + ] + }, + { + "type": "object", + "properties": { + "contents": { + "$ref": "#/$defs/__schema1" + }, + "meta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "number" + }, + "modified": { + "type": "number" + }, + "isPrivate": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "sortKey": { + "type": "number" + } + }, + "required": [ + "id" + ] + } + } + } + ] } }, "required": [ @@ -423,6 +690,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -449,19 +786,6 @@ "id" ] }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "subEnvironments": { "type": "array", "items": { @@ -470,6 +794,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -495,31 +889,9 @@ "required": [ "id" ] - }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] } - }, - "required": [ - "name" - ] + } } - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" } } }, @@ -935,6 +1307,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -961,19 +1403,6 @@ "id" ] }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "subEnvironments": { "type": "array", "items": { @@ -982,6 +1411,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -1007,31 +1506,9 @@ "required": [ "id" ] - }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] } - }, - "required": [ - "name" - ] + } } - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" } } } @@ -1618,6 +2095,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -1644,19 +2191,6 @@ "id" ] }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "subEnvironments": { "type": "array", "items": { @@ -1665,6 +2199,76 @@ "name": { "type": "string" }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "dataPropertyOrder": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "kvPairData": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "json", + "str", + "secret" + ] + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "type" + ] + } + }, + "environmentType": { + "type": "string", + "enum": [ + "json", + "kv" + ] + }, "meta": { "type": "object", "properties": { @@ -1690,31 +2294,9 @@ "required": [ "id" ] - }, - "data": { - "$ref": "#/$defs/__schema1" - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" - }, - "color": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] } - }, - "required": [ - "name" - ] + } } - }, - "dataPropertyOrder": { - "$ref": "#/$defs/__schema1" } } } diff --git a/scripts/circular-references/check-cycle-references.mjs b/scripts/circular-references/check-cycle-references.mjs new file mode 100755 index 0000000000..8527ce4db9 --- /dev/null +++ b/scripts/circular-references/check-cycle-references.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/* global process, console */ +// Runs dependency-cruiser per workspace package to find circular imports, and fails only on +// cycles that are not already recorded in the baseline file. Run with --update-baseline to +// regenerate the baseline from the current state (e.g. after fixing a cycle). +// +// Exits 0 when the tree matches the baseline, 1 when a new cycle appeared, and 2 when the +// baseline is merely stale. CI distinguishes the last two when reporting. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +// Resolve dependency-cruiser's own entry point rather than the `.bin` shim, and run it with the +// current Node binary. On Windows the shim is split into an extensionless shell script (which +// `execFileSync` cannot launch) and a `.cmd` (which Node >=18 refuses to spawn without a shell, +// see CVE-2024-27980), so going through `.bin` makes this script POSIX-only for no benefit. +const depcruiseBin = path.join(repoRoot, 'node_modules', 'dependency-cruiser', 'bin', 'dependency-cruise.mjs'); +const configPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dependency-cruiser.json'); +const baselinePath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'known-violations.json'); + +/** A cycle exists that the baseline does not record — a regression. */ +const EXIT_NEW_CYCLES = 1; +/** The baseline records cycles that no longer exist — it just needs regenerating. */ +const EXIT_BASELINE_DRIFT = 2; + +// Same scope as `npm run lint`/`type-check`/`test` (--workspaces --if-present): the packages +// actually declared as npm workspaces, not every directory under packages/ (e.g. +// insomnia-component-docs is intentionally excluded and its deps aren't installed at the root). +const rootPackageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const packageDirs = rootPackageJson.workspaces + .map(workspace => ({ name: path.basename(workspace), dir: path.join(repoRoot, workspace) })) + .sort((a, b) => a.name.localeCompare(b.name)); +const packageNames = packageDirs.map(({ name }) => name); + +function packageForAbsPath(absPath) { + const found = packageDirs.find(({ dir }) => absPath === dir || absPath.startsWith(dir + path.sep)); + return found ? found.name : null; +} + +// Rotates a cycle's node list to start at its lexicographically smallest entry, so the same +// logical cycle reported starting from different nodes (which dependency-cruiser does) collapses +// to one canonical key. +function canonicalize(repoRelNodes) { + let minIndex = 0; + for (let i = 1; i < repoRelNodes.length; i++) { + if (repoRelNodes[i] < repoRelNodes[minIndex]) minIndex = i; + } + return [...repoRelNodes.slice(minIndex), ...repoRelNodes.slice(0, minIndex)].join(' -> '); +} + +function cruisePackage({ name, dir }) { + const tsconfigPath = path.join(dir, 'tsconfig.json'); + const args = ['--config', configPath]; + if (fs.existsSync(tsconfigPath)) args.push('--ts-config', 'tsconfig.json'); + // Exclude node_modules and any local build/dist output (untracked, non-reproducible artifacts + // from a prior local build) at any depth, e.g. packages/insomnia/build/. + args.push('-x', 'node_modules|(^|/)(build|dist)(/|$)', '--output-type', 'json', '.'); + + let stdout; + try { + stdout = execFileSync(process.execPath, [depcruiseBin, ...args], { + cwd: dir, + encoding: 'utf8', + maxBuffer: 1024 * 1024 * 100, + }); + } catch (error) { + if (error?.status !== 1 || !error.stdout) throw error; + // dependency-cruiser exits with status 1 when forbidden violations are found. Parse its JSON + // output so the baseline comparison can decide whether those cycles are new. + stdout = error.stdout; + } + const result = JSON.parse(stdout); + + // key: canonical signature -> Set of packages it touches + const cycles = new Map(); + for (const violation of result.summary.violations) { + if (violation.type !== 'cycle') continue; + // A cycle needs >=2 distinct modules; length-1 "cycles" are resolver artifacts (e.g. a bare + // specifier like `esbuild` misresolved to a same-named local file `esbuild.ts`), not real + // circular dependencies. + if (violation.cycle.length <= 1) continue; + const repoRelNodes = violation.cycle.map(node => { + const abs = path.resolve(dir, node.name); + return path.relative(repoRoot, abs).split(path.sep).join('/'); + }); + const key = canonicalize(repoRelNodes); + if (cycles.has(key)) continue; + const attributed = new Set(repoRelNodes.map(p => packageForAbsPath(path.resolve(repoRoot, p))).filter(Boolean)); + cycles.set(key, attributed); + } + return cycles; +} + +function cruiseAll() { + // packageName -> Set + const byPackage = new Map(packageNames.map(name => [name, new Set()])); + for (const pkg of packageDirs) { + const cycles = cruisePackage(pkg); + for (const [key, attributed] of cycles) { + for (const pkgName of attributed) { + byPackage.get(pkgName).add(key); + } + } + } + return byPackage; +} + +function readBaseline() { + if (!fs.existsSync(baselinePath)) return new Map(); + const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + return new Map(Object.entries(raw).map(([name, keys]) => [name, new Set(keys)])); +} + +function writeBaseline(byPackage) { + const raw = {}; + for (const name of packageNames) { + raw[name] = [...byPackage.get(name)].sort(); + } + fs.writeFileSync(baselinePath, JSON.stringify(raw, null, 2) + '\n'); +} + +function main() { + const updateBaseline = process.argv.includes('--update-baseline'); + const current = cruiseAll(); + + if (updateBaseline) { + writeBaseline(current); + const total = [...current.values()].reduce((sum, set) => sum + set.size, 0); + console.log( + `Baseline updated: ${baselinePath} (${total} cycle attributions across ${packageNames.length} packages)`, + ); + return; + } + + const baseline = readBaseline(); + let hasNew = false; + let hasDrift = false; + for (const name of packageNames) { + const currentKeys = current.get(name); + const baselineKeys = baseline.get(name) || new Set(); + const newKeys = [...currentKeys].filter(key => !baselineKeys.has(key)); + const removedKeys = [...baselineKeys].filter(key => !currentKeys.has(key)); + console.log( + `${name}: ${currentKeys.size} cycle(s)${newKeys.length ? `, ${newKeys.length} NEW` : ''}${removedKeys.length ? `, ${removedKeys.length} REMOVED` : ''}`, + ); + if (newKeys.length) { + hasNew = true; + for (const key of newKeys) console.log(` NEW: ${key}`); + } + if (removedKeys.length) { + hasDrift = true; + for (const key of removedKeys) console.log(` REMOVED FROM BASELINE: ${key}`); + } + } + + if (hasNew || hasDrift) { + if (hasNew) console.log('\nNew circular dependencies detected that are not in the baseline.'); + if (hasDrift) console.log('\nBaseline contains circular dependencies no longer present in the current tree.'); + console.log(`Run "npm run check-cycle-references:baseline" and commit the updated ${path.basename(baselinePath)}.`); + // Both cases need the baseline regenerated, but they mean opposite things — a new cycle is a + // regression, while drift alone means cycles were fixed and the baseline was left behind. + // Exit 1 vs 2 so CI can say which happened instead of reporting every failure as "new". + process.exit(hasNew ? EXIT_NEW_CYCLES : EXIT_BASELINE_DRIFT); + } + console.log('\nNo new circular dependencies.'); +} + +main(); diff --git a/scripts/circular-references/dependency-cruiser.json b/scripts/circular-references/dependency-cruiser.json new file mode 100644 index 0000000000..2f1c572ade --- /dev/null +++ b/scripts/circular-references/dependency-cruiser.json @@ -0,0 +1,4 @@ +{ + "forbidden": [{ "name": "no-circular", "severity": "warn", "from": {}, "to": { "circular": true } }], + "options": { "tsPreCompilationDeps": true } +} diff --git a/scripts/circular-references/known-violations.json b/scripts/circular-references/known-violations.json new file mode 100644 index 0000000000..4c95f7c1f4 --- /dev/null +++ b/scripts/circular-references/known-violations.json @@ -0,0 +1,116 @@ +{ + "insomnia": [ + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/render.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/common/utils/vault.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/render.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/common/utils/vault.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/network/concurrency.renderer.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/insomnia.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/insomnia.ts -> packages/insomnia-scripting-environment/src/objects/send-request.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia/src/common/import.ts -> packages/insomnia/src/common/insomnia-v5.ts", + "packages/insomnia/src/common/render.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia/src/main/git-service.ts -> packages/insomnia/src/sync/git/repo-file-watcher.ts", + "packages/insomnia/src/main/plugin-window.ts -> packages/insomnia/src/main/prompt-bridge.ts -> packages/insomnia/src/main/window-utils.ts", + "packages/insomnia/src/main/plugin-window.ts -> packages/insomnia/src/main/window-utils.ts", + "packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/plugins/index.ts", + "packages/insomnia/src/main/templating-worker-database.ts -> packages/insomnia/src/templating/index.ts -> packages/insomnia/src/plugins/index.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/credentials.tsx -> packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/credentials.tsx -> packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx -> packages/insomnia/src/ui/components/settings/number-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/credentials.tsx -> packages/insomnia/src/ui/components/settings/cloud-service-credentials.tsx -> packages/insomnia/src/ui/hooks/use-plan.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/enum-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/enum-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/number-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/text-array-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/text-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/vault-key-panel.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/vault-key-panel.tsx -> packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/vault-key-panel.tsx -> packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx -> packages/insomnia/src/ui/hooks/use-account-server-data.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/general.tsx -> packages/insomnia/src/ui/components/settings/vault-key-panel.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx -> packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx -> packages/insomnia/src/ui/hooks/use-account-server-data.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx -> packages/insomnia/src/ui/hooks/use-account-server-data.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx -> packages/insomnia/src/ui/hooks/use-plan.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx -> packages/insomnia/src/ui/hooks/use-plan.tsx -> packages/insomnia/src/ui/hooks/use-account-server-data.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/masked-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/plugins.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/scripting-settings.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/shortcuts.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/text-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/theme-panel.tsx -> packages/insomnia/src/ui/hooks/theme.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-cio.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-global-keyboard-shortcuts.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-global-keyboard-shortcuts.ts -> packages/insomnia/src/ui/components/keydown-binder.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-global-keyboard-shortcuts.ts -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-settings-side-effects.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/containers/app-hooks.tsx -> packages/insomnia/src/ui/hooks/use-theme-change.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/keydown-binder.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/settings/shortcuts.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/hooks/use-editor-refresh.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/hooks/use-plan.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/markdown-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/markdown-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/markdown-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/code-prompt-modal.tsx -> packages/insomnia/src/ui/components/markdown-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/keydown-binder.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/generate-code-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/generate-code-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx -> packages/insomnia/src/ui/components/diff-view-editor.tsx -> packages/insomnia/src/ui/hooks/theme.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/response-debug-modal.tsx -> packages/insomnia/src/ui/components/viewers/response-timeline-viewer.tsx -> packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/settings-modal.tsx -> packages/insomnia/src/ui/components/settings/boolean-setting.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/merge-editor.tsx -> packages/insomnia/src/ui/components/.client/codemirror/base-imports.ts -> packages/insomnia/src/ui/components/.client/codemirror/extensions/nunjucks-tags.ts -> packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx -> packages/insomnia/src/ui/components/templating/tag-editor.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/external-vault/hashicorp-vault-form.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx -> packages/insomnia/src/ui/components/.client/codemirror/merge-editor.tsx -> packages/insomnia/src/ui/hooks/theme.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/sync-merge-modal.tsx -> packages/insomnia/src/ui/components/diff-view-editor.tsx -> packages/insomnia/src/ui/hooks/theme.ts", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx", + "packages/insomnia/src/root.tsx -> packages/insomnia/src/ui/modals.tsx -> packages/insomnia/src/ui/components/modals/upgrade-plan-modal.tsx -> packages/insomnia/src/ui/hooks/use-plan.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx -> packages/insomnia/src/ui/components/dropdowns/content-type-dropdown.tsx -> packages/insomnia/src/ui/hooks/use-request.ts", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx -> packages/insomnia/src/ui/context/app/runner-context.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx -> packages/insomnia/src/ui/hooks/use-runner-request-list.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx -> packages/insomnia/src/ui/hooks/use-runner-request-list.tsx -> packages/insomnia/src/ui/context/app/runner-context.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx -> packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx -> packages/insomnia/src/ui/components/panes/grpc-response-pane.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/editors/mock-response-headers-editor.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/mocks/mock-response-pane.tsx", + "packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.mock-route.$mockRouteId.tsx -> packages/insomnia/src/ui/components/mocks/mock-url-bar.tsx", + "packages/insomnia/src/sync/git/providers/github.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts", + "packages/insomnia/src/sync/git/providers/gitlab.ts -> packages/insomnia/src/sync/git/utils.ts -> packages/insomnia/src/sync/git/providers/index.ts", + "packages/insomnia/src/ui/components/modals/export-requests-modal.tsx -> packages/insomnia/src/ui/components/settings/import-export.tsx", + "packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx -> packages/insomnia/src/ui/components/settings/vault-key-panel.tsx", + "packages/insomnia/src/ui/components/tabs/request-delete-fallback.ts -> packages/insomnia/src/ui/components/tabs/tab.tsx -> packages/insomnia/src/ui/components/tabs/tab-list.tsx", + "packages/insomnia/src/ui/components/tabs/tab-list.tsx -> packages/insomnia/src/ui/components/tabs/tab.tsx", + "packages/insomnia/src/ui/components/tabs/tab-list.tsx -> packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx", + "packages/insomnia/src/ui/components/tabs/tab-list.tsx -> packages/insomnia/src/ui/hooks/use-insomnia-tab.ts -> packages/insomnia/src/ui/components/tabs/tab.tsx", + "packages/insomnia/src/ui/components/tabs/tab-list.tsx -> packages/insomnia/src/ui/hooks/use-insomnia-tab.ts -> packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx", + "packages/insomnia/src/ui/components/tabs/tab.tsx -> packages/insomnia/src/ui/context/app/insomnia-tab-context.tsx", + "packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx -> packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx", + "packages/insomnia/src/ui/components/viewers/response-multipart-viewer.tsx -> packages/insomnia/src/ui/components/viewers/response-viewer.tsx" + ], + "insomnia-analytics": [], + "insomnia-api": [], + "insomnia-data": [], + "insomnia-inso": [], + "insomnia-scripting-environment": [ + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/render.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/common/utils/vault.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/render.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/common/utils/vault.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/network/concurrency.renderer.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/collection.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts -> packages/insomnia/src/runtimes/index.ts -> packages/insomnia/src/runtimes/types.ts -> packages/insomnia-scripting-environment/src/objects/index.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/insomnia.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/insomnia.ts -> packages/insomnia-scripting-environment/src/objects/send-request.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts", + "packages/insomnia-scripting-environment/src/objects/index.ts -> packages/insomnia-scripting-environment/src/objects/response.ts -> packages/insomnia/src/network/network.ts" + ], + "insomnia-smoke-test": [], + "insomnia-testing": [], + "insomnia-vcs": [] +}