From d285404f50ee18aecae9aa5c4fa2c2c35f9cfdcb Mon Sep 17 00:00:00 2001 From: Bingbing Date: Tue, 1 Sep 2026 15:24:20 +0800 Subject: [PATCH 01/61] fix: use unique id for remote files in command search to avoid the list jammed (#10461) ## Background - Cloud Sync workspace uses local workspace id as its id, which may duplicate with the workspace id in the local db. (Clone a github repo -> convert to cloud sync -> clone it again) ## Changes - Use team project id + project id as the unique id --- packages/insomnia/src/routes/remote-files.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/insomnia/src/routes/remote-files.tsx b/packages/insomnia/src/routes/remote-files.tsx index 2b2acda035..c593bc7796 100644 --- a/packages/insomnia/src/routes/remote-files.tsx +++ b/packages/insomnia/src/routes/remote-files.tsx @@ -49,7 +49,9 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) { const files = remoteFiles.map(file => { const parentProject = projects.find(project => project.remoteId === file.teamProjectId); return { - id: file.id, + // Use a composite ID to ensure uniqueness. Otherwise, some dirty remote files might collide with each other or with local db. + id: `${file.teamProjectId}:${file.id}`, + workspaceId: file.id, url: href('/organization/:organizationId', { organizationId: file.organizationId, }), From 20b29355b381737a99ad51b41b362fda41456195 Mon Sep 17 00:00:00 2001 From: kwburns-kong Date: Tue, 1 Sep 2026 10:28:44 -0400 Subject: [PATCH 02/61] fix: set persist-credentials: false on non-pushing actions/checkout steps (#10447) actions/checkout steps across CI workflows leaves GITHUB_TOKEN persisted in git config after checkout. None of these steps perform a git push using that persisted credential, disable credential persistence on the actions. --- .github/workflows/release-build.yml | 2 ++ .github/workflows/release-recurring.yml | 2 ++ .github/workflows/release-start.yml | 1 + .github/workflows/sast.yml | 2 ++ .github/workflows/test-cli.yml | 2 ++ .github/workflows/test-e2e.yml | 2 ++ .github/workflows/test.yml | 1 + 7 files changed, 12 insertions(+) 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..86539c3a23 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1eafd5f4c..755f22b430 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,6 +109,7 @@ jobs: with: ref: ${{ github.event.pull_request.base.ref }} path: insomnia-base + persist-credentials: false - name: Setup Node (base branch tree) if: github.event_name == 'pull_request' && always() From baaaf6ab1a71d7a4fd4e4884f662055c80fee3e5 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Wed, 2 Sep 2026 15:24:13 +0800 Subject: [PATCH 03/61] chore: fix playwright port detect (#10462) ## Background - In the latest vite up gradation, the log is `VITE v7.3.1 ready in 1145 ms` instead of `VITE ready in` - Playwright SIGKILLs the process by default, which leaves a stale .vite-port file ## Changes - Update the wait pattern - Use gracefulShutdown - Always try to cleanup when receive any exit event --- packages/insomnia-smoke-test/playwright.config.ts | 5 ++++- packages/insomnia/vite.config.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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/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 {} + }); }, }; } From 4c114c449f4f799d24da912868f33342b2724d26 Mon Sep 17 00:00:00 2001 From: amy-kong-hq Date: Wed, 2 Sep 2026 16:17:14 +0800 Subject: [PATCH 04/61] feat: e2e re-run only fails (#10457) * debug: make case fail to test rerun last fail * fix: last-run json not found * fix: revert debug test steps --- .github/workflows/test-e2e.yml | 76 +++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 86539c3a23..c43f75d047 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -102,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 From 8ea672ab0181242090c6c034165150f38ef97bd8 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Wed, 2 Sep 2026 16:41:27 +0800 Subject: [PATCH 05/61] fix(ui): re-render pane templates on project environment switch (#10467) [INS-2791](https://konghq.atlassian.net/browse/INS-2791) ## Summary - Switching a request's project environment (activeGlobalEnvironment) left the request pane's template values stale, since its remount `key` only tracked the workspace environment (activeEnvironment). - Added the project environment id/modified timestamp to the URL bar's remount key so it also refreshes on project environment switch. --- packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx | 4 ++-- packages/insomnia/src/ui/components/panes/request-pane.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx index 5203caf096..198638d32e 100644 --- a/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx +++ b/packages/insomnia/src/ui/components/mcp/mcp-request-pane.tsx @@ -102,7 +102,7 @@ export const McpRequestPane: FC = ({ 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/panes/request-pane.tsx b/packages/insomnia/src/ui/components/panes/request-pane.tsx index 4ea7d6063c..9b89a1d3f7 100644 --- a/packages/insomnia/src/ui/components/panes/request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/request-pane.tsx @@ -77,9 +77,9 @@ export const RequestPane: FC = ({ environmentId, settings, onPaste }) => }; const gitVersion = useGitVCSVersion(); - const { activeEnvironment, vcsVersion } = useWorkspaceLoaderData()!; + const { activeEnvironment, activeGlobalEnvironment, vcsVersion } = useWorkspaceLoaderData()!; // Force re-render when we switch requests, the environment gets modified, or the (Git|Sync)VCS version changes - const uniqueKey = `${activeEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}::${activeRequestMeta?.activeResponseId}`; + const uniqueKey = `${activeEnvironment?._id}::${activeEnvironment?.modified}::${activeGlobalEnvironment?._id}::${activeGlobalEnvironment?.modified}::${requestId}::${gitVersion}::${vcsVersion}::${activeRequestMeta?.activeResponseId}`; if (!activeRequest) { return ; From 3e97ba7bfe82cd9e0a7c1546294698d2565a33bd Mon Sep 17 00:00:00 2001 From: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:18:36 +0800 Subject: [PATCH 06/61] One VCS instance per workspace [INS-2026] (#10458) * 1 * One VCS instance per workspace * Remove cloud sync doc. --- .../tests/smoke/cloud-sync.test.ts | 46 +++ .../src/__tests__/backend-projects.test.ts | 96 +++++ .../insomnia-vcs/src/__tests__/vcs.test.ts | 94 +---- packages/insomnia-vcs/src/backend-projects.ts | 215 ++++++++++ packages/insomnia-vcs/src/index.ts | 4 + packages/insomnia-vcs/src/session.ts | 60 +++ .../insomnia-vcs/src/store/current-store.ts | 17 + packages/insomnia-vcs/src/vcs.ts | 376 +++--------------- packages/insomnia/src/entry.preload.ts | 67 ++-- .../__tests__/sync-initialization.test.ts | 16 +- .../src/main/cloud-sync/initialization.ts | 6 +- packages/insomnia/src/main/cloud-sync/ipc.ts | 101 +++-- packages/insomnia/src/main/cloud-sync/vcs.ts | 101 +++-- packages/insomnia/src/main/ipc/electron.ts | 1 + ...kspaceId.insomnia-sync.branch.checkout.tsx | 2 +- ...orkspaceId.insomnia-sync.branch.create.tsx | 4 +- ...orkspaceId.insomnia-sync.branch.delete.tsx | 4 +- ...workspaceId.insomnia-sync.branch.merge.tsx | 2 +- ...kspaceId.insomnia-sync.create-snapshot.tsx | 4 +- ...space.$workspaceId.insomnia-sync.fetch.tsx | 10 +- ...kspace.$workspaceId.insomnia-sync.pull.tsx | 2 +- ...kspace.$workspaceId.insomnia-sync.push.tsx | 2 +- ...ace.$workspaceId.insomnia-sync.restore.tsx | 2 +- ...ce.$workspaceId.insomnia-sync.rollback.tsx | 2 +- ...space.$workspaceId.insomnia-sync.stage.tsx | 4 +- ...e.$workspaceId.insomnia-sync.sync-data.tsx | 20 +- ...ace.$workspaceId.insomnia-sync.unstage.tsx | 4 +- ...ject.$projectId.workspace.$workspaceId.tsx | 11 +- ...Id.project.$projectId.workspace.delete.tsx | 4 +- .../app/insomnia-event-stream-context.tsx | 2 +- .../insomnia/src/ui/organization-utils.ts | 8 +- packages/insomnia/src/ui/sync-utils.ts | 19 + 32 files changed, 742 insertions(+), 564 deletions(-) create mode 100644 packages/insomnia-vcs/src/__tests__/backend-projects.test.ts create mode 100644 packages/insomnia-vcs/src/backend-projects.ts create mode 100644 packages/insomnia-vcs/src/session.ts create mode 100644 packages/insomnia-vcs/src/store/current-store.ts diff --git a/packages/insomnia-smoke-test/tests/smoke/cloud-sync.test.ts b/packages/insomnia-smoke-test/tests/smoke/cloud-sync.test.ts index 0fed8cabd7..046f39feb5 100644 --- a/packages/insomnia-smoke-test/tests/smoke/cloud-sync.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/cloud-sync.test.ts @@ -180,4 +180,50 @@ test.describe('Cloud Sync', () => { await expect.soft(insomnia.navigationSidebar.unsyncedWorkspaceRow('My Collection R1')).toBeHidden(); await expect.soft(insomnia.navigationSidebar.workspaceRow('My Collection R1')).toBeHidden(); }); + + // Regression test for the main-process VCS singleton mutable-state bug: a single `_backendProject` + // field shared across every workspace meant that activating one workspace's backend project while + // another workspace's activation was still in flight could make the second call "win" for both, + // so the loser ended up reading/writing the wrong workspace's local sync data. Each workspace now + // gets its own VCS instance, so concurrently activating two different workspaces must never let + // one clobber the other's active backend project. Uses synthetic workspace ids (not part of the + // mock server's fixtures) since this only exercises local VCS state, no network calls involved. + test('keeps concurrently-activated workspaces on their own backend project', async ({ page }) => { + const workspaceA = { id: 'wrk_concurrency_test_a', name: 'Concurrency Test A' }; + const workspaceB = { id: 'wrk_concurrency_test_b', name: 'Concurrency Test B' }; + + const result = await page.evaluate( + async ({ a, b }) => { + const sync = (window as any).main.sync; + // Fire both workspaces' activation concurrently, the way two open workspaces being + // synced around the same time would on the main process side. + await Promise.all([ + sync.switchAndCreateBackendProjectIfNotExist(a.id, a.id, a.name), + sync.switchAndCreateBackendProjectIfNotExist(b.id, b.id, b.name), + ]); + + const [activeA, activeB] = await Promise.all([ + sync.getActiveBackendProject(a.id), + sync.getActiveBackendProject(b.id), + ]); + + return { activeA, activeB }; + }, + { a: workspaceA, b: workspaceB }, + ); + + expect.soft(result.activeA?.rootDocumentId).toBe(workspaceA.id); + expect.soft(result.activeB?.rootDocumentId).toBe(workspaceB.id); + expect.soft(result.activeA?.id).not.toBe(result.activeB?.id); + + // Clean up the local backend projects created for this test's synthetic workspace ids. + await page.evaluate( + async ({ a, b }) => { + const sync = (window as any).main.sync; + await sync.removeBackendProjectsForRoot(a.id); + await sync.removeBackendProjectsForRoot(b.id); + }, + { a: workspaceA, b: workspaceB }, + ); + }); }); diff --git a/packages/insomnia-vcs/src/__tests__/backend-projects.test.ts b/packages/insomnia-vcs/src/__tests__/backend-projects.test.ts new file mode 100644 index 0000000000..db94acbfc4 --- /dev/null +++ b/packages/insomnia-vcs/src/__tests__/backend-projects.test.ts @@ -0,0 +1,96 @@ +import { createBuilder } from '@develohpanda/fluent-builder'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { projectSchema } from '../__schemas__/type-schemas'; +import { getBackendProjectById, hasBackendProjectForRootDocument, storeBackendProject } from '../backend-projects'; +import { configureStore } from '../store/current-store'; +import MemoryDriver from '../store/drivers/memory-driver'; +import type { BackendProject } from '../types'; + +const projectBuilder = createBuilder(projectSchema); + +describe('hasBackendProjectForRootDocument', () => { + let driver: MemoryDriver; + let backendProject: BackendProject; + + beforeEach(async () => { + backendProject = projectBuilder.reset().build(); + + driver = new MemoryDriver(); + configureStore(driver); + + driver.setItem('/projects/', Buffer.from(JSON.stringify([backendProject]))); + driver.setItem(`/projects/${backendProject.id}/`, Buffer.from('')); + driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from(JSON.stringify(backendProject))); + }); + + it('should return true if has project', async () => { + const hasProject = await hasBackendProjectForRootDocument(backendProject.rootDocumentId); + + expect(hasProject).toBe(true); + }); + + it('should return false if has no project', async () => { + const hasProject = await hasBackendProjectForRootDocument('some other id'); + + expect(hasProject).toBe(false); + }); +}); + +describe('storeBackendProject()', () => { + let driver: MemoryDriver; + let backendProject: BackendProject; + + beforeEach(() => { + driver = new MemoryDriver(); + configureStore(driver); + backendProject = projectBuilder.reset().build(); + }); + + it('writes backend project metadata when missing', async () => { + const setItemSpy = vi.spyOn(driver, 'setItem'); + + await storeBackendProject(backendProject); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer)); + expect(await getBackendProjectById(backendProject.id)).toEqual(backendProject); + }); + + it('skips identical backend project metadata writes', async () => { + const setItemSpy = vi.spyOn(driver, 'setItem'); + + await storeBackendProject(backendProject); + setItemSpy.mockClear(); + + await storeBackendProject(backendProject); + + expect(setItemSpy).not.toHaveBeenCalled(); + }); + + it('writes changed backend project metadata', async () => { + const setItemSpy = vi.spyOn(driver, 'setItem'); + const updatedProject = { + ...backendProject, + name: `${backendProject.name} Updated`, + }; + + await storeBackendProject(backendProject); + setItemSpy.mockClear(); + + await storeBackendProject(updatedProject); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + expect(await getBackendProjectById(backendProject.id)).toEqual(updatedProject); + }); + + it('writes backend project metadata when existing meta.json is corrupted', async () => { + await driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from('{', 'utf8')); + const setItemSpy = vi.spyOn(driver, 'setItem'); + + await storeBackendProject(backendProject); + + expect(setItemSpy).toHaveBeenCalledTimes(1); + expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer)); + }); +}); diff --git a/packages/insomnia-vcs/src/__tests__/vcs.test.ts b/packages/insomnia-vcs/src/__tests__/vcs.test.ts index e6156bc35d..e2abb29648 100644 --- a/packages/insomnia-vcs/src/__tests__/vcs.test.ts +++ b/packages/insomnia-vcs/src/__tests__/vcs.test.ts @@ -1,26 +1,25 @@ // @ts-nocheck import { createBuilder } from '@develohpanda/fluent-builder'; -import type { BackendProject } from 'insomnia-data'; import { models } from 'insomnia-data'; import { deterministicStringify } from 'insomnia-data/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { baseModelSchema, workspaceModelSchema } from '../__schemas__/model-schemas'; -import { projectSchema } from '../__schemas__/type-schemas'; +import { configureStore } from '../store/current-store'; import MemoryDriver from '../store/drivers/memory-driver'; import { hashDocument } from '../util'; import { chunkArray, VCS } from '../vcs'; const baseModelBuilder = createBuilder(baseModelSchema); const workspaceModelBuilder = createBuilder(workspaceModelSchema); -const projectBuilder = createBuilder(projectSchema); function newDoc(id) { return baseModelBuilder.reset()._id(id).build(); } async function vcs(branch) { - const v = new VCS({ driver: new MemoryDriver() }); + configureStore(new MemoryDriver()); + const v = new VCS({ workspaceId: 'workspace_1' }); await v.switchAndCreateBackendProjectIfNotExist('workspace_1', 'Test Workspace'); await v.checkout([], branch); return v; @@ -971,93 +970,6 @@ describe('VCS', () => { }); }); - describe('hasBackendProjectForRootDocument', () => { - let vcs: VCS; - let backendProject: BackendProject; - - beforeEach(async () => { - backendProject = projectBuilder.reset().build(); - - const driver = new MemoryDriver(); - vcs = new VCS({ driver }); - - driver.setItem('/projects/', Buffer.from(JSON.stringify([backendProject]))); - driver.setItem(`/projects/${backendProject.id}/`, Buffer.from('')); - driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from(JSON.stringify(backendProject))); - }); - - it('should return true if has project', async () => { - const hasProject = await vcs.hasBackendProjectForRootDocument(backendProject.rootDocumentId); - - expect(hasProject).toBe(true); - }); - - it('should return false if has no project', async () => { - const hasProject = await vcs.hasBackendProjectForRootDocument('some other id'); - - expect(hasProject).toBe(false); - }); - }); - - describe('_storeBackendProject()', () => { - let driver: MemoryDriver; - let vcs: VCS; - let backendProject: BackendProject; - - beforeEach(() => { - driver = new MemoryDriver(); - vcs = new VCS({ driver }); - backendProject = projectBuilder.reset().build(); - }); - - it('writes backend project metadata when missing', async () => { - const setItemSpy = vi.spyOn(driver, 'setItem'); - - await vcs._storeBackendProject(backendProject); - - expect(setItemSpy).toHaveBeenCalledTimes(1); - expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer)); - expect(await vcs._getBackendProjectById(backendProject.id)).toEqual(backendProject); - }); - - it('skips identical backend project metadata writes', async () => { - const setItemSpy = vi.spyOn(driver, 'setItem'); - - await vcs._storeBackendProject(backendProject); - setItemSpy.mockClear(); - - await vcs._storeBackendProject(backendProject); - - expect(setItemSpy).not.toHaveBeenCalled(); - }); - - it('writes changed backend project metadata', async () => { - const setItemSpy = vi.spyOn(driver, 'setItem'); - const updatedProject = { - ...backendProject, - name: `${backendProject.name} Updated`, - }; - - await vcs._storeBackendProject(backendProject); - setItemSpy.mockClear(); - - await vcs._storeBackendProject(updatedProject); - - expect(setItemSpy).toHaveBeenCalledTimes(1); - expect(await vcs._getBackendProjectById(backendProject.id)).toEqual(updatedProject); - }); - - it('writes backend project metadata when existing meta.json is corrupted', async () => { - await driver.setItem(`/projects/${backendProject.id}/meta.json`, Buffer.from('{', 'utf8')); - const setItemSpy = vi.spyOn(driver, 'setItem'); - - await vcs._storeBackendProject(backendProject); - - expect(setItemSpy).toHaveBeenCalledTimes(1); - expect(setItemSpy).toHaveBeenCalledWith(`/projects/${backendProject.id}/meta.json`, expect.any(Buffer)); - }); - }); - it('validate branch names', async () => { expect(VCS.validateBranchName('branchA')).toEqual(''); expect(VCS.validateBranchName('feat/branch-A')).toEqual(''); diff --git a/packages/insomnia-vcs/src/backend-projects.ts b/packages/insomnia-vcs/src/backend-projects.ts new file mode 100644 index 0000000000..23717707b2 --- /dev/null +++ b/packages/insomnia-vcs/src/backend-projects.ts @@ -0,0 +1,215 @@ +import path from 'node:path'; + +import { deterministicStringify, generateId } from 'insomnia-data/common'; + +import { runGraphQL } from './session'; +import { getStore } from './store/current-store'; +import type { BackendProject, BackendProjectWithTeams, BackendProjectWithTeamsAndTeamProjectId, Branch } from './types'; + +// These helpers never read a VCS instance's active `_backendProject` — they operate on an +// explicit `rootDocumentId`/`projectId`/`project`, or scan every local project. Keeping them +// outside the `VCS` class means they can be called (and the shared store touched) without ever +// activating a workspace-scoped VCS instance. + +export async function getBackendProjectById(projectId: string): Promise { + return getStore().getItem(`/projects/${projectId}/meta.json`); +} + +export async function storeBackendProject(project: BackendProject) { + let existingProject: BackendProject | null = null; + try { + existingProject = await getBackendProjectById(project.id); + } catch (err) { + console.warn('[sync] Failed to get existing backend project %s', project.id, err); + } + + if (existingProject && deterministicStringify(existingProject) === deterministicStringify(project)) { + console.debug('[sync] Skipping store due to no backend changes'); + return; + } + + return getStore().setItem(`/projects/${project.id}/meta.json`, project); +} + +export async function removeProject(project: BackendProject) { + console.log(`[sync] Remove local project ${project.id}`); + return getStore().removeItem(`/projects/${project.id}/meta.json`); +} + +export async function localBackendProjects(): Promise { + const backendProjects: BackendProject[] = []; + const keys = await getStore().keys('/projects/', false); + + for (const key of keys) { + const id = path.basename(key); + const p = await getBackendProjectById(id); + + if (p === null) { + // Folder exists but project meta file is gone + continue; + } + + backendProjects.push(p); + } + + return backendProjects; +} + +export async function getBranches(backendProjectId: string): Promise { + const branches: Branch[] = []; + + for (const p of await getStore().keys(`/projects/${backendProjectId}/branches/`)) { + const b = await getStore().getItem(p); + + if (b === null) { + // Should never happen + throw new Error(`Failed to get branch path=${p}`); + } + + branches.push(b); + } + + return branches; +} + +export async function getBackendProjectByRootDocument(rootDocumentId: string): Promise { + if (!rootDocumentId) { + throw new Error('No root document ID supplied for backend project'); + } + + // First, try finding the project + const backendProjects = await localBackendProjects(); + let matchedBackendProjects = backendProjects.filter(p => p.rootDocumentId === rootDocumentId); + + // If there is more than one project for root, try pruning unused ones by branch activity + if (matchedBackendProjects.length > 1) { + for (const p of matchedBackendProjects) { + const branches = await getBranches(p.id); + + if (!branches.find(b => b.snapshots.length > 0)) { + await removeProject(p); + matchedBackendProjects = matchedBackendProjects.filter(({ id }) => id !== p.id); + console.log(`[sync] Remove inactive project for root ${rootDocumentId}`); + } + } + } + + // If there are still too many, error out + if (matchedBackendProjects.length > 1) { + console.log('[sync] Multiple backend projects matched for root', { + backendProjects, + matchedBackendProjects, + rootDocumentId, + }); + throw new Error('More than one backend project matched query'); + } + + return matchedBackendProjects[0] || null; +} + +export async function getOrCreateBackendProjectByRootDocument( + rootDocumentId: string, + name: string, +): Promise { + let project = await getBackendProjectByRootDocument(rootDocumentId); + + // If we still don't have a project, create one + if (!project) { + const id = generateId('prj'); + project = { + id, + name, + rootDocumentId, + }; + await storeBackendProject(project); + console.log(`[sync] Created backend project ${project.id}`); + } + + return project; +} + +export async function hasBackendProjectForRootDocument(rootDocumentId: string): Promise { + return Boolean(await getBackendProjectByRootDocument(rootDocumentId)); +} + +export async function removeBackendProjectsForRoot(rootDocumentId: string) { + const all = await localBackendProjects(); + const toRemove = all.filter(p => p.rootDocumentId === rootDocumentId); + + for (const backendProject of toRemove) { + await removeProject(backendProject); + } +} + +export async function remoteBackendProjects({ teamId, teamProjectId }: { teamId: string; teamProjectId: string }) { + console.log( + `[remoteBackendProjects] Fetching remote workspaces for teamId=${teamId} teamProjectId=${teamProjectId}`, + ); + const { projects } = await runGraphQL<{ projects: BackendProjectWithTeams[] }>( + ` + query ($teamId: ID, $teamProjectId: ID) { + projects(teamId: $teamId, teamProjectId: $teamProjectId) { + id + name + rootDocumentId + teams { + id + name + } + } + } + `, + { + teamId, + teamProjectId, + }, + 'projects', + ); + + console.log(`[remoteBackendProjects] Fetched ${projects.length} remote workspaces`); + + return projects.map(backend => ({ + id: backend.id, + name: backend.name, + rootDocumentId: backend.rootDocumentId, + // A backend project is guaranteed to exist on exactly one team + team: backend.teams[0], + })); +} + +export async function remoteBackendProjectsOfTeam({ teamId }: { teamId: string }) { + console.log(`[remoteBackendProjectsOfTeam] Fetching remote workspaces for teamId=${teamId}`); + + const { projects } = await runGraphQL<{ projects: BackendProjectWithTeamsAndTeamProjectId[] }>( + ` + query ($teamId: ID, $allProjects: Boolean) { + projects(teamId: $teamId, allProjects: $allProjects) { + id + name + rootDocumentId + teamProjectId + teams { + id + name + } + } + } + `, + { + teamId, + allProjects: true, + }, + 'projects', + ); + + console.log(`[remoteBackendProjectsOfTeam] Fetched ${projects.length} remote workspaces`); + + return projects.map(backend => ({ + id: backend.id, + name: backend.name, + rootDocumentId: backend.rootDocumentId, + teamProjectId: backend.teamProjectId, + // A backend project is guaranteed to exist on exactly one team + team: backend.teams[0], + })); +} diff --git a/packages/insomnia-vcs/src/index.ts b/packages/insomnia-vcs/src/index.ts index 879788369e..e849087f40 100644 --- a/packages/insomnia-vcs/src/index.ts +++ b/packages/insomnia-vcs/src/index.ts @@ -1,7 +1,11 @@ +export * from './backend-projects'; + export { default as FileSystemDriver } from './store/drivers/file-system-driver'; export { default as MemoryDriver } from './store/drivers/memory-driver'; +export { configureStore } from './store/current-store'; + export { VCS, type VcsOptions } from './vcs'; export * from './crypt'; diff --git a/packages/insomnia-vcs/src/session.ts b/packages/insomnia-vcs/src/session.ts new file mode 100644 index 0000000000..a93adea245 --- /dev/null +++ b/packages/insomnia-vcs/src/session.ts @@ -0,0 +1,60 @@ +import { runVcsGraphQL } from 'insomnia-api'; +import type { UserSession } from 'insomnia-data'; +import { services } from 'insomnia-data'; + +import * as crypt from './crypt'; + +// TODO: This is a temporary solution to get the private key from the session. +export async function getPrivateKey(sessionData?: UserSession): Promise { + if (!sessionData) { + throw new Error("Can't get private key: session is blank."); + } + + const { symmetricKey, encPrivateKey } = sessionData; + + if (!symmetricKey || !encPrivateKey) { + throw new Error("Can't get private key: session is missing keys."); + } + + const privateKeyStr = await crypt.decryptAES(symmetricKey, encPrivateKey); + return JSON.parse(privateKeyStr) as JsonWebKey; +} + +export async function assertSession() { + const sessionData = await services.userSession.get(); + const { accountId, id, publicKey, symmetricKey } = sessionData; + const privateKey = await getPrivateKey(sessionData); + + if (!id) { + throw new Error('Not logged in'); + } + + return { + accountId, + sessionId: id, + privateKey, + publicKey, + symmetricKey, + }; +} + +export async function runGraphQL(query: string, variables: Record, name: string): Promise { + const { sessionId } = await assertSession(); + const { data, errors } = await runVcsGraphQL({ + query, + variables, + sessionId, + name, + }); + + if (errors && errors.length) { + console.log(`[sync] Failed to query ${name}`, errors); + throw new Error(`Failed to query ${name}: ${errors[0].message}`); + } + + if (data == null) { + throw new Error(`Failed to query ${name}: no data returned`); + } + + return data; +} diff --git a/packages/insomnia-vcs/src/store/current-store.ts b/packages/insomnia-vcs/src/store/current-store.ts new file mode 100644 index 0000000000..ce33bca2e1 --- /dev/null +++ b/packages/insomnia-vcs/src/store/current-store.ts @@ -0,0 +1,17 @@ +import type { BaseDriver } from './drivers/base'; +import compress from './hooks/compress'; +import Store from './index'; + +let store: Store | null = null; + +export function configureStore(driver: BaseDriver): void { + store = new Store(driver, [compress]); +} + +export function getStore(): Store { + if (!store) { + throw new Error('VCS store has not been configured. Call configureStore() first.'); + } + + return store; +} diff --git a/packages/insomnia-vcs/src/vcs.ts b/packages/insomnia-vcs/src/vcs.ts index 26acd57412..9d883ee6a3 100644 --- a/packages/insomnia-vcs/src/vcs.ts +++ b/packages/insomnia-vcs/src/vcs.ts @@ -2,24 +2,21 @@ // - [ ] Rename things that run a fetch to fetchSomething... // - [ ] Make sure that pull handles updating the parentId to the current project._id import crypto from 'node:crypto'; -import path from 'node:path'; import clone from 'clone'; -import { runVcsGraphQL } from 'insomnia-api'; -import type { BaseModel, UserSession } from 'insomnia-data'; -import type { Operation } from 'insomnia-data'; -import { services } from 'insomnia-data'; -import { deterministicStringify, generateId } from 'insomnia-data/common'; +import type { BaseModel, Operation } from 'insomnia-data'; +import { + getBackendProjectById, + getBranches, + getOrCreateBackendProjectByRootDocument, + storeBackendProject, +} from './backend-projects'; import * as crypt from './crypt'; -import Store from './store'; -import type { BaseDriver } from './store/drivers/base'; -import FileSystemDriver from './store/drivers/file-system-driver'; -import compress from './store/hooks/compress'; +import { assertSession, runGraphQL } from './session'; +import { getStore } from './store/current-store'; import type { BackendProject, - BackendProjectWithTeams, - BackendProjectWithTeamsAndTeamProjectId, Branch, DocumentKey, Head, @@ -85,19 +82,11 @@ const generateAES256KeyInNode = async (): Promise => { }; }; -export type VcsOptions = { +export interface VcsOptions { + workspaceId: string; conflictHandler?: ConflictHandler; testMode?: boolean; -} & ( - | { - driver?: undefined; - dataPath: string; - } - | { - driver: BaseDriver; - dataPath?: undefined; - } -); +} // Stage/Unstage // Staged items are about to be committed @@ -114,17 +103,15 @@ export class VCS { return ''; } - _store: Store; - _driver: BaseDriver; + readonly workspaceId: string; // stored by key `/projects/${project.id}/meta.json` _backendProject: BackendProject | null; _conflictHandler?: ConflictHandler | null; _testMode: boolean | undefined; _stageByBackendProjectId: Record = {}; - constructor({ driver, dataPath, conflictHandler, testMode }: VcsOptions) { - this._driver = driver || FileSystemDriver.create(dataPath!); - this._store = new Store(this._driver, [compress]); + constructor({ workspaceId, conflictHandler, testMode }: VcsOptions) { + this.workspaceId = workspaceId; this._testMode = testMode; this._conflictHandler = conflictHandler; // To be set later @@ -135,29 +122,16 @@ export class VCS { this._backendProject = backendProject; console.debug(`[sync] Activated project ${backendProject.id}`); // Store it because it might not be yet - await this._storeBackendProject(backendProject); + await storeBackendProject(backendProject); } hasBackendProject() { return this._backendProject !== null; } - async hasBackendProjectForRootDocument(rootDocumentId: string) { - return Boolean(await this._getBackendProjectByRootDocument(rootDocumentId)); - } - - async removeBackendProjectsForRoot(rootDocumentId: string) { - const all = await this._allBackendProjects(); - const toRemove = all.filter(p => p.rootDocumentId === rootDocumentId); - - for (const backendProject of toRemove) { - await this._removeProject(backendProject); - } - } - async archiveProject() { const backendProjectId = this._backendProjectId(); - await this._runGraphQL( + await runGraphQL( ` mutation ($id: ID!) { projectArchive(id: $id) @@ -169,7 +143,7 @@ export class VCS { 'projectArchive', ); console.log(`[sync] Archived remote project ${backendProjectId}`); - await this._store.removeItem(`/projects/${backendProjectId}/meta.json`); + await getStore().removeItem(`/projects/${backendProjectId}/meta.json`); this._backendProject = null; } @@ -178,87 +152,10 @@ export class VCS { } async switchAndCreateBackendProjectIfNotExist(rootDocumentId: string, name: string) { - const project = await this._getOrCreateBackendProjectByRootDocument(rootDocumentId, name); + const project = await getOrCreateBackendProjectByRootDocument(rootDocumentId, name); await this.setBackendProject(project); } - async localBackendProjects() { - return this._allBackendProjects(); - } - - async remoteBackendProjects({ teamId, teamProjectId }: { teamId: string; teamProjectId: string }) { - console.log( - `[remoteBackendProjects] Fetching remote workspaces for teamId=${teamId} teamProjectId=${teamProjectId}`, - ); - const { projects } = await this._runGraphQL<{ projects: BackendProjectWithTeams[] }>( - ` - query ($teamId: ID, $teamProjectId: ID) { - projects(teamId: $teamId, teamProjectId: $teamProjectId) { - id - name - rootDocumentId - teams { - id - name - } - } - } - `, - { - teamId, - teamProjectId, - }, - 'projects', - ); - - console.log(`[remoteBackendProjects] Fetched ${projects.length} remote workspaces`); - - return projects.map(backend => ({ - id: backend.id, - name: backend.name, - rootDocumentId: backend.rootDocumentId, - // A backend project is guaranteed to exist on exactly one team - team: backend.teams[0], - })); - } - - async remoteBackendProjectsOfTeam({ teamId }: { teamId: string }) { - console.log(`[remoteBackendProjectsOfTeam] Fetching remote workspaces for teamId=${teamId}`); - - const { projects } = await this._runGraphQL<{ projects: BackendProjectWithTeamsAndTeamProjectId[] }>( - ` - query ($teamId: ID, $allProjects: Boolean) { - projects(teamId: $teamId, allProjects: $allProjects) { - id - name - rootDocumentId - teamProjectId - teams { - id - name - } - } - } - `, - { - teamId, - allProjects: true, - }, - 'projects', - ); - - console.log(`[remoteBackendProjectsOfTeam] Fetched ${projects.length} remote workspaces`); - - return projects.map(backend => ({ - id: backend.id, - name: backend.name, - rootDocumentId: backend.rootDocumentId, - teamProjectId: backend.teamProjectId, - // A backend project is guaranteed to exist on exactly one team - team: backend.teams[0], - })); - } - async blobFromLastSnapshot(key: string) { const branch = await this._getCurrentBranch(); const snapshot = await this._getLatestSnapshot(branch.name); @@ -632,7 +529,7 @@ export class VCS { } async getRemoteBranchNames(): Promise { - const { branches } = await this._runGraphQL<{ branches: { name: string }[] | null }>( + const { branches } = await runGraphQL<{ branches: { name: string }[] | null }>( ` query ($projectId: ID!) { branches(project: $projectId) { @@ -649,7 +546,7 @@ export class VCS { } async getBranchNames(): Promise { - const branches = await this._getBranches(); + const branches = await getBranches(this._backendProjectId()); return branches.map(b => b.name); } @@ -731,7 +628,7 @@ export class VCS { remoteProject = await this._createRemoteProject({ ...localProject, teamId, teamProjectId }); } - await this._storeBackendProject(remoteProject); + await storeBackendProject(remoteProject); return remoteProject; } @@ -955,29 +852,8 @@ export class VCS { return snapshot; } - async _runGraphQL(query: string, variables: Record, name: string): Promise { - const { sessionId } = await this._assertSession(); - const { data, errors } = await runVcsGraphQL({ - query, - variables, - sessionId, - name, - }); - - if (errors && errors.length) { - console.log(`[sync] Failed to query ${name}`, errors); - throw new Error(`Failed to query ${name}: ${errors[0].message}`); - } - - if (data == null) { - throw new Error(`Failed to query ${name}: no data returned`); - } - - return data; - } - async _queryBlobsMissing(ids: string[]): Promise { - const { blobsMissing } = await this._runGraphQL<{ blobsMissing: { missing: string[] } }>( + const { blobsMissing } = await runGraphQL<{ blobsMissing: { missing: string[] } }>( ` query ($projectId: ID!, $ids: [ID!]!) { blobsMissing(project: $projectId, ids: $ids) { @@ -995,7 +871,7 @@ export class VCS { } async _queryRemoveBranch(branchName: string) { - await this._runGraphQL( + await runGraphQL( ` mutation ($projectId: ID!, $branch: String!) { branchRemove(project: $projectId, name: $branch) @@ -1009,7 +885,7 @@ export class VCS { } async _queryBranch(branchName: string): Promise { - const { branch } = await this._runGraphQL<{ branch: Branch | null }>( + const { branch } = await runGraphQL<{ branch: Branch | null }>( ` query ($projectId: ID!, $branch: String!) { branch(project: $projectId, name: $branch) { @@ -1032,7 +908,7 @@ export class VCS { let allSnapshots: Snapshot[] = []; for (const ids of chunkArray(allIds, 20)) { - const { snapshots } = await this._runGraphQL<{ snapshots: Snapshot[] }>( + const { snapshots } = await runGraphQL<{ snapshots: Snapshot[] }>( ` query ($ids: [ID!]!, $projectId: ID!) { snapshots(ids: $ids, project: $projectId) { @@ -1068,7 +944,7 @@ export class VCS { // push snapshots to the backend's current branch async _queryPushSnapshots(allSnapshots: Snapshot[]) { - const { accountId } = await this._assertSession(); + const { accountId } = await assertSession(); for (const snapshots of chunkArray(allSnapshots, 20)) { // This bit of logic fills in any missing author IDs from times where @@ -1080,7 +956,7 @@ export class VCS { } const branch = await this._getCurrentBranch(); - const { snapshotsCreate } = await this._runGraphQL<{ snapshotsCreate: Snapshot[] }>( + const { snapshotsCreate } = await runGraphQL<{ snapshotsCreate: Snapshot[] }>( ` mutation ($projectId: ID!, $snapshots: [SnapshotInput!]!, $branchName: String!) { snapshotsCreate(project: $projectId, snapshots: $snapshots, branch: $branchName) { @@ -1129,7 +1005,7 @@ export class VCS { const result: Record = {}; for (const ids of chunkArray(allIds, 50)) { - const { blobs } = await this._runGraphQL<{ blobs: { id: string; content: string }[] }>( + const { blobs } = await runGraphQL<{ blobs: { id: string; content: string }[] }>( ` query ($ids: [ID!]!, $projectId: ID!) { blobs(ids: $ids, project: $projectId) { @@ -1167,7 +1043,7 @@ export class VCS { id: i.id, content: i.content, })); - const { blobsCreate } = await this._runGraphQL<{ blobsCreate: { count: number } }>( + const { blobsCreate } = await runGraphQL<{ blobsCreate: { count: number } }>( ` mutation ($projectId: ID!, $blobs: [BlobInput!]!) { blobsCreate(project: $projectId, blobs: $blobs) { @@ -1221,7 +1097,7 @@ export class VCS { } async _queryBackendProjectKey() { - const { projectKey } = await this._runGraphQL<{ projectKey: { encSymmetricKey: string } }>( + const { projectKey } = await runGraphQL<{ projectKey: { encSymmetricKey: string } }>( ` query ($projectId: ID!) { projectKey(projectId: $projectId) { @@ -1238,7 +1114,7 @@ export class VCS { } async _queryProject(): Promise { - const { project } = await this._runGraphQL<{ project: BackendProject | null }>( + const { project } = await runGraphQL<{ project: BackendProject | null }>( ` query ($id: ID!) { project(id: $id) { @@ -1267,7 +1143,7 @@ export class VCS { teamId, }); - const { teamMemberKeys } = await this._runGraphQL<{ + const { teamMemberKeys } = await runGraphQL<{ teamMemberKeys: { memberKeys: { accountId: string; @@ -1328,7 +1204,7 @@ export class VCS { // Use the local project ID if available; fall back to the workspace ID as a stable identifier. const localProjectId = this._backendProject ? this._backendProject.id : workspaceId; - const { projectCreate } = await this._runGraphQL<{ projectCreate: BackendProject }>( + const { projectCreate } = await runGraphQL<{ projectCreate: BackendProject }>( ` mutation ( $name: String!, @@ -1369,15 +1245,11 @@ export class VCS { async _getBackendProject(): Promise { const projectId = this._backendProject ? this._backendProject.id : 'n/a'; - return this._getBackendProjectById(projectId); - } - - async _getBackendProjectById(projectId: string): Promise { - return this._store.getItem(`/projects/${projectId}/meta.json`); + return getBackendProjectById(projectId); } async _getBackendProjectSymmetricKey() { - const { privateKey, symmetricKey } = await this._assertSession(); + const { privateKey, symmetricKey } = await assertSession(); if (this._testMode) { // use the session symmetric key in playwright tests @@ -1389,22 +1261,6 @@ export class VCS { return JSON.parse(symmetricKeyStr); } - // TODO: This is a temporary solution to get the private key from the session. - async _getPrivateKey(sessionData?: UserSession): Promise { - if (!sessionData) { - throw new Error("Can't get private key: session is blank."); - } - - const { symmetricKey, encPrivateKey } = sessionData; - - if (!symmetricKey || !encPrivateKey) { - throw new Error("Can't get private key: session is missing keys."); - } - - const privateKeyStr = await crypt.decryptAES(symmetricKey, encPrivateKey); - return JSON.parse(privateKeyStr) as JsonWebKey; - } - async _assertBackendProject() { const project = await this._getBackendProject(); @@ -1415,24 +1271,8 @@ export class VCS { return project; } - async _storeBackendProject(project: BackendProject) { - let existingProject: BackendProject | null = null; - try { - existingProject = await this._getBackendProjectById(project.id); - } catch (err) { - console.warn('[sync] Failed to get existing backend project %s', project.id, err); - } - - if (existingProject && deterministicStringify(existingProject) === deterministicStringify(project)) { - console.debug('[sync] Skipping store due to no backend changes'); - return; - } - - return this._store.setItem(`/projects/${project.id}/meta.json`, project); - } - async _getHead(): Promise { - const head = await this._store.getItem(`/projects/${this._backendProjectId()}/head.json`); + const head = await getStore().getItem(`/projects/${this._backendProjectId()}/head.json`); if (head === null) { await this._storeHead({ branch: 'master' }); @@ -1447,24 +1287,6 @@ export class VCS { return this._getOrCreateBranch(head.branch); } - async _assertSession() { - const sessionData = await services.userSession.get(); - const { accountId, id, publicKey, symmetricKey } = sessionData; - const privateKey = await this._getPrivateKey(sessionData); - - if (!id) { - throw new Error('Not logged in'); - } - - return { - accountId, - sessionId: id, - privateKey, - publicKey, - symmetricKey, - }; - } - async _assertBranch(branchName: string) { const branch = await this._getBranch(branchName); @@ -1484,26 +1306,7 @@ export class VCS { } async _getBranch(name: string): Promise { - return this._store.getItem(`/projects/${this._backendProjectId()}/branches/${encodeBranchName(name)}.json`); - } - - async _getBranches(backendProjectId?: string) { - const branches: Branch[] = []; - - const pId = backendProjectId || this._backendProjectId(); - - for (const p of await this._store.keys(`/projects/${pId}/branches/`)) { - const b = await this._store.getItem(p); - - if (b === null) { - // Should never happen - throw new Error(`Failed to get branch path=${p}`); - } - - branches.push(b); - } - - return branches; + return getStore().getItem(`/projects/${this._backendProjectId()}/branches/${encodeBranchName(name)}.json`); } async _getOrCreateBranch(name: string): Promise { @@ -1526,80 +1329,8 @@ export class VCS { return branch; } - async _getBackendProjectByRootDocument(rootDocumentId: string) { - if (!rootDocumentId) { - throw new Error('No root document ID supplied for backend project'); - } - - // First, try finding the project - const backendProjects = await this._allBackendProjects(); - let matchedBackendProjects = backendProjects.filter(p => p.rootDocumentId === rootDocumentId); - - // If there is more than one project for root, try pruning unused ones by branch activity - if (matchedBackendProjects.length > 1) { - for (const p of matchedBackendProjects) { - const branches = await this._getBranches(p.id); - - if (!branches.find(b => b.snapshots.length > 0)) { - await this._removeProject(p); - matchedBackendProjects = matchedBackendProjects.filter(({ id }) => id !== p.id); - console.log(`[sync] Remove inactive project for root ${rootDocumentId}`); - } - } - } - - // If there are still too many, error out - if (matchedBackendProjects.length > 1) { - console.log('[sync] Multiple backend projects matched for root', { - backendProjects, - matchedBackendProjects, - rootDocumentId, - }); - throw new Error('More than one backend project matched query'); - } - - return matchedBackendProjects[0] || null; - } - - async _getOrCreateBackendProjectByRootDocument(rootDocumentId: string, name: string) { - let project: BackendProject | null = await this._getBackendProjectByRootDocument(rootDocumentId); - - // If we still don't have a project, create one - if (!project) { - const id = generateId('prj'); - project = { - id, - name, - rootDocumentId, - }; - await this._storeBackendProject(project); - console.log(`[sync] Created backend project ${project.id}`); - } - - return project; - } - - async _allBackendProjects() { - const backendProjects: BackendProject[] = []; - const keys = await this._store.keys('/projects/', false); - - for (const key of keys) { - const id = path.basename(key); - const p: BackendProject | null = await this._getBackendProjectById(id); - - if (p === null) { - // Folder exists but project meta file is gone - continue; - } - - backendProjects.push(p); - } - - return backendProjects; - } - async _assertSnapshot(id: string) { - const snapshot: Snapshot | null = await this._store.getItem( + const snapshot: Snapshot | null = await getStore().getItem( `/projects/${this._backendProjectId()}/snapshots/${id}.json`, ); @@ -1615,7 +1346,7 @@ export class VCS { } async _getSnapshot(id: string) { - const snapshot: Snapshot | null = await this._store.getItem( + const snapshot: Snapshot | null = await getStore().getItem( `/projects/${this._backendProjectId()}/snapshots/${id}.json`, ); @@ -1634,7 +1365,7 @@ export class VCS { } async _storeSnapshot(snapshot: Snapshot) { - return this._store.setItem(`/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`, snapshot); + return getStore().setItem(`/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`, snapshot); } async _storeSnapshots(snapshots: Snapshot[]) { @@ -1642,7 +1373,7 @@ export class VCS { for (const snapshot of snapshots) { const p = `/projects/${this._backendProjectId()}/snapshots/${snapshot.id}.json`; - const promise = this._store.setItem(p, snapshot); + const promise = getStore().setItem(p, snapshot); // @ts-expect-error -- TSCONVERSION appears to be a genuine error promises.push(promise); } @@ -1659,29 +1390,24 @@ export class VCS { branch.modified = new Date(); // toLowerCase may introduce issues under case sensitive filesystems - return this._store.setItem( + return getStore().setItem( `/projects/${this._backendProjectId()}/branches/${encodeBranchName(branch.name).toLowerCase()}.json`, branch, ); } async _removeBranch(branch: Branch) { - return this._store.removeItem( + return getStore().removeItem( `/projects/${this._backendProjectId()}/branches/${encodeBranchName(branch.name)}.json`, ); } - async _removeProject(project: BackendProject) { - console.log(`[sync] Remove local project ${project.id}`); - return this._store.removeItem(`/projects/${project.id}/meta.json`); - } - async _storeHead(head: Head) { - await this._store.setItem(`/projects/${this._backendProjectId()}/head.json`, head); + await getStore().setItem(`/projects/${this._backendProjectId()}/head.json`, head); } _getBlob(blobId: string) { - return this._store.getItem( + return getStore().getItem( `/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`, ) as Promise; } @@ -1697,7 +1423,7 @@ export class VCS { } async _storeBlob(blobId: string, content: Record | null) { - return this._store.setItem( + return getStore().setItem( `/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`, content, ); @@ -1709,7 +1435,7 @@ export class VCS { for (const blobId of Object.keys(blobsToStore)) { const buff = Buffer.from(blobsToStore[blobId], 'utf8'); promises.push( - this._store.setItem( + getStore().setItem( `/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`, buff, ), @@ -1724,7 +1450,7 @@ export class VCS { for (const blobId of Object.keys(decryptedBlobs)) { promises.push( - this._store.setItemRaw( + getStore().setItemRaw( `/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`, decryptedBlobs[blobId], ), @@ -1735,13 +1461,13 @@ export class VCS { } async _getBlobRaw(blobId: string) { - return this._store.getItemRaw( + return getStore().getItemRaw( `/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`, ); } async _hasBlob(blobId: string) { - return this._store.hasItem(`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`); + return getStore().hasItem(`/projects/${this._backendProjectId()}/blobs/${blobId.slice(0, 2)}/${blobId.slice(2)}`); } getActiveBackendProject() { diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 407f50602c..fcf894765c 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -150,9 +150,9 @@ const electronStorage: electronStorageBridgeAPI = { setItem: (key, value) => invokeWithNormalizedError('electronStorage.setItem', key, value), }; -const invokeSyncMethod = async (methodName: string, ...args: unknown[]) => { +const invokeSyncMethod = async (workspaceId: string, methodName: string, ...args: unknown[]) => { try { - return (await invokeWithNormalizedError('sync.invoke', methodName, ...args)) as T; + return (await invokeWithNormalizedError('sync.invoke', workspaceId, methodName, ...args)) as T; } catch (error) { if (isUserAbortResolveMergeConflictError(error)) { throw new UserAbortResolveMergeConflictError( @@ -164,39 +164,44 @@ const invokeSyncMethod = async (methodName: string, ...args: unknown[]) => { } }; +const invokeGlobalSyncMethod = async (methodName: string, ...args: unknown[]) => { + return (await invokeWithNormalizedError('sync.invokeGlobal', methodName, ...args)) as T; +}; + const sync: SyncBridgeAPI = { - archiveProject: () => invokeSyncMethod('archiveProject'), - checkout: (...args) => invokeSyncMethod('checkout', ...args), - compareRemoteBranch: () => invokeSyncMethod('compareRemoteBranch'), - fork: (...args) => invokeSyncMethod('fork', ...args), - getActiveBackendProject: () => invokeSyncMethod('getActiveBackendProject'), - getBranchNames: () => invokeSyncMethod('getBranchNames'), - getCurrentBranchName: () => invokeSyncMethod('getCurrentBranchName'), - getHistory: (...args) => invokeSyncMethod('getHistory', ...args), - getHistoryCount: () => invokeSyncMethod('getHistoryCount'), - getRemoteBranchNames: () => invokeSyncMethod('getRemoteBranchNames'), - getVersion: () => invokeSyncMethod('getVersion'), - hasBackendProject: () => invokeSyncMethod('hasBackendProject'), - localBackendProjects: () => invokeSyncMethod('localBackendProjects'), - merge: (...args) => invokeSyncMethod('merge', ...args), - pull: (...args) => invokeSyncMethod('pull', ...args), + archiveProject: workspaceId => invokeSyncMethod(workspaceId, 'archiveProject'), + checkout: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'checkout', ...args), + compareRemoteBranch: workspaceId => invokeSyncMethod(workspaceId, 'compareRemoteBranch'), + fork: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'fork', ...args), + getActiveBackendProject: workspaceId => invokeSyncMethod(workspaceId, 'getActiveBackendProject'), + getBranchNames: workspaceId => invokeSyncMethod(workspaceId, 'getBranchNames'), + getCurrentBranchName: workspaceId => invokeSyncMethod(workspaceId, 'getCurrentBranchName'), + getHistory: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'getHistory', ...args), + getHistoryCount: workspaceId => invokeSyncMethod(workspaceId, 'getHistoryCount'), + getRemoteBranchNames: workspaceId => invokeSyncMethod(workspaceId, 'getRemoteBranchNames'), + getVersion: workspaceId => invokeSyncMethod(workspaceId, 'getVersion'), + hasBackendProject: workspaceId => invokeSyncMethod(workspaceId, 'hasBackendProject'), + localBackendProjects: () => invokeGlobalSyncMethod('localBackendProjects'), + merge: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'merge', ...args), + pull: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'pull', ...args), pullRemoteBackendProject: options => invokeWithNormalizedError('sync.pullRemoteBackendProject', options), - push: (...args) => invokeSyncMethod('push', ...args), - remoteBackendProjects: (...args) => invokeSyncMethod('remoteBackendProjects', ...args), - remoteBackendProjectsOfTeam: (...args) => invokeSyncMethod('remoteBackendProjectsOfTeam', ...args), - removeBackendProjectsForRoot: (...args) => invokeSyncMethod('removeBackendProjectsForRoot', ...args), - removeBranch: (...args) => invokeSyncMethod('removeBranch', ...args), - removeRemoteBranch: (...args) => invokeSyncMethod('removeRemoteBranch', ...args), - rollback: (...args) => invokeSyncMethod('rollback', ...args), - rollbackToLatest: (...args) => invokeSyncMethod('rollbackToLatest', ...args), + push: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'push', ...args), + remoteBackendProjects: (...args) => invokeGlobalSyncMethod('remoteBackendProjects', ...args), + remoteBackendProjectsOfTeam: (...args) => invokeGlobalSyncMethod('remoteBackendProjectsOfTeam', ...args), + hasBackendProjectForRootDocument: (...args) => invokeGlobalSyncMethod('hasBackendProjectForRootDocument', ...args), + removeBackendProjectsForRoot: (...args) => invokeGlobalSyncMethod('removeBackendProjectsForRoot', ...args), + removeBranch: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'removeBranch', ...args), + removeRemoteBranch: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'removeRemoteBranch', ...args), + rollback: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'rollback', ...args), + rollbackToLatest: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'rollbackToLatest', ...args), resolveConflict: options => ipcRenderer.send('sync.resolveConflict', options), cancelConflict: options => ipcRenderer.send('sync.cancelConflict', options), - stage: (...args) => invokeSyncMethod('stage', ...args), - status: (...args) => invokeSyncMethod('status', ...args), - switchAndCreateBackendProjectIfNotExist: (...args) => - invokeSyncMethod('switchAndCreateBackendProjectIfNotExist', ...args), - takeSnapshot: (...args) => invokeSyncMethod('takeSnapshot', ...args), - unstage: (...args) => invokeSyncMethod('unstage', ...args), + stage: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'stage', ...args), + status: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'status', ...args), + switchAndCreateBackendProjectIfNotExist: (workspaceId, ...args) => + invokeSyncMethod(workspaceId, 'switchAndCreateBackendProjectIfNotExist', ...args), + takeSnapshot: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'takeSnapshot', ...args), + unstage: (workspaceId, ...args) => invokeSyncMethod(workspaceId, 'unstage', ...args), }; const git: GitServiceAPI = { diff --git a/packages/insomnia/src/main/__tests__/sync-initialization.test.ts b/packages/insomnia/src/main/__tests__/sync-initialization.test.ts index a5da2f1640..8de8be7d05 100644 --- a/packages/insomnia/src/main/__tests__/sync-initialization.test.ts +++ b/packages/insomnia/src/main/__tests__/sync-initialization.test.ts @@ -2,7 +2,7 @@ import { models, services } from 'insomnia-data'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; -import { getMainVCS } from '~/main/cloud-sync/vcs'; +import { getVCSForWorkspace } from '~/main/cloud-sync/vcs'; import { initializeLocalBackendProjectAndMarkForSync, pushSnapshotOnInitialize, @@ -43,7 +43,7 @@ vi.mock('insomnia-data', () => ({ })); vi.mock('~/main/cloud-sync/vcs', () => ({ - getMainVCS: vi.fn(), + getVCSForWorkspace: vi.fn(), })); vi.mock('~/sync/vcs/initialize-backend-project', () => ({ @@ -77,7 +77,7 @@ describe('sync-initialization', () => { vi.mocked(fetchAndCacheOrganizationStorageRule).mockResolvedValue({ enableCloudSync: true, } as any); - vi.mocked(getMainVCS).mockReturnValue(mockVcs); + vi.mocked(getVCSForWorkspace).mockReturnValue(mockVcs); vi.mocked(initializeLocalBackendProjectAndMarkForSync).mockResolvedValue(); vi.mocked(pushSnapshotOnInitialize).mockResolvedValue(); }); @@ -88,7 +88,7 @@ describe('sync-initialization', () => { await initializeWorkspaceBackendProject({ workspaceId: workspace._id }); expect(services.workspaceMeta.getOrCreateByParentId).not.toHaveBeenCalled(); - expect(getMainVCS).not.toHaveBeenCalled(); + expect(getVCSForWorkspace).not.toHaveBeenCalled(); expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); }); @@ -97,7 +97,7 @@ describe('sync-initialization', () => { await initializeWorkspaceBackendProject({ workspaceId: workspace._id }); - expect(getMainVCS).not.toHaveBeenCalled(); + expect(getVCSForWorkspace).not.toHaveBeenCalled(); expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); }); @@ -107,7 +107,7 @@ describe('sync-initialization', () => { await syncNewWorkspaceIfNeeded({ workspaceId: workspace._id }); expect(fetchAndCacheOrganizationStorageRule).not.toHaveBeenCalled(); - expect(getMainVCS).not.toHaveBeenCalled(); + expect(getVCSForWorkspace).not.toHaveBeenCalled(); expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); expect(pushSnapshotOnInitialize).not.toHaveBeenCalled(); }); @@ -121,7 +121,7 @@ describe('sync-initialization', () => { expect(services.environment.getOrCreateForParentId).not.toHaveBeenCalled(); expect(services.cookieJar.getOrCreateForParentId).not.toHaveBeenCalled(); - expect(getMainVCS).not.toHaveBeenCalled(); + expect(getVCSForWorkspace).not.toHaveBeenCalled(); expect(initializeLocalBackendProjectAndMarkForSync).not.toHaveBeenCalled(); expect(pushSnapshotOnInitialize).not.toHaveBeenCalled(); }); @@ -132,7 +132,7 @@ describe('sync-initialization', () => { expect(services.environment.getOrCreateForParentId).toHaveBeenCalledWith(workspace._id); expect(services.cookieJar.getOrCreateForParentId).toHaveBeenCalledWith(workspace._id); expect(services.workspaceMeta.getOrCreateByParentId).toHaveBeenCalledWith(workspace._id); - expect(getMainVCS).toHaveBeenCalled(); + expect(getVCSForWorkspace).toHaveBeenCalled(); expect(initializeLocalBackendProjectAndMarkForSync).toHaveBeenCalledWith({ vcs: mockVcs, workspace, diff --git a/packages/insomnia/src/main/cloud-sync/initialization.ts b/packages/insomnia/src/main/cloud-sync/initialization.ts index b8b4769879..1e823d8654 100644 --- a/packages/insomnia/src/main/cloud-sync/initialization.ts +++ b/packages/insomnia/src/main/cloud-sync/initialization.ts @@ -2,7 +2,7 @@ import { models, services } from 'insomnia-data'; import { fetchAndCacheOrganizationStorageRule } from '~/common/organization-storage-rules'; import { invariant } from '~/common/utils/invariant'; -import { getMainVCS } from '~/main/cloud-sync/vcs'; +import { getVCSForWorkspace } from '~/main/cloud-sync/vcs'; import { initializeLocalBackendProjectAndMarkForSync, pushSnapshotOnInitialize, @@ -22,7 +22,7 @@ export const initializeWorkspaceBackendProject = async ({ workspaceId }: { works return; } - const vcs = getMainVCS(); + const vcs = getVCSForWorkspace(workspaceId); await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace, @@ -53,7 +53,7 @@ export const syncNewWorkspaceIfNeeded = async ({ workspaceId }: { workspaceId: s await services.workspaceMeta.getOrCreateByParentId(workspace._id); try { - const vcs = getMainVCS(); + const vcs = getVCSForWorkspace(workspaceId); await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace, diff --git a/packages/insomnia/src/main/cloud-sync/ipc.ts b/packages/insomnia/src/main/cloud-sync/ipc.ts index 855ea66f39..50888b6eab 100644 --- a/packages/insomnia/src/main/cloud-sync/ipc.ts +++ b/packages/insomnia/src/main/cloud-sync/ipc.ts @@ -15,49 +15,68 @@ import type { Operation } from '../../common/database'; import { ipcMainHandle, ipcMainOn } from '../ipc/electron'; import { cancelPendingSyncConflict, - invokeMainVCS, + invokeGlobalVCS, + invokeVCSForWorkspace, + pullRemoteBackendProject, type PullRemoteBackendProjectOptions, - pullRemoteBackendProjectWithSingleton, resolvePendingSyncConflict, } from './vcs'; +// Methods scoped to a single workspace's VCS instance — every call must say which workspace. export interface SyncBridgeMethods { - archiveProject: () => Promise; - checkout: (candidates: StatusCandidate[], branchName: string) => Promise; - compareRemoteBranch: () => Promise; - fork: (newBranchName: string) => Promise; - getBranchNames: () => Promise; - getCurrentBranchName: () => Promise; - getHistory: (count?: number) => Promise; - getHistoryCount: () => Promise; - getRemoteBranchNames: () => Promise; - getVersion: () => Promise; - localBackendProjects: () => Promise; - merge: (candidates: StatusCandidate[], otherBranchName: string, snapshotMessage?: string) => Promise; - pull: (options: { - candidates: StatusCandidate[]; - teamId: string; - teamProjectId: string; - projectId: string; - }) => Promise; - push: (options: { teamId: string; teamProjectId: string }) => Promise; - remoteBackendProjects: (options: { teamId: string; teamProjectId: string }) => Promise; - remoteBackendProjectsOfTeam: (options: { teamId: string }) => Promise; - removeBackendProjectsForRoot: (rootDocumentId: string) => Promise; - removeBranch: (branchName: string) => Promise; - removeRemoteBranch: (branchName: string) => Promise; - rollback: (snapshotId: string, candidates: StatusCandidate[]) => Promise; - rollbackToLatest: (candidates: StatusCandidate[]) => Promise; - stage: (stageEntries: StageEntry[]) => Promise; - status: (candidates: StatusCandidate[]) => Promise; - switchAndCreateBackendProjectIfNotExist: (rootDocumentId: string, name: string) => Promise; - takeSnapshot: (name: string) => Promise; - unstage: (stageEntries: StageEntry[]) => Promise; + archiveProject: (workspaceId: string) => Promise; + checkout: (workspaceId: string, candidates: StatusCandidate[], branchName: string) => Promise; + compareRemoteBranch: (workspaceId: string) => Promise; + fork: (workspaceId: string, newBranchName: string) => Promise; + getBranchNames: (workspaceId: string) => Promise; + getCurrentBranchName: (workspaceId: string) => Promise; + getHistory: (workspaceId: string, count?: number) => Promise; + getHistoryCount: (workspaceId: string) => Promise; + getRemoteBranchNames: (workspaceId: string) => Promise; + getVersion: (workspaceId: string) => Promise; + merge: ( + workspaceId: string, + candidates: StatusCandidate[], + otherBranchName: string, + snapshotMessage?: string, + ) => Promise; + pull: ( + workspaceId: string, + options: { + candidates: StatusCandidate[]; + teamId: string; + teamProjectId: string; + projectId: string; + }, + ) => Promise; + push: (workspaceId: string, options: { teamId: string; teamProjectId: string }) => Promise; + removeBranch: (workspaceId: string, branchName: string) => Promise; + removeRemoteBranch: (workspaceId: string, branchName: string) => Promise; + rollback: (workspaceId: string, snapshotId: string, candidates: StatusCandidate[]) => Promise; + rollbackToLatest: (workspaceId: string, candidates: StatusCandidate[]) => Promise; + stage: (workspaceId: string, stageEntries: StageEntry[]) => Promise; + status: (workspaceId: string, candidates: StatusCandidate[]) => Promise; + switchAndCreateBackendProjectIfNotExist: ( + workspaceId: string, + rootDocumentId: string, + name: string, + ) => Promise; + takeSnapshot: (workspaceId: string, name: string) => Promise; + unstage: (workspaceId: string, stageEntries: StageEntry[]) => Promise; + getActiveBackendProject: (workspaceId: string) => Promise; + hasBackendProject: (workspaceId: string) => Promise; } -export interface SyncBridgeAPI extends SyncBridgeMethods { - getActiveBackendProject: () => Promise; - hasBackendProject: () => Promise; +// Methods that never read a VCS instance's active backend project — no workspaceId needed. +export interface GlobalSyncBridgeMethods { + localBackendProjects: () => Promise; + remoteBackendProjects: (options: { teamId: string; teamProjectId: string }) => Promise; + remoteBackendProjectsOfTeam: (options: { teamId: string }) => Promise; + hasBackendProjectForRootDocument: (rootDocumentId: string) => Promise; + removeBackendProjectsForRoot: (rootDocumentId: string) => Promise; +} + +export interface SyncBridgeAPI extends SyncBridgeMethods, GlobalSyncBridgeMethods { pullRemoteBackendProject: (options: PullRemoteBackendProjectOptions) => Promise<{ projectId: string; workspaceId: string; @@ -67,12 +86,16 @@ export interface SyncBridgeAPI extends SyncBridgeMethods { } export const registerSyncHandlers = () => { - ipcMainHandle('sync.invoke', (event, methodName: string, ...args: unknown[]) => { - return invokeMainVCS(event.sender, methodName, ...args); + ipcMainHandle('sync.invoke', (event, workspaceId: string, methodName: string, ...args: unknown[]) => { + return invokeVCSForWorkspace(event.sender, workspaceId, methodName, ...args); + }); + + ipcMainHandle('sync.invokeGlobal', (_event, methodName: string, ...args: unknown[]) => { + return invokeGlobalVCS(methodName, ...args); }); ipcMainHandle('sync.pullRemoteBackendProject', (event, options: PullRemoteBackendProjectOptions) => { - return pullRemoteBackendProjectWithSingleton(event.sender, options); + return pullRemoteBackendProject(event.sender, options); }); ipcMainOn('sync.resolveConflict', (event, options: { handlerId: string; conflicts: MergeConflict[] }) => { diff --git a/packages/insomnia/src/main/cloud-sync/vcs.ts b/packages/insomnia/src/main/cloud-sync/vcs.ts index e35e002df9..32ee7cb036 100644 --- a/packages/insomnia/src/main/cloud-sync/vcs.ts +++ b/packages/insomnia/src/main/cloud-sync/vcs.ts @@ -5,7 +5,16 @@ import { app, type WebContents } from 'electron'; import type { RemoteProject } from 'insomnia-data'; import { services } from 'insomnia-data'; import type { BackendProjectWithTeam, MergeConflict } from 'insomnia-vcs'; -import { VCS } from 'insomnia-vcs'; +import { + configureStore, + FileSystemDriver, + hasBackendProjectForRootDocument, + localBackendProjects, + remoteBackendProjects, + remoteBackendProjectsOfTeam, + removeBackendProjectsForRoot, + VCS, +} from 'insomnia-vcs'; import { PLAYWRIGHT_TEST } from '~/common/constants'; import { invariant } from '~/common/utils/invariant'; @@ -31,7 +40,18 @@ export interface PullRemoteBackendProjectOptions { const syncInvocationContext = new AsyncLocalStorage(); const pendingConflictResolutions = new Map(); -let mainVCS: VCS | null = null; +// One VCS instance per workspace, so a `_backendProject` mutation triggered by one workspace's +// sync.invoke call can never bleed into another workspace's concurrently in-flight operation. +const vcsByWorkspaceId = new Map(); + +let storeConfigured = false; +const ensureStoreConfigured = () => { + if (!storeConfigured) { + const dataPath = process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'); + configureStore(FileSystemDriver.create(dataPath)); + storeConfigured = true; + } +}; const requestConflictResolution = (conflicts: MergeConflict[], labels: { ours: string; theirs: string }) => { const context = syncInvocationContext.getStore(); @@ -53,28 +73,33 @@ const requestConflictResolution = (conflicts: MergeConflict[], labels: { ours: s }); }; -function createVCS() { - return new VCS({ - dataPath: process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'), - conflictHandler: requestConflictResolution, - testMode: !!PLAYWRIGHT_TEST, - }); -} +export const getVCSForWorkspace = (workspaceId: string): VCS => { + ensureStoreConfigured(); + let vcs = vcsByWorkspaceId.get(workspaceId); -export const getMainVCS = () => { - if (mainVCS) { - return mainVCS; + if (!vcs) { + vcs = new VCS({ + workspaceId, + conflictHandler: requestConflictResolution, + testMode: !!PLAYWRIGHT_TEST, + }); + vcsByWorkspaceId.set(workspaceId, vcs); } - mainVCS = createVCS(); - return mainVCS; + + return vcs; }; export const runWithSyncRenderer = (sender: WebContents, callback: () => Promise | T) => { return syncInvocationContext.run({ sender }, callback); }; -export const invokeMainVCS = async (sender: WebContents, methodName: string, ...args: unknown[]) => { - const vcs = getMainVCS(); +export const invokeVCSForWorkspace = async ( + sender: WebContents, + workspaceId: string, + methodName: string, + ...args: unknown[] +) => { + const vcs = getVCSForWorkspace(workspaceId); const method = vcs[methodName as keyof VCS]; if (typeof method !== 'function') { @@ -84,6 +109,28 @@ export const invokeMainVCS = async (sender: WebContents, methodName: string, ... return runWithSyncRenderer(sender, () => (method as (...args: unknown[]) => unknown).apply(vcs, args)); }; +// Methods that never read a VCS instance's active `_backendProject` — they don't need a +// workspace-scoped instance at all, so they're dispatched straight to insomnia-vcs's exported +// functions instead of through the reflective, workspace-keyed `invokeVCSForWorkspace`. +const GLOBAL_VCS_METHODS = { + localBackendProjects, + remoteBackendProjects, + remoteBackendProjectsOfTeam, + hasBackendProjectForRootDocument, + removeBackendProjectsForRoot, +}; + +export const invokeGlobalVCS = async (methodName: string, ...args: unknown[]) => { + ensureStoreConfigured(); + const method = GLOBAL_VCS_METHODS[methodName as keyof typeof GLOBAL_VCS_METHODS]; + + if (typeof method !== 'function') { + throw new TypeError(`Unknown global VCS method: ${methodName}`); + } + + return (method as (...args: unknown[]) => unknown)(...args); +}; + export const resolvePendingSyncConflict = ({ handlerId, sender, @@ -116,21 +163,17 @@ export const cancelPendingSyncConflict = ({ handlerId, sender }: { handlerId: st pendingConflictResolution.reject(new UserAbortResolveMergeConflictError()); }; -export const pullRemoteBackendProjectWithSingleton = async ( +export const pullRemoteBackendProject = async ( sender: WebContents, { organizationId, backendProjectId, remoteId }: PullRemoteBackendProjectOptions, ) => { return runWithSyncRenderer(sender, async () => { - // Use the singleton only for the remote listing (read-only network call). - // The actual pull uses an isolated VCS instance so the singleton's active - // backend project is never mutated, preventing cross-workspace interference - // with concurrent sync.invoke calls. - const vcs = getMainVCS(); - const remoteBackendProjects = await vcs.remoteBackendProjects({ + ensureStoreConfigured(); + const projects = await remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId, }); - const backendProject = remoteBackendProjects.find(project => project.id === backendProjectId) as + const backendProject = projects.find(project => project.id === backendProjectId) as | BackendProjectWithTeam | undefined; @@ -139,11 +182,13 @@ export const pullRemoteBackendProjectWithSingleton = async ( const project = await services.project.getByRemoteId(remoteId); invariant(project?.remoteId, 'Project is not a remote project'); - const pullVCS = createVCS(); - - await pullVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId); + // backendProject.rootDocumentId is the target workspace's id, so the pull runs on that + // workspace's own VCS instance directly — no need for a separate isolated instance to shield + // a singleton's mutable state from other concurrent sync.invoke calls. + await removeBackendProjectsForRoot(backendProject.rootDocumentId); + const vcs = getVCSForWorkspace(backendProject.rootDocumentId); const { workspaceId } = await pullBackendProject({ - vcs: pullVCS, + vcs, backendProject, remoteProject: project as RemoteProject, }); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 3e121feee7..6ebe5be37e 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -177,6 +177,7 @@ export type HandleChannels = | 'socketIO.event.send' | 'syncNewWorkspaceIfNeeded' | 'sync.invoke' + | 'sync.invokeGlobal' | 'sync.pullRemoteBackendProject' | 'templatingDb.getAuthToken' | 'socketIO.open' 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.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx index 08bd2f9b2d..927699c6e9 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx @@ -33,6 +33,7 @@ import { pushSnapshotOnInitialize } from '~/sync/vcs/initialize-backend-project' import { Icon } from '~/ui/components/icon'; import { showResourceNotFoundToast } from '~/ui/components/toast-notification'; import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; +import { syncVCSLikeForWorkspace } from '~/ui/sync-utils'; import { createFetcherLoadHook } from '~/ui/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId'; @@ -281,11 +282,15 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) let vcsVersion = null; if (isLoggedInIsCloudProjectAndIsNotGitRepo) { try { - await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspaceId, activeWorkspace.name); + await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspaceId, workspaceId, activeWorkspace.name); if (activeWorkspaceMeta.pushSnapshotOnInitialize) { - await pushSnapshotOnInitialize({ vcs: window.main.sync, workspace: activeWorkspace, project: activeProject }); + await pushSnapshotOnInitialize({ + vcs: syncVCSLikeForWorkspace(workspaceId), + workspace: activeWorkspace, + project: activeProject, + }); } - vcsVersion = await window.main.sync.getVersion(); + vcsVersion = await window.main.sync.getVersion(workspaceId); } catch (err) { console.warn('Failed to initialize VCS', err); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx index f796cb9471..d0f3ddcf7d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.delete.tsx @@ -15,11 +15,11 @@ async function deleteCloudSyncWorkspace(workspace: Workspace, project: Project, if (models.project.isRemoteProject(project) && !isGitSync) { try { - await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name); + await window.main.sync.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace._id, workspace.name); // For cloud sync workspaces, delete only local file or also delete remote copy await (localOnly ? window.main.sync.removeBackendProjectsForRoot(workspace._id) - : window.main.sync.archiveProject()); + : window.main.sync.archiveProject(workspace._id)); // Emit cloud sync file change event when cloud sync workspace is deleted to refresh the remote projects list cache uiEventBus.emit(CLOUD_SYNC_FILE_CHANGE); } catch (err) { 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..7c3155565a 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 @@ -73,7 +73,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 && diff --git a/packages/insomnia/src/ui/organization-utils.ts b/packages/insomnia/src/ui/organization-utils.ts index 3c0b5f8370..29860d4df9 100644 --- a/packages/insomnia/src/ui/organization-utils.ts +++ b/packages/insomnia/src/ui/organization-utils.ts @@ -3,6 +3,7 @@ import type { Project } from 'insomnia-data'; import { services } from 'insomnia-data'; import { invariant } from '~/common/utils/invariant'; +import { syncVCSLikeForWorkspace } from '~/ui/sync-utils'; // TODO: move vcs into services so we can remove this file. import { @@ -17,12 +18,12 @@ import { export async function updateLocalProjectToRemote({ project, - vcs, + getVcsForWorkspace, sessionId, organizationId, }: { project: Project; - vcs: SyncVCSLike; + getVcsForWorkspace: (workspaceId: string) => SyncVCSLike; sessionId: string; organizationId: string; }) { @@ -46,6 +47,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 +111,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 From 1b9c08d007ea2232bb688411cda657fcff8bd9c1 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Thu, 3 Sep 2026 16:52:20 +0800 Subject: [PATCH 07/61] feat(ui): split environment picker into project/collection dropdowns (#10446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Splits the single "Manage Environments" popover into two independent dropdowns — project-level and collection-level — each with a scope tooltip and an inheritance-order hint (folder overrides collection, which overrides project). - Replaces the hidden "+" menu in the collection environment editor and the project-level full-page environment editor with explicit, always-visible "Add Sub Environment" / "Add Private Sub Environment" buttons. - Adds an "Add Project Environment" action to the project dropdown (does not auto-select the new environment). - Adds a keyboard shortcut for the project dropdown (Cmd/Ctrl+Alt+E), alongside the existing collection-environment shortcut (Cmd/Ctrl+Shift+E). - Fixes smoke tests broken by removing the old single-picker trigger, and adds new coverage for the split dropdowns and create flows. --- packages/insomnia-data/common-src/hotkeys.ts | 7 +- packages/insomnia-data/common-src/settings.ts | 1 + .../migration/local-to-cloud-projects.test.ts | 2 +- .../after-response-script-features.test.ts | 23 +- .../tests/smoke/environment-cascade.test.ts | 2 +- .../environment-editor-interactions.test.ts | 99 +++- .../tests/smoke/focus-and-keyboard.test.ts | 5 +- .../tests/smoke/global-environments.test.ts | 6 +- .../tests/smoke/insomnia-vault.test.ts | 19 +- .../smoke/key-value-editor-blank-row.test.ts | 4 +- .../smoke/pre-request-script-features.test.ts | 24 +- .../tests/smoke/url-bar-undo.test.ts | 4 +- ...tId.workspace.$workspaceId.environment.tsx | 64 +-- .../src/ui/components/environment-picker.tsx | 485 +++++++++--------- .../workspace-environments-edit-modal.tsx | 256 +++++---- .../workspace/workspace-pane-header.tsx | 11 + 16 files changed, 525 insertions(+), 487 deletions(-) diff --git a/packages/insomnia-data/common-src/hotkeys.ts b/packages/insomnia-data/common-src/hotkeys.ts index d05ab0a94f..f2b617247e 100644 --- a/packages/insomnia-data/common-src/hotkeys.ts +++ b/packages/insomnia-data/common-src/hotkeys.ts @@ -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..3da9debafb 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' diff --git a/packages/insomnia-smoke-test/tests/migration/local-to-cloud-projects.test.ts b/packages/insomnia-smoke-test/tests/migration/local-to-cloud-projects.test.ts index 16421a455c..75237d2655 100644 --- a/packages/insomnia-smoke-test/tests/migration/local-to-cloud-projects.test.ts +++ b/packages/insomnia-smoke-test/tests/migration/local-to-cloud-projects.test.ts @@ -30,7 +30,7 @@ testWithLegacyDatabase('Run data migration to version 8', async ({ page, userCon // Open migrated local migrated collection that should have Git Sync await page.getByLabel('Local Collection').click(); - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByLabel('Select a Collection').getByRole('option', { name: 'Mars' }).press('Enter'); await page.locator('body').click(); // The collection is moved to a local project diff --git a/packages/insomnia-smoke-test/tests/smoke/after-response-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/after-response-script-features.test.ts index 58c4aae309..bbb1a0977e 100644 --- a/packages/insomnia-smoke-test/tests/smoke/after-response-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/after-response-script-features.test.ts @@ -6,7 +6,11 @@ import { test } from '../../playwright/test'; test.describe('after-response script features tests', () => { test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); - test('after-response scripts: transient vars, assertions, and environment/global persistence', async ({ page, app, insomnia }) => { + test('after-response scripts: transient vars, assertions, and environment/global persistence', async ({ + page, + app, + insomnia, + }) => { // import global environment const globalEnvText = await loadFixture('script-global-environment.yaml'); await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), globalEnvText); @@ -81,7 +85,7 @@ test.describe('after-response script features tests', () => { await expect.soft(statusTag1).toContainText('200 OK'); // verify persisted environment - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); const responseBody = page.getByRole('dialog').getByTestId('CodeEditor').locator('.CodeMirror-line'); const rows1 = await responseBody.allInnerTexts(); @@ -100,9 +104,7 @@ test.describe('after-response script features tests', () => { await page.locator('body').click(); await insomnia.navigationSidebar.clickRequestOrFolder('persist global environment'); // activate global sub environment - await page.getByLabel('Manage Environments').click(); - await page.getByPlaceholder('Choose a project environment').click(); - await page.getByRole('option', { name: 'Script Environment' }).click(); + await page.getByLabel('Select a Project Environment').click(); await page.getByRole('option', { name: 'Sub Script Env' }).click(); await page.locator('body').click(); // send @@ -119,8 +121,9 @@ test.describe('after-response script features tests', () => { await page.getByText('log: globals sub').click(); await page.getByText('log: baseGlobals base').click(); // view sub environment has been updated - await page.getByLabel('Manage Environments').click(); - await page.getByLabel('Manage project environment').click(); + await page.getByLabel('Select a Project Environment').click(); + await page.getByRole('option', { name: 'Script Environment', exact: true }).hover(); + await page.getByLabel('Edit Script Environment').click(); await page.getByLabel('Environment name').getByText('Sub Script Env').first().click(); let globalSubEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line'); let globalSubRows = await globalSubEditor.allInnerTexts(); @@ -141,7 +144,11 @@ test.describe('after-response script features tests', () => { }); }); - test('insomnia.expect covers each matcher (equal/property/lengthOf/include/within/keys) and test.skip is reported', async ({ page, app, insomnia }) => { + test('insomnia.expect covers each matcher (equal/property/lengthOf/include/within/keys) and test.skip is reported', async ({ + page, + app, + insomnia, + }) => { const text = await loadFixture('after-response-collection.yaml'); await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); diff --git a/packages/insomnia-smoke-test/tests/smoke/environment-cascade.test.ts b/packages/insomnia-smoke-test/tests/smoke/environment-cascade.test.ts index c1375e79db..6c044e2b66 100644 --- a/packages/insomnia-smoke-test/tests/smoke/environment-cascade.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/environment-cascade.test.ts @@ -41,7 +41,7 @@ test.describe('environment cascade / override', () => { }; const selectEnvironment = async (page: Page, insomnia: any, environmentName: string, requestName: string) => { - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); // wait for the Manage Environments dialog to close before interacting with the picker await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' }); diff --git a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts index a3fecfe446..191aecefed 100644 --- a/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/environment-editor-interactions.test.ts @@ -16,16 +16,15 @@ test.describe('Environment Editor', () => { await page.getByRole('dialog').waitFor({ state: 'hidden' }); // create a new shared environment (becomes active on creation) - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); - await page.getByTestId('CreateEnvironmentDropdown').click(); - await page.getByRole('menuitemradio', { name: 'Shared Environment' }).press('Enter'); + await page.getByTestId('AddSubEnvironment').click(); // wait for the new row to appear before clicking it await page.getByRole('row', { name: 'New Environment' }).waitFor({ state: 'visible' }); await page.getByRole('row', { name: 'New Environment' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click(); - + // wait for the Manage Environments dialog to close before interacting with the picker await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' }); @@ -36,7 +35,7 @@ test.describe('Environment Editor', () => { // send request: verify variables fall back to base env (new env is empty) await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); await page.getByRole('button', { name: 'Send' }).click(); - + // wait for a response before switching to console await page.locator('[data-testid="response-status-tag"]:visible').waitFor({ state: 'visible', timeout: 25_000 }); await page.getByRole('tab', { name: 'Console' }).click(); @@ -44,7 +43,7 @@ test.describe('Environment Editor', () => { await expect.soft(page.getByText('baseenv1')).toBeVisible(); // duplicate ExampleA and rename the copy to Gandalf - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); await page.getByRole('row', { name: 'ExampleA' }).getByLabel('Environment Actions').click(); await page.getByText('Duplicate').click(); @@ -81,7 +80,7 @@ test.describe('Environment Editor', () => { await expect.soft(page.getByText('subenvB1')).toBeVisible(); // add new variables to Gandalf via JSON editor - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); await page.locator('pre').filter({ hasText: '"exampleNumber": 2222,' }).click(); const dialog = page.getByRole('dialog'); @@ -92,7 +91,7 @@ test.describe('Environment Editor', () => { // blur the editor before closing so the debounce flush is triggered by the button's mousedown await dialog.getByRole('button', { name: 'Close' }).click(); - + // wait for the Manage Environments dialog to fully close before navigating await page.getByRole('heading', { name: 'Manage Environments' }).waitFor({ state: 'hidden' }); await page.getByLabel('Manage collection environments').press('Escape'); @@ -100,7 +99,7 @@ test.describe('Environment Editor', () => { await insomnia.navigationSidebar.clickRequestOrFolder('New Request'); // switch to table view and edit Gandalf environment - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); // explicitly select Gandalf so table edits target the correct sub-environment @@ -167,14 +166,14 @@ test.describe('Environment Editor', () => { await page.getByRole('menuitemradio', { name: 'JSON' }).click(); await waitForSync(); await secondRow.getByRole('button', { name: 'Edit JSON' }).click(); - + // wait for the JSON modal before typing await page.getByRole('dialog').getByTestId('CodeEditor').waitFor({ state: 'visible' }); const bodyEditor = page.getByRole('dialog').getByTestId('CodeEditor').getByRole('textbox'); await bodyEditor.focus(); await page.keyboard.press('ControlOrMeta+a'); await page.keyboard.type('{"anotherString":"kvAnotherStr","anotherNumber": 12345}'); - + // submit and wait for the JSON modal to fully close before proceeding await page.getByRole('button', { name: 'Modal Submit' }).click(); await page.getByRole('dialog', { name: 'Modal' }).waitFor({ state: 'hidden' }); @@ -187,7 +186,9 @@ test.describe('Environment Editor', () => { // dismiss the environment picker dropdown if it appeared await page.locator('body').click(); try { - await page.getByRole('listbox', { name: 'Select a Collection Environment' }).waitFor({ state: 'hidden', timeout: 3000 }); + await page + .getByRole('listbox', { name: 'Select a Collection Environment' }) + .waitFor({ state: 'hidden', timeout: 3000 }); } catch { await page.keyboard.press('Escape'); } @@ -217,7 +218,7 @@ test.describe('Environment Editor', () => { await page.getByRole('dialog').waitFor({ state: 'hidden' }); // activate ExampleA environment - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('option', { name: 'ExampleA' }).press('Enter'); await page.getByRole('option', { name: 'ExampleA' }).press('Escape'); @@ -230,7 +231,7 @@ test.describe('Environment Editor', () => { await expect.soft(page.getByText('subenvA0')).toBeVisible(); // open env editor, select ExampleA, switch to table view, disable exampleString - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); await page.getByRole('button', { name: 'Table Edit' }).click(); @@ -246,7 +247,9 @@ test.describe('Environment Editor', () => { // dismiss the environment picker dropdown if it appeared await page.locator('body').click(); try { - await page.getByRole('listbox', { name: 'Select a Collection Environment' }).waitFor({ state: 'hidden', timeout: 3000 }); + await page + .getByRole('listbox', { name: 'Select a Collection Environment' }) + .waitFor({ state: 'hidden', timeout: 3000 }); } catch { await page.keyboard.press('Escape'); } @@ -261,4 +264,70 @@ test.describe('Environment Editor', () => { await expect.soft(page.getByText('baseenv0')).toBeVisible(); await expect.soft(page.getByText('subenvA0')).toBeHidden(); }); + + test('project and collection environment dropdowns open and close independently', async ({ page }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + + const projectListbox = page.getByRole('listbox', { name: 'Select a Project Environment' }); + const collectionListbox = page.getByRole('listbox', { name: 'Select a Collection Environment' }); + + // opening the collection dropdown does not open the project dropdown + await page.getByLabel('Select a Collection Environment').click(); + await expect.soft(collectionListbox).toBeVisible(); + await expect.soft(projectListbox).toBeHidden(); + await page.keyboard.press('Escape'); + await expect.soft(collectionListbox).toBeHidden(); + + // opening the project dropdown does not open the collection dropdown + await page.getByLabel('Select a Project Environment').click(); + await expect.soft(projectListbox).toBeVisible(); + await expect.soft(collectionListbox).toBeHidden(); + await page.keyboard.press('Escape'); + await expect.soft(projectListbox).toBeHidden(); + }); + + test('Add Project Environment creates a project environment without activating it', async ({ page, insomnia }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + + // the "+" button in the project dropdown header opens the create-workspace modal + await page.getByLabel('Select a Project Environment').click(); + await page.getByLabel('Add Project Environment').click(); + await page.getByPlaceholder('Enter a name for your Environment').fill('My New Project Env'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('dialog', { name: 'Create or update dialog' }).waitFor({ state: 'hidden' }); + await insomnia.pressEscape(); + + // creating navigates into the new environment's own page; go back to the collection + await insomnia.navigationSidebar.selectWorkspace('My first collection'); + + // the new environment shows up in the project dropdown's list, but is not auto-selected + await expect.soft(page.getByLabel('Select a Project Environment')).toContainText('No Project Environment'); + await page.getByLabel('Select a Project Environment').click(); + await expect.soft(page.getByRole('option', { name: 'My New Project Env' })).toBeVisible(); + }); + + test('Add Sub Environment and Add Private Sub Environment create environments with the correct privacy', async ({ + page, + }) => { + await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); + + await page.getByLabel('Select a Collection Environment').click(); + await page.getByRole('button', { name: 'Manage collection environments' }).click(); + + // create the shared sub-environment and rename it so it can be told apart from the private one + await page.getByTestId('AddSubEnvironment').click(); + const sharedRow = page.getByRole('row', { name: 'New Environment' }); + await sharedRow.waitFor({ state: 'visible' }); + await sharedRow.locator('[data-editable=true]').dblclick(); + await sharedRow.locator('input').fill('Shared Sub Env'); + await sharedRow.locator('input').press('Enter'); + await page.getByRole('row', { name: 'Shared Sub Env' }).waitFor({ state: 'visible' }); + await expect.soft(page.getByRole('row', { name: 'Shared Sub Env' }).locator('.fa-lock')).toHaveCount(0); + + // create the private sub-environment + await page.getByTestId('AddPrivateSubEnvironment').click(); + const privateRow = page.getByRole('row', { name: 'New Environment' }); + await privateRow.waitFor({ state: 'visible' }); + await expect.soft(privateRow.locator('.fa-lock')).toBeVisible(); + }); }); diff --git a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts index 3a3ed33403..90851603de 100644 --- a/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/focus-and-keyboard.test.ts @@ -52,10 +52,9 @@ test.describe('Focus and keyboard navigation', () => { test('the KV environment editor focuses the blank row Name', async ({ page }) => { await page.getByRole('button', { name: 'Create request collection', exact: true }).click(); - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); - await page.getByTestId('CreateEnvironmentDropdown').click(); - await page.getByRole('menuitemradio', { name: 'Shared Environment' }).press('Enter'); + await page.getByTestId('AddSubEnvironment').click(); // New environments default to KV mode, so selecting the new (empty) environment renders the KV // editor whose trailing blank row's Name should be focused. diff --git a/packages/insomnia-smoke-test/tests/smoke/global-environments.test.ts b/packages/insomnia-smoke-test/tests/smoke/global-environments.test.ts index e66d8e0ee1..71f3b50169 100644 --- a/packages/insomnia-smoke-test/tests/smoke/global-environments.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/global-environments.test.ts @@ -29,8 +29,7 @@ test.describe('Global Environments', () => { await page.getByRole('button', { name: 'Send' }).click(); await page.getByRole('heading', { name: '2 environment variables are' }).click(); await page.getByRole('button', { name: 'Cancel' }).click(); - await page.getByLabel('Manage Environments').click(); - await page.getByPlaceholder('Choose a project environment').click(); + await page.getByLabel('Select a Project Environment').click(); await page.getByRole('option', { name: 'global-environment' }).click(); await page.getByText('New Environment').click(); await page.locator('body').click(); @@ -49,8 +48,7 @@ test.describe('Global Environments', () => { await page.getByLabel('Create in project').click(); await page.getByLabel('Create', { exact: true }).getByText('Environment').click(); await page.getByRole('button', { name: 'Create', exact: true }).click(); - await page.getByTestId('CreateEnvironmentDropdown').click(); - await page.getByText('Private environment').click(); + await page.getByTestId('AddPrivateSubEnvironment').click(); await page.getByLabel('New Environment').click(); await page.getByLabel('New Environment').getByLabel('Project Actions').click(); await page.getByText('Duplicate').click(); diff --git a/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts b/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts index 5eb96073e5..b44eb0c039 100644 --- a/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/insomnia-vault.test.ts @@ -96,8 +96,7 @@ test.describe('Check vault used in environment', () => { await page.getByLabel('Create', { exact: true }).getByText('Environment').click(); await page.getByPlaceholder('Enter a name for your Environment').fill('New Global Vault Environment'); await page.getByRole('button', { name: 'Create', exact: true }).click(); - await page.getByTestId('CreateEnvironmentDropdown').click(); - await page.getByText('Private environment').click(); + await page.getByTestId('AddPrivateSubEnvironment').click(); // activate created private environment await page.getByRole('grid', { name: 'Environments' }).getByText('New Environment').click(); @@ -153,10 +152,10 @@ test.describe('Check vault used in environment', () => { await page.getByRole('button', { name: 'Scan' }).click(); await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); // activate existing global private vault environment from import - await page.getByLabel('Manage Environments').click(); - await page.getByPlaceholder('Choose a project environment').click(); - await page.getByRole('option', { name: 'New Global Vault Environment' }).click(); + await page.getByLabel('Select a Project Environment').click(); await page.getByRole('option', { name: 'New Environment' }).click(); + await page.keyboard.press('Escape'); + await page.getByLabel('Select a Collection Environment').click(); await page.getByText('Base Environment1').click(); await page.locator('body').click(); @@ -176,12 +175,12 @@ test.describe('Check vault used in environment', () => { await page.locator('.app').press('Escape'); // activate global private vault environment from import - await page.getByLabel('Manage Environments').click(); - await page.getByPlaceholder('Choose a project environment').click(); - await page.getByRole('option', { name: 'Global env workspace with secret vault' }).click(); + await page.getByLabel('Select a Project Environment').click(); await page.getByText('global vault env with secret').click(); + await page.keyboard.press('Escape'); // activate legacy array vault environment + await page.getByLabel('Select a Collection Environment').click(); await page.getByText('legacy vault value array').click(); await page.locator('body').click(); // activate request @@ -201,7 +200,7 @@ test.describe('Check vault used in environment', () => { await page.getByText('vault_array_b').click(); // activate legacy object vault environment - await page.getByLabel('Manage Environments').click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByText('legacy vault value object').click(); await page.locator('body').click(); // activate request @@ -219,7 +218,7 @@ test.describe('Check vault used in environment', () => { await page.getByText('world').click(); // activate invalid vault environment - await page.getByLabel('Manage Environments').click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByText('base with vault').click(); await page.locator('body').click(); // activate request diff --git a/packages/insomnia-smoke-test/tests/smoke/key-value-editor-blank-row.test.ts b/packages/insomnia-smoke-test/tests/smoke/key-value-editor-blank-row.test.ts index 159199f53a..aa3e79f706 100644 --- a/packages/insomnia-smoke-test/tests/smoke/key-value-editor-blank-row.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/key-value-editor-blank-row.test.ts @@ -66,7 +66,7 @@ test.describe('Key-value editor blank row', () => { await page.getByRole('dialog').waitFor({ state: 'hidden' }); // Open the table editor for the ExampleA sub-environment. - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); await page.getByRole('button', { name: 'Table Edit' }).click(); @@ -95,7 +95,7 @@ test.describe('Key-value editor blank row', () => { await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); await page.getByRole('dialog').waitFor({ state: 'hidden' }); - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); await page.getByLabel('Environments', { exact: true }).getByText('ExampleA').click(); await page.getByRole('button', { name: 'Table Edit' }).click(); diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts index 169a96ab39..f539a4a44b 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts @@ -486,7 +486,7 @@ test.describe('pre-request features tests', () => { await expect.soft(statusTag).toContainText('200 OK'); // verify persisted environment - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('button', { name: 'Manage collection environments' }).click(); const responseBody = page.getByRole('dialog').getByTestId('CodeEditor').locator('.CodeMirror-line'); const rows = await responseBody.allInnerTexts(); @@ -528,10 +528,8 @@ test.describe('pre-request features tests', () => { }); // activate global environment - await page.getByLabel('Manage Environments').click(); - await page.getByPlaceholder('Choose a project environment').click(); - await page.getByRole('option', { name: 'Script Environment' }).click(); - await page.getByRole('option', { name: 'Base Script Env' }).click(); + await page.getByLabel('Select a Project Environment').click(); + await page.getByRole('option', { name: 'Script Environment', exact: true }).click(); await page.locator('body').click(); // send await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click(); @@ -540,8 +538,9 @@ test.describe('pre-request features tests', () => { await page.getByText('log: globals base').click(); await page.getByText('log: baseGlobals base').click(); // view base environment has been updated - await page.getByLabel('Manage Environments').click(); - await page.getByLabel('Manage project environment').click(); + await page.getByLabel('Select a Project Environment').click(); + await page.getByRole('option', { name: 'Script Environment', exact: true }).hover(); + await page.getByLabel('Edit Script Environment').click(); await page.getByLabel('Environment name').getByText('Base Script Env').click(); const globalBaseEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line'); const globalBaseRows = await globalBaseEditor.allInnerTexts(); @@ -556,7 +555,7 @@ test.describe('pre-request features tests', () => { // switch back to request collection tab await page.getByLabel('Insomnia Tabs').getByLabel('persist global environment').first().click(); // activate global sub environment - await page.getByLabel('Manage Environments').click(); + await page.getByLabel('Select a Project Environment').click(); await page.getByRole('option', { name: 'Sub Script Env' }).click(); await page.locator('body').click(); // send @@ -566,8 +565,9 @@ test.describe('pre-request features tests', () => { await page.getByText('log: globals sub').click(); await page.getByText('log: baseGlobals base').click(); // view sub environment has been updated - await page.getByLabel('Manage Environments').click(); - await page.getByLabel('Manage project environment').click(); + await page.getByLabel('Select a Project Environment').click(); + await page.getByRole('option', { name: 'Script Environment', exact: true }).hover(); + await page.getByLabel('Edit Script Environment').click(); await page.getByLabel('Environment name').getByText('Sub Script Env').first().click(); const globalSubEditor = page.getByTestId('CodeEditor').locator('.CodeMirror-line'); const globalSubRows = await globalSubEditor.allInnerTexts(); @@ -583,7 +583,7 @@ test.describe('pre-request features tests', () => { const statusTag = page.locator('[data-testid="response-status-tag"]:visible'); await insomnia.navigationSidebar.clickRequestOrFolder('update kv pair environment'); // switch to table view environment - await page.getByLabel('Manage Environments').click(); + await page.getByLabel('Select a Collection Environment').click(); const manageBtn = page.getByRole('button', { name: 'Manage collection environments' }); await expect.soft(manageBtn).toBeEnabled(); await manageBtn.click(); @@ -604,7 +604,7 @@ test.describe('pre-request features tests', () => { await expect.soft(statusTag).toContainText('200 OK'); // verify table environments have been updated - const verifyManageBtn = page.getByRole('button', { name: 'Manage Environments' }); + const verifyManageBtn = page.getByLabel('Select a Collection Environment'); await expect.soft(verifyManageBtn).toBeEnabled(); await verifyManageBtn.click(); const verifyCollectionBtn = page.getByRole('button', { name: 'Manage collection environments' }); diff --git a/packages/insomnia-smoke-test/tests/smoke/url-bar-undo.test.ts b/packages/insomnia-smoke-test/tests/smoke/url-bar-undo.test.ts index 1f0c3021c0..48fe420f20 100644 --- a/packages/insomnia-smoke-test/tests/smoke/url-bar-undo.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/url-bar-undo.test.ts @@ -131,13 +131,13 @@ test('URL bar: switching environment refreshes the rendered preview', async ({ p const tag = page.locator(TAG_SEL).first(); // ExampleA -> subenvA0 - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('option', { name: 'ExampleA' }).press('Enter'); await page.getByRole('option', { name: 'ExampleA' }).press('Escape'); await expect.soft(tag).toHaveAttribute('title', /subenvA0/); // ExampleB -> subenvB0 (preview must refresh on switch) - await page.getByRole('button', { name: 'Manage Environments' }).click(); + await page.getByLabel('Select a Collection Environment').click(); await page.getByRole('option', { name: 'ExampleB' }).press('Enter'); await page.getByRole('option', { name: 'ExampleB' }).press('Escape'); await expect.soft(tag).toHaveAttribute('title', /subenvB0/); 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/ui/components/environment-picker.tsx b/packages/insomnia/src/ui/components/environment-picker.tsx index 946cd7a4f4..5ce2f7b8a9 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,6 +71,7 @@ export const EnvironmentPicker = ({ }; const { features } = useOrganizationPermissions(); + const storageRules = useOrganizationStorageRule(organizationId); const isUsingInsomniaCloudSync = Boolean( models.project.isRemoteProject(activeProject) && !activeWorkspaceMeta?.gitRepositoryId, ); @@ -73,162 +91,108 @@ 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 ( - - - - -
- - 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, - }; - }), - ]} - > -
- - -
- - 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) { @@ -243,48 +207,71 @@ export const EnvironmentPicker = ({ 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" + className={listBoxClassName} > {item => ( {({ isSelected }) => ( - - - - + /> + {item.name} - {isSelected && } + {isSelected && ( + + )} + {item.workspaceId && ( + + )} )} )} -
- - Collection Environments +
+
+
+ + + + + + + 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="max-h-fit flex-1 overflow-y-auto p-2 text-sm select-none focus:outline-hidden" - > - {item => ( - - {({ isSelected }) => ( - - - - - - {item.name} - - {isSelected && } - - )} - - )} - -
- -
-
-
+ + + + { + 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 && ( + + )} + + )} + + )} + + + + + {isNewProjectEnvironmentModalOpen && ( + + )} + ); }; diff --git a/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx index a63806a62c..d1e6d15989 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-environments-edit-modal.tsx @@ -16,7 +16,6 @@ import { Modal, ModalOverlay, Popover, - Text, ToggleButton, useDragAndDrop, } from 'react-aria-components'; @@ -29,6 +28,7 @@ import { useEnvironmentCreateActionFetcher } from '~/routes/organization.$organi import { useEnvironmentDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.delete'; import { useEnvironmentDuplicateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.duplicate'; import { useEnvironmentUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.environment.update'; +import { Tooltip } from '~/ui/components/tooltip'; import { useToggleEnvironmentType } from '~/ui/hooks/use-toggle-environment-type'; import { docsAfterResponseScript, docsTemplateTags } from '../../../common/documentation'; @@ -135,8 +135,8 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { onClose: () => voi }[] = [ { 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({ @@ -152,8 +152,8 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { onClose: () => voi }, { 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({ @@ -276,144 +276,122 @@ export const WorkspaceEnvironmentsEditModal = ({ onClose }: { onClose: () => voi
- { - if (keys !== 'all') { - const [environmentId] = keys.values(); - setSelectedEnvironmentId(environmentId.toString()); - } - }} - > - {item => { - return ( - -
+
+ {createEnvironmentActionsList.map(action => ( + + - - { - environmentActionsList.find(({ id }) => key === id)?.action(item); - }} - items={environmentActionsList} - 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" + + {action.name} + + + ))} +
+ { + if (keys !== 'all') { + const [environmentId] = keys.values(); + setSelectedEnvironmentId(environmentId.toString()); + } + }} + > + {item => { + return ( + +
+ + + { + name && + updateEnvironmentFetcher.submit({ + organizationId, + projectId, + workspaceId, + patch: { + name, + }, + environmentId: item._id, + }); + }} + /> + {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 => ( - -
+ + + + { + environmentActionsList.find(({ id }) => key === id)?.action(item); + }} + items={environmentActionsList} + 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/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 && ( From 3b77d58829812be59c3d588c62dd4be307475da1 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Thu, 3 Sep 2026 18:06:26 +0800 Subject: [PATCH 08/61] fix(ui): hide project environment picker in scratchpad (#10472) --- .../src/ui/components/environment-picker.tsx | 217 +++++++++--------- 1 file changed, 110 insertions(+), 107 deletions(-) diff --git a/packages/insomnia/src/ui/components/environment-picker.tsx b/packages/insomnia/src/ui/components/environment-picker.tsx index 5ce2f7b8a9..d58f298493 100644 --- a/packages/insomnia/src/ui/components/environment-picker.tsx +++ b/packages/insomnia/src/ui/components/environment-picker.tsx @@ -76,6 +76,7 @@ export const EnvironmentPicker = ({ 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(); @@ -143,113 +144,115 @@ export const EnvironmentPicker = ({ return (
- - - - - - - Project Environments - -
- - - - -
-
- { - if (keys === 'all' || !keys) { - return; - } - const [environmentId] = keys.values(); - - setActiveGlobalEnvironmentFetcher.submit({ - organizationId, - projectId, - workspaceId, - environmentId: environmentId.toString(), - }); - }} - className={listBoxClassName} - > - {item => ( - + + + + + - {({ isSelected }) => ( - - - - {item.name} - - {isSelected && ( - - )} - {item.workspaceId && ( - - )} - - )} - - )} - -
-
-
+ Project Environments + +
+ + + + +
+ + { + if (keys === 'all' || !keys) { + return; + } + const [environmentId] = keys.values(); + + setActiveGlobalEnvironmentFetcher.submit({ + organizationId, + projectId, + workspaceId, + environmentId: environmentId.toString(), + }); + }} + className={listBoxClassName} + > + {item => ( + + {({ isSelected }) => ( + + + + {item.name} + + {isSelected && ( + + )} + {item.workspaceId && ( + + )} + + )} + + )} + + + + + )} + + 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/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 }; +}; From 7c334de276ba6d05135088f10fb83553297e7c04 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Mon, 7 Sep 2026 16:22:26 +0800 Subject: [PATCH 13/61] chore: remove KSAI workflow (#10478) KSAI's reusable workflow at Kong/ksai isn't accessible from this repo, causing every run to fail with "workflow was not found". --- .github/workflows/ksai.yml | 122 ------------------------------------- 1 file changed, 122 deletions(-) delete mode 100644 .github/workflows/ksai.yml diff --git a/.github/workflows/ksai.yml b/.github/workflows/ksai.yml deleted file mode 100644 index a6eed27a71..0000000000 --- a/.github/workflows/ksai.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: KSAI - -permissions: {} - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - pull_request: - types: [review_requested, opened, synchronize, reopened, edited] - pull_request_review: - types: [submitted, dismissed] - 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 - -jobs: - ksai: - name: KSAI - 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 == 'workflow_dispatch' && - (inputs.pr != '' || inputs.issue_number != '' || inputs.work_ref != '')) || - ((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' && - 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/.github/workflows/ksai.yml@787399f2c1528478ef6917de1ef59ad37508be46 # ksai/v3.30.1 - 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 # dispatch sidebar reviews and successor plan runs - with: - trigger_phrase: ${{ vars.KSAI_TRIGGER_PHRASE }} - runs_on: '["kong-inc-dedicated","size-md","amd64"]' - federation_rule_id: fdrl_01SuXMcSBJPmwTbmPrnBkVFa - organization_id: 4ce7a6d3-9549-4842-bd51-1def5eba611b - service_account_id: svac_019MqwtwTW6SEKnEUuDUnMYE - workspace_id: wrkspc_01G7dX83HGYMZDwLuJNPnA5T - continuation_workflow: ksai.yml - clear_request: true - disabled_commands: test - 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 }} - secrets: - ksai_private_key: ${{ secrets.KSAI_PRIVATE_KEY }} - - hold: - if: >- - github.event.pull_request.head.repo.full_name == github.repository && - ((github.event_name == 'pull_request' && - contains(fromJSON('["opened", "synchronize", "reopened", "edited"]'), github.event.action) && - (github.event.action != 'edited' || github.event.changes.base.ref.from != '')) || - (github.event_name == 'pull_request_review' && - github.event.sender.type != 'Bot' && - github.event.review.state != 'commented')) - runs-on: ['kong-inc-dedicated', 'size-md', 'amd64'] - permissions: - contents: read # download the action below, which lives in a private repository - pull-requests: write # dismiss an approval from somebody who contributed to the pull request - statuses: write # report the check the ruleset makes required - timeout-minutes: 5 - concurrency: - group: independent-approval-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - - uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 - with: - egress-policy: audit - - - name: Hold the pull request for an independent approval - uses: Kong/ksai/.github/actions/require-independent-approval@73909cdbfb48edae3efe82f15b5b65503dc3bc98 # require-independent-approval/v1.0.2 - with: - github-token: ${{ github.token }} - pr-number: ${{ github.event.pull_request.number }} - head-sha: ${{ github.event.pull_request.head.sha }} From 511cd126c4a4ec48365ef864d2bc5a2edc9dc301 Mon Sep 17 00:00:00 2001 From: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:37:34 +0800 Subject: [PATCH 14/61] refactor: account data fetching (#10463) --- packages/insomnia/src/common/organization.ts | 69 +++++++++++---- ...ganizationId.project.$projectId._index.tsx | 6 +- ...Id.workspace.$workspaceId.debug.runner.tsx | 6 +- ...zation.sync-organizations-and-projects.tsx | 7 -- .../insomnia/src/routes/organization.sync.tsx | 29 ------- packages/insomnia/src/routes/organization.tsx | 49 ++--------- .../editors/mock-response-extractor.tsx | 6 +- .../ui/components/header-plan-indicator.tsx | 7 +- .../import-modal/import-projects-modal.tsx | 6 +- .../modals/input-vault-key-modal.tsx | 4 +- .../modals/invite-modal/invite-form.tsx | 6 +- .../modals/invite-modal/invite-modal.tsx | 6 +- .../modals/workspace-duplicate-modal.tsx | 6 +- .../project/project-type-warning.tsx | 6 +- .../ui/components/settings/import-export.tsx | 7 +- .../app/insomnia-event-stream-context.tsx | 8 +- .../src/ui/hooks/use-account-server-data.ts | 86 +++++++++++++++++++ packages/insomnia/src/ui/hooks/use-plan.tsx | 18 ++-- .../insomnia/src/ui/hooks/use-user-service.ts | 5 +- packages/insomnia/src/ui/utils/router.ts | 3 +- 20 files changed, 197 insertions(+), 143 deletions(-) delete mode 100644 packages/insomnia/src/routes/organization.sync.tsx create mode 100644 packages/insomnia/src/ui/hooks/use-account-server-data.ts diff --git a/packages/insomnia/src/common/organization.ts b/packages/insomnia/src/common/organization.ts index 405a6be7d1..e6f89e14fb 100644 --- a/packages/insomnia/src/common/organization.ts +++ b/packages/insomnia/src/common/organization.ts @@ -1,26 +1,63 @@ -import { getCurrentPlan, getUserProfile } from 'insomnia-api'; +import { type CurrentPlan, getCurrentPlan, getUserProfile, type Organization, type User } from 'insomnia-api'; import { services } from 'insomnia-data'; import { invariant } from '~/common/utils/invariant'; +export interface OrganizationData { + organizations: Organization[]; + user?: User; + currentPlan?: CurrentPlan; +} + +// Fetches the organization triple (organizations / user / current plan) from the +// cloud API in parallel. Shared by both the TanStack query (the source of truth for +// React consumers) and `syncOrganizations` (the localStorage write path still used by +// the non-React readers). Throws if any of the three fail their invariant. +export async function fetchOrganizationData(sessionId: string): Promise { + const [organizations, user, currentPlan] = await Promise.all([ + services.organization.list(), + getUserProfile({ sessionId }), + getCurrentPlan({ sessionId }), + ]); + + invariant(organizations, 'Failed to load organizations'); + invariant(user && user.id, 'Failed to load user'); + invariant(currentPlan && currentPlan.planId, 'Failed to load current plan'); + + return { organizations, user, currentPlan }; +} + +// Write-through helper: persists the organization triple to localStorage so the +// non-React readers (`getInitialEntry`, various loaders, event-stream handlers, etc.) +// keep working while the migration to TanStack Query is incremental. +export function writeOrganizationDataToLocalStorage(accountId: string, data: OrganizationData) { + invariant(accountId, 'Account ID is not defined'); + localStorage.setItem(`${accountId}:spaces`, JSON.stringify(data.organizations)); + localStorage.setItem(`${accountId}:user`, JSON.stringify(data.user)); + localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(data.currentPlan)); +} + +// Reads the persisted triple back from localStorage. Used to seed the query's +// `initialData` so the first paint is instant (matching the old loader, which read +// localStorage synchronously). Defaults mirror the previous loader behaviour. +export function readOrganizationDataFromLocalStorage(accountId: string): OrganizationData { + const userRaw = localStorage.getItem(`${accountId}:user`); + const currentPlanRaw = localStorage.getItem(`${accountId}:currentPlan`); + return { + organizations: JSON.parse(localStorage.getItem(`${accountId}:spaces`) || '[]') as Organization[], + // Leave these undefined (not `{}`) when the key is absent, so consumers can rely on the + // declared optional shape — casting `{}` to User/CurrentPlan hides missing fields from the + // type checker and causes runtime NPEs (e.g. `currentPlan.type.includes`). + user: userRaw ? (JSON.parse(userRaw) as User) : undefined, + currentPlan: currentPlanRaw ? (JSON.parse(currentPlanRaw) as CurrentPlan) : undefined, + }; +} + // This is reusable action/loader implementations. export async function syncOrganizations(sessionId: string, accountId: string) { try { - const [organizations, user, currentPlan] = await Promise.all([ - services.organization.list(), - getUserProfile({ sessionId }), - getCurrentPlan({ sessionId }), - ]); - - invariant(organizations, 'Failed to load organizations'); - invariant(user && user.id, 'Failed to load user'); - invariant(currentPlan && currentPlan.planId, 'Failed to load current plan'); - - invariant(accountId, 'Account ID is not defined'); - - localStorage.setItem(`${accountId}:spaces`, JSON.stringify(organizations)); - localStorage.setItem(`${accountId}:user`, JSON.stringify(user)); - localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlan)); + const data = await fetchOrganizationData(sessionId); + writeOrganizationDataToLocalStorage(accountId, data); } catch (error) { console.log('[organization] Failed to load Organizations', error); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index f3adfc6339..c319d97bac 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -33,7 +33,6 @@ import { getAllLocalFiles } from '~/common/project'; import { sortMethodMap } from '~/common/sorting'; import { invariant } from '~/common/utils/invariant'; import { useRootLoaderData } from '~/root'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file'; import { useProjectLoaderData, useProjectRouteContext } from '~/routes/organization.$organizationId.project.$projectId'; import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new'; @@ -53,6 +52,7 @@ import { OrganizationTabList } from '~/ui/components/tabs/tab-list'; import { TimeFromNow } from '~/ui/components/time-from-now'; import { showResourceNotFoundToast } from '~/ui/components/toast-notification'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { useGitFileIssues } from '~/ui/hooks/use-git-file-issues'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useOrganizationData } from '~/ui/hooks/use-organization-data'; @@ -97,7 +97,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { ) .map(f => f.formData?.get('backendProjectId')); - const organizationData = useOrganizationLoaderData(); + const organizations = useOrganizations(); const { presence } = useInsomniaEventStreamContext(); const { issuesByWorkspaceId } = useGitFileIssues(); const storageRules = useOrganizationStorageRule(organizationId); @@ -120,7 +120,7 @@ const Component = ({ loaderData }: Route.ComponentProps) => { const [importModalType, setImportModalType] = useState<'file' | 'clipboard' | 'uri' | null>(null); const [isNewProjectModalOpen, setIsNewProjectModalOpen] = useState(false); const [isUpdateProjectModalOpen, setIsUpdateProjectModalOpen] = useState(false); - const organization = organizationData?.organizations.find(o => o.id === organizationId); + const organization = organizations.find(o => o.id === organizationId); const isUserOwner = Boolean(organization?.is_owner); const collectionItems = useMemo( () => diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx index bd6548f6a6..0730062ca9 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.runner.tsx @@ -33,7 +33,6 @@ import { buildRunnerItemKey, type RunnerItemStatus, type RunnerLiveItem } from ' import { invariant } from '~/common/utils/invariant'; import { defaultSendActionRuntime } from '~/network/network'; import { useRootLoaderData } from '~/root'; -import { useOrganizationLoaderData } from '~/routes/organization'; import type { CollectionRunnerContext } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send'; import { sendActionImplementation } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.send'; import { AnalyticsEvent } from '~/ui/analytics'; @@ -55,6 +54,7 @@ import { Tooltip } from '~/ui/components/tooltip'; import { ResponseTimelineViewer } from '~/ui/components/viewers/response-timeline-viewer'; import { useInsomniaTabContext } from '~/ui/context/app/insomnia-tab-context'; import { useRunnerContext } from '~/ui/context/app/runner-context'; +import { useCurrentPlan } from '~/ui/hooks/use-account-server-data'; import { buildRunnerTabId } from '~/ui/hooks/use-insomnia-tab'; import { useRunnerRequestList } from '~/ui/hooks/use-runner-request-list'; import { @@ -155,7 +155,7 @@ export const Runner: FC = () => { const [searchParams] = useSearchParams(); const [errorMsg, setErrorMsg] = useState(null); - const organizationData = useOrganizationLoaderData(); + const currentPlan = useCurrentPlan(); const targetFolderId = searchParams.get('folder') || ''; const { organizationId, projectId, workspaceId } = useParams() as { @@ -290,7 +290,7 @@ export const Runner: FC = () => { window.main.trackAnalyticsEvent({ event: AnalyticsEvent.collectionRunExecute, - properties: { plan: organizationData?.currentPlan?.type || 'scratchpad', iterations: iterationCount }, + properties: { plan: currentPlan?.type || 'scratchpad', iterations: iterationCount }, }); updateTabById?.(buildRunnerTabId(workspaceId, targetFolderId), { temporary: false }); 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..61e782adec 100644 --- a/packages/insomnia/src/routes/organization.tsx +++ b/packages/insomnia/src/routes/organization.tsx @@ -1,9 +1,9 @@ -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 { useRootLoaderData } from '~/root'; @@ -30,45 +30,10 @@ import { InsomniaEventStreamProvider } from '~/ui/context/app/insomnia-event-str 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 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; @@ -194,8 +159,10 @@ const LoginUserActions = ({ ); }; -const Component = ({ loaderData }: Route.ComponentProps) => { - const { organizations, user, currentPlan } = loaderData; +const Component = () => { + const organizations = useOrganizations(); + const user = useCurrentUser(); + const currentPlan = useCurrentPlan(); const { settings } = useRootLoaderData()!; const workspaceData = useWorkspaceLoaderData(); 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/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/modals/import-modal/import-projects-modal.tsx b/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx index cdf4db716b..1b9c599e04 100644 --- a/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/import-modal/import-projects-modal.tsx @@ -9,13 +9,13 @@ import * as reactUse from 'react-use'; import type { ScanResult } from '~/common/import'; import { importScannedResources } from '~/routes/import.resources'; import { scanImportResources } from '~/routes/import.scan'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { createProject } from '~/routes/organization.$organizationId.project.new'; import { Checkbox } from '~/ui/components/base/checkbox'; import { Modal, type ModalHandle } from '~/ui/components/base/modal'; import { ModalHeader } from '~/ui/components/base/modal-header'; import { Icon } from '~/ui/components/icon'; import { Button } from '~/ui/components/themed-button'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder'; import { showModal } from '..'; @@ -503,9 +503,9 @@ export const ImportProjectsModal = ({ organizationId, onHide }: { organizationId modalRef.current?.show(); }, []); - const organizationData = useOrganizationLoaderData(); + const organizations = useOrganizations(); const organizationName = - organizationData?.organizations.find(org => org.id === organizationId)?.name || 'Organization'; + organizations.find(org => org.id === organizationId)?.name || 'Organization'; const [projectItems, setProjectItems] = useState([]); const [processingUIStatus, setProcessingUiStatus] = useState<'loading' | 'importing' | 'error' | 'complete'>( diff --git a/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx b/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx index cf2ce58f51..8c1c4faaa1 100644 --- a/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/input-vault-key-modal.tsx @@ -5,10 +5,10 @@ import { Button, Dialog, Heading, Input, Modal, ModalOverlay } from 'react-aria- import { useRootLoaderData } from '~/root'; import { useResetVaultKeyFetcher } from '~/routes/auth.reset-vault-key'; import { useValidateVaultKeyActionFetcher } from '~/routes/auth.validate-vault-key'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { PromptButton } from '~/ui/components/base/prompt-button'; import { Icon } from '~/ui/components/icon'; import { VaultKeyDisplayInput } from '~/ui/components/settings/vault-key-panel'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; export interface InputVaultKeyModalProps { onClose: (vaultKey?: string) => void; @@ -23,7 +23,7 @@ export const InputVaultKeyModal = (props: InputVaultKeyModalProps) => { const [resetDone, setResetDone] = useState(false); const resetVaultKeyFetcher = useResetVaultKeyFetcher(); const validateVaultKeyFetcher = useValidateVaultKeyActionFetcher(); - const { organizations } = useOrganizationLoaderData()!; + const organizations = useOrganizations(); const isLoading = resetVaultKeyFetcher.state !== 'idle' || validateVaultKeyFetcher.state !== 'idle'; useEffect(() => { diff --git a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx index 88a411e512..5fb4aeb113 100644 --- a/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx +++ b/packages/insomnia/src/ui/components/modals/invite-modal/invite-form.tsx @@ -18,11 +18,11 @@ import { getAppWebsiteBaseURL } from '~/common/constants'; import { docsPricingLearnMoreLink } from '~/common/documentation'; import { debounce } from '~/common/misc'; import { useRootLoaderData } from '~/root'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { useCollaboratorsSearchLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-search'; import { AnalyticsEvent } from '~/ui/analytics'; import { Icon } from '~/ui/components/icon'; import { useIsLightTheme } from '~/ui/hooks/theme'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { startInvite } from './encryption'; import { OrganizationMemberRolesSelector, SELECTOR_TYPE } from './organization-member-roles-selector'; @@ -122,8 +122,8 @@ export const InviteForm = ({ const [searchParams, setSearchParams] = useSearchParams(); const { userSession } = useRootLoaderData()!; - const organizationData = useOrganizationLoaderData(); - const organization = organizationData?.organizations.find(o => o.id === organizationId); + const organizations = useOrganizations(); + const organization = organizations.find(o => o.id === organizationId); const isUserOwner = Boolean(organization?.is_owner); const sessionId = userSession.id; diff --git a/packages/insomnia/src/ui/components/modals/invite-modal/invite-modal.tsx b/packages/insomnia/src/ui/components/modals/invite-modal/invite-modal.tsx index c39e5fee25..1804f1088c 100644 --- a/packages/insomnia/src/ui/components/modals/invite-modal/invite-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/invite-modal/invite-modal.tsx @@ -30,7 +30,6 @@ import { useParams, useSearchParams } from 'react-router'; import { getAppWebsiteBaseURL } from '~/common/constants'; import { debounce } from '~/common/misc'; import { invariant } from '~/common/utils/invariant'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { useCollaboratorsFetcher } from '~/routes/organization.$organizationId.collaborators'; import { useInviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId'; import { useReinviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite'; @@ -42,6 +41,7 @@ import { PromptButton } from '~/ui/components/base/prompt-button'; import { Icon } from '~/ui/components/icon'; import { AlertModal } from '~/ui/components/modals/alert-modal'; import { showModal } from '~/ui/components/modals/index'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { InviteForm } from './invite-form'; import { OrganizationMemberRolesSelector, SELECTOR_TYPE } from './organization-member-roles-selector'; @@ -597,8 +597,8 @@ export const InviteModalContainer: FC<{ }> = ({ isOpen, setIsOpen }) => { const [loadingOrgInfo, setLoadingOrgInfo] = useState(true); const { organizationId } = useParams(); - const organizationData = useOrganizationLoaderData(); - const currentOrg = organizationData?.organizations.find(o => o.id === organizationId); + const organizations = useOrganizations(); + const currentOrg = organizations.find(o => o.id === organizationId); const [allRoles, setAllRoles] = useState([]); const [currentUserRoleInOrg, setCurrentUserRoleInOrg] = useState(null); const [orgFeatures, setOrgFeatures] = useState(null); diff --git a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx index 1a00a3a06a..28f4d868ca 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-duplicate-modal.tsx @@ -5,8 +5,8 @@ import React, { type FC, type MouseEventHandler, useEffect, useRef, useState } f import { OverlayContainer } from 'react-aria'; import { href, useNavigate, useParams } from 'react-router'; -import { useOrganizationLoaderData } from '~/routes/organization'; import { useWorkspaceMoveActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.move'; +import { useOrganizations } from '~/ui/hooks/use-account-server-data'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '../../../common/get-workspace-label'; @@ -26,7 +26,7 @@ export const WorkspaceDuplicateModal: FC = ({ work organizationId: string; projectId: string; }; - const organizationData = useOrganizationLoaderData(); + const organizations = useOrganizations(); const [selectedOrgId, setSelectedOrgId] = useState(organizationId); const [projectOptions, setProjectOptions] = useState([]); const [selectedProjectId, setSelectedProjectId] = useState(''); @@ -115,7 +115,7 @@ export const WorkspaceDuplicateModal: FC = ({ work