Merge branch 'develop' into test/refactor_e2e_tests

This commit is contained in:
Bob Qiu committed 2026-10-22 17:30:05 +08:00
commit bdbeb8a22f
26 files changed
+1404 -71

No files matched your search

+8 -18
View File
@@ -4030,16 +4030,6 @@
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@ravanallc/grpc-server-reflection": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@ravanallc/grpc-server-reflection/-/grpc-server-reflection-0.1.6.tgz",
"integrity": "sha512-jd2GIZZSv1faOcrVX22cBKdWuaneCHKlvV5vEIv1SA6Kxwt88m60qUiGo/oH8MLxSoWh7gnuGH+bNjWhPScYqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"google-protobuf": "^3.19.1"
}
},
"node_modules/@react-aria/autocomplete": {
"version": "3.0.0-rc.6",
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
@@ -15360,9 +15350,9 @@
}
},
"node_modules/google-protobuf": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz",
"integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-4.0.3.tgz",
"integrity": "sha512-DI6iiRmF8fE/EzvGK5F9b8RxZeumHC2AAhi3xwOEh+GT5eRhJeW1aOtkDxn0W7cVCFQAtwK+ZDwIO1BYz+9WMA==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/gopd": {
@@ -15464,12 +15454,12 @@
}
},
"node_modules/grpc-reflection-js": {
"version": "0.3.0",
"resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#f806b5a0dc2092b7a6fb54dfb66c38fb58231774",
"version": "0.4.0",
"resolved": "git+ssh://git@github.com/Kong/grpc-reflection-js.git#e0b00b8b76f5d58135abadf70a23d6c2022f05ad",
"license": "MIT",
"dependencies": {
"@types/google-protobuf": "^3.7.2",
"google-protobuf": "^3.12.2",
"@types/google-protobuf": "^3.15.12",
"google-protobuf": "^4.0.3",
"protobufjs": "^7.2.2"
},
"peerDependencies": {
@@ -27014,8 +27004,8 @@
"version": "13.3.1",
"license": "Apache-2.0",
"devDependencies": {
"@grpc/reflection": "^1.0.4",
"@playwright/test": "1.59.1",
"@ravanallc/grpc-server-reflection": "^0.1.6",
"@types/express": "^4.17.21",
"@types/oidc-provider": "^8.8.1",
"@types/uuid": "^9.0.8",
@@ -26,7 +26,6 @@ export const keyboardShortcutDescriptions: Record<KeyboardShortcut, string> = {
request_showGenerateCodeEditor: 'Generate Code',
sidebar_focusFilter: 'Filter Sidebar',
sidebar_showCreateDropdown: 'Open Create Dropdown',
sidebar_deleteItem: 'Delete Sidebar Item',
sidebar_toggle: 'Toggle Sidebar',
sidebar_rename: 'Rename Item',
response_focus: 'Focus Response',
@@ -130,10 +129,6 @@ const defaultRegistry: HotKeyRegistry = {
macKeys: [{ meta: true, keyCode: keyboardKeys.n.keyCode }],
winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.n.keyCode }],
},
sidebar_deleteItem: {
macKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }],
winLinuxKeys: [{ keyCode: keyboardKeys.delete.keyCode }, { keyCode: keyboardKeys.backspace.keyCode }],
},
sidebar_toggle: {
macKeys: [{ meta: true, keyCode: keyboardKeys.backslash.keyCode }],
winLinuxKeys: [{ ctrl: true, keyCode: keyboardKeys.backslash.keyCode }],
@@ -47,7 +47,6 @@ export type KeyboardShortcut =
| 'request_showGenerateCodeEditor'
| 'sidebar_focusFilter'
| 'sidebar_showCreateDropdown'
| 'sidebar_deleteItem'
| 'sidebar_toggle'
| 'sidebar_rename'
| 'response_focus'
@@ -29,6 +29,17 @@ collection:
eventName: eventname
desc: this is a listener
isOpen: false
body:
meta:
id: socket-io-payload_9a1c4f1b4f3b4d2e9c0a7d6e5f4b3a21
created: 1753709130600
modified: 1753757257862
eventName: hello
ack: false
args:
- id: 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f
value: '"world"'
mode: application/json
cookieJar:
name: Default Jar
meta:
+1 -1
View File
@@ -20,8 +20,8 @@
"serve": "esr server/index.ts"
},
"devDependencies": {
"@grpc/reflection": "^1.0.4",
"@playwright/test": "1.59.1",
"@ravanallc/grpc-server-reflection": "^0.1.6",
"@types/express": "^4.17.21",
"@types/oidc-provider": "^8.8.1",
"@types/uuid": "^9.0.8",
+4 -3
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
import * as grpc from '@grpc/grpc-js';
import type { HandleCall } from '@grpc/grpc-js/build/src/server-call';
import * as protoLoader from '@grpc/proto-loader';
import { addReflection } from '@ravanallc/grpc-server-reflection';
import { ReflectionService } from '@grpc/reflection';
const PROTO_PATH = path.resolve('../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
@@ -181,9 +181,10 @@ const routeChat: HandleCall<any, any> = (call: any) => {
export const startGRPCServer = (port: number) => {
return new Promise<void>((resolve, reject) => {
const server = new grpc.Server();
const reflectionService = new ReflectionService(packageDefinition);
// Enable reflection
addReflection(server, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin');
reflectionService.addToServer(server);
// @ts-expect-error generated from proto file
server.addService(routeguide.RouteGuide.service, {
@@ -211,7 +212,7 @@ export const startGRPCServer = (port: number) => {
const serverWithTLS = new grpc.Server();
// Enable reflection
addReflection(serverWithTLS, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin');
reflectionService.addToServer(serverWithTLS);
// @ts-expect-error generated from proto file
serverWithTLS.addService(routeguide.RouteGuide.service, {
@@ -10,6 +10,7 @@ import { test } from '../../playwright/test';
// - create/rename/settings dialogs focus the Name field
// - the navigation sidebar expands/collapses folders with Left/Right arrows
// - Cmd/Ctrl-N adds the new request inside the selected folder
// - Delete on a focused sidebar row asks to delete it; Backspace never does
//
// CodeMirror-backed editors (OneLineEditor) expose focus via `data-focused="on"` on their
// `.editor__container`, which is what these tests assert against.
@@ -49,6 +50,22 @@ test.describe('Focus and keyboard navigation', () => {
await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1);
});
test('clicking Add moves focus off the Add button so typing lands in the new Name cell', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByRole('tab', { name: 'Params' }).click();
const addButton = page.getByTestId('request-pane').getByRole('button', { name: 'Add', exact: true });
await addButton.click();
// The Add button keeps focus until the new editor takes it; the autofocus re-grab must not treat
// it as the user having deliberately moved to another control.
await expect.soft(page.locator(focusedEditorWithChild('key-value-editor__name'))).toHaveCount(1);
await expect.soft(addButton).not.toBeFocused();
await page.keyboard.type('smoke-key');
await expect.soft(page.locator('.editor__container:has([id^="key-value-editor__name"]) .CodeMirror-code').first()).toHaveText('smoke-key');
});
test('the KV environment editor focuses the blank row Name', async ({ page }) => {
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
@@ -150,6 +167,37 @@ test.describe('Focus and keyboard navigation', () => {
await expect.soft(page.getByLabel('Collapse test folder')).toBeVisible();
});
test('Delete on a focused sidebar row opens the delete confirmation, Backspace does not', async ({ page }) => {
const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])');
const deleteDialog = page.getByRole('dialog').filter({ hasText: 'Delete Folder' });
// Backspace must not delete (it's reserved for editing text), even with a row focused.
await folderRow.focus();
await folderRow.press('Backspace');
await expect.soft(deleteDialog).toBeHidden();
// Delete on the focused row asks for confirmation; cancelling keeps the folder.
await folderRow.press('Delete');
await expect.soft(deleteDialog).toBeVisible();
await deleteDialog.getByRole('button', { name: 'Cancel' }).click();
await expect.soft(deleteDialog).toBeHidden();
await expect.soft(page.getByTestId('request-node-test folder')).toBeVisible();
// Confirming removes it.
await folderRow.press('Delete');
await deleteDialog.getByRole('button', { name: 'Delete' }).click();
await expect.soft(page.getByTestId('request-node-test folder')).toBeHidden();
});
test('Delete inside the sidebar filter edits text and does not open the delete confirmation', async ({
page,
insomnia,
}) => {
await insomnia.navigationSidebar.fillFilter('test');
await insomnia.navigationSidebar.filterInput.press('Delete');
await expect.soft(page.getByRole('dialog')).toBeHidden();
});
test('Cmd/Ctrl-N adds the request inside the selected folder', async ({ page }) => {
const folderRow = page.locator('[role="row"]:has([data-testid="request-node-test folder"])');
@@ -20,6 +20,14 @@ test('can make socket.io connection', async ({ app, page, insomnia }) => {
await insomnia.navigationSidebar.clickRequestOrFolder('Socket.IO Request');
await expect.soft(page.locator('.app')).toContainText('http://localhost:4020');
// The body lives in its own document, so it only survives the import if the v5 file carries it.
// Params is the initially selected tab and react-aria unmounts the panels it is not showing,
// so the Body tab has to be opened before any of it can be asserted.
await page.getByRole('tab', { name: 'Body' }).click();
await expect.soft(page.getByLabel('Event Name')).toHaveValue('hello');
await expect.soft(page.locator('[data-testid="CodeEditor"]:visible').first()).toContainText('world');
await page.click('text=Connect');
await expect.soft(statusTag).toContainText('Connected', { ignoreCase: true });
await page.getByRole('tab', { name: 'Console' }).click();
@@ -570,6 +570,29 @@ describe('RequestCollectionSchema (recursive union)', () => {
expect(parsed).toHaveLength(5);
});
// The union is not discriminated: without a `meta.id` prefix to go on, the only thing keeping
// a Socket.IO item out of the WebSocket branch (which would strip its Socket.IO-only keys) are
// the `z.undefined()` guards.
it('keeps eventListeners on a Socket.IO item that has no meta', () => {
const item = makeSocketIORequest({ meta: undefined });
const [parsed] = RequestCollectionSchema.parse([item]);
expect(parsed).toMatchObject({ eventListeners: [{ id: 'ev1', eventName: 'join' }] });
});
// `eventListeners` is dropped so the body guard is the only thing rejecting the WebSocket
// branch here — otherwise this would pass on the strength of the eventListeners guard alone
it('keeps the body of a Socket.IO item that has no meta', () => {
const item = makeSocketIORequest({
meta: undefined,
eventListeners: undefined,
body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] },
});
const [parsed] = RequestCollectionSchema.parse([item]);
expect(parsed).toMatchObject({
body: { eventName: 'chat', ack: true, args: [{ id: 'a1', value: '"hi"', mode: 'application/json' }] },
});
});
it('rejects leaf nodes with forbidden props (e.g., children on Request)', () => {
const bad = structuredClone(makeHttpRequest());
// @ts-expect-error - children is not allowed on Request
@@ -0,0 +1,315 @@
/**
* A Socket.IO / WebSocket request's body lives in its own document (SocketIOPayload /
* WebSocketPayload) rather than on the request, and used to be absent from the v5 format
* entirely. These tests pin it to the format: INS-3123.
*/
import type { Workspace } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { beforeEach, describe, expect, it } from 'vitest';
import YAML from 'yaml';
import { database as db } from '../database';
import { importResourcesToNewWorkspace } from '../import';
import { getInsomniaV5DataExport, importInsomniaV5Data } from '../insomnia-v5';
const WORKSPACE_ID = 'wrk_payload';
const SOCKET_IO_REQUEST_ID = 'socketio-req_payload';
const WEBSOCKET_REQUEST_ID = 'ws-req_payload';
const exportWorkspace = (workspaceId = WORKSPACE_ID) =>
getInsomniaV5DataExport({ workspaceId, includePrivateEnvironments: false });
const findNode = (yaml: string, name: string) =>
YAML.parse(yaml).collection.find((item: { name: string }) => item.name === name);
const createSocketIORequest = (patch: Record<string, unknown> = {}) =>
services.socketIORequest.create({
_id: SOCKET_IO_REQUEST_ID,
name: 'socketio-request',
parentId: WORKSPACE_ID,
url: 'https://socket.example.com',
metaSortKey: 1,
...patch,
});
// @vitest-environment jsdom
describe('Socket.IO / WebSocket payloads in the v5 format', () => {
beforeEach(async () => {
await db.init({ inMemoryOnly: true });
await services.project.create({ _id: 'proj_payload', name: 'Payload Project' });
await services.workspace.create({
_id: WORKSPACE_ID,
name: 'Payload Workspace',
parentId: 'proj_payload',
scope: 'collection',
});
await services.environment.getOrCreateForParentId(WORKSPACE_ID);
await services.settings.getOrCreate();
});
describe('export', () => {
// Opening a Socket.IO request creates an empty payload document, which must not turn into a
// `body:` block — otherwise every repo gains a spurious git diff from merely browsing
it('omits body for a payload created by only opening the request', async () => {
await createSocketIORequest();
await services.socketIOPayload.getOrCreateByParentId(SOCKET_IO_REQUEST_ID);
expect(findNode(await exportWorkspace(), 'socketio-request').body).toBeUndefined();
});
it('omits body for an untouched WebSocket payload', async () => {
await services.webSocketRequest.create({
_id: WEBSOCKET_REQUEST_ID,
name: 'websocket-request',
parentId: WORKSPACE_ID,
url: 'wss://echo.example.com',
metaSortKey: 2,
});
await services.webSocketPayload.create({ parentId: WEBSOCKET_REQUEST_ID });
expect(findNode(await exportWorkspace(), 'websocket-request').body).toBeUndefined();
});
it('emits every arg once any of them has content', async () => {
await createSocketIORequest();
await services.socketIOPayload.create({
parentId: SOCKET_IO_REQUEST_ID,
args: [
{ id: 'arg1', value: '"world"', mode: 'application/json' },
{ id: 'arg2', value: '', mode: 'text/plain' },
],
});
expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toEqual([
{ id: 'arg1', value: '"world"', mode: 'application/json' },
{ id: 'arg2', value: '', mode: 'text/plain' },
]);
});
// Arg rows and content types are configuration in their own right. Calling these untouched
// would omit the body, and the next sync's `deleteOrphans` would then delete the payload.
it('emits a body for an arg the user added but left blank', async () => {
await createSocketIORequest();
const { args } = models.socketIOPayload.init();
await services.socketIOPayload.create({
parentId: SOCKET_IO_REQUEST_ID,
args: [...args, { id: 'arg2', value: '', mode: args[0].mode }],
});
expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toHaveLength(2);
});
it('emits a body when only an arg content type changed', async () => {
await createSocketIORequest();
const { args } = models.socketIOPayload.init();
await services.socketIOPayload.create({
parentId: SOCKET_IO_REQUEST_ID,
args: [{ ...args[0], mode: 'text/plain' }],
});
expect(findNode(await exportWorkspace(), 'socketio-request').body.args).toMatchObject([
{ value: '', mode: 'text/plain' },
]);
});
});
describe('import', () => {
it('recreates the payload as a child of its request', async () => {
await createSocketIORequest();
await services.socketIOPayload.create({
_id: 'socket-io-payload_known',
parentId: SOCKET_IO_REQUEST_ID,
eventName: 'chat message',
ack: true,
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
const resources = importInsomniaV5Data(await exportWorkspace());
const payload = resources.find(resource => resource.type === 'SocketIOPayload');
expect(payload).toMatchObject({
_id: 'socket-io-payload_known',
parentId: SOCKET_IO_REQUEST_ID,
eventName: 'chat message',
ack: true,
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
expect(payload).toHaveProperty('_type', 'socketio_payload');
});
// `importResourcesToNewWorkspace` keys its id map by `_id`, so payloads sharing one literal
// placeholder would collapse into a single document. Hand-written YAML may well omit
// `body.meta`, which is what puts them on that placeholder path.
it('gives bodies without a meta id distinct payload ids', async () => {
const yaml = `type: collection.insomnia.rest/5.0
schema_version: "5.1"
name: No Body Meta
meta:
id: wrk_no_body_meta
collection:
- url: https://socket.example.com
name: First
meta:
id: socketio-req_first
body:
eventName: first
args:
- id: arg1
value: '"a"'
- url: https://socket.example.com
name: Second
meta:
id: socketio-req_second
body:
eventName: second
args:
- id: arg2
value: '"b"'
`;
const payloads = importInsomniaV5Data(yaml).filter(resource => resource.type === 'SocketIOPayload');
expect(payloads).toHaveLength(2);
expect(payloads[0]._id).not.toBe(payloads[1]._id);
expect(payloads[0].parentId).not.toBe(payloads[1].parentId);
});
// Git Sync writes imported documents to the database verbatim, so a generated payload id has
// to survive the export that validates it. A non-conforming one throws inside
// `InsomniaFileSchema.parse`, which empties the export — and an empty export is skipped by
// every flush site, silently stopping db-to-fs sync for the whole workspace.
it('re-exports a body that arrived without a body.meta', async () => {
const yaml = `type: collection.insomnia.rest/5.0
schema_version: "5.1"
name: Payload Workspace
meta:
id: ${WORKSPACE_ID}
collection:
- url: https://socket.example.com
name: socketio-request
meta:
id: ${SOCKET_IO_REQUEST_ID}
body:
eventName: chat message
args:
- id: arg1
value: '"world"'
- url: wss://echo.example.com
name: websocket-request
meta:
id: ${WEBSOCKET_REQUEST_ID}
body:
value: '{"ping":1}'
`;
for (const doc of importInsomniaV5Data(yaml)) {
await db.update(doc);
}
const reExported = await exportWorkspace();
expect(reExported).not.toBe('');
expect(findNode(reExported, 'socketio-request').body).toMatchObject({ eventName: 'chat message' });
expect(findNode(reExported, 'websocket-request').body).toMatchObject({ value: '{"ping":1}' });
});
// The union guards keep the two realtime types apart inside `RequestCollectionSchema`, but
// `walkCollection` routes with the base schemas, so this has to be asserted end to end
it('keeps a Socket.IO item without meta on the Socket.IO branch', () => {
const yaml = `type: collection.insomnia.rest/5.0
schema_version: "5.1"
name: No Meta
meta:
id: wrk_no_meta
collection:
- url: https://socket.example.com
name: Rooms
eventListeners:
- id: ev1
eventName: joined
body:
eventName: chat message
ack: true
args:
- id: arg1
value: '"world"'
`;
const resources = importInsomniaV5Data(yaml);
expect(resources.map(resource => resource.type)).toEqual(
expect.arrayContaining(['SocketIORequest', 'SocketIOPayload']),
);
expect(resources.map(resource => resource.type)).not.toContain('WebSocketPayload');
expect(resources.find(resource => resource.type === 'SocketIOPayload')).toMatchObject({
eventName: 'chat message',
ack: true,
});
});
});
// The three calls the Duplicate Collection action makes, in order
it('preserves payloads when a collection is duplicated (INS-3123)', async () => {
await createSocketIORequest();
await services.socketIOPayload.create({
parentId: SOCKET_IO_REQUEST_ID,
eventName: 'hello',
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
await services.webSocketRequest.create({
_id: WEBSOCKET_REQUEST_ID,
name: 'websocket-request',
parentId: WORKSPACE_ID,
url: 'wss://echo.example.com',
metaSortKey: 2,
});
await services.webSocketPayload.create({
parentId: WEBSOCKET_REQUEST_ID,
value: '{"ping":1}',
mode: 'application/json',
});
// An untouched request, which must stay empty in the copy
await services.socketIORequest.create({
_id: 'socketio-req_untouched',
name: 'untouched',
parentId: WORKSPACE_ID,
url: 'https://socket.example.com',
metaSortKey: 3,
});
await services.socketIOPayload.getOrCreateByParentId('socketio-req_untouched');
const oldWorkspace = (await services.workspace.getById(WORKSPACE_ID)) as Workspace;
const content = await exportWorkspace();
const data = importInsomniaV5Data(content);
const newWorkspace = await importResourcesToNewWorkspace({
projectId: 'proj_payload',
workspaceToImport: { ...oldWorkspace, name: 'Payload Workspace (Copy)' },
resourceCacheItem: {
resources: data,
content: JSON.stringify(data),
importer: { id: 'insomnia-v5', name: 'Insomnia v5 Importer', description: '' },
},
});
const copiedRequests = await services.socketIORequest.findByParentId(newWorkspace._id);
const copiedSocketIO = copiedRequests.find(request => request.name === 'socketio-request');
const [copiedWebSocket] = await services.webSocketRequest.findByParentId(newWorkspace._id);
expect(newWorkspace._id).not.toBe(WORKSPACE_ID);
expect(copiedSocketIO?._id).not.toBe(SOCKET_IO_REQUEST_ID);
await expect(services.socketIOPayload.getByParentId(copiedSocketIO!._id)).resolves.toMatchObject({
eventName: 'hello',
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
await expect(services.webSocketPayload.getByParentId(copiedWebSocket._id)).resolves.toMatchObject({
value: '{"ping":1}',
mode: 'application/json',
});
const copiedUntouched = copiedRequests.find(request => request.name === 'untouched');
await expect(services.socketIOPayload.getByParentId(copiedUntouched!._id)).resolves.toBeUndefined();
});
});
@@ -6,7 +6,7 @@
*/
import type { Request } from 'insomnia-data';
import { EnvironmentKvPairDataType, services } from 'insomnia-data';
import { EnvironmentKvPairDataType, EnvironmentType, services } from 'insomnia-data';
import { beforeEach, describe, expect, it } from 'vitest';
import YAML from 'yaml';
@@ -400,6 +400,43 @@ spec:
expect(parsed.spec).toBeDefined();
});
// A Socket.IO request used to be dropped from a design doc's export, which in a Git Sync
// project meant `deleteOrphans` removed the request itself on the next sync
it('exports Socket.IO requests from a design workspace', async () => {
const workspace = await services.workspace.create({
_id: 'wrk_design_socketio',
name: 'Design Workspace',
parentId: 'proj_test',
scope: 'design',
});
await services.environment.create({
_id: 'env_design_socketio',
name: 'Base Env',
parentId: workspace._id,
data: {},
});
await services.apiSpec.getOrCreateForParentId(workspace._id, {
_id: 'spec_design_socketio',
contents: '{"openapi": "3.0.0"}',
contentType: 'json',
});
await services.socketIORequest.create({
_id: 'socketio-req_design',
name: 'design-socketio',
parentId: workspace._id,
url: 'https://socket.example.com',
metaSortKey: 1,
});
const result = await getInsomniaV5DataExport({
workspaceId: workspace._id,
includePrivateEnvironments: false,
});
const parsed = YAML.parse(result);
expect(parsed.collection.map((item: { name: string }) => item.name)).toContain('design-socketio');
});
it('handles mock server scope', async () => {
const workspace = await services.workspace.create({
_id: 'wrk_mock',
@@ -693,6 +730,20 @@ spec:
eventListeners: [{ id: 'listener1', eventName: 'join', desc: 'on join', isOpen: true }],
metaSortKey: 5,
});
await services.webSocketPayload.create({
_id: 'ws-payload_order',
parentId: 'ws-req_order',
name: 'payload-order',
value: '{"ping":1}',
mode: 'application/json',
});
await services.socketIOPayload.create({
_id: 'socket-io-payload_order',
parentId: 'socketio-req_order',
eventName: 'chat message',
ack: true,
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
const cookieJar = await services.cookieJar.getOrCreateForParentId(workspace._id);
await services.cookieJar.update(cookieJar, {
@@ -795,9 +846,13 @@ spec:
'headers',
'parameters',
'pathParameters',
'body',
]);
expect(Object.keys(exportedWebSocket.settings)).toEqual(['encodeUrl', 'cookies', 'followRedirects']);
expect(Object.keys(exportedWebSocket.settings.cookies)).toEqual(['store', 'send']);
expect(Object.keys(exportedWebSocket.body)).toEqual(['meta', 'name', 'value', 'mode']);
expect(Object.keys(exportedWebSocket.body.meta)).toEqual(['id', 'created', 'modified']);
expect(exportedWebSocket.body.meta.id).toBe('ws-payload_order');
const exportedSocketIO = findItem('socketio-order');
expect(Object.keys(exportedSocketIO)).toEqual([
@@ -810,10 +865,15 @@ spec:
'parameters',
'pathParameters',
'eventListeners',
'body',
]);
expect(Object.keys(exportedSocketIO.settings)).toEqual(['encodeUrl', 'cookies', 'path']);
expect(Object.keys(exportedSocketIO.settings.cookies)).toEqual(['store', 'send']);
expect(Object.keys(exportedSocketIO.eventListeners[0])).toEqual(['id', 'eventName', 'desc', 'isOpen']);
expect(Object.keys(exportedSocketIO.body)).toEqual(['meta', 'eventName', 'ack', 'args']);
expect(Object.keys(exportedSocketIO.body.meta)).toEqual(['id', 'created', 'modified']);
expect(exportedSocketIO.body.meta.id).toBe('socket-io-payload_order');
expect(Object.keys(exportedSocketIO.body.args[0])).toEqual(['id', 'value', 'mode']);
expect(Object.keys(parsed.cookieJar)).toEqual(['name', 'meta', 'cookies']);
expect(Object.keys(parsed.cookieJar.cookies[0])).toEqual([
@@ -836,6 +896,123 @@ spec:
});
});
describe('confidentialKeys', () => {
const kvPair = (name: string, value: string, isConfidential: boolean, enabled = true) => ({
id: `envPair_${name}_${value}`,
name,
value,
type: EnvironmentKvPairDataType.STRING,
enabled,
isConfidential,
});
it('exports the names of confidential variables for environments and folders', async () => {
const baseEnvironment = await services.environment.create({
_id: 'env_base',
name: 'Base Env',
parentId: 'wrk_test',
data: { token: 'secret', host: 'example.com', apiKey: 'key', renamed: 'public' },
environmentType: EnvironmentType.KVPAIR,
kvPairData: [
kvPair('token', 'secret', true),
kvPair('host', 'example.com', false),
kvPair('apiKey', 'key', true),
// only the last enabled row with a name takes effect
kvPair('renamed', 'old', true),
kvPair('renamed', 'public', false),
kvPair('disabled', 'value', true, false),
],
});
await services.environment.create({
_id: 'env_sub',
name: 'Sub Env',
parentId: baseEnvironment._id,
data: { password: 'p@ss' },
environmentType: EnvironmentType.KVPAIR,
kvPairData: [kvPair('password', 'p@ss', true)],
});
await services.environment.create({
_id: 'env_json',
name: 'JSON Env',
parentId: baseEnvironment._id,
data: { stale: 'value' },
// kvPairData is not the source of truth in the JSON view
environmentType: EnvironmentType.JSON,
kvPairData: [kvPair('stale', 'value', true)],
});
await services.requestGroup.create({
_id: 'fld_confidential',
name: 'Folder',
parentId: 'wrk_test',
environment: { folderToken: 'folder-secret' },
environmentType: EnvironmentType.KVPAIR,
kvPairData: [kvPair('folderToken', 'folder-secret', true)],
});
const exported = YAML.parse(
await getInsomniaV5DataExport({ workspaceId: 'wrk_test', includePrivateEnvironments: false }),
);
expect(exported.environments.confidentialKeys).toEqual(['apiKey', 'token']);
const subEnvironments = Object.fromEntries(exported.environments.subEnvironments.map((e: any) => [e.name, e]));
expect(subEnvironments['Sub Env'].confidentialKeys).toEqual(['password']);
expect(subEnvironments['JSON Env']).not.toHaveProperty('confidentialKeys');
expect(exported.collection[0].confidentialKeys).toEqual(['folderToken']);
});
it('restores the table view and confidential flags from confidentialKeys', () => {
const result = importInsomniaV5Data(`
type: collection.insomnia.rest/5.0
name: Test Collection
meta:
id: wrk_test
collection:
- name: Folder
meta:
id: fld_confidential
environment:
folderToken: folder-secret
confidentialKeys:
- folderToken
environments:
name: Base Env
meta:
id: env_base
data:
token: secret
host: example.com
confidentialKeys:
- token
subEnvironments:
- name: Sub Env
meta:
id: env_sub
data:
password: p@ss
confidentialKeys:
- password
- name: Plain Env
meta:
id: env_plain
data:
foo: bar
`);
const byId = Object.fromEntries(result.map(doc => [doc._id, doc as any]));
const flags = (doc: any) =>
Object.fromEntries(doc.kvPairData.map((pair: any) => [pair.name, pair.isConfidential]));
expect(byId.env_base.environmentType).toBe(EnvironmentType.KVPAIR);
expect(flags(byId.env_base)).toEqual({ token: true, host: false });
expect(byId.env_sub.environmentType).toBe(EnvironmentType.KVPAIR);
expect(flags(byId.env_sub)).toEqual({ password: true });
expect(byId.fld_confidential.environmentType).toBe(EnvironmentType.KVPAIR);
expect(flags(byId.fld_confidential)).toEqual({ folderToken: true });
// files without confidentialKeys import exactly as before
expect(byId.env_plain).not.toHaveProperty('environmentType');
expect(byId.env_plain).not.toHaveProperty('kvPairData');
});
});
describe('Edge Cases', () => {
it('imports collection without meta', () => {
const yaml = `
@@ -82,6 +82,8 @@ export const CookieJarSchema = z.object({
const { name, data, color, dataPropertyOrder, ...rest } = environment.baseEnvironmentSchema.omit({
metaSortKey: true,
}).shape;
// Names of the confidential variables in `data`; see getConfidentialKeys in insomnia-v5.ts.
const confidentialKeys = z.array(z.string()).optional();
export const EnvironmentSchema = z.object({
name,
meta: MetaSchema.optional(),
@@ -96,11 +98,13 @@ export const EnvironmentSchema = z.object({
dataPropertyOrder,
color,
...rest,
confidentialKeys,
}),
)
.optional(),
dataPropertyOrder,
...rest,
confidentialKeys,
});
const {
@@ -201,6 +205,7 @@ export const RequestGroupSchema = z.object({
})
.optional(),
...restRequestGroupSchemaShape,
confidentialKeys,
});
const {
@@ -239,6 +244,39 @@ export const RequestSchema = z.object({
pathParameters: requestPathParametersShape.nullable(),
});
/**
* A WebSocket/Socket.IO request's body lives in its own document (WebSocketPayload /
* SocketIOPayload), not on the request itself. `meta.id` carries that document's real id so
* that re-importing a file updates the same document instead of orphaning it and creating a
* new one — which is what git sync does on every pull.
*/
export const WebsocketRequestBodySchema = z.object({
meta: MetaSchema.extend({
id: z.string().startsWith('ws-payload'),
}).optional(),
name: z.string().optional().default(''),
value: z.string().optional().default(''),
mode: z.string().optional().default('application/json'),
});
export const SocketIORequestBodySchema = z.object({
meta: MetaSchema.extend({
id: z.string().startsWith('socket-io-payload'),
}).optional(),
eventName: z.string().optional().default(''),
ack: z.boolean().optional().default(false),
args: z
.array(
z.object({
id: z.string(),
value: z.string().optional().default(''),
mode: z.string().optional().default('application/json'),
}),
)
.optional()
.default([]),
});
const {
url: webSocketRequestUrlShape,
name: webSocketRequestNameShape,
@@ -264,6 +302,7 @@ export const WebsocketRequestSchema = z.object({
}),
...restWebSocketRequestSchemaShape,
pathParameters: webSocketRequestPathParametersShape.nullable(),
body: WebsocketRequestBodySchema.optional(),
});
const {
@@ -288,6 +327,7 @@ export const SocketIORequestSchema = z.object({
path: undefined,
}),
...restSocketIORequestSchemaShape,
body: SocketIORequestBodySchema.optional(),
});
// Keep the key order consistent so that export to YAML maintains the same order as before: meta sits after authentication
@@ -328,6 +368,12 @@ const RequestGroupWithChildrenSchema: z.ZodType<RequestGroup> = RequestGroupSche
pathParameters: z.undefined(),
});
/**
* NOTE: this is a plain union, not a discriminated one, so branch order and the `z.undefined()`
* guards below are what keep the request types apart. Any key unique to the WebSocket or the
* Socket.IO branch must also be added as `z.undefined()` to the other one, otherwise an item
* without a `meta.id` prefix to discriminate on matches the wrong branch and loses that key.
*/
export const RequestCollectionSchema = z
.union([
GRPCRequestSchema.extend({
@@ -343,11 +389,24 @@ export const RequestCollectionSchema = z
// These undefined properties are added to differentiate between the different types of children in the union
children: z.undefined(),
method: z.undefined(),
// Socket.IO-only keys. Without these guards a Socket.IO item that has no `meta` matches
// this branch and silently loses them, because z.object strips unknown keys.
eventListeners: z.undefined(),
body: WebsocketRequestBodySchema.extend({
eventName: z.undefined(),
ack: z.undefined(),
args: z.undefined(),
}).optional(),
}),
SocketIORequestSchema.extend({
// These undefined properties are added to differentiate between the different types of children in the union
children: z.undefined(),
method: z.undefined(),
body: SocketIORequestBodySchema.extend({
name: z.undefined(),
value: z.undefined(),
mode: z.undefined(),
}).optional(),
}),
RequestGroupWithChildrenSchema,
])
+241 -6
View File
@@ -28,20 +28,22 @@ import type {
RequestGroup,
RequestHeader,
RequestParameter,
SocketIOPayload,
SocketIORequest,
UnitTest,
UnitTestSuite,
WebSocketPayload,
WebSocketRequest,
Workspace,
WorkspaceScope,
} from 'insomnia-data';
import { EnvironmentKvPairDataType, models } from 'insomnia-data';
import { EnvironmentKvPairDataType, EnvironmentType, models } from 'insomnia-data';
import { parse, stringify } from 'yaml';
import { type AllExportTypes, MODELS_BY_EXPORT_TYPE } from '~/common/import';
import { migrateToLatestYaml } from '~/common/insomnia-schema-migrations';
import { INSOMNIA_SCHEMA_VERSION } from '~/common/insomnia-schema-migrations/schema-version';
import { maskVaultEnvironmentData } from '~/common/utils/environment-utils';
import { getKVPairFromData, maskVaultEnvironmentData } from '~/common/utils/environment-utils';
import { invariant } from '~/common/utils/invariant';
import { database } from './database';
@@ -133,6 +135,81 @@ function mapMeta(resource: Request | WebSocketRequest | SocketIORequest | GrpcRe
};
}
/**
* Maps a payload document's metadata to v5 export format.
*
* Deliberately narrower than `mapMeta`: a payload has no description and no sort order, and
* `filterEmptyValue` keeps `""`, `false` and `0`, so reusing `mapMeta` would add three
* meaningless lines to every exported payload.
*/
function mapPayloadMeta(payload: SocketIOPayload | WebSocketPayload) {
return {
id: payload._id,
created: payload.created,
modified: payload.modified,
};
}
/**
* Maps a Socket.IO request's payload document to the v5 `body` field.
*
* Merely opening a Socket.IO request creates an empty payload document (via
* `services.socketIOPayload.getOrCreateByParentId`), so an untouched payload is omitted
* entirely — otherwise every repo would gain a spurious `body:` block and a git diff. This
* cannot be delegated to `removeEmptyFields`, which preserves `""` and `false`.
*/
function mapSocketIOBody(payload?: SocketIOPayload): Insomnia_SocketIORequest['body'] {
if (!payload) {
return;
}
// Compared against the model's own defaults rather than just checking for empty values: an arg
// the user added but left blank, or whose content type they changed, is real configuration.
// Calling it untouched would drop it from the file and let `deleteOrphans` delete the payload
// on the next sync. `init()` cannot be compared wholesale — it mints a fresh arg id each call.
const defaults = models.socketIOPayload.init();
const args = payload.args ?? [];
const isUntouched =
!payload.eventName &&
!payload.ack &&
args.length === defaults.args.length &&
args.every((arg, index) => !arg.value && arg.mode === defaults.args[index].mode);
if (isUntouched) {
return;
}
return {
meta: mapPayloadMeta(payload),
eventName: payload.eventName ?? '',
ack: payload.ack ?? false,
// Emit every arg once any of them has content, so arg ids, order and per-arg mode survive
args,
};
}
/**
* Maps a WebSocket request's payload document to the v5 `body` field. See `mapSocketIOBody`
* for why an untouched payload is omitted.
*/
function mapWebSocketBody(payload?: WebSocketPayload): Insomnia_WebsocketRequest['body'] {
if (!payload) {
return;
}
const defaults = models.webSocketPayload.init();
if (payload.name === defaults.name && payload.value === defaults.value && payload.mode === defaults.mode) {
return;
}
return {
meta: mapPayloadMeta(payload),
name: payload.name,
value: payload.value,
mode: payload.mode,
};
}
/**
* Maps metadata from RequestGroup to v5 export format
* Similar to mapMeta but specifically for request groups
@@ -227,6 +304,56 @@ function filterEmptyValue(value: string | number | boolean | null | undefined) {
return true;
}
/**
* v5 files carry an environment's variables only as `data`, so its KV rows -- and the isConfidential
* flag that lives on them -- are rebuilt on import. `confidentialKeys` carries just the names of the
* confidential variables so the flag survives export/import, Duplicate / Move and Git Sync.
*
* Mirrors getDataFromKVPair: only the last enabled row with a given name takes effect. Sorted so the
* output is stable across import/export round-trips (canonical-file relies on that).
*/
function getConfidentialKeys(
environmentType: EnvironmentType | undefined,
kvPairData: EnvironmentKvPairData[] | undefined,
): string[] {
if (environmentType !== EnvironmentType.KVPAIR || !kvPairData) {
return [];
}
const isConfidentialByName = new Map<string, boolean>();
for (const pair of kvPairData) {
if (pair.enabled && pair.name) {
isConfidentialByName.set(pair.name, !!pair.isConfidential);
}
}
return [...isConfidentialByName]
.filter(([, isConfidential]) => isConfidential)
.map(([name]) => name)
.sort();
}
/**
* Inverse of getConfidentialKeys: rebuilds the KV rows from `data` and flags the confidential ones.
* Confidential flags only exist in the table view, so the environment is restored as KVPAIR.
* Without confidentialKeys nothing is added, which keeps the import unchanged for every other file.
*/
function getKvPairFieldsFromConfidentialKeys(
data: Record<string, any>,
dataPropertyOrder: Record<string, any> | null | undefined,
confidentialKeys: string[] | undefined,
): { environmentType?: EnvironmentType; kvPairData?: EnvironmentKvPairData[] } {
if (!confidentialKeys?.length) {
return {};
}
const confidentialNames = new Set(confidentialKeys);
return {
environmentType: EnvironmentType.KVPAIR,
kvPairData: getKVPairFromData(data, dataPropertyOrder ?? null).map(pair => ({
...pair,
isConfidential: confidentialNames.has(pair.name),
})),
};
}
/**
* Recursively removes empty fields from an object or array
* This is used to clean up export data by removing null, undefined, and empty objects
@@ -327,6 +454,11 @@ function getEnvironments(file: InsomniaFile): Environment[] {
color: file.environments.color || null,
data: (file.environments.data as Record<string, any>) || {},
dataPropertyOrder: (file.environments.dataPropertyOrder as Record<string, any>) || undefined,
...getKvPairFieldsFromConfidentialKeys(
(file.environments.data as Record<string, any>) || {},
file.environments.dataPropertyOrder,
file.environments.confidentialKeys,
),
name: file.environments.name || 'Base Environment',
};
@@ -342,6 +474,11 @@ function getEnvironments(file: InsomniaFile): Environment[] {
color: environment.color || null,
data: (environment.data as Record<string, any>) || {},
dataPropertyOrder: (environment.dataPropertyOrder as Record<string, any>) || undefined,
...getKvPairFieldsFromConfidentialKeys(
(environment.data as Record<string, any>) || {},
environment.dataPropertyOrder,
environment.confidentialKeys,
),
name: environment.name || `Environment ${index}`,
parentId: baseEnvironment._id,
})) || [];
@@ -498,11 +635,38 @@ function getTestSuites(file: InsomniaFile): (UnitTestSuite | UnitTest)[] {
return [];
}
/**
* Tells a Socket.IO collection node apart from a WebSocket one.
*
* `WebsocketRequestSchema.safeParse` cannot do this on its own: once `metaSortKey` is
* destructured out neither schema has a required field, and `z.object` strips unknown keys, so a
* Socket.IO item parses cleanly as a WebSocket request and silently loses its `eventListeners`
* and `body`. The `z.undefined()` guards on `RequestCollectionSchema` keep the two apart inside
* the union, but `walkCollection` routes with the base schemas, so it needs its own check.
*
* `meta.id` is authoritative when present — every Insomnia-generated file has it. Otherwise fall
* back to keys only one of the two can carry. An item with none of them is genuinely
* indistinguishable and stays on the WebSocket branch, as it did before.
*/
function isSocketIOCollectionNode(item: Record<string, any>): boolean {
if (models.socketIORequest.isSocketIORequestId(item.meta?.id)) {
return true;
}
if (models.webSocketRequest.isWebSocketRequestId(item.meta?.id)) {
return false;
}
if (item.eventListeners || item.settings?.path !== undefined) {
return true;
}
const body = item.body;
return Boolean(body && ('eventName' in body || 'ack' in body || 'args' in body));
}
function getCollection(
file: InsomniaFile,
): (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] {
): (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] {
if (file.type === 'collection.insomnia.rest/5.0' || file.type === 'spec.insomnia.rest/5.0') {
const resources: (Request | WebSocketRequest | SocketIORequest | GrpcRequest | RequestGroup)[] = [];
const resources: (Request | WebSocketRequest | WebSocketPayload | SocketIORequest | SocketIOPayload | GrpcRequest | RequestGroup)[] = [];
function walkCollection(
collection: Extract<InsomniaFile, { type: 'collection.insomnia.rest/5.0' }>['collection'],
@@ -530,6 +694,11 @@ function getCollection(
environment: (item.environment as Record<string, any>) || {},
// 🚧 WARNING 🚧 If we set the order to an empty object instead of undefined it will remove the environment from the folder due to filtering logic (related to json-order)
environmentPropertyOrder: (item.environmentPropertyOrder as Record<string, any>) || undefined,
...getKvPairFieldsFromConfidentialKeys(
(item.environment as Record<string, any>) || {},
item.environmentPropertyOrder,
item.confidentialKeys,
),
};
resources.push(requestGroup);
@@ -598,7 +767,7 @@ function getCollection(
resources.push(grpcRequest);
} else {
const wbRequest = WebsocketRequestSchema.safeParse(item);
if (wbRequest.success) {
if (wbRequest.success && !isSocketIOCollectionNode(item)) {
const data = wbRequest.data;
const websocketRequest: WithExportType<WebSocketRequest> = {
...mapMetaToInsomniaMeta(
@@ -624,6 +793,34 @@ function getCollection(
};
resources.push(websocketRequest);
if (data.body) {
const webSocketPayload: WithExportType<WebSocketPayload> = {
// Derived from the owning request's id rather than a shared literal placeholder:
// `importResourcesToNewWorkspace` keys its id map by `_id`, so a shared one would
// collapse every payload in the file into a single document.
//
// It must also carry the model's own prefix. Git Sync persists this `_id`
// verbatim, and the next export validates it against `WebsocketRequestBodySchema`
// — a non-conforming id throws there, which empties the export and silently stops
// db-to-fs sync for the whole workspace.
...mapMetaToInsomniaMeta(
data.body.meta || {
id: `${models.webSocketPayload.prefix}_${websocketRequest._id}`,
},
),
type: 'WebSocketPayload',
_type: 'websocket_payload',
// Must be the request's id as it appears in this same flat array, so that the
// import's second pass can resolve it through the id map
parentId: websocketRequest._id,
name: data.body.name,
value: data.body.value,
mode: data.body.mode,
};
resources.push(webSocketPayload);
}
} else {
const socketIORequest = SocketIORequestSchema.safeParse(item);
if (socketIORequest.success) {
@@ -653,6 +850,28 @@ function getCollection(
};
resources.push(socketIO);
if (data.body) {
const socketIOPayload: WithExportType<SocketIOPayload> = {
// See the WebSocket payload above for why the id is prefixed and derived from
// the owning request
...mapMetaToInsomniaMeta(
data.body.meta || {
id: `${models.socketIOPayload.prefix}_${socketIO._id}`,
},
),
type: 'SocketIOPayload',
_type: 'socketio_payload',
parentId: socketIO._id,
// A Socket.IO payload has no user-facing name; '' is the base model's default
name: '',
eventName: data.body.eventName,
ack: data.body.ack,
args: data.body.args,
};
resources.push(socketIOPayload);
}
}
}
}
@@ -856,6 +1075,16 @@ export async function getInsomniaV5DataExport({
return false;
});
// Payload documents are children of their request rather than collection nodes of their own,
// so they are looked up by parentId while building the owning request's node, and are never
// passed to `getCollectionFromResources`.
const socketIOPayloadsByRequestId = new Map(
exportableResources.filter(models.socketIOPayload.isSocketIOPayload).map(payload => [payload.parentId, payload]),
);
const webSocketPayloadsByRequestId = new Map(
exportableResources.filter(models.webSocketPayload.isWebSocketPayload).map(payload => [payload.parentId, payload]),
);
/**
* Recursively builds a collection structure from flat resource list
* This function converts the flat list of resources into a hierarchical structure
@@ -918,6 +1147,7 @@ export async function getInsomniaV5DataExport({
authentication: resource.authentication,
environment: resource.environment,
environmentPropertyOrder: resource.environmentPropertyOrder,
confidentialKeys: getConfidentialKeys(resource.environmentType, resource.kvPairData),
headers: mapHeaders(resource.headers),
};
collection.push(requestGroup);
@@ -940,6 +1170,7 @@ export async function getInsomniaV5DataExport({
headers: mapHeaders(resource.headers),
parameters: mapParameters(resource.parameters),
pathParameters: resource.pathParameters,
body: mapWebSocketBody(webSocketPayloadsByRequestId.get(resource._id)),
};
collection.push(webSocketRequest);
} else if (models.socketIORequest.isSocketIORequest(resource)) {
@@ -960,6 +1191,7 @@ export async function getInsomniaV5DataExport({
parameters: mapParameters(resource.parameters),
pathParameters: resource.pathParameters,
eventListeners: resource.eventListeners,
body: mapSocketIOBody(socketIOPayloadsByRequestId.get(resource._id)),
};
collection.push(socketIORequest);
} else if (models.grpcRequest.isGrpcRequest(resource)) {
@@ -1023,6 +1255,7 @@ export async function getInsomniaV5DataExport({
isPrivate: baseEnvironment.isPrivate,
},
data: baseEnvironment.data,
confidentialKeys: getConfidentialKeys(baseEnvironment.environmentType, baseEnvironment.kvPairData),
color: baseEnvironment.color,
subEnvironments: subEnvironments.map(maskVaultEnvironmentData).map(subEnvironment => ({
name: subEnvironment.name,
@@ -1034,6 +1267,7 @@ export async function getInsomniaV5DataExport({
sortKey: subEnvironment.metaSortKey,
},
data: subEnvironment.data,
confidentialKeys: getConfidentialKeys(subEnvironment.environmentType, subEnvironment.kvPairData),
color: subEnvironment.color,
})),
};
@@ -1237,7 +1471,8 @@ export async function getInsomniaV5DataExport({
models.requestGroup.isRequestGroup(resource) ||
models.request.isRequest(resource) ||
models.webSocketRequest.isWebSocketRequest(resource) ||
models.grpcRequest.isGrpcRequest(resource),
models.grpcRequest.isGrpcRequest(resource) ||
models.socketIORequest.isSocketIORequest(resource),
),
workspace._id,
),
+1
View File
@@ -246,6 +246,7 @@ const git: GitServiceAPI = {
stagePartialContent: options => invokeWithNormalizedError('git.stagePartialContent', options),
discardPartialContent: options => invokeWithNormalizedError('git.discardPartialContent', options),
diffFileLoader: options => invokeWithNormalizedError('git.diffFileLoader', options),
fileFormatIssueLoader: options => invokeWithNormalizedError('git.fileFormatIssueLoader', options),
getRepositoryDirectoryTree: options => invokeWithNormalizedError('git.getRepositoryDirectoryTree', options),
migrateLegacyInsomniaFolderToFile: options =>
invokeWithNormalizedError('git.migrateLegacyInsomniaFolderToFile', options),
+41 -1
View File
@@ -51,7 +51,12 @@ import { insomniaSchemaTypeToScope } from '../common/insomnia-v5';
import { buildInsomniaChangeReport, type InsomniaChangeReport } from '../common/insomnia-v5-change-report';
import { checkStrictSchemaMatch } from '../common/zod/check-schema-mismatch';
import { findUnknownFieldRanges, type UnknownFieldRange } from '../common/zod/find-unknown-field-ranges';
import { canonicalizeInsomniaFileContent, isUndiscardableChange } from '../sync/git/canonical-file';
import {
canonicalizeInsomniaFileContent,
getInsomniaFileFormatIssue,
type InsomniaFileFormatIssue,
isUndiscardableChange,
} from '../sync/git/canonical-file';
import { fsClient } from '../sync/git/fs-client';
import { CURRENT_MIGRATION_VERSION, migrateRepoStructureIfNeeded } from '../sync/git/git-repo-migration';
import GitVCS, {
@@ -3592,6 +3597,37 @@ export const diffFileLoader = async ({
}
};
/**
* Whether either side of a file's diff isn't a file as Insomnia writes it (eg. edited by hand), which the
* Visual view can't reliably show or act on. Kept out of `diffFileLoader` so the import -> export replay it
* takes doesn't hold up showing the diff.
*/
export const fileFormatIssueLoader = async ({
projectId,
workspaceId,
filepath,
staged,
}: {
projectId: string;
workspaceId?: string;
filepath: string;
staged: boolean;
}): Promise<InsomniaFileFormatIssue | null> => {
try {
await getGitRepository({ workspaceId, projectId });
const fileStatus = await GitVCS.fileStatus(filepath);
// The raw contents: the diff's `before` is migrated and reordered for display
const sides = staged
? [fileStatus.rawHead, fileStatus.rawStage]
: [fileStatus.rawStage || fileStatus.rawHead, fileStatus.workdir];
const issues = await Promise.all(sides.map(getInsomniaFileFormatIssue));
return issues.includes('invalid') ? 'invalid' : issues.includes('non-canonical') ? 'non-canonical' : null;
} catch (err) {
console.warn('[git] Failed to check the format of the file', filepath, err);
return null;
}
};
interface GitRepoFile {
id: string;
name: string;
@@ -4110,6 +4146,7 @@ export interface GitServiceAPI {
stagePartialContent: typeof stagePartialContentAction;
discardPartialContent: typeof discardPartialContentAction;
diffFileLoader: typeof diffFileLoader;
fileFormatIssueLoader: typeof fileFormatIssueLoader;
getRepositoryDirectoryTree: typeof getRepositoryDirectoryTree;
migrateLegacyInsomniaFolderToFile: typeof migrateLegacyInsomniaFolderToFile;
fetchGitRemoteBranches: typeof fetchGitRemoteBranches;
@@ -4226,6 +4263,9 @@ export const registerGitServiceAPI = () => {
discardPartialContentAction(options),
);
ipcMainHandle('git.diffFileLoader', (_, options: Parameters<typeof diffFileLoader>[0]) => diffFileLoader(options));
ipcMainHandle('git.fileFormatIssueLoader', (_, options: Parameters<typeof fileFormatIssueLoader>[0]) =>
fileFormatIssueLoader(options),
);
ipcMainHandle('git.getRepositoryDirectoryTree', (_, options: Parameters<typeof getRepositoryDirectoryTree>[0]) =>
getRepositoryDirectoryTree(options),
);
@@ -62,6 +62,7 @@ export type HandleChannels =
| 'git.deleteGitBranch'
| 'git.diff'
| 'git.diffFileLoader'
| 'git.fileFormatIssueLoader'
| 'git.discardChanges'
| 'git.fetchGitRemoteBranches'
| 'git.getProjectGitFileIssues'
@@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { BrowserWindow } from 'electron';
import type { McpResponse, RequestHeader } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import type { Dispatcher } from 'undici';
import { type Dispatcher, fetch as undiciFetch } from 'undici';
import { redactConfidentialText } from '~/common/network/sensitive-value-collector';
import { type ConnectionContext, getFetchDispatcher, writeEventLogAndNotify, writeTimeline } from '~/main/mcp/common';
@@ -21,6 +21,11 @@ interface NodeRequestInit extends RequestInit {
dispatcher?: Dispatcher;
}
// Use undici's own fetch: the global (Electron built-in) fetch ships an older undici whose handler API
// is incompatible with dispatchers created by the installed undici.
const dispatcherFetch = (url: string | URL, init: NodeRequestInit) =>
undiciFetch(url, init as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
export const createStreamableHTTPTransport = async (
context: ConnectionContext,
options: OpenMcpHTTPClientConnectionOptions,
@@ -110,7 +115,7 @@ const wrappedFetch = async (
const requestMethodLine = `${method.toUpperCase()} ${url} ${isJsonRequest && requestBody?.method ? `\nJSON-RPC Method: ${requestBody.method}` : ''}`;
const headersOut = requestHeaders.map(({ name, value }) => `${name}: ${value}`).join('\n');
const start = performance.now();
const response = await fetch(url, {
const response = await dispatcherFetch(url, {
...init,
dispatcher: await getFetchDispatcher(requestId),
} as NodeRequestInit);
@@ -205,7 +210,7 @@ const wrappedFetch = async (
};
writeEventLogAndNotify(context, authRequestEvent);
try {
const response = await fetch(url, {
const response = await dispatcherFetch(url, {
...init,
dispatcher: await getFetchDispatcher(requestId),
} as NodeRequestInit);
@@ -12,6 +12,7 @@ import { getInsomniaV5DataExport, tryImportV5Data } from '../../../common/insomn
import {
canonicalizeInsomniaFileContent,
getCanonicalInsomniaFileContent,
getInsomniaFileFormatIssue,
isUndiscardableChange,
} from '../canonical-file';
@@ -146,6 +147,37 @@ routes:
});
});
describe('getInsomniaFileFormatIssue', () => {
it('finds no issue in a file Insomnia wrote itself', async () => {
expect(await getInsomniaFileFormatIssue(await exportThroughDatabase(reorderedCollection))).toBeNull();
});
it('flags a valid file Insomnia would write differently, eg. one edited by hand', async () => {
const exported = await exportThroughDatabase(reorderedCollection);
const withComment = exported.replace('collection:\n', 'collection:\n # added by hand\n');
const withDuplicateId = buildCollection([
{ id: 'fld_a', name: 'Folder A', created: 2000 },
{ id: 'fld_a', name: 'Folder A copy', created: 3000 },
]);
expect(await getInsomniaFileFormatIssue(reorderedCollection)).toBe('non-canonical');
expect(await getInsomniaFileFormatIssue(withComment)).toBe('non-canonical');
expect(await getInsomniaFileFormatIssue(withDuplicateId)).toBe('non-canonical');
});
it('flags an Insomnia file that does not match the file format', async () => {
const notAList = 'type: collection.insomnia.rest/5.0\nname: C\ncollection: not a list\n';
expect(await getInsomniaFileFormatIssue(notAList)).toBe('invalid');
});
it('ignores empty sides and files that are not Insomnia files', async () => {
expect(await getInsomniaFileFormatIssue(null)).toBeNull();
expect(await getInsomniaFileFormatIssue('')).toBeNull();
expect(await getInsomniaFileFormatIssue('openapi: 3.0.0\ninfo:\n title: Spec\n')).toBeNull();
});
});
describe('getCanonicalInsomniaFileContent', () => {
it.each(insomniaFixtures)(
'matches the real import/export pipeline for %s',
@@ -179,6 +211,19 @@ routes:
expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent);
});
it('keeps confidentialKeys through the round-trip so the flag survives Git Sync', async () => {
const content = `${buildCollection([{ id: 'fld_a', name: 'Folder A', created: 2000 }])} data:
token: secret
host: example.com
confidentialKeys:
- token
`;
const canonicalContent = await getCanonicalInsomniaFileContent(content);
expect(canonicalContent).toBe(await exportThroughDatabase(content));
expect(YAML.parse(canonicalContent!).environments.confidentialKeys).toEqual(['token']);
expect(await getCanonicalInsomniaFileContent(canonicalContent!)).toBe(canonicalContent);
});
it('does not read from, write to or notify the app database', async () => {
await db.update({ _id: 'req_app', type: models.request.type, parentId: 'wrk_app' } as BaseModel);
const appChanges: unknown[] = [];
@@ -0,0 +1,139 @@
/**
* Socket.IO / WebSocket payloads through the Git Sync project fs→db path.
*
* Lives in its own file because `repo-file-watcher.test.ts` module-mocks `common/insomnia-v5`,
* and these tests need the real import/export.
*
* Before the v5 format carried the payload, `deleteOrphans` removed every payload document on
* each real fs→db sync — a `git pull`, a `checkout`, an edit to any part of the YAML, or just
* restarting the app — because the importer could never produce one. See INS-3123.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { services } from 'insomnia-data';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { database as db } from '../../../common/database';
import { RepoFileWatcherRegistry } from '../repo-file-watcher';
const REPO_ID = 'git_repo_payload';
const PROJECT_ID = 'proj_payload';
const WORKSPACE_ID = 'wrk_payload';
const REQUEST_ID = 'socketio-req_payload';
const PAYLOAD_ID = 'socket-io-payload_payload';
const YAML_PATH = 'insomnia.wrk_payload.yaml';
const collectionYaml = ({ withBody = true, withRequest = true, requestName = 'Rooms' } = {}) => `type: collection.insomnia.rest/5.0
schema_version: "5.1"
name: Payload Collection
meta:
id: ${WORKSPACE_ID}
created: 1000
modified: 1000
collection:${
withRequest
? `
- url: https://socket.example.com
name: ${requestName}
meta:
id: ${REQUEST_ID}
created: 1000
modified: 1000
sortKey: 1${
withBody
? `
body:
meta:
id: ${PAYLOAD_ID}
created: 1000
modified: 1000
eventName: chat message
ack: true
args:
- id: arg1
value: '"world"'
mode: application/json`
: ''
}`
: ' []'
}
environments:
name: Base Environment
meta:
id: env_payload
created: 1000
modified: 1000
isPrivate: false
`;
describe('RepoFileWatcher keeps Socket.IO payloads through fs→db syncs', () => {
let repoDir: string;
let registry: RepoFileWatcherRegistry;
let absPath: string;
beforeEach(async () => {
await db.init({ inMemoryOnly: true }, true);
await services.project.create({ _id: PROJECT_ID, name: 'Payload Project' });
repoDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'insomnia-repo-watcher-payload-'));
absPath = path.join(repoDir, YAML_PATH);
registry = new RepoFileWatcherRegistry({ onDbSynced: vi.fn(), onProblemsChanged: vi.fn() });
});
afterEach(async () => {
registry.stopAll();
await fs.promises.rm(repoDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it('imports a body into a real payload document keyed by the id in the file', async () => {
await fs.promises.writeFile(absPath, collectionYaml(), 'utf8');
await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID);
await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({
_id: PAYLOAD_ID,
eventName: 'chat message',
ack: true,
args: [{ id: 'arg1', value: '"world"', mode: 'application/json' }],
});
});
// Re-importing the file is what a `git pull` or `checkout` triggers — the exact moment the
// payload used to be deleted as an orphan.
//
// The edit has to change the file: `readIfChanged` dedups on a content hash, so re-importing
// byte-identical content returns early and `deleteOrphans` never runs at all.
it('keeps the payload when an unrelated edit re-imports the file', async () => {
await fs.promises.writeFile(absPath, collectionYaml(), 'utf8');
await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID);
await fs.promises.writeFile(absPath, collectionYaml({ requestName: 'Rooms renamed' }), 'utf8');
await registry.importAllFiles(REPO_ID);
await registry.flushNow(REPO_ID);
// Proves the import actually ran, so the payload assertion below is not vacuous
await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toMatchObject({
name: 'Rooms renamed',
});
await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toMatchObject({
_id: PAYLOAD_ID,
eventName: 'chat message',
});
});
it('deletes the payload along with its request when the request leaves the file', async () => {
await fs.promises.writeFile(absPath, collectionYaml(), 'utf8');
await registry.startWatcher(REPO_ID, repoDir, PROJECT_ID);
await expect(services.socketIOPayload.getByParentId(REQUEST_ID)).resolves.toBeDefined();
await fs.promises.writeFile(absPath, collectionYaml({ withRequest: false }), 'utf8');
await registry.importAllFiles(REPO_ID);
await registry.flushNow(REPO_ID);
await expect(services.socketIORequest.getById(REQUEST_ID)).resolves.toBeUndefined();
await expect(services.socketIOPayload.getById(PAYLOAD_ID)).resolves.toBeUndefined();
});
});
@@ -22,9 +22,7 @@ import { hasSignificantChanges } from '~/common/significant-diff-detection';
* or null if `content` is not an importable Insomnia file.
*/
export async function getCanonicalInsomniaFileContent(content: string): Promise<string | null> {
const newLineIndex = content.indexOf('\n');
const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim();
if (!InsomniaFileTypeValues.some(type => firstLine.includes(type))) {
if (!isInsomniaFileContent(content)) {
return null;
}
@@ -53,6 +51,32 @@ export async function getCanonicalInsomniaFileContent(content: string): Promise<
return exportedContent || null;
}
// Whether `content` declares itself an Insomnia file on its first line (eg. `type: collection.insomnia.rest/5.0`).
function isInsomniaFileContent(content: string) {
const newLineIndex = content.indexOf('\n');
const firstLine = (newLineIndex === -1 ? content : content.slice(0, newLineIndex)).trim();
return InsomniaFileTypeValues.some(type => firstLine.includes(type));
}
export type InsomniaFileFormatIssue = 'invalid' | 'non-canonical';
/**
* How `content` differs from a file Insomnia writes itself: `invalid` when it's an Insomnia file that
* doesn't match the file format, `non-canonical` when it does but Insomnia would write it differently
* (eg. it was edited by hand — comments, formatting, order, missing or duplicated parts, unrecognized
* fields — or written by another version of Insomnia), and null when it's exactly what Insomnia writes,
* empty, or not an Insomnia file at all.
*/
export async function getInsomniaFileFormatIssue(content: string | null): Promise<InsomniaFileFormatIssue | null> {
if (!content || !isInsomniaFileContent(content)) {
return null;
}
if (tryImportV5Data(content).error) {
return 'invalid';
}
return (await getCanonicalInsomniaFileContent(content)) === content ? null : 'non-canonical';
}
/**
* `content` as the Git Sync pipeline would write it, or `content` itself when it can't be replayed
* (not an importable Insomnia file, or a workspace the export can't produce, e.g. a file without a
@@ -378,6 +378,11 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
// moved to another control (e.g. Tab from the URL bar to Send) — until the editor holds focus
// or the window elapses.
const deadline = Date.now() + 500;
// The control that held focus when we mounted (typically the "Add" button that triggered this
// editor). It keeps focus until we take it, so it must not be mistaken for the user having
// deliberately moved elsewhere. Cleared once the editor holds focus, so a later deliberate
// return to that control is respected.
let initialActiveElement = document.activeElement;
const ensureFocus = () => {
const cm = codeMirror.current;
if (!cm) {
@@ -399,6 +404,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
const role = active?.getAttribute('role');
const userMovedToAnotherControl =
!!active &&
active !== initialActiveElement &&
(active.tagName === 'INPUT' ||
active.tagName === 'TEXTAREA' ||
active.tagName === 'SELECT' ||
@@ -416,6 +422,8 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
}
cm.focus();
cm.getDoc().setCursor(cm.getDoc().lineCount(), 0);
} else {
initialActiveElement = null;
}
if (Date.now() < deadline) {
requestAnimationFrame(ensureFocus);
@@ -1956,6 +1956,9 @@ const OriginalGitProjectStagingModal: FC<
onShowTextView={() => setDiffViewMode('text')}
actionGate={actionGate}
isActionInFlight={isActionInFlight}
// One warning above the diff at most: unrecognized fields, or a change that is only Insomnia
// reformatting the file, already have their own, which say more than the format warning would.
hideFormatWarning={Boolean(hasUnknownFields || undiscardableFilePath)}
/>
)}
</div>
@@ -5,6 +5,7 @@ import { useGitProjectDiscardPartialContentActionFetcher } from '~/routes/git.di
import { useGitProjectStagePartialContentActionFetcher } from '~/routes/git.stage-entity';
import { showToast } from '../../toast-notification';
import { WarningBanner } from '../../warning-banner';
import type { ActionGate } from './action-gate';
import { CookieJarDiffCard } from './cookie-jar-diff-card';
import { computeVisualDiff, getBaseEnvironmentId } from './diff-engine';
@@ -41,8 +42,12 @@ interface Props {
actionGate: ActionGate;
// Whether an action, here or file-level, is still in flight: every card's buttons wait for it.
isActionInFlight: boolean;
// Set when the diff already has a warning saying more than the format warning would (eg. unrecognized fields).
hideFormatWarning?: boolean;
}
type FormatIssue = Awaited<ReturnType<typeof window.main.git.fileFormatIssueLoader>>;
// Shown instead of cards when there are none to show; the Text view still has the full diff.
const TextViewHint: FC<{ message: string; onShowTextView: () => void }> = ({ message, onShowTextView }) => (
<div className="flex h-full flex-1 items-center justify-center p-4 text-center text-(--hl)">
@@ -67,8 +72,32 @@ export const EntityDiffList: FC<Props> = ({
onShowTextView,
actionGate,
isActionInFlight,
hideFormatWarning,
}) => {
const { entities, unparseable } = useMemo(() => computeVisualDiff(before, after), [before, after]);
// Whether either side isn't a file as Insomnia writes it (eg. edited by hand): `invalid` doesn't match the
// Insomnia file format at all, `non-canonical` does but Insomnia would write it differently. Checked in the
// background, as it replays both sides through import -> export, so it doesn't hold up showing the diff.
// Kept per file, so a refresh after an action doesn't flash the warning, and another file never shows it.
const fileKey = `${filepath}:${staged}`;
const [formatCheck, setFormatCheck] = useState<{ fileKey: string; issue: FormatIssue } | null>(null);
useEffect(() => {
let cancelled = false;
window.main.git
.fileFormatIssueLoader({ projectId, workspaceId, filepath, staged })
.then(issue => {
if (!cancelled) {
setFormatCheck({ fileKey, issue });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [projectId, workspaceId, filepath, staged, fileKey, before, after]);
const formatIssue = formatCheck?.fileKey === fileKey ? formatCheck.issue : null;
const baseEnvironmentId = useMemo(() => getBaseEnvironmentId(after), [after]);
const stagePartialContentFetcher = useGitProjectStagePartialContentActionFetcher();
const discardPartialContentFetcher = useGitProjectDiscardPartialContentActionFetcher();
@@ -152,6 +181,15 @@ export const EntityDiffList: FC<Props> = ({
return <TextViewHint message="Unable to parse this file for a visual diff." onShowTextView={onShowTextView} />;
}
if (formatIssue === 'invalid') {
return (
<TextViewHint
message="This file doesn't match the Insomnia file format, so the Visual view can't show it."
onShowTextView={onShowTextView}
/>
);
}
if (entities.length === 0) {
// Also covers metadata-only (eg. timestamps) and formatting-only changes, and non-Insomnia files
// such as the project's .spectral.yaml lint ruleset — not just moves.
@@ -173,6 +211,18 @@ export const EntityDiffList: FC<Props> = ({
return (
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
{formatIssue === 'non-canonical' && !hideFormatWarning && (
// Cards are matched up by id and actions splice entities into a file written the way Insomnia exports it,
// so a file in any other shape (eg. edited by hand) can show incomplete cards and lose what isn't an entity.
<WarningBanner className="shrink-0 text-sm" data-testid="visual-diff-format-warning">
This file isn&apos;t in the format Insomnia writes, eg. it was edited by hand or by another version. Cards may
be incomplete, and staging or discarding a card rewrites the whole file, losing comments and formatting.{' '}
<Button onPress={onShowTextView} className="cursor-pointer underline hover:brightness-125">
Open the Text view
</Button>{' '}
for the exact changes.
</WarningBanner>
)}
<div className="flex shrink-0 items-center gap-3 text-xs">
<span className="font-semibold text-(--color-font)">
{entities.length} {entities.length === 1 ? 'entity' : 'entities'} changed
@@ -837,33 +837,6 @@ const ProjectNavigationSidebarInner = (
});
});
},
sidebar_deleteItem: event => {
// Only act when a row itself is focused, not an inner control (button/input), so Backspace keeps editing text.
const active = document.activeElement;
if (!(active instanceof HTMLElement) || active.closest('[data-key]') !== active) {
return;
}
const { focusedItem } = getFocusedItem();
if (
!focusedItem ||
(focusedItem.kind !== 'project' &&
focusedItem.kind !== 'workspace' &&
focusedItem.kind !== 'collectionChild' &&
focusedItem.kind !== 'pinnedRequest')
) {
return;
}
event.preventDefault();
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.keyboardShortcutUsed,
properties: {
source: 'navigation-sidebar',
action: 'deleteItem',
},
});
showDeleteModal(focusedItem);
},
});
const { hasKonnectPat } = settings;
@@ -991,7 +964,7 @@ const ProjectNavigationSidebarInner = (
className="group/tree flex-1 overflow-y-auto pb-(--padding-sm)"
data-testid="project-navigation-tree-container"
onKeyDownCapture={(e: React.KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft' && e.key !== 'Delete') {
return;
}
const active = document.activeElement;
@@ -1003,6 +976,29 @@ const ProjectNavigationSidebarInner = (
if (!rowEl || rowEl !== active) {
return;
}
if (e.key === 'Delete') {
const { focusedItem } = getFocusedItem();
if (
!focusedItem ||
(focusedItem.kind !== 'project' &&
focusedItem.kind !== 'workspace' &&
focusedItem.kind !== 'collectionChild' &&
focusedItem.kind !== 'pinnedRequest')
) {
return;
}
e.preventDefault();
e.stopPropagation();
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.keyboardShortcutUsed,
properties: {
source: 'navigation-sidebar',
action: 'deleteItem',
},
});
showDeleteModal(focusedItem);
return;
}
const docId = (active.dataset.key || '').replace(/^pinned-request-/, '');
const item = visibleFlatItems.find(i => i.doc._id === docId && i.kind !== 'pinnedRequest');
if (!item) {
+161 -1
View File
@@ -310,6 +310,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -369,6 +375,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
@@ -892,6 +904,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -951,6 +969,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
@@ -1525,6 +1549,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -1584,6 +1614,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -2348,6 +2384,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -2407,6 +2449,12 @@
"json",
"kv"
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
@@ -3866,8 +3914,53 @@
}
]
},
"body": {
"type": "object",
"properties": {
"meta": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^ws-payload.*"
},
"created": {
"type": "number"
},
"modified": {
"type": "number"
},
"isPrivate": {
"type": "boolean"
},
"description": {
"type": "string"
},
"sortKey": {
"type": "number"
}
},
"required": [
"id"
]
},
"name": {
"type": "string"
},
"value": {
"type": "string"
},
"mode": {
"type": "string"
},
"eventName": {},
"ack": {},
"args": {}
}
},
"children": {},
"method": {}
"method": {},
"eventListeners": {}
}
},
{
@@ -4476,6 +4569,67 @@
"disableUserAgentHeader": {
"type": "boolean"
},
"body": {
"type": "object",
"properties": {
"meta": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^socket-io-payload.*"
},
"created": {
"type": "number"
},
"modified": {
"type": "number"
},
"isPrivate": {
"type": "boolean"
},
"description": {
"type": "string"
},
"sortKey": {
"type": "number"
}
},
"required": [
"id"
]
},
"eventName": {
"type": "string"
},
"ack": {
"type": "boolean"
},
"args": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string"
},
"mode": {
"type": "string"
}
},
"required": [
"id"
]
}
},
"name": {},
"value": {},
"mode": {}
}
},
"children": {},
"method": {}
}
@@ -5068,6 +5222,12 @@
}
]
},
"confidentialKeys": {
"type": "array",
"items": {
"type": "string"
}
},
"method": {},
"url": {},
"parameters": {},