Integrate Konnect sync with the new sidebar [INS-2358] (#9960)

* feat: breadcrumbs in nav (#9813)

* feat: New Navigation SideBar - P1 (#9808)

add initial navigation side bar
add debug page collection list back
support drag sidebar width
hide org selector on scratchpad
support scratcpad
support kconnect
* feat: support drag & drop on the new sidebar (#9814)

---------

Co-authored-by: Bingbing <[email protected]>

* feat: fix scratchpad for the new sidebar (#9815)

* feat: mcp ia navigation (#9819)

* feat: mcp ia navigation

* fix: ca status ui

* fix

* Apply suggestion from @Copilot

Co-authored-by: Copilot <[email protected]>

* fix

---------

Co-authored-by: Copilot <[email protected]>

* feat: Add workspace dropdown for new sidebar (#9821)

* support workspace-dropdown in new sidenav

* make the drag more easy to touch

* feat: auto detect select resource then expand and scroll to (#9835)

* feat: list un-synced remote workspaces in sidebar (#9853)

* Add nav support for unsynced workspace
* support inline change of request name
* fix expand issues

* feat: fix UI and remove debug settings in design spec and test (#9823)

* fix: fix UI style

* feat: remove debug settings in spec

* fix: remove debug settings in document test

* feat: add segment events [INS-2356]

* Feat/cloud sync bar (#9866)

* fix: fix MCP breadcrumb

* feat: sync bar

* fix: fix type and unused imports

* feat: Add organization actions inside organization selector (#9856)

* add organization actions inside organization selector

* fix issues

* fix type issues

* Fix style issue when no projects exist (#9896)

* feat: enhance dnd (#9877)

* feat: enhance dnd

* fix: suggestions from copilot

* fix: fix UI styles

* feat: Pin/unpin request and collection request sorting (#9903)

add basic sort support
support toggle header & sidebar
add sort support
fix issues from comment

* fix: tooltip and icon for env picker (#9905)

* Feat/ia merge (#9904)

* refactor: route fs backed cleanup (#9806)

* refactor: shared browser safe helper cleanup (#9810)

* refactor: shared browser-safe helper cleanup

* style: run eslint autofix

* fix: preserve empty url handling

* fix: address remaining copilot comments on pr3

* remove loader class

* fix: unhandledrejection error (#9774)

* fix: resolve sentry promise error (#9786)

* fix: resolve sentry promise error

* fix: leave fallback when error

* fix: improve credential validation handling in GitRepoForm to avoid a loop of re-loading the list of repos and branches (#9820)

* add e2e and cli skills (#9818)

* add e2e and cli skills

* address feedback

* address feedback

* move to claude

* feat: konnect integration proxy url and regex support (#9811)

* chore: move konnect sync behind feature flag (#9832)

* chore: isolate gRPC proto file preparation behind IPC boundary (#9828)

* chore: isolate gRPC proto file preparation behind IPC boundary

Move proto temp-file creation out of the renderer by adding a
grpc.writeProtoFile IPC handler (main process) and wiring it up
in the preload bridge. The renderer's ProtoFilesModal previously
called writeProtoFile() directly, pulling node:fs / node:os /
node:path into the renderer bundle. It now calls
window.main.grpc.writeProtoFile(protoFile._id) instead.

Changes:
- src/main/ipc/electron.ts: add 'grpc.writeProtoFile' to HandleChannels
- src/main/ipc/grpc.ts: export writeProtoFileById helper, add to
  gRPCBridgeAPI, register ipcMainHandle('grpc.writeProtoFile')
- src/entry.preload.ts: wire grpc.writeProtoFile via ipcRenderer.invoke
- src/ui/components/modals/proto-files-modal.tsx: remove direct
  write-proto-file import; use window.main.grpc.writeProtoFile in
  the directory-import validation loop
- config/renderer-node-import-baseline.json: remove 5 stale/resolved
  baseline entries (proto-directory-loader.tsx x2 already gone;
  write-proto-file.ts fs/os/path x3 now main-process-only)
- src/main/ipc/__tests__/grpc.test.ts: add writeProtoFileById unit
  tests as contract coverage for the new privileged bridge path

Co-authored-by: Copilot <[email protected]>

* fix: validate proto syntax in writeProtoFileById IPC handler

The directory-import validation loop relied on writeProtoFile for proto
content validation, but writeProtoFile only writes the temp file without
parsing. Add a protoLoader.load call inside writeProtoFileById so invalid
proto syntax throws before the result is returned to the renderer.

Also update the test to mock @grpc/proto-loader.load and assert it is
called with the correct file path and includeDirs.

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>

* feat: konnect integration strips nunjucks templates on sync (#9831)

* fix(Git Sync): auto-resolve non-YAML file conflicts to remote during merge (#9798)

* fix: filter conflict paths to include only YAML files

* fix: enhance conflict resolution by auto-resolving non-YAML files to theirs

* fix: keep buffer raw so that binary files are not corrupted

Co-authored-by: Copilot <[email protected]>

* fix: enhance merge conflict handling by introducing auto-resolved conflicts for non-YAML files

* fix: add test for handling merge conflicts, ensuring only YAML conflicts are returned

* fix: prevent HEAD update during auto-resolve of merge conflicts

* fix: enhance merge conflict resolution by auto-completing merges when all conflicts are non-YAML

---------

Co-authored-by: Copilot <[email protected]>

* feat: konnect integration expressions support (#9830)

* Show more specific error when creating mock route fails (#9841)

* fix: insomnia-ai-plugin uses securedPath (INS-2244) (#9748)

* feat: add custom npm registry mirror setting for plugin installation (#9837)

* feat: default user-agent for cURL imports [INS-2416] (#9838)

* feat: default user-agent for cURL imports

* respect disableAppVersionUserAgent setting

* fix: view transition error - [INS-2316] (#9792)

* fix: view transition error

* fix

* change default behavior when delete cloud sync workspaces (#9844)

* feat: integrate v3 user endpoints (#9785)

* feat: integrate v3 user endpoints

* feat: use public sdk for insomnia-api

* chore: applied PoLP to workflows (#9840)

* chore: resolve GHA warning annotations and reduce CI time [INS-2312] (#9839)

* fix: resolves INS-2366 (#9852)

* fix: resolves INS-2366 dependency issues

* Refactor:use electron store for oauth session (#9851)

* move oauth session to electron storage

* create electron storage bridge

* use electronStorage bridge for managing oauth window handles

* fix build

* move key to constants

* tolerate changing userData folder path

* Update packages/insomnia/src/main/ipc/electron-storage.ts

Co-authored-by: Copilot <[email protected]>

* init store

* fix singleton class

* feedback

* feedback

* Update packages/insomnia/src/main/electron-storage.ts

Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>

* chore: decouple releases (#9842)

* INS-2145 Decouple releases

* fix security error

* fix

* check version

* refactor: auth header to main (#9834)

* remove deprecated baseUrl

* add failing test

* fix AI playwright runs

* move getAuthHeader to main

* address feedback about dynamic import

* move oauth 1 + 2 flow to main

* handle bad cookie

* handle bad apikey

* fix imports

* block main process imports

* extract plugins

* fix vite config

* console log

* move init store

* Fix OAuth imports after rebase

Co-authored-by: Copilot <[email protected]>

* clean up

* Revert config changes

Co-authored-by: Copilot <[email protected]>

* clean up hawk

* use bridge

* update node require

* remove this

* define process type

* remove 14

* ignore reports folder

* fix e2e tests

* address feedback

* remove unused

* tidy constants

* feat: add getOAuth2Token IPC bridge

Co-authored-by: Copilot <[email protected]>

* fix tests

---------

Co-authored-by: Copilot <[email protected]>

* fix: Support pin and unpin websocket and socketio requests (#9865)

* support pin websocket and socketio requests

* feat(Git Sync): Add support for canonical repository output (#9789)

* initial support for canonical repo output (#9739)

* Feat/git repo output sync queue (#9790)

* feat: implement SyncQueue for serial async task processing

* refactor: enhance repo file watcher for improved sync and error handling

- Replace NeDB client with a unified disk client for all file operations.
- Introduce a serial queue to manage sync tasks and prevent race conditions.
- Implement content-hash deduplication to avoid unnecessary file imports.
- Add problem tracking for YAML files with conflicts or parse errors.
- Streamline watcher start/stop logic and improve notification handling.
- Ensure immediate DB→FS flush before git operations to maintain consistency.
- Enhance import logic to handle workspace deletions and renames effectively.

* refactor: simplify projectRoutableFSClient by removing unused parameters and consolidating logic

* feat: add git.db-synced event listener for revalidation in Root component

* feat: add button to open local repository folder in ProjectSettingsForm

* refactor: remove unused GitProjectNeDBClient

* refactor: update imports to use services for workspace and workspaceMeta

* refactor: update models usage to services in git repo migration and project settings form

* refactor: streamline file watcher initialization and import process

* feat: ensure immediate processing of pending debounced imports in RepoFileWatcher

* refactor: improve file rename handling in RepoFileWatcher to prevent data loss

* feat: enhance RepoFileWatcher to track last written hash and sync mtime for improved file management

* refactor: remove unused parameters from upsertDocs in RepoFileWatcher for cleaner code

* fix revalidator (#9826)

* fix: handle detached HEAD during rebase in getCurrentBranch method (#9843)

* fix: handle detached HEAD during rebase in getCurrentBranch method

* fix: add return type to getCurrentBranch method

* fix: refresh ui after sync (#9848)

* fix: (git cli)skip flush problematic files (#9846)

* fix: skip flush problematic files

* fix

* feat: (git cli)ux for invalide status (#9836)

* feat: ux for invalide status

* update ux

* fix

* fix

* add tab warning

* del log

* feat(Git Sync): Handle non-origin remotes (#9833)

* feat(git): detect non-origin branch tracking and guard sync operations

- Add getBranchTrackingRemote(), getRemoteUrl(), getBranchRemoteInfo() to GitVCS
- Add getBranchRemoteInfo IPC endpoint with BranchRemoteInfo interface
- Add assertBranchOnOrigin() guard to push, pull, fetch, commitAndPush
- canPushLoader returns { canPush: false } for non-origin branches
- Add unit tests for remote detection methods

* feat(git): add support for non-origin branch tracking and display warnings in UI

* Show local git repo path [INS-2315] (#9858)

* Update the style of local git folder path in project setting modal

* Add Git CLI tip in commit changes modal

* Repo Migration flow [INS-2256] (#9824)

* initial support for canonical repo output (#9739)

* feat: enhance git repository migration with concurrency guard and symlink handling

* feat: enhance git repository migration with config sanitization and file overwrite handling

* feat: implement repo migration version tracking and improve migration idempotency

* feat: add runAllGitRepoMigrations function and migration view for Git projects

Co-authored-by: Copilot <[email protected]>

* fix: reset initial migration status to 'default' in MigrationView component

* refactor: simplify MigrationView component and update navigation logic

* refactor: remove legacy directory structure migration from loadGitRepository function

* feat: enhance runAllGitRepoMigrations to return logs and improve error handling in MigrationView

* feat: update runAllGitRepoMigrations to return detailed logs and failed projects; enhance MigrationView to handle migration results

* feat: optimize runAllGitRepoMigrations by batch-fetching git repositories and improving project filtering

* feat: introduce CURRENT_MIGRATION_VERSION constant for migration tracking and update references in git-repo-migration and router

* feat: handle failed projects in runAllGitRepoMigrations by converting them to local projects

Co-authored-by: Copilot <[email protected]>

* feat: integrate CURRENT_MIGRATION_VERSION for migration tracking and update router logic to handle migration screen visibility

* feat: reorder import statements in ProjectSettingsForm for consistency

* feat: update MigrationStatus type and related logic for better error handling

* feat: enhance migration logging with detailed error stack and include CURRENT_MIGRATION_VERSION in logs

* feat: simplify migration logging messages for clarity and consistency

* feat: improve migration check logic to prioritize version stamp over disk layout

* feat: add tests for migrateRepoStructureIfNeeded function to ensure migration logic correctness

* feat: update migration logic to re-run when old git/ directory exists, ensuring correct migration handling

* test: update migration tests to ensure directory existence checks are accurate

* refactor: remove redundant useEffect for localStorage in Component

* feat: enhance path validation in runAllGitRepoMigrations to prevent path traversal vulnerabilities

* feat: enhance path handling in migration functions to prevent directory traversal vulnerabilities

* feat: enhance directory traversal protection in moveDirectoryContents function

---------

Co-authored-by: James Gatz <[email protected]>
Co-authored-by: Copilot <[email protected]>

* fix: Delete old folders (#9867)

* refactor: remove unused migration version handling from localStorage

* fix: update directory removal logic to handle non-empty directories

---------

Co-authored-by: Curry Yang <[email protected]>
Co-authored-by: yaoweiprc <[email protected]>
Co-authored-by: Pavlos Koutoglou <[email protected]>
Co-authored-by: Copilot <[email protected]>

* refactor: move sync code to main (#9827)

* move sync code to main

* improve sync tests

* update plan

* test: reset cloud sync smoke state

Co-authored-by: Copilot <[email protected]>

* guard against bad test env

* fix skill

* remove new test

* udpate plan

* with proxy

* checkpoint

* move files

* autofix

* update plan

* make all sync bridge async

* fix window imports

* refactor: move main-only sync helpers

Co-authored-by: Copilot <[email protected]>

* fix lint

* move chunkArray tests

* smaller interfaces

* move store under vcs

* move cloud-sync to main

* create a second vcs for pull operations

* added a invoke wrapper to remove error prefixes

* rebase error

---------

Co-authored-by: Copilot <[email protected]>

* chore: update insomnia-plugin-ai (#9862)

* fix: bump node-libcurl and add ipv6 tests (#9869)

* feat: revamping pre/post scripting sandbox (#9794)

* feat: revamping pre/post scripting sandbox
* feat: added UI setting to enable/disable specific checks

* fix false positives

* revert

* fix: user can not resolve conflict in app (#9872)

* fix: conflict ux

* fix

* fix

* Git server for smoke test [INS-2258] (#9816)

* Git server for smoke test

* Try to solve flaky test

* feat: remove unused Git hook samples and add Credentials tab functionality

- Deleted various sample Git hook scripts from the git-server fixture, including post-update, pre-applypatch, pre-commit, pre-merge-commit, pre-push, pre-rebase, pre-receive, prepare-commit-msg, push-to-checkout, sendemail-validate, and update hooks.
- Introduced a new PreferencesCredentialsTab class to manage Git credentials within the Insomnia Preferences.
- Updated the PreferencesPage to include the new Credentials tab for Git credentials management.
- Enhanced the ProjectPage with a method to create a Git Sync project, including branch creation and switching.
- Added comprehensive tests for Git Sync functionality, including creating branches, committing changes, and merging branches.
- Updated UI components to support new features, including data-testid attributes for better testability.

Co-authored-by: Copilot <[email protected]>

* feat: update path import and add Git sync tests

* revert package.json

* Update package.json

* feat: add new dependencies for Git HTTP mock server and related utilities

* refactor: remove commented-out code in addAccessTokenGitCredential function

* fix: update export tests to use toHaveLength for file count assertions

---------

Co-authored-by: Copilot <[email protected]>

* feat: import deep-link login experience [INS-2416] (#9860)

* refactor: replace node:url with URL in cert and proxy match (#9515)

* refactor: import to main (#9809)

* squash

* re add comments

* fix process fork

* update base line 18 left

* revert

* check cert url without node

* fix handlerId

* exclude url matches cert host from scope

* fix rebase

* Fix style issue that file list in the middle of commit modal is collapsed [INS-2315] (#9875)

* Fix style issue that file list in the middle of commit modal is collapsed

* fix: update links to Git Sync documentation in staging modal and project settings form

* Chore: playwright dx v2 (#9876)

* Update E2E test for git sync [INS-2258] (#9878)

* Add more test cases for git sync

* tmp

* Update package.json

* feat: update migration image and urls (#9868)

* feat: update migration image and path for improved clarity

* feat: update error message and support links in migration view

* feat(Git Sync): Downgrade -> Upgrade path (#9882)

* feat: add mechanism to flush newer DB workspaces to disk during downgrade

* feat: implement effective Git repository ID handling for project connections

* feat: enhance Git repository ID handling for improved project queries and updates

* Chore: refine e2e docs by agent for agent (#9881)

* improve agent docs

* added error context note

* chore: Security update for dependencies and github workflows (#9884)

* chore: resolves INS-2457, INS-2458, INS-2459, and INS-2460.

* feat: include app version in window title [INS-2465] (#9888)

* feat(Git Sync): improve git migration onboarding UX and local file system access [INS-2462] (#9890)

* feat(Migration): enhance migration summary with total projects count and improve UI feedback

* fix(Migration): clarify update instructions and improve user messaging

* style(ManualCommitForm): adjust text sizes for improved readability

* style(StagingModal): adjust layout and spacing for improved UI consistency

* fix(ManualCommitForm): update clipboard text to include 'cd' command for easier navigation

* fix(ProjectSettingsForm): update repository path copy functionality and add option to open in file system

* fix(ManualCommitForm): enhance file system interaction with tooltips for better user guidance

* fix(ProjectSettingsForm): add tooltip for 'Open in file system' button to enhance user guidance

* fix(GitProjectSyncDropdown): add 'Open folder' action to sync dropdown for easier access to repository path

* fix(Component): display relative path of current issue in modal for better context

* fix(git-service): count only successfully migrated projects in totalProjects

* fix(project-settings-form): platform-aware shell quoting for cd command

* fix(git-project-staging-modal): platform-aware shell quoting for cd command

* fix(project-settings-form): update aria-label to reflect cd command clipboard content

* fix(git-project-staging-modal): update aria-label to reflect shell command clipboard content

* fix(ManualCommitForm): replace tooltip with dialog for enhanced information display

* fix(MigrationView): update migrated count calculation to reflect total projects

---------

Co-authored-by: James Gatz <[email protected]>

* chore: normalize konnect api responses (#9895)

* feat(Git Sync): enhance migration view with best practices note and UI improvements (#9900)

* chore: comment out smctl credentials save in workflow (#9898)

the command is no longer executed while keeping it in place for future reference.

* feat: migrate model imports, base types, org model and helpers (#9802)

* fix

* fix vcsinstance

* fix type issues

---------

Co-authored-by: Jack Kavanagh <[email protected]>
Co-authored-by: James Gatz <[email protected]>
Co-authored-by: Shelby <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: yaoweiprc <[email protected]>
Co-authored-by: kwburns-kong <[email protected]>
Co-authored-by: jeremyjpj0916 <[email protected]>
Co-authored-by: Ryan Willis <[email protected]>
Co-authored-by: Kent Wang <[email protected]>
Co-authored-by: Alison Sabuwala <[email protected]>
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Co-authored-by: Jay Wu <[email protected]>
Co-authored-by: Pavlos Koutoglou <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Fares Osman <[email protected]>
Co-authored-by: Bingbing <[email protected]>
Co-authored-by: Vivek Thuravupala <[email protected]>

* fix: fix dnd for empty nodes (#9909)

* fix: fix dnd for empty nodes

* remove console log

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* initial fix for critical paths (#9913)

* 1.enhance the test component

* add test attributes

* fix basic test case

* fix smoke test p1

* fix smoke test p2

* fix e2e test failure p3

* fix smoke test p4

* fix remaining e2e failures

* feat: secondary sidebar&git cli error modal adaptation (#9916)

* feat: secondary sidebar&git cli error modal
* fix

* Fix: Document design page do not show collection list (#9930)

* fix no collection list showed in design page document tab

* use common constant for the wrapper id

* fix: move parent position control into modal component

---------

Co-authored-by: Curry Yang <[email protected]>

* Test: Fix all smoke test failures due to new navigation sidebar (#9921)

* enhance the test component
* add test attributes
* fix smoke test

* Fix failed git-sync E2E tests.

* fix type issues

* fix all e2e failure

* update screenshot

* fix issues from pr comment

* fix toggle issues

* fix grpc and smoke test issue

* fix slot issue

* tmp

* tmp

* Integrate Konnect sync with the new sidebar

* Apply style changes from https://github.com/Kong/insomnia/pull/9963

* fix: Using react-aria tab and extract common component for sidebar tab (#9967)

* extract common component and refine tabs

* fix issues

* streamlines button classes and spacing tweaks (#9969)

* Try to fix error in Check renderer Node import baseline

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Cancel sync err message

* Fix review issues

* minor ux fix (#9971)

* Disable disconnect button while validating PAT

---------

Co-authored-by: xdm <[email protected]>
Co-authored-by: Kent Wang <[email protected]>
Co-authored-by: Bingbing <[email protected]>
Co-authored-by: Curry Yang <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Jack Kavanagh <[email protected]>
Co-authored-by: James Gatz <[email protected]>
Co-authored-by: Shelby <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: kwburns-kong <[email protected]>
Co-authored-by: jeremyjpj0916 <[email protected]>
Co-authored-by: Ryan Willis <[email protected]>
Co-authored-by: Alison Sabuwala <[email protected]>
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Co-authored-by: Jay Wu <[email protected]>
Co-authored-by: Pavlos Koutoglou <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Fares Osman <[email protected]>
Co-authored-by: Vivek Thuravupala <[email protected]>
Co-authored-by: Curry Yang <[email protected]>
Co-authored-by: Missy Turco <[email protected]>
This commit is contained in:
yaoweiprc
2026-05-28 11:44:55 +08:00
committed by GitHub
co-authored by Copilot Autofix powered by AI xdm Kent Wang Bingbing Curry Yang Jack Kavanagh James Gatz Shelby Copilot kwburns-kong jeremyjpj0916 Ryan Willis Alison Sabuwala aikido-pr-checks[bot] Jay Wu Pavlos Koutoglou Copilot Fares Osman Vivek Thuravupala Curry Yang Missy Turco
parent 7cd8854f24
commit 637ae78d43
15 changed files with 930 additions and 831 deletions
+1
View File
@@ -30,6 +30,7 @@
"Dismissable",
"getinsomnia",
"inso",
"Konnect",
"libcurl",
"mockbin",
"Revalidator",
+1 -1
View File
@@ -20,7 +20,7 @@
"verify-bundle-plugins": "esr --cache ./scripts/verify-bundle-plugins.ts",
"install-x64-native-dependencies": "esr --cache ./scripts/install-x64-native-dependencies.ts",
"build": "react-router build && esr --cache ./scripts/build.ts --noErrorTruncation",
"analyze:renderer-node-imports": "cross-env INSOMNIA_NODE_IMPORT_REPORT=1 react-router build",
"analyze:renderer-node-imports": "cross-env NODE_OPTIONS=--max-old-space-size=8192 INSOMNIA_NODE_IMPORT_REPORT=1 react-router build",
"check:renderer-node-imports": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts",
"update:renderer-node-import-baseline": "npm run analyze:renderer-node-imports && esr --cache ./scripts/check-renderer-node-imports.ts --write-baseline",
"build:react-router": "react-router build",
@@ -113,7 +113,6 @@ export const getMockServiceBinURL = (mockServer: MockServer, path: string) => {
export const getAIServiceURL = () => env.INSOMNIA_AI_URL || 'https://ai-helper.insomnia.rest';
export const getKonnectApiBaseURL = () => env.KONNECT_API_URL || 'https://global.api.konghq.com';
export const isKonnectSyncEnabled = () => !!env.KONNECT_SYNC_ENABLED;
// App website
export const getAppWebsiteBaseURL = () => env.INSOMNIA_APP_WEBSITE_URL || 'https://app.insomnia.rest';
+175 -50
View File
@@ -67,7 +67,7 @@ interface ServiceSyncContext {
onProgress?: (message: string) => void;
}
function zeroCounts(): SyncCounts {
export function zeroCounts(): SyncCounts {
return { total: 0, created: 0, updated: 0, deleted: 0, skipped: 0 };
}
@@ -114,14 +114,17 @@ interface ExistingRequestData {
async function loadExistingRequestData(workspaceId: string): Promise<ExistingRequestData> {
// Include requests up to 2 levels deep (workspace → route folders → path×protocol sub-folders).
const topFolders = await db.find<RequestGroup>(models.requestGroup.type, { parentId: workspaceId });
const subFolders = topFolders.length > 0
? await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: topFolders.map(f => f._id) } })
: [];
const subFolders =
topFolders.length > 0
? await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: topFolders.map(f => f._id) } })
: [];
const allFolders = [...topFolders, ...subFolders];
const parentIds = [workspaceId, ...allFolders.map(f => f._id)];
const query = { parentId: { $in: parentIds }, konnectRouteKey: { $ne: null } };
const httpDocs = (await db.find<Request>(models.request.type, query)).filter(r => r.konnectRouteKey != null);
const wsDocs = (await db.find<WebSocketRequest>(models.webSocketRequest.type, query)).filter(r => r.konnectRouteKey != null);
const wsDocs = (await db.find<WebSocketRequest>(models.webSocketRequest.type, query)).filter(
r => r.konnectRouteKey != null,
);
const grpcDocs = (await db.find<GrpcRequest>(models.grpcRequest.type, query)).filter(r => r.konnectRouteKey != null);
return {
maps: {
@@ -144,7 +147,10 @@ async function syncGrpcRoute(
const grpcProtocols = route.protocols.filter(p => p === 'grpc' || p === 'grpcs') as ('grpc' | 'grpcs')[];
const multiProtocol = grpcProtocols.length > 1;
const paths = route.paths ?? [null];
const metadata = Object.entries(route.headers ?? {}).map(([n, values]: [string, string[]]) => ({ name: n.toLowerCase(), value: values[0] }));
const metadata = Object.entries(route.headers ?? {}).map(([n, values]: [string, string[]]) => ({
name: n.toLowerCase(),
value: values[0],
}));
const routeFolderId = await upsertRouteFolder(workspaceId, routeDisplayName(route), route.id);
@@ -172,12 +178,31 @@ async function syncGrpcRoute(
const konnectManagedHeaderNames = metadata.map(h => h.name);
if (existing) {
const merged = mergeHeaders(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? []);
if (existing.url !== url || existing.name !== name || existing.protoMethodName !== protoMethodName || konnectHeadersChanged(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? [])) {
await insoservices.grpcRequest.update(existing, { url, name, protoMethodName, metadata: merged, konnectManagedHeaderNames });
if (
existing.url !== url ||
existing.name !== name ||
existing.protoMethodName !== protoMethodName ||
konnectHeadersChanged(existing.metadata ?? [], metadata, existing.konnectManagedHeaderNames ?? [])
) {
await insoservices.grpcRequest.update(existing, {
url,
name,
protoMethodName,
metadata: merged,
konnectManagedHeaderNames,
});
routeCounts.updated++;
}
} else {
await insoservices.grpcRequest.create({ parentId, url, name, protoMethodName, metadata, konnectRouteKey: key, konnectManagedHeaderNames });
await insoservices.grpcRequest.create({
parentId,
url,
name,
protoMethodName,
metadata,
konnectRouteKey: key,
konnectManagedHeaderNames,
});
routeCounts.created++;
}
}
@@ -222,12 +247,31 @@ async function syncWsRoute(
if (existing) {
const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []);
const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters);
if (existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) {
await insoservices.webSocketRequest.update(existing, { url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames });
if (
existing.url !== url ||
existing.name !== name ||
konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) ||
pathParametersChanged(existing.pathParameters ?? [], pathParameters)
) {
await insoservices.webSocketRequest.update(existing, {
url,
name,
headers: merged,
pathParameters: mergedPathParams,
konnectManagedHeaderNames,
});
routeCounts.updated++;
}
} else {
await insoservices.webSocketRequest.create({ parentId, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames });
await insoservices.webSocketRequest.create({
parentId,
url,
name,
headers,
pathParameters,
konnectRouteKey: key,
konnectManagedHeaderNames,
});
routeCounts.created++;
}
}
@@ -275,12 +319,34 @@ async function syncHttpRoute(
if (existing) {
const merged = mergeHeaders(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []);
const mergedPathParams = mergePathParameters(existing.pathParameters ?? [], pathParameters);
if (existing.method !== method || existing.url !== url || existing.name !== name || konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) || pathParametersChanged(existing.pathParameters ?? [], pathParameters)) {
await insoservices.request.update(existing, { method, url, name, headers: merged, pathParameters: mergedPathParams, konnectManagedHeaderNames });
if (
existing.method !== method ||
existing.url !== url ||
existing.name !== name ||
konnectHeadersChanged(existing.headers ?? [], headers, existing.konnectManagedHeaderNames ?? []) ||
pathParametersChanged(existing.pathParameters ?? [], pathParameters)
) {
await insoservices.request.update(existing, {
method,
url,
name,
headers: merged,
pathParameters: mergedPathParams,
konnectManagedHeaderNames,
});
routeCounts.updated++;
}
} else {
await insoservices.request.create({ parentId, method, url, name, headers, pathParameters, konnectRouteKey: key, konnectManagedHeaderNames });
await insoservices.request.create({
parentId,
method,
url,
name,
headers,
pathParameters,
konnectRouteKey: key,
konnectManagedHeaderNames,
});
routeCounts.created++;
}
}
@@ -300,13 +366,22 @@ async function deleteStaleRequests(
): Promise<void> {
// Delete konnect-managed requests whose key no longer matches an incoming route
const stale: (() => Promise<void>)[] = [
...[...existingData.maps.http.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.request.remove(r)),
...[...existingData.maps.ws.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.webSocketRequest.remove(r)),
...[...existingData.maps.grpc.values()].filter(r => !incomingKeys.has(r.konnectRouteKey!)).map(r => () => insoservices.grpcRequest.remove(r)),
...[...existingData.maps.http.values()]
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
.map(r => () => insoservices.request.remove(r)),
...[...existingData.maps.ws.values()]
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
.map(r => () => insoservices.webSocketRequest.remove(r)),
...[...existingData.maps.grpc.values()]
.filter(r => !incomingKeys.has(r.konnectRouteKey!))
.map(r => () => insoservices.grpcRequest.remove(r)),
];
// Delete user-added requests (no konnectRouteKey) that live in the workspace or its folders.
const noKeyQuery = { parentId: { $in: existingData.parentIds }, $or: [{ konnectRouteKey: null }, { konnectRouteKey: { $exists: false } }] };
const noKeyQuery = {
parentId: { $in: existingData.parentIds },
$or: [{ konnectRouteKey: null }, { konnectRouteKey: { $exists: false } }],
};
const userHttp = await db.find<Request>(models.request.type, noKeyQuery);
const userWs = await db.find<WebSocketRequest>(models.webSocketRequest.type, noKeyQuery);
const userGrpc = await db.find<GrpcRequest>(models.grpcRequest.type, noKeyQuery);
@@ -326,12 +401,16 @@ async function deleteStaleRequests(
const folderIds = existingData.folders.map(f => f._id);
const foldersWithChildren = new Set<string>([
...(await db.find<Request>(models.request.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
...(await db.find<WebSocketRequest>(models.webSocketRequest.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
...(await db.find<WebSocketRequest>(models.webSocketRequest.type, { parentId: { $in: folderIds } })).map(
r => r.parentId,
),
...(await db.find<GrpcRequest>(models.grpcRequest.type, { parentId: { $in: folderIds } })).map(r => r.parentId),
...(await db.find<RequestGroup>(models.requestGroup.type, { parentId: { $in: folderIds } })).map(f => f.parentId),
]);
for (const folder of existingData.folders) {
if (!folder.konnectRouteId) { continue; }
if (!folder.konnectRouteId) {
continue;
}
if (!incomingRouteIds.has(folder.konnectRouteId) || !foldersWithChildren.has(folder._id)) {
await insoservices.requestGroup.remove(folder);
}
@@ -359,7 +438,12 @@ async function syncServiceWorkspace(
workspace = existingWorkspace;
}
} else {
workspace = await insoservices.workspace.create({ parentId: project._id, name: serviceName, scope: 'collection', konnectServiceId: service.id });
workspace = await insoservices.workspace.create({
parentId: project._id,
name: serviceName,
scope: 'collection',
konnectServiceId: service.id,
});
counts.services.created++;
}
counts.services.total++;
@@ -371,7 +455,9 @@ async function syncServiceWorkspace(
}
await insoservices.cookieJar.getOrCreateForParentId(workspace._id);
const incomingRoutes = (await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal)).map(sanitizeRoute);
const incomingRoutes = (await fetchRoutesForService(pat, controlPlane.id, service.id, region, signal)).map(
sanitizeRoute,
);
const existingData = await loadExistingRequestData(workspace._id);
const incomingKeys = new Set<string>();
const incomingRouteIds = new Set<string>();
@@ -396,7 +482,11 @@ async function syncServiceWorkspace(
if (isL4) {
counts.routes.skipped++;
skippedRoutes.push({ routeName, reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`, serviceName });
skippedRoutes.push({
routeName,
reason: `Unsupported protocol: ${effectiveRoute.protocols.join(', ')}`,
serviceName,
});
continue;
}
@@ -416,9 +506,14 @@ async function syncServiceWorkspace(
// Host header only applies to HTTP/WS — gRPC uses :authority which Insomnia derives from the URL
const headers = [
...(effectiveRoute.hosts?.[0] ? [{ name: 'host', value: effectiveRoute.hosts[0] }] : []),
...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({ name: name.toLowerCase(), value: values[0] })),
...Object.entries(effectiveRoute.headers ?? {}).map(([name, values]) => ({
name: name.toLowerCase(),
value: values[0],
})),
];
await (isWs ? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys) : syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
await (isWs
? syncWsRoute(effectiveRoute, workspace._id, headers, existingData.maps.ws, counts.routes, incomingKeys)
: syncHttpRoute(effectiveRoute, workspace._id, headers, existingData.maps.http, counts.routes, incomingKeys));
}
}
@@ -432,13 +527,14 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project:
parentId: project._id,
scope: 'environment',
});
const envWorkspace = existingEnvWorkspaces.length > 0
? existingEnvWorkspaces[0]
: await insoservices.workspace.create({
parentId: project._id,
name: `${controlPlane.name} Environment`,
scope: 'environment',
});
const envWorkspace =
existingEnvWorkspaces.length > 0
? existingEnvWorkspaces[0]
: await insoservices.workspace.create({
parentId: project._id,
name: `${controlPlane.name} Environment`,
scope: 'environment',
});
const projectEnv = await insoservices.environment.getOrCreateForParentId(envWorkspace._id);
const existingKvPairs = projectEnv.kvPairData ?? [];
@@ -446,7 +542,13 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project:
const proxyDefaults = deriveProxyVarDefaults(controlPlane.proxy_urls);
const newKvPairs = [...KONNECT_PROXY_VAR_NAMES]
.filter(name => !existingByName.has(name))
.map(name => ({ id: `env_${name}`, name, value: proxyDefaults[name] ?? '', type: EnvironmentKvPairDataType.STRING, enabled: true }));
.map(name => ({
id: `env_${name}`,
name,
value: proxyDefaults[name] ?? '',
type: EnvironmentKvPairDataType.STRING,
enabled: true,
}));
// For existing vars that are still empty, fill in from proxy_urls if available
const updatedExisting = existingKvPairs.map(kv => {
@@ -497,7 +599,10 @@ async function syncControlPlane(
let project = existingProjectsByKonnectId.get(controlPlane.id);
if (project) {
if (project.name !== controlPlane.name || project.konnectClusterType !== controlPlane.config.cluster_type) {
project = await insoservices.project.update(project, { name: controlPlane.name, konnectClusterType: controlPlane.config.cluster_type });
project = await insoservices.project.update(project, {
name: controlPlane.name,
konnectClusterType: controlPlane.config.cluster_type,
});
acc.controlPlaneCounts.updated++;
}
} else {
@@ -517,10 +622,12 @@ async function syncControlPlane(
const services = await fetchAllServices(pat, controlPlane.id, region, signal);
// Load existing Konnect workspaces for this project once, keyed by service id
const existingWorkspaces = (await db.find<Workspace>(models.workspace.type, {
parentId: project._id,
konnectServiceId: { $ne: null },
})).filter(w => w.konnectServiceId != null);
const existingWorkspaces = (
await db.find<Workspace>(models.workspace.type, {
parentId: project._id,
konnectServiceId: { $ne: null },
})
).filter(w => w.konnectServiceId != null);
const existingWorkspaceByServiceId = new Map(existingWorkspaces.map(w => [w.konnectServiceId!, w]));
const incomingServiceIds = new Set(services.map(s => s.id));
@@ -531,12 +638,20 @@ async function syncControlPlane(
for (let i = 0; i < services.length; i += CONCURRENCY) {
signal?.throwIfAborted();
const batch = services.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(batch.map(async service => {
const localCounts = { services: zeroCounts(), routes: zeroCounts() };
const localSkipped: SkippedRoute[] = [];
await syncServiceWorkspace(ctx, service, existingWorkspaceByServiceId.get(service.id), localCounts, localSkipped);
return { counts: localCounts, skipped: localSkipped };
}));
const batchResults = await Promise.all(
batch.map(async service => {
const localCounts = { services: zeroCounts(), routes: zeroCounts() };
const localSkipped: SkippedRoute[] = [];
await syncServiceWorkspace(
ctx,
service,
existingWorkspaceByServiceId.get(service.id),
localCounts,
localSkipped,
);
return { counts: localCounts, skipped: localSkipped };
}),
);
for (const { counts, skipped } of batchResults) {
mergeCounts(acc.serviceCounts, counts.services);
mergeCounts(acc.routeCounts, counts.routes);
@@ -567,10 +682,12 @@ export async function syncKonnect({ pat, organizationId, signal, onProgress }: S
try {
// Load all existing Konnect projects up front to avoid per Control Plane queries
const existingProjects = (await db.find<Project>(models.project.type, {
parentId: organizationId,
konnectControlPlaneId: { $ne: null },
})).filter(p => p.konnectControlPlaneId != null);
const existingProjects = (
await db.find<Project>(models.project.type, {
parentId: organizationId,
konnectControlPlaneId: { $ne: null },
})
).filter(p => p.konnectControlPlaneId != null);
const existingProjectsByKonnectId = new Map(existingProjects.map(p => [p.konnectControlPlaneId!, p]));
const incomingControlPlaneIds = new Set<string>();
const syncCtx: SyncContext = { pat, organizationId, existingProjectsByKonnectId, signal, onProgress };
@@ -604,6 +721,14 @@ export async function syncKonnect({ pat, organizationId, signal, onProgress }: S
const errorMessage = err instanceof Error ? err.message : String(err);
const durationMs = Date.now() - startTime;
return { success: false, controlPlanes: acc.controlPlaneCounts, services: acc.serviceCounts, routes: acc.routeCounts, skippedRoutes: acc.skippedRoutes, durationMs, error: errorMessage };
return {
success: false,
controlPlanes: acc.controlPlaneCounts,
services: acc.serviceCounts,
routes: acc.routeCounts,
skippedRoutes: acc.skippedRoutes,
durationMs,
error: errorMessage,
};
}
}
@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react';
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
import { database } from '~/common/database';
import { services } from '~/insomnia-data';
import { validatePat } from '~/konnect/api';
import { useRootLoaderData } from '~/root';
import { AnalyticsEvent } from '~/ui/analytics';
import { useSettingsPatcher } from '../../hooks/use-request';
import { Icon } from '../icon';
export const KonnectSettingsModal = ({
onClose,
syncKonnectProjectsAndNotifyRef,
}: {
onClose: () => void;
syncKonnectProjectsAndNotifyRef: React.MutableRefObject<() => Promise<void>>;
}) => {
const { settings } = useRootLoaderData()!;
const patchSettings = useSettingsPatcher();
const [pat, setPat] = useState('');
const [isPatVisible, setIsPatVisible] = useState(false);
// 'idle' | 'validating' | 'valid' | 'invalid'
const [status, setStatus] = useState<'idle' | 'validating' | 'valid' | 'invalid'>('idle');
const [validationError, setValidationError] = useState<string | null>(null);
// Controls whether the disconnect confirmation screen is shown
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// Validate the given PAT against the Konnect API and update status/error state accordingly.
const validateAndSetStatus = async (trimmed: string) => {
setStatus('validating');
setValidationError(null);
const result = await validatePat(trimmed);
setStatus(result.valid ? 'valid' : 'invalid');
if (!result.valid) {
setValidationError(result.error ?? 'Invalid PAT. Check your input and try again.');
}
return result;
};
// On mount: if a PAT is already stored, load it from secure storage and validate it.
useEffect(() => {
if (settings.hasKonnectPat) {
window.main.secretStorage.getSecret('konnectPat').then(secret => {
if (secret) {
setPat(secret);
validateAndSetStatus(secret);
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Validate the PAT, persist it to secure storage, and trigger an initial sync.
const handleConnect = async () => {
const trimmed = pat.trim();
if (!trimmed) {
return;
}
const result = await validateAndSetStatus(trimmed);
if (result.valid) {
await window.main.secretStorage.setSecret('konnectPat', trimmed);
patchSettings({ hasKonnectPat: true });
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated });
syncKonnectProjectsAndNotifyRef.current();
}
};
// Delete all Konnect-synced projects from the local DB, remove the stored PAT, and close the modal.
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
const allProjects = await services.project.list();
const konnectProjects = allProjects.filter(p => p.konnectControlPlaneId != null);
const bufferId = await database.bufferChangesIndefinitely();
try {
for (const project of konnectProjects) {
await services.project.remove(project);
}
} finally {
await database.flushChanges(bufferId);
}
await window.main.secretStorage.deleteSecret('konnectPat');
patchSettings({ hasKonnectPat: false });
onClose();
} finally {
setIsDisconnecting(false);
}
};
// A connected state means the PAT is saved and has not been found invalid.
const isConnected = settings.hasKonnectPat && status !== 'invalid';
return (
<ModalOverlay
isOpen
isDismissable
onOpenChange={isOpen => {
if (!isOpen) {
onClose();
}
}}
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
>
<Modal className="flex w-full max-w-3xl flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)">
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-hidden">
{({ close }) => (
<div className="flex h-full flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Heading slot="title" className="text-lg font-bold">
{showDisconnectConfirm ? 'Disconnect Kong Konnect?' : 'Kong Konnect settings'}
</Heading>
<Button
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-sm text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
{showDisconnectConfirm ? (
<>
<p className="text-sm text-(--hl)">
Disconnecting will remove your Personal Access Token and delete related project data. This action
cannot be undone. Are you sure?
</p>
<div className="flex items-center justify-end gap-3 pt-2">
<Button
className="rounded-xs border border-solid border-(--hl-sm) px-4 py-2 text-sm text-(--color-font) hover:bg-(--hl-xs)"
onPress={() => setShowDisconnectConfirm(false)}
>
Cancel
</Button>
<Button
className="rounded-xs bg-(--color-danger) px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
isDisabled={isDisconnecting}
onPress={handleDisconnect}
>
{isDisconnecting ? <Icon icon="spinner" className="animate-spin" /> : 'Disconnect & delete data'}
</Button>
</div>
</>
) : (
<>
<div className="flex flex-col gap-3">
<label className="text-sm font-semibold" htmlFor="konnect-modal-pat">
Personal Access Token
</label>
<p className="text-sm text-(--hl)">
Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects.
</p>
<button
className="w-fit text-sm text-(--color-font) underline hover:opacity-80"
onClick={() => window.main.openInBrowser('https://cloud.konghq.com/global/account/tokens')}
>
Generate new PAT <Icon icon="arrow-up-right-from-square" className="text-xs" />
</button>
<div className="relative">
<input
id="konnect-modal-pat"
type={isPatVisible ? 'text' : 'password'}
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1.5 pr-8 text-(--color-font) focus:border-(--hl-lg) focus:outline-hidden"
placeholder={
isConnected
? 'Enter new PAT to replace existing'
: 'e.g. kpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
}
value={pat}
onChange={e => {
setPat(e.target.value);
if (status !== 'idle') {
setStatus('idle');
setValidationError(null);
}
}}
autoComplete="off"
/>
<Button
aria-label={isPatVisible ? 'Hide PAT' : 'Show PAT'}
className="absolute top-1/2 right-2 -translate-y-1/2 text-sm text-(--hl) hover:text-(--color-font)"
onPress={() => setIsPatVisible(v => !v)}
>
<Icon icon={isPatVisible ? 'eye-slash' : 'eye'} />
</Button>
</div>
{status === 'invalid' && (
<p className="text-sm text-(--color-danger)">
{validationError ?? 'Invalid PAT. Check your input and try again.'}
</p>
)}
{(status === 'valid' || (isConnected && status === 'idle')) && (
<p className="text-sm text-(--color-success)">Connected</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1.5 text-sm text-(--color-font) hover:bg-(--hl-xs) disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
isDisabled={!pat.trim() || status === 'validating'}
onPress={handleConnect}
>
{status === 'validating' ? <Icon icon="spinner" className="animate-spin" /> : 'Connect & Sync'}
</Button>
{isConnected && (
<Button
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1.5 text-sm font-semibold text-(--color-font) hover:bg-(--hl-xs)"
onPress={() => setShowDisconnectConfirm(true)}
isDisabled={status === 'validating'}
>
Disconnect
</Button>
)}
</div>
</>
)}
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};
@@ -1,15 +1,12 @@
import { getOrganizationFeatures } from 'insomnia-api';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { useParams } from 'react-router';
import { AI_PLUGIN_NAME, isKonnectSyncEnabled } from '~/common/constants';
import { models } from '~/insomnia-data';
import { AI_PLUGIN_NAME } from '~/common/constants';
import { useRootLoaderData } from '~/root';
import { AnalyticsEvent } from '~/ui/analytics';
import { AISettings } from '~/ui/components/settings/ai-settings';
import { CredentialsSettings } from '~/ui/components/settings/credentials';
import { KonnectSettings } from '~/ui/components/settings/konnect-settings';
import { ScriptingSettings } from '~/ui/components/settings/scripting-settings';
import { getAppVersion, getProductName } from '../../../common/constants';
@@ -32,7 +29,16 @@ export interface SettingsModalHandle {
show: (options?: { tab?: SettingsModalTabKey }) => void;
}
type SettingsModalTabKey = 'data' | 'keyboard' | 'themes' | 'plugins' | 'general' | 'proxy' | 'credentials' | 'ai' | 'scripting' | 'konnect';
type SettingsModalTabKey =
| 'data'
| 'keyboard'
| 'themes'
| 'plugins'
| 'general'
| 'proxy'
| 'credentials'
| 'ai'
| 'scripting';
export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props, ref) => {
const [defaultTabKey, setDefaultTabKey] = useState('general');
@@ -42,29 +48,12 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
const { organizationId } = useParams() as { organizationId?: string };
const [shouldShowAiSettingsTab, setShouldShowAiSettingsTab] = useState(false);
const [shouldShowKonnectTab, setShouldShowKonnectTab] = useState(false);
useEffect(() => {
const checkFeatures = async () => {
const plugins = await pluginsBridge.getBundlePlugins();
const aiPlugin = plugins.find(p => p.name === AI_PLUGIN_NAME);
setShouldShowAiSettingsTab(!!aiPlugin && !!userSession.id);
if (
isKonnectSyncEnabled() &&
userSession.id &&
organizationId &&
!models.organization.isScratchpadOrganizationId(organizationId)
) {
try {
const res = await getOrganizationFeatures({ organizationId, sessionId: userSession.id });
setShouldShowKonnectTab(res?.features?.konnectSync?.enabled ?? false);
} catch {
setShouldShowKonnectTab(false);
}
} else {
setShouldShowKonnectTab(false);
}
};
checkFeatures();
}, [userSession.id, organizationId]);
@@ -173,14 +162,6 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
AI Settings
</Tab>
)}
{shouldShowKonnectTab && (
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
id="konnect"
>
Konnect
</Tab>
)}
</TabList>
<TabPanel className="h-full w-full overflow-y-auto" id="general">
<General />
@@ -244,11 +225,6 @@ export const SettingsModal = forwardRef<SettingsModalHandle, ModalProps>((props,
<AISettings />
</TabPanel>
)}
{shouldShowKonnectTab && (
<TabPanel className="h-full w-full overflow-y-auto" id="konnect">
<KonnectSettings />
</TabPanel>
)}
</Tabs>
</ModalBody>
</Modal>
@@ -1,301 +0,0 @@
import type { StorageRules } from 'insomnia-api';
import type React from 'react';
import { Button, GridList, GridListItem, Input, SearchField } from 'react-aria-components';
import { useNavigate } from 'react-router';
import * as reactUse from 'react-use';
import type { GitRepository, Project } from '~/insomnia-data';
import { models } from '~/insomnia-data';
import type { SyncResult } from '~/konnect/sync';
import { AnalyticsEvent } from '~/ui/analytics';
import { useKonnectSync } from '../../hooks/use-konnect-sync';
import { AvatarGroup } from '../avatar';
import { ProjectDropdown } from '../dropdowns/project-dropdown';
import { Icon } from '../icon';
import { showModal } from '../modals';
import { AlertModal } from '../modals/alert-modal';
import { AskModal } from '../modals/ask-modal';
export type ProjectWithPresence = Project & {
gitRepository?: GitRepository;
presence: {
key: string;
alt: string;
src: string;
}[];
};
interface ProjectListSidebarProps {
organizationId: string;
activeProjectId?: string;
projects: ProjectWithPresence[];
storageRules: StorageRules;
onCreateProject: () => void;
konnectSyncEnabled: boolean;
}
const TAB_CLASS_ACTIVE = 'border-b-2 border-solid border-b-(--color-surprise) px-3 py-1 text-xs uppercase text-(--color-font)';
const TAB_CLASS_INACTIVE = 'px-3 py-1 text-xs uppercase text-(--hl) hover:bg-(--hl-xs)';
const ROW_CLASS = 'relative flex h-(--line-height-xs) w-full items-center gap-2 overflow-hidden px-4 text-(--hl) outline-hidden transition-colors select-none group-hover:bg-(--hl-xs) group-focus:bg-(--hl-sm) group-aria-selected:text-(--color-font)';
const ACTION_BUTTON_CLASS = 'flex h-full items-center gap-1 rounded-xs px-2 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)';
const ProjectGridList = ({ label, items, activeProjectId, organizationId, children }: {
label: string;
items: ProjectWithPresence[];
activeProjectId?: string;
organizationId: string;
children: (item: ProjectWithPresence) => React.ReactNode;
}) => {
const navigate = useNavigate();
return (
<GridList
aria-label={label}
items={items}
className="flex-1 overflow-y-auto py-(--padding-sm) data-empty:py-0"
disallowEmptySelection
selectedKeys={activeProjectId ? [activeProjectId] : ['']}
selectionMode="single"
onSelectionChange={keys => {
if (keys !== 'all') {
const [value] = keys.values();
navigate({ pathname: `/organization/${organizationId}/project/${value}` });
}
}}
>
{item => (
<GridListItem key={item._id} id={item._id} textValue={item.name} className="group outline-hidden select-none">
<div className={ROW_CLASS}>
<span className="absolute top-0 left-0 h-full w-[2px] bg-transparent transition-colors group-aria-selected:bg-(--color-surprise)" />
{children(item)}
</div>
</GridListItem>
)}
</GridList>
);
};
const FilterSearchField = ({ label, value, onChange, isDisabled }: { label: string; value: string; onChange: (value: string) => void; isDisabled?: boolean }) => (
<SearchField aria-label={label} className="group relative flex-1" value={value} onChange={onChange} isDisabled={isDisabled}>
<Input
placeholder="Filter"
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
/>
<div className="absolute top-0 right-0 flex h-full items-center px-2">
<Button className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)">
<Icon icon="close" />
</Button>
</div>
</SearchField>
);
const filterByName = (items: ProjectWithPresence[], query: string | undefined) =>
query ? items.filter(p => p.name?.toLowerCase().includes(query.toLowerCase())) : items;
function showSkippedRoutesModal(result: SyncResult | null) {
if (!result?.success || !result.skippedRoutes.length) { return; }
const byService = new Map<string, string[]>();
for (const { serviceName, routeName, reason } of result.skippedRoutes) {
const list = byService.get(serviceName) ?? [];
list.push(`${routeName} — ${reason}`);
byService.set(serviceName, list);
}
showModal(AlertModal, {
title: 'Skipped Routes',
message: (
<div>
<p>{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:</p>
{[...byService.entries()].map(([service, routes]) => (
<div key={service} style={{ margin: '8px 0' }}>
<strong>{service}</strong>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{routes.map(r => <li key={r}>{r}</li>)}
</ul>
</div>
))}
</div>
),
});
}
export const ProjectListSidebar = ({
organizationId,
activeProjectId,
projects,
storageRules,
onCreateProject,
konnectSyncEnabled,
}: ProjectListSidebarProps) => {
const [storedTab, setActiveTab] = reactUse.useLocalStorage<'projects' | 'konnect'>(
`${organizationId}:sidebar-tab`,
'projects',
);
const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects');
const [projectListFilter, setProjectListFilter] = reactUse.useLocalStorage(
`${organizationId}:project-list-filter`,
'',
);
const [konnectFilter, setKonnectFilter] = reactUse.useLocalStorage(
`${organizationId}:konnect-filter`,
'',
);
const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync();
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
const filteredProjects = filterByName(nonKonnectProjects, projectListFilter);
const filteredKonnectProjects = filterByName(konnectProjects, konnectFilter);
const handleSync = async () => {
if (!konnectSyncEnabled) {
return;
}
const runAndNotify = async () => {
const result = await startSync(organizationId);
showSkippedRoutesModal(result);
};
const isResync = konnectProjects.length > 0;
if (isResync) {
showModal(AskModal, {
title: 'Re-sync Konnect',
message: (
<div>
<p>Re-syncing will make the following changes:</p>
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li><strong>Reset</strong> — request method, URL, name, and Konnect-managed headers</li>
<li><strong>Delete</strong> — requests added manually or no longer in Konnect</li>
<li><strong>Preserve</strong> — body, auth, query params, scripts, description, and user-added headers</li>
</ul>
<p>This cannot be undone. Continue?</p>
</div>
),
yesText: 'Re-sync',
noText: 'Cancel',
color: 'warning',
onDone: async (confirmed: boolean) => {
if (confirmed) {
await runAndNotify();
}
},
});
} else {
await runAndNotify();
}
};
const tabBar = (
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
<button className={activeTab === 'projects' ? TAB_CLASS_ACTIVE : TAB_CLASS_INACTIVE} onClick={() => setActiveTab('projects')}>
Projects ({nonKonnectProjects.length})
</button>
{konnectSyncEnabled && (
<button className={activeTab === 'konnect' ? TAB_CLASS_ACTIVE : TAB_CLASS_INACTIVE} onClick={() => setActiveTab('konnect')}>
Konnect ({konnectProjects.length})
</button>
)}
</div>
);
if (activeTab === 'konnect' && konnectSyncEnabled) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
{tabBar}
<div className="flex justify-between gap-1 p-(--padding-sm)">
<FilterSearchField label="Konnect filter" value={konnectFilter || ''} onChange={setKonnectFilter} />
{syncing ? (
<Button
aria-label="Cancel sync"
onPress={() => cancelSync()}
className={ACTION_BUTTON_CLASS}
>
Cancel
<Icon icon="stop-circle" />
</Button>
) : (
<Button
aria-label="Sync Konnect"
onPress={handleSync}
className={ACTION_BUTTON_CLASS}
>
Sync
<Icon icon="refresh" />
</Button>
)}
</div>
{syncing && (
<p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>
)}
{syncError && (
<p className="px-4 pb-1 text-xs text-(--color-danger)">{syncError}</p>
)}
<ProjectGridList label="Konnect projects" items={filteredKonnectProjects} activeProjectId={activeProjectId} organizationId={organizationId}>
{item => {
return (
<>
<Icon icon='server' />
<span className="truncate">{item.name}</span>
</>
);
}}
</ProjectGridList>
{konnectProjects.length === 0 && !syncing && (
<p className="px-4 py-2 text-xs text-(--hl) italic">
No Konnect projects yet. Click sync to import your control planes.
</p>
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{tabBar}
<div className="flex justify-between gap-1 p-(--padding-sm)">
<FilterSearchField
label="Projects filter"
value={projectListFilter || ''}
isDisabled={nonKonnectProjects.length === 0}
onChange={value => {
setProjectListFilter(value);
if (value.trim() !== '') {
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.filterCreatedProjects });
}
}}
/>
<Button
aria-label="Create new Project"
onPress={onCreateProject}
className="flex aspect-square h-full items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="plus-circle" />
</Button>
</div>
<ProjectGridList label="Projects" items={filteredProjects} activeProjectId={activeProjectId} organizationId={organizationId}>
{item => (
<>
<Icon icon={models.project.isRemoteProject(item) ? 'globe-americas' : models.project.isGitProject(item) ? ['fab', 'git-alt'] : 'laptop'} />
<span className="truncate">{item.name}</span>
<span className="flex-1" />
{item.presence.length > 0 && <AvatarGroup size="small" maxAvatars={3} items={item.presence} />}
{item._id !== models.project.SCRATCHPAD_PROJECT_ID && (
<ProjectDropdown organizationId={organizationId} project={item} storageRules={storageRules} />
)}
</>
)}
</ProjectGridList>
</div>
);
};
@@ -1,109 +0,0 @@
import { useState } from 'react';
import { Button } from 'react-aria-components';
import { validatePat } from '~/konnect/api';
import { useRootLoaderData } from '~/root';
import { AnalyticsEvent } from '~/ui/analytics';
import { useSettingsPatcher } from '../../hooks/use-request';
export const KonnectSettings = () => {
const { settings } = useRootLoaderData()!;
const patchSettings = useSettingsPatcher();
const [pat, setPat] = useState('');
const [status, setStatus] = useState<'idle' | 'validating' | 'valid' | 'invalid'>('idle');
const [validationError, setValidationError] = useState<string | null>(null);
const handleValidate = async () => {
const trimmed = pat.trim();
if (!trimmed) {
return;
}
setStatus('validating');
setValidationError(null);
const result = await validatePat(trimmed);
setStatus(result.valid ? 'valid' : 'invalid');
if (result.valid) {
await window.main.secretStorage.setSecret('konnectPat', trimmed);
patchSettings({ hasKonnectPat: true });
setPat('');
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated });
} else {
setValidationError(result.error ?? 'PAT is invalid or could not connect to Konnect.');
}
};
const handleClear = async () => {
await window.main.secretStorage.deleteSecret('konnectPat');
patchSettings({ hasKonnectPat: false });
setPat('');
setStatus('idle');
};
return (
<div className="p-4">
<h2 className="sticky top-0 left-0 z-10 bg-(--color-bg) pt-2 pb-2 text-lg font-bold">Kong Konnect</h2>
<p className="mb-4 text-sm text-(--hl)">
Enter a Personal Access Token (PAT) to sync your Konnect control planes into Insomnia projects.
Generate one at{' '}
<a
className="underline"
href="https://cloud.konghq.com/global/account/tokens"
onClick={e => {
e.preventDefault();
window.main.openInBrowser('https://cloud.konghq.com/global/account/tokens');
}}
>
https://cloud.konghq.com/global/account/tokens
</a>
.
</p>
<div className="mb-4 flex flex-col gap-2">
<label className="text-sm font-semibold" htmlFor="konnect-pat">
Personal Access Token
</label>
{settings.hasKonnectPat ? (
<div className="flex items-center gap-2">
<span className="text-sm text-(--hl)">kpat_••••••••</span>
<span className="text-xs text-(--color-success)">Saved</span>
</div>
) : null}
<input
id="konnect-pat"
type="password"
className="rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
placeholder={settings.hasKonnectPat ? 'Enter new PAT to replace existing' : 'kpat_...'}
value={pat}
onChange={e => {
setPat(e.target.value);
setStatus('idle');
setValidationError(null);
}}
autoComplete="off"
/>
{status === 'valid' && <p className="text-xs text-(--color-success)">PAT is valid and saved.</p>}
{status === 'invalid' && <p className="text-xs text-(--color-danger)">{validationError}</p>}
</div>
<div className="flex gap-2">
<Button
className="rounded-xs bg-(--color-surprise) px-3 py-1 text-sm text-white hover:opacity-90 disabled:opacity-50"
isDisabled={!pat.trim() || status === 'validating'}
onPress={handleValidate}
>
{status === 'validating' ? 'Validating...' : 'Validate & Save'}
</Button>
{settings.hasKonnectPat && (
<Button
className="rounded-xs border border-solid border-(--hl-sm) px-3 py-1 text-sm text-(--color-font) hover:bg-(--hl-xs)"
onPress={handleClear}
>
Clear
</Button>
)}
</div>
</div>
);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,30 @@
import { Button } from 'react-aria-components';
import bgUrl from './bg.png';
interface KonnectSyncIntroProps {
onConfigure: () => void;
}
export const KonnectSyncIntro = ({ onConfigure }: KonnectSyncIntroProps) => {
return (
<div className="m-2 flex flex-col overflow-hidden rounded-md bg-black text-center text-white">
<img src={bgUrl} alt="" className="w-full object-cover" />
<div className="flex flex-col items-center gap-5 px-4 pb-10">
<span className="rounded-sm border border-[#b5f021] px-2 py-0.5 text-xs font-semibold text-[#b5f021]">NEW</span>
<div className="flex flex-col gap-1">
<h3 className="text-base leading-snug font-bold">Auto-sync your gateway service routes</h3>
<p className="text-sm text-[#aaa]">
Get right into testing your gateway configuration in Insomnia with the new Konnect platform integration.
</p>
</div>
<Button
onPress={onConfigure}
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-4 py-2 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
>
Configure
</Button>
</div>
</div>
);
};
@@ -1,67 +0,0 @@
import type { ReactNode } from 'react';
import { Button, Input, SearchField } from 'react-aria-components';
import { Icon } from '../../icon';
interface SidebarHeaderProps {
isScratchPad: boolean;
tabs: { name: 'projects' | 'konnect'; label: ReactNode }[];
activeTab: 'projects' | 'konnect';
onTabChange: (tab: 'projects' | 'konnect') => void;
filterValue: string;
onFilterChange: (value: string) => void;
isFilterDisabled: boolean;
actionButton: ReactNode;
}
// Sidebar header component including project & konnect tabs, filter input, and action button.
export const SidebarHeader = ({
isScratchPad,
tabs,
activeTab,
onTabChange,
filterValue,
onFilterChange,
isFilterDisabled,
actionButton,
}: SidebarHeaderProps) => (
<>
<div className="flex shrink-0 border-b border-solid border-b-(--hl-md)">
{!isScratchPad &&
tabs.map(({ name, label }) => (
<button
type="button"
key={name}
className={`border-b-2 border-solid px-4 py-2 text-xs ${activeTab === name ? 'border-(--color-surprise) text-(--color-font)' : 'border-b-transparent text-(--hl) hover:bg-(--hl-xs)'}`}
data-testid={`sidebar-tab-${name}`}
onClick={() => onTabChange(name)}
>
{label}
</button>
))}
</div>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SearchField
aria-label="Projects filter"
className="group relative flex-1"
value={filterValue}
isDisabled={isFilterDisabled}
onChange={onFilterChange}
>
<Input
placeholder="Filter"
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
/>
<div className="absolute top-0 right-0 flex h-full items-center px-2">
<Button
aria-label="Clear search"
className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="close" />
</Button>
</div>
</SearchField>
{actionButton}
</div>
</>
);
@@ -1,10 +1,11 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { StorageRules } from 'insomnia-api';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, GridList, GridListItem } from 'react-aria-components';
import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import { Button as BasicButton } from '~/basic-components/button';
import type { SortOrder } from '~/common/constants';
import { fuzzyMatchAll } from '~/common/misc';
import {
@@ -20,19 +21,20 @@ import { useProjectLoaderData } from '~/routes/organization.$organizationId.proj
import { AnalyticsEvent } from '~/ui/analytics';
import { KongLogo } from '~/ui/components/kong-logo';
import { showModal } from '~/ui/components/modals';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal';
import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node';
import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro';
import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node';
import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context';
import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import { useKonnectSync } from '~/ui/hooks/use-konnect-sync';
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
import insomniaLogo from '~/ui/images/insomnia-logo.svg';
import { isPrimaryClickModifier } from '~/ui/utils';
import { Icon } from '../../icon';
import { SidebarHeader } from './project-navigation-sidebar-header';
import {
type AllRequestsAndMetaInWorkspace,
filterCollection,
@@ -48,45 +50,97 @@ import { useSidebarDragAndDrop } from './use-sidebar-drag-and-drop';
import { WorkspaceNode } from './workspace-node';
interface ProjectNavigationSidebarProps {
storageRules: StorageRules;
activeNodeId?: string;
konnectSyncEnabled: boolean;
storageRules: StorageRules;
onCreateProject: () => void;
}
function showSkippedRoutesModal(result: SyncResult | null) {
if (!result?.success || !result.skippedRoutes.length) {
return;
}
const byService = new Map<string, string[]>();
for (const { serviceName, routeName, reason } of result.skippedRoutes) {
const list = byService.get(serviceName) ?? [];
list.push(`${routeName} — ${reason}`);
byService.set(serviceName, list);
}
showModal(AlertModal, {
title: 'Skipped Routes',
message: (
<div>
<p>{result.skippedRoutes.length} route(s) were skipped because they cannot be represented in Insomnia:</p>
{[...byService.entries()].map(([service, routes]) => (
<div key={service} style={{ margin: '8px 0' }}>
<strong>{service}</strong>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{routes.map(r => (
<li key={r}>{r}</li>
))}
</ul>
</div>
))}
</div>
),
});
}
const SidebarSearchField = ({
value,
isDisabled,
onChange,
}: {
value: string;
isDisabled: boolean;
onChange: (value: string) => void;
}) => (
<SearchField
aria-label="Projects filter"
className="group relative flex-1"
value={value}
isDisabled={isDisabled}
onChange={onChange}
>
<Input
placeholder="Filter"
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
/>
<div className="absolute top-0 right-0 flex h-full items-center px-2">
<Button
aria-label="Clear search"
className="flex aspect-square w-5 items-center justify-center rounded-xs text-sm text-(--color-font) ring-1 ring-transparent transition-all group-data-empty:hidden hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
<Icon icon="close" />
</Button>
</div>
</SearchField>
);
const SideBarTabList = ({
konnectSyncEnabled,
isScratchPad,
nonKonnectProjectLength,
konnectProjectsLength,
}: {
konnectSyncEnabled: boolean;
isScratchPad: boolean;
nonKonnectProjectLength: number;
konnectProjectsLength: number;
}) => {
return (
<TabList
aria-label="Sidebar navigation"
className="flex h-(--line-height-sm) w-full shrink-0 border-b border-solid border-b-(--hl-md)"
>
<Tab
id="projects"
className={`flex h-full shrink-0 items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none ${konnectSyncEnabled ? 'cursor-pointer hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)' : 'text-(--color-font)!'}`}
data-testid="sidebar-tab-projects"
>
{isScratchPad ? 'Projects' : `Projects (${nonKonnectProjectLength})`}
</Tab>
{konnectSyncEnabled && !isScratchPad && (
<Tab
id="konnect"
className="flex h-full shrink-0 cursor-pointer items-center justify-between px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
data-testid="sidebar-tab-konnect"
>
<span className="flex items-center gap-2">
<KongLogo />
Konnect ({konnectProjectsLength})
</span>
</Tab>
)}
</TabList>
);
};
const NewProjectButton = ({ onPress, isDisabled }: { onPress: () => void; isDisabled?: boolean }) => (
<BasicButton
aria-label="Create new Project"
onPress={onPress}
isDisabled={isDisabled}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
>
<Icon icon="plus" className="h-2.5 w-2.5" />
<span>New Project</span>
</BasicButton>
);
export const ProjectNavigationSidebar = ({
konnectSyncEnabled,
storageRules,
konnectSyncEnabled,
onCreateProject,
}: ProjectNavigationSidebarProps) => {
const navigate = useNavigate();
@@ -97,7 +151,7 @@ export const ProjectNavigationSidebar = ({
requestId?: string;
requestGroupId?: string;
};
const { userSession } = useRootLoaderData()!;
const { userSession, settings } = useRootLoaderData()!;
const projectLoaderData = useProjectLoaderData()!;
const { projects, projectsSyncStatusPromise } = projectLoaderData;
const [checkAllProjectSyncStatus] = useLoaderDeferData<Record<string, boolean>>(
@@ -133,7 +187,7 @@ export const ProjectNavigationSidebar = ({
);
const activeTab = !konnectSyncEnabled ? 'projects' : (storedTab ?? 'projects');
const isProjectTabActive = activeTab === 'projects';
const { syncing, progress, error: syncError, startSync, cancelSync } = useKonnectSync();
const { syncing, progress, startSync, cancelSync } = useKonnectSync();
const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId);
const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null);
@@ -153,6 +207,8 @@ export const ProjectNavigationSidebar = ({
// ref to track whether we are currently fetching unsynced files for cloud sync projects to avoid duplicate requests
const isFetchingUnsyncedFilesRef = useRef(false);
const syncKonnectProjectsAndNotifyRef = useRef<() => Promise<void>>(async () => {});
const isScratchPad = activeProjectId === models.project.SCRATCHPAD_PROJECT_ID;
const projectsWithPresence = useMemo(
@@ -232,48 +288,66 @@ export const ProjectNavigationSidebar = ({
return setUnsyncedFilesByProjectId(result);
}, [organizationId, cloudSyncProjectIdsKey]);
const syncKonnectProjectsAndNotify = async () => {
const result = await startSync(organizationId);
setLastSyncResult(result ?? null);
setShowSyncDetails(false);
setCopiedReason(null);
};
syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify;
const handleSync = async () => {
if (!konnectSyncEnabled) {
return;
}
const runAndNotify = async () => {
const result = await startSync(organizationId);
showSkippedRoutesModal(result);
};
const isResync = konnectProjects.length > 0;
if (isResync) {
showModal(AskModal, {
title: 'Re-sync Konnect',
title: 'Sync updates from Konnect',
message: (
<div>
<p>Re-syncing will make the following changes:</p>
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
<li>
<strong>Reset</strong> — request method, URL, name, and Konnect-managed headers
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<KongLogo width={20} height={20} />
<span className="text-sm font-medium">Konnect</span>
</div>
<span className="text-(--hl)">→</span>
<div className="flex items-center gap-1">
<img src={insomniaLogo} alt="Insomnia" className="h-5 w-5" />
<span className="text-sm font-medium">Insomnia</span>
</div>
</div>
<p className="text-sm text-(--hl)">
Sync the latest changes from your Konnect organization into Insomnia. This will:
</p>
<ul className="flex flex-col gap-1 text-sm">
<li className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
Keep your local custom changes (never pushed to Konnect)
</li>
<li>
<strong>Delete</strong> — requests added manually or no longer in Konnect
<li className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
Update existing resources to match Konnect
</li>
<li>
<strong>Preserve</strong> — body, auth, query params, scripts, description, and user-added headers
<li className="flex items-start gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-(--color-font)" />
Remove collections or environments tied to control planes, services, or routes deleted in Konnect
</li>
</ul>
<p>This cannot be undone. Continue?</p>
</div>
),
yesText: 'Re-sync',
yesText: 'Sync Now',
noText: 'Cancel',
color: 'warning',
color: 'surprise',
onDone: async (confirmed: boolean) => {
if (confirmed) {
await runAndNotify();
await syncKonnectProjectsAndNotify();
}
},
});
} else {
await runAndNotify();
await syncKonnectProjectsAndNotify();
}
};
@@ -705,191 +779,319 @@ export const ProjectNavigationSidebar = ({
? [selectedItemId]
: [];
const projectsActionButton = !isScratchPad ? (
<Button
aria-label="Create new Project"
onPress={onCreateProject}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
>
<Icon icon="plus" className="h-2.5 w-2.5" />
<span>New Project</span>
</Button>
) : null;
const konnectActionButton = syncing ? (
<Button
aria-label="Cancel sync"
onPress={cancelSync}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
>
Cancel
<Icon icon="stop-circle" />
</Button>
) : (
<Button
aria-label="Sync Konnect"
onPress={handleSync}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
>
Sync
<Icon icon="refresh" />
</Button>
);
const { hasKonnectPat } = settings;
const showKonnectSyncIntro = konnectSyncEnabled && !isProjectTabActive && !hasKonnectPat;
const [showKonnectConfigModal, setShowKonnectConfigModal] = useState(false);
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null);
const [showSyncDetails, setShowSyncDetails] = useState(false);
const [copiedReason, setCopiedReason] = useState<string | null>(null);
const skippedRoutesByReason = useMemo(() => {
const map = new Map<string, string[]>();
for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) {
const list = map.get(reason) ?? [];
list.push(`${routeName} — ${serviceName}`);
map.set(reason, list);
}
return map;
}, [lastSyncResult]);
return (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
<SidebarHeader
isScratchPad={isScratchPad}
tabs={[
{ name: 'projects', label: `Projects (${nonKonnectProjects.length})` },
{
name: 'konnect',
label: (
<span className="flex items-center gap-1">
<KongLogo />
Konnect ({konnectProjects.length})
</span>
),
},
]}
activeTab={activeTab}
onTabChange={tab => setActiveTab(tab)}
filterValue={isProjectTabActive ? filterInputValue : konnectFilter || ''}
onFilterChange={isProjectTabActive ? setFilterInputValue : setKonnectFilter}
isFilterDisabled={false}
actionButton={isProjectTabActive ? projectsActionButton : konnectActionButton}
/>
{!isProjectTabActive && syncing && <p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>}
{!isProjectTabActive && syncError && <p className="px-4 pb-1 text-xs text-(--color-danger)">{syncError}</p>}
<div
ref={parentRef}
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
data-testid="project-navigation-tree-container"
>
<GridList
aria-label="Project Navigation Tree"
items={virtualizer.getVirtualItems()}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
className="outline-hidden"
selectedKeys={selectedKeys}
selectionMode="single"
dragAndDropHooks={sidebarDragAndDropHooks}
>
{virtualItem => {
const item = visibleFlatItems[virtualItem.index];
if (!item) return null;
return (
<GridListItem
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
textValue={item.doc.name || item.kind}
onAuxClick={e => {
if (e.button === 1 && item.kind === 'collectionChild') {
e.preventDefault();
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: true, shouldNavigate: true, searchParams },
);
}
}}
onPress={async e => {
const docId = item.doc._id;
if (item.kind === 'project') {
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched });
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
}
} else if (item.kind === 'workspace') {
if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
toggleProjectOrWorkspace(docId);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
routeInfo?.routeId !== 'runner'
) {
toggleRequestGroups([docId], item.workspace);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
}
}}
className="group outline-hidden select-none"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.kind === 'project' && (
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
<Tabs selectedKey={activeTab} onSelectionChange={key => setActiveTab(key as 'projects' | 'konnect')}>
<SideBarTabList
konnectSyncEnabled={konnectSyncEnabled}
isScratchPad={isScratchPad}
nonKonnectProjectLength={nonKonnectProjects.length}
konnectProjectsLength={konnectProjects.length}
/>
</Tabs>
{showKonnectSyncIntro ? (
<KonnectSyncIntro onConfigure={() => setShowKonnectConfigModal(true)} />
) : (
<>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SidebarSearchField
value={isProjectTabActive ? filterInputValue : (konnectFilter ?? '')}
isDisabled={projects.length === 0}
onChange={isProjectTabActive ? setFilterInputValue : setKonnectFilter}
/>
{isProjectTabActive ? (
!isScratchPad && <NewProjectButton onPress={onCreateProject} isDisabled={projects.length === 0} />
) : (
<div className="flex items-center gap-1">
{syncing ? (
<Button
aria-label="Cancel sync"
onPress={cancelSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
Cancel
<Icon icon="stop-circle" />
</Button>
) : (
<Button
aria-label="Sync Konnect"
onPress={handleSync}
className="flex h-full items-center justify-center gap-1 rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
<Icon icon="refresh" />
Sync
</Button>
)}
<Button
aria-label="Konnect settings"
onPress={() => setShowKonnectConfigModal(true)}
className="flex aspect-square h-full items-center justify-center rounded-xs border border-solid border-(--hl-sm) px-2 text-sm text-(--color-font) transition-all hover:bg-(--hl-xs) focus:outline-none"
>
<Icon icon="gear" />
</Button>
</div>
)}
</div>
{item.kind === 'workspace' && (
<WorkspaceNode
item={item}
onToggle={toggleProjectOrWorkspace}
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOder => {
if (item.doc.scope === 'collection') {
setCollectionSortOrders(prev => {
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
return newCollectionSortOrders;
});
{!isProjectTabActive && syncing && (
<p className="truncate px-4 pb-1 text-xs text-(--hl) italic">{progress}</p>
)}
<div
ref={parentRef}
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
data-testid="project-navigation-tree-container"
>
<GridList
aria-label="Project Navigation Tree"
items={virtualizer.getVirtualItems()}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
className="outline-hidden"
selectedKeys={selectedKeys}
selectionMode="single"
dragAndDropHooks={sidebarDragAndDropHooks}
>
{virtualItem => {
const item = visibleFlatItems[virtualItem.index];
if (!item) return null;
return (
<GridListItem
// Prefix pinned-request to the key and id to ensure pinned items have a different key and id from non-pinned items with the same doc._id
key={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${virtualItem.key}`}
id={`${item.kind === 'pinnedRequest' ? 'pinned-request-' : ''}${item.doc._id}`}
textValue={item.doc.name || item.kind}
onAuxClick={e => {
if (e.button === 1 && item.kind === 'collectionChild') {
e.preventDefault();
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: true, shouldNavigate: true, searchParams },
);
}
}}
/>
)}
onPress={async e => {
const docId = item.doc._id;
if (item.kind === 'project') {
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
} else {
!isScratchPad && window.main.trackAnalyticsEvent({ event: AnalyticsEvent.projectSwitched });
!isScratchPad && navigate(`/organization/${organizationId}/project/${docId}`);
}
} else if (item.kind === 'workspace') {
if (routeInfo?.resourceId === docId && routeInfo?.routeId !== 'runner') {
toggleProjectOrWorkspace(docId);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.doc,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
} else if (item.kind === 'collectionChild' || item.kind === 'pinnedRequest') {
if (
routeInfo?.resourceId === docId &&
models.requestGroup.isRequestGroupId(docId) &&
routeInfo?.routeId !== 'runner'
) {
toggleRequestGroups([docId], item.workspace);
} else {
tabNavigate(
{
organization: organizationId,
project: item.project,
workspace: item.workspace,
item: item.doc,
},
{ withTab: isPrimaryClickModifier(e), shouldNavigate: true, searchParams },
);
}
}
}}
className="group outline-hidden select-none"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{item.kind === 'project' && (
<ProjectNode item={item} onToggle={toggleProjectOrWorkspace} storageRules={storageRules} />
)}
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
{item.kind === 'workspace' && (
<WorkspaceNode
item={item}
onToggle={toggleProjectOrWorkspace}
sortOrder={collectionSortOrders[item.doc._id] || 'type-manual'}
onSortOrderChange={newSortOder => {
if (item.doc.scope === 'collection') {
setCollectionSortOrders(prev => {
const newCollectionSortOrders = { ...prev, [item.doc._id]: newSortOder };
return newCollectionSortOrders;
});
}
}}
/>
)}
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'pinnedHeader' && <PinnedHeaderNode />}
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'collectionChild' && (
<RequestNode item={item} onToggleFolder={toggleRequestGroups} />
)}
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
{item.kind === 'pinnedRequest' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
<EmptyNode item={item} storageRules={storageRules} />
) : null}
</GridListItem>
);
}}
</GridList>
</div>
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
{item.kind === 'emptyProject' || item.kind === 'emptyCollection' || item.kind === 'emptyFolder' ? (
<EmptyNode item={item} storageRules={storageRules} />
) : null}
</GridListItem>
);
}}
</GridList>
</div>
{!isProjectTabActive && lastSyncResult && (
<div
className={`m-2 flex items-start justify-between gap-2 rounded-sm p-3 text-xs ${
!lastSyncResult.success
? 'bg-[rgba(58,18,8,1)]'
: lastSyncResult.skippedRoutes.length > 0
? 'bg-[rgba(250,173,20,0.15)]'
: 'bg-[rgba(82,196,26,0.15)]'
}`}
>
<div className="flex min-w-0 items-start gap-3">
<Icon
icon={
lastSyncResult.success && lastSyncResult.skippedRoutes.length === 0
? 'circle-check'
: 'exclamation-triangle'
}
className={lastSyncResult.success && lastSyncResult.skippedRoutes.length === 0 ? 'mt-1.5' : 'mt-1'}
/>
<div className="min-w-0">
<p className="font-semibold text-(--color-font)">
{lastSyncResult.success
? lastSyncResult.skippedRoutes.length > 0
? 'Sync complete, with warnings'
: 'Sync complete'
: 'Sync failed'}
</p>
<p className="mt-0.5 text-(--hl)">
{!lastSyncResult.success
? lastSyncResult.error
: lastSyncResult.routes.created === 0 &&
lastSyncResult.routes.updated === 0 &&
lastSyncResult.routes.deleted === 0 &&
lastSyncResult.routes.skipped === 0
? 'Already up-to-date with Konnect.'
: [
lastSyncResult.routes.created > 0 && `${lastSyncResult.routes.created} request(s) added`,
lastSyncResult.routes.updated > 0 && `${lastSyncResult.routes.updated} request(s) updated`,
lastSyncResult.routes.deleted > 0 && `${lastSyncResult.routes.deleted} request(s) deleted`,
lastSyncResult.routes.skipped > 0 && `${lastSyncResult.routes.skipped} route(s) skipped`,
]
.filter(Boolean)
.join(', ') + '.'}
</p>
{lastSyncResult.success && lastSyncResult.skippedRoutes.length > 0 && (
<>
<button
className="mt-1 flex items-center gap-1 text-(--hl) hover:text-(--color-font)"
onClick={() => setShowSyncDetails(prev => !prev)}
>
<Icon icon={showSyncDetails ? 'chevron-down' : 'chevron-right'} className="h-2.5 w-2.5" />
{showSyncDetails ? 'Hide details' : 'Show details'}
</button>
{showSyncDetails && (
<div className="mt-2 max-h-48 space-y-2 overflow-y-auto">
{[...skippedRoutesByReason.entries()].map(([reason, routes]) => {
const MAX_SHOW = 5;
const visible = routes.slice(0, MAX_SHOW);
const extra = routes.length - MAX_SHOW;
return (
<div key={reason}>
<p className="text-(--hl)">{reason} for the following routes:</p>
<ul className="mt-1 space-y-0.5 pl-3">
{visible.map(r => (
<li key={r} className="list-disc text-(--color-font)">
{r}
</li>
))}
</ul>
{extra > 0 && (
<div className="mt-1 flex items-center gap-2 pl-3 text-(--hl)">
<span>+ {extra} more</span>
<button
className="underline hover:text-(--color-font)"
onClick={() => {
navigator.clipboard.writeText(routes.join('\n'));
setCopiedReason(reason);
setTimeout(() => setCopiedReason(null), 2000);
}}
>
{copiedReason === reason ? 'Copied' : 'Copy full list'}
</button>
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</div>
</div>
<button
className="-mt-2 shrink-0 text-xl text-(--hl) hover:text-(--color-font)"
onClick={() => setLastSyncResult(null)}
>
<Icon icon="close" />
</button>
</div>
)}
</>
)}
{showKonnectConfigModal && (
<KonnectSettingsModal
onClose={() => setShowKonnectConfigModal(false)}
syncKonnectProjectsAndNotifyRef={syncKonnectProjectsAndNotifyRef}
/>
)}
</div>
);
};
@@ -900,27 +1102,18 @@ export const EmptyProjectNavigationSidebar = ({ onCreateProject }: { onCreatePro
return (
<div className="flex flex-1 flex-col overflow-hidden" data-testid="global-navigation-sidebar">
<SidebarHeader
isScratchPad={isScratchPad}
tabs={[{ name: 'projects', label: 'Projects (0)' }]}
activeTab="projects"
onTabChange={() => {}}
filterValue=""
onFilterChange={() => {}}
isFilterDisabled
actionButton={
!isScratchPad ? (
<Button
aria-label="Create new Project"
onPress={onCreateProject}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 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)"
>
<Icon icon="plus" className="h-2.5 w-2.5" />
<span>New Project</span>
</Button>
) : null
}
/>
<Tabs>
<SideBarTabList
konnectSyncEnabled={false}
isScratchPad={isScratchPad}
nonKonnectProjectLength={0}
konnectProjectsLength={0}
/>
</Tabs>
<div className="flex justify-between gap-1 p-(--padding-sm)">
<SidebarSearchField value="" isDisabled onChange={() => {}} />
{!isScratchPad && <NewProjectButton onPress={onCreateProject} />}
</div>
</div>
);
};
@@ -20,7 +20,6 @@ import { useInsomniaTab } from '~/ui/hooks/use-insomnia-tab';
import { type ChangeBufferEvent, type ChangeType, database } from '../../../common/database';
import { debounce } from '../../../common/misc';
import { INSOMNIA_TAB_HEIGHT } from '../../constant';
import { useInsomniaTabContext } from '../../context/app/insomnia-tab-context';
import { type Size, useResizeObserver } from '../../hooks/use-resize-observer';
import { Icon } from '../icon';
@@ -373,7 +372,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = ''
if (!tabList.length) return null;
return (
<div className="box-content flex bg-(--color-bg)" style={{ height: `${INSOMNIA_TAB_HEIGHT + 1}px` }}>
<div className="box-content flex h-(--line-height-sm) bg-(--color-bg)">
<Button
onPress={scrollLeft}
isDisabled={leftScrollDisable}
@@ -393,7 +392,7 @@ export const OrganizationTabList = ({ showActiveStatus = true, currentPage = ''
disallowEmptySelection
selectionMode="single"
selectionBehavior="replace"
className="flex h-[41px] w-fit"
className="flex h-(--line-height-sm) w-fit"
dragAndDropHooks={dragAndDropHooks}
items={tabList}
dependencies={[issuesByWorkspaceId]}
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { useRevalidator } from 'react-router';
import type { SyncResult } from '../../konnect/sync';
import { syncKonnect } from '../../konnect/sync';
import { syncKonnect, zeroCounts } from '../../konnect/sync';
import { AnalyticsEvent } from '../analytics';
const REVALIDATE_DEBOUNCE_MS = 500;
@@ -10,25 +10,33 @@ const REVALIDATE_DEBOUNCE_MS = 500;
interface KonnectSyncState {
syncing: boolean;
progress: string;
error: string | null;
}
export interface UseKonnectSyncResult {
syncing: boolean;
progress: string;
error: string | null;
startSync: (organizationId: string) => Promise<SyncResult | null>;
startSync: (organizationId: string) => Promise<SyncResult>;
cancelSync: () => void;
}
export function useKonnectSync(): UseKonnectSyncResult {
const [state, setState] = useState<KonnectSyncState>({ syncing: false, progress: '', error: null });
const [state, setState] = useState<KonnectSyncState>({ syncing: false, progress: '' });
const abortRef = useRef<AbortController | null>(null);
const revalidateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { revalidate } = useRevalidator();
const startSync = async (organizationId: string): Promise<SyncResult | null> => {
if (abortRef.current) { return null; }
const startSync = async (organizationId: string): Promise<SyncResult> => {
if (abortRef.current) {
return {
success: false,
error: 'Sync already in progress.',
controlPlanes: zeroCounts(),
services: zeroCounts(),
routes: zeroCounts(),
skippedRoutes: [],
durationMs: 0,
};
}
const controller = new AbortController();
abortRef.current = controller;
@@ -36,10 +44,18 @@ export function useKonnectSync(): UseKonnectSyncResult {
const pat = await window.main.secretStorage.getSecret('konnectPat');
if (!pat) {
abortRef.current = null;
setState({ syncing: false, progress: '', error: 'No PAT found. Go to Preferences → Konnect to add one.' });
return null;
setState({ syncing: false, progress: '' });
return {
success: false,
error: 'No Konnect PAT found.',
controlPlanes: zeroCounts(),
services: zeroCounts(),
routes: zeroCounts(),
skippedRoutes: [],
durationMs: 0,
};
}
setState({ syncing: true, progress: 'Starting sync...', error: null });
setState({ syncing: true, progress: 'Starting sync...' });
const result = await syncKonnect({
pat,
@@ -47,20 +63,24 @@ export function useKonnectSync(): UseKonnectSyncResult {
signal: controller.signal,
onProgress: message => {
setState(s => ({ ...s, progress: message }));
if (revalidateTimerRef.current) { clearTimeout(revalidateTimerRef.current); }
if (revalidateTimerRef.current) {
clearTimeout(revalidateTimerRef.current);
}
revalidateTimerRef.current = setTimeout(revalidate, REVALIDATE_DEBOUNCE_MS);
},
});
abortRef.current = null;
if (revalidateTimerRef.current) { clearTimeout(revalidateTimerRef.current); revalidateTimerRef.current = null; }
if (revalidateTimerRef.current) {
clearTimeout(revalidateTimerRef.current);
revalidateTimerRef.current = null;
}
revalidate();
const cancelled = controller.signal.aborted;
setState({
syncing: false,
progress: '',
error: !result.success && !cancelled ? (result.error ?? 'Sync failed') : null,
});
window.main.trackAnalyticsEvent({
@@ -86,6 +106,10 @@ export function useKonnectSync(): UseKonnectSyncResult {
},
});
if (cancelled) {
result.error = 'Sync cancelled by user.';
}
return result;
};
@@ -93,5 +117,5 @@ export function useKonnectSync(): UseKonnectSyncResult {
abortRef.current?.abort();
};
return { syncing: state.syncing, progress: state.progress, error: state.error, startSync, cancelSync };
return { syncing: state.syncing, progress: state.progress, startSync, cancelSync };
}
@@ -9,6 +9,7 @@ export function useUserService() {
const isEssential = currentPlan?.type === 'free';
const isEnterpriseLike = isEnterpriseOwner || isEnterpriseMember;
const isTrailing = currentPlan?.status === 'trialing';
const isPaidEnterprise = isEnterpriseLike && !isTrailing;
const trialDaysLeft: number | null =
isTrailing && currentPlan?.trialingEnd ? diffInDayCeil(new Date(currentPlan?.trialingEnd), new Date()) : null;
return {
@@ -17,6 +18,7 @@ export function useUserService() {
isEnterpriseMember,
isEssential,
isEnterpriseLike,
isPaidEnterprise,
canUpgrade: !isEnterpriseLike,
displayName: [user?.first_name, user?.last_name].filter(Boolean).join(' ') || user?.email,
isTrailing,