mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-04 20:13:48 -05:00
Merge branch 'develop' into test/refactor_e2e_tests
This commit is contained in:
36 files changed
+1665
-528
No files matched your search
@@ -2,7 +2,7 @@ import type { BaseModel, ProjectLintRuleset, Workspace } from 'insomnia-data';
|
||||
import { models } from 'insomnia-data';
|
||||
|
||||
// Key for VCS to delete before computing changes
|
||||
const DELETE_KEY: keyof BaseModel = 'modified';
|
||||
export const DELETE_KEY: keyof BaseModel = 'modified';
|
||||
|
||||
type ResetModelKeys<T extends BaseModel> = {
|
||||
[K in keyof T]?: T[K] | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { strings } from 'insomnia-data/common';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import type { BaseModel } from './base-types';
|
||||
import { type BaseModel, createModelSchema } from './base-types';
|
||||
|
||||
export const name = 'Workspace';
|
||||
export const type = 'Workspace';
|
||||
@@ -10,14 +11,6 @@ export const canSync = true;
|
||||
|
||||
export const SCRATCHPAD_WORKSPACE_ID = 'wrk_scratchpad';
|
||||
|
||||
export interface BaseWorkspace {
|
||||
name: string;
|
||||
description: string;
|
||||
certificates?: any; // deprecated
|
||||
scope: 'design' | 'collection' | 'mock-server' | 'environment' | 'mcp';
|
||||
konnectServiceId?: string | null;
|
||||
}
|
||||
|
||||
export type WorkspaceScope = BaseWorkspace['scope'];
|
||||
|
||||
export const WorkspaceScopeKeys = {
|
||||
@@ -28,11 +21,20 @@ export const WorkspaceScopeKeys = {
|
||||
mcp: 'mcp',
|
||||
} as const;
|
||||
|
||||
export type Workspace = BaseModel & BaseWorkspace;
|
||||
export const baseWorkspaceSchema = z.object({
|
||||
name: z.string().default(`New ${strings.collection.singular}`),
|
||||
description: z.string().default(''),
|
||||
certificates: z.any().optional(), // deprecated
|
||||
scope: z.enum(Object.values(WorkspaceScopeKeys)),
|
||||
konnectServiceId: z.string().optional().nullable(),
|
||||
});
|
||||
export type BaseWorkspace = z.infer<typeof baseWorkspaceSchema>;
|
||||
|
||||
export const schema = createModelSchema(type, prefix).extend(baseWorkspaceSchema.shape);
|
||||
export type Workspace = z.infer<typeof schema>;
|
||||
|
||||
export const isWorkspace = (model: Pick<BaseModel, 'type'>): model is Workspace => model.type === type;
|
||||
|
||||
export const optionalKeys = ['konnectServiceId'];
|
||||
export const isWorkspaceId = (id?: string | null) => id?.startsWith(prefix + '_');
|
||||
|
||||
export const isDesign = (workspace: Pick<Workspace, 'scope'>) => workspace.scope === WorkspaceScopeKeys.design;
|
||||
@@ -46,6 +48,8 @@ export const isEnvironment = (workspace: Pick<Workspace, 'scope'>) =>
|
||||
|
||||
export const isMcp = (workspace: Pick<Workspace, 'scope'>) => workspace.scope === WorkspaceScopeKeys.mcp;
|
||||
|
||||
export const optionalKeys: (keyof BaseWorkspace)[] = ['konnectServiceId'];
|
||||
|
||||
export const init = (): BaseWorkspace => ({
|
||||
name: `New ${strings.collection.singular}`,
|
||||
description: '',
|
||||
|
||||
@@ -64,12 +64,7 @@ test('Setup external vault and used in request', async ({ app, page, insomnia })
|
||||
await page.locator('#close-add-cloud-credential-modal').click();
|
||||
|
||||
// close the settings
|
||||
await page.locator('.app').press('Escape');
|
||||
// dismiss any remaining modal overlay before interacting with the sidebar
|
||||
await page
|
||||
.locator('[data-close-modal="true"]')
|
||||
.click()
|
||||
.catch(() => {});
|
||||
await page.getByRole('button', { name: 'Modal Close Button' }).click();
|
||||
|
||||
// used in request
|
||||
await insomnia.navigationSidebar.clickRequestOrFolder('External Vault Tag');
|
||||
@@ -91,18 +86,21 @@ test('Setup external vault and used in request', async ({ app, page, insomnia })
|
||||
// test aws vault tag
|
||||
await page.locator(`[data-template^="${externalVaultTestCases.aws.tagPrefix}"]`).click();
|
||||
await page.getByLabel('Credential For Vault Service').selectOption(awsCredentialName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click();
|
||||
const previewText = page.getByRole('dialog').getByLabel('Live Preview');
|
||||
await expect.soft(previewText).toHaveText(externalVaultTestCases.aws.expectedResult);
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
// test gcp vault tag
|
||||
await page.locator(`[data-template^="${externalVaultTestCases.gcp.tagPrefix}"]`).click();
|
||||
await page.getByLabel('Credential For Vault Service').selectOption(gcpCredentialName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click();
|
||||
const gcpPreviewText = page.getByRole('dialog').getByLabel('Live Preview');
|
||||
await expect.soft(gcpPreviewText).toHaveText(externalVaultTestCases.gcp.expectedResult);
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
// test hashicorp vault tag
|
||||
await page.locator(`[data-template^="${externalVaultTestCases.hashicorp.tagPrefix}"]`).click();
|
||||
await page.getByLabel('Credential For Vault Service').selectOption(hashicorpCredentialName);
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click();
|
||||
const hashicorpPreviewText = page.getByRole('dialog').getByLabel('Live Preview');
|
||||
await expect.soft(hashicorpPreviewText).toHaveText(externalVaultTestCases.hashicorp.expectedResult);
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { models } from 'insomnia-data';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { checkDocCompatibilityFromSchema, findDocUnknownFieldRanges } from '../zod/check-doc-compatibility';
|
||||
|
||||
// Like a real synced blob, there is no `modified`: hashDocument deletes it before the blob is stored.
|
||||
const makeEnvironment = (extra: Record<string, unknown> = {}) => ({
|
||||
...models.environment.init(),
|
||||
_id: 'env_1',
|
||||
type: models.environment.type,
|
||||
parentId: 'wrk_1',
|
||||
created: 1,
|
||||
...extra,
|
||||
});
|
||||
|
||||
// hashDocument also resets a workspace's `parentId` to null (see resetKeys in insomnia-data).
|
||||
const makeWorkspace = (extra: Record<string, unknown> = {}) => ({
|
||||
...models.workspace.init(),
|
||||
_id: 'wrk_1',
|
||||
type: models.workspace.type,
|
||||
parentId: null,
|
||||
created: 1,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const toBlob = (doc: object) => JSON.stringify(doc);
|
||||
|
||||
// A request blob as it is stored by cloud sync.
|
||||
const requestBlob = {
|
||||
_id: 'req_cc3d9e822ceb4eefbb7f74114b6e4860',
|
||||
authentication: {},
|
||||
body: {},
|
||||
created: 1_767_691_001_603,
|
||||
description: '',
|
||||
headers: [{ description: '', disabled: false, name: 'User-Agent', value: 'insomnia/12.2.0' }],
|
||||
isPrivate: false,
|
||||
metaSortKey: -1_767_335_591_505.5,
|
||||
method: 'GET',
|
||||
name: 'Request Tow',
|
||||
parameters: [],
|
||||
parentId: 'wrk_ad2095fbf3b14d76b638696336aef369',
|
||||
pathParameters: [],
|
||||
settingDisableRenderRequestBody: false,
|
||||
settingEncodeUrl: true,
|
||||
settingFollowRedirects: 'global',
|
||||
settingRebuildPath: true,
|
||||
settingSendCookies: true,
|
||||
settingStoreCookies: true,
|
||||
type: 'Request',
|
||||
url: 'https://httpbin.org/get',
|
||||
};
|
||||
|
||||
describe('checkDocCompatibilityFromSchema on a stored request blob', () => {
|
||||
it('is ok for a blob that matches the Request schema', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(requestBlob))).toBe('strict-match');
|
||||
});
|
||||
|
||||
it('reports unknown-fields when the blob has a field this version does not know', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, addedByNewerVersion: 1 }))).toBe('match-with-unknown-fields');
|
||||
});
|
||||
|
||||
it('reports unsupported when a known field has a value or format this version does not accept', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, method: 42 }))).toBe('mismatch');
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, headers: 'a-new-format' }))).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('reports unsupported when a required field is missing (e.g. renamed by a newer version)', () => {
|
||||
const { created: _renamed, ...withoutCreated } = requestBlob;
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ ...withoutCreated, createdAt: requestBlob.created }))).toBe(
|
||||
'mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('only reports unknown-fields when the renamed field has a default, since the old name is then optional', () => {
|
||||
const { url: _renamed, ...withoutUrl } = requestBlob;
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ ...withoutUrl, uri: requestBlob.url }))).toBe('match-with-unknown-fields');
|
||||
});
|
||||
|
||||
it('highlights the unknown field in the diff text', () => {
|
||||
const blob = toBlob({ ...requestBlob, addedByNewerVersion: 1 });
|
||||
const yamlText = stringify(JSON.parse(blob));
|
||||
const line = yamlText.split('\n').findIndex(l => l.startsWith('addedByNewerVersion:')) + 1;
|
||||
|
||||
expect(findDocUnknownFieldRanges(blob, yamlText)).toEqual([
|
||||
expect.objectContaining({ startLine: line, endLine: line, label: 'addedByNewerVersion' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDocCompatibilityFromSchema on a stored workspace blob', () => {
|
||||
it('is ok even though hashDocument reset its parentId to null', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace()))).toBe('strict-match');
|
||||
});
|
||||
|
||||
it('reports unknown-fields for a field this version does not know', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace({ addedByNewerVersion: true })))).toBe(
|
||||
'match-with-unknown-fields',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports unsupported for a scope added by a newer version', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace({ scope: 'a-scope-from-the-future' })))).toBe(
|
||||
'mismatch',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDocCompatibilityFromSchema', () => {
|
||||
it('is ok for a doc that matches its model schema', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment()))).toBe('strict-match');
|
||||
});
|
||||
|
||||
it('reports unknown-fields for a field its model schema does not know', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment({ addedByNewerVersion: true })))).toBe(
|
||||
'match-with-unknown-fields',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports unknown-fields for an unknown field nested inside a known one', () => {
|
||||
const doc = makeEnvironment({
|
||||
kvPairData: [{ id: 'a', name: 'k', value: 'v', type: 'str', enabled: true, future: 1 }],
|
||||
});
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(doc))).toBe('match-with-unknown-fields');
|
||||
});
|
||||
|
||||
it('prefers unsupported when a doc has both a mismatch and an unknown field', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment({ parentId: 123, extra: 1 })))).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('is ok for empty, invalid and non-object blobs', () => {
|
||||
expect(checkDocCompatibilityFromSchema()).toBe('strict-match');
|
||||
expect(checkDocCompatibilityFromSchema('')).toBe('strict-match');
|
||||
expect(checkDocCompatibilityFromSchema('null')).toBe('strict-match');
|
||||
expect(checkDocCompatibilityFromSchema('not json')).toBe('strict-match');
|
||||
expect(checkDocCompatibilityFromSchema('[]')).toBe('strict-match');
|
||||
});
|
||||
|
||||
it('is ok for unknown model types, which have no schema to check against', () => {
|
||||
expect(checkDocCompatibilityFromSchema(toBlob({ _id: 'x_1', type: 'NotAModel', extra: 1 }))).toBe('strict-match');
|
||||
});
|
||||
|
||||
it('returns the same result for repeated calls', () => {
|
||||
const blob = toBlob(makeEnvironment({ addedByNewerVersion: true }));
|
||||
expect(checkDocCompatibilityFromSchema(blob)).toBe('match-with-unknown-fields');
|
||||
expect(checkDocCompatibilityFromSchema(blob)).toBe('match-with-unknown-fields');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDocUnknownFieldRanges', () => {
|
||||
it('highlights the unknown field in the YAML rendering of the doc', () => {
|
||||
const blob = toBlob(makeEnvironment({ addedByNewerVersion: { a: 1, b: 2 } }));
|
||||
const yamlText = stringify(JSON.parse(blob));
|
||||
const line = yamlText.split('\n').findIndex(l => l.startsWith('addedByNewerVersion:')) + 1;
|
||||
|
||||
expect(findDocUnknownFieldRanges(blob, yamlText)).toEqual([
|
||||
expect.objectContaining({ startLine: line, endLine: line + 2, label: 'addedByNewerVersion' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for a clean doc, a missing blob or an unsupported doc', () => {
|
||||
const blob = toBlob(makeEnvironment());
|
||||
expect(findDocUnknownFieldRanges(blob, stringify(JSON.parse(blob)))).toEqual([]);
|
||||
expect(findDocUnknownFieldRanges(undefined, '{}')).toEqual([]);
|
||||
|
||||
const unsupported = toBlob(makeEnvironment({ parentId: 123, extra: 1 }));
|
||||
expect(findDocUnknownFieldRanges(unsupported, stringify(JSON.parse(unsupported)))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ vi.mock('~/common/runtime', () => ({
|
||||
|
||||
import type { Cookie, Request, Response } from 'insomnia-data';
|
||||
import { models, services } from 'insomnia-data';
|
||||
import { EnvironmentKvPairDataType } from 'insomnia-data';
|
||||
|
||||
import { database as db } from '../../common/database';
|
||||
import { getRenderedRequestAndContext } from '../../common/render';
|
||||
@@ -441,6 +442,71 @@ describe('export', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generate Code vs explicit export separation', () => {
|
||||
it('exportHarWithRequest with purpose=preview masks confidential env values when setting is ON (default)', async () => {
|
||||
const workspace = await services.workspace.create();
|
||||
const baseEnv = await services.environment.getOrCreateForParentId(workspace._id);
|
||||
await services.environment.update(baseEnv, {
|
||||
data: { token: 'real-secret' },
|
||||
kvPairData: [
|
||||
{
|
||||
id: 'pair_token',
|
||||
name: 'token',
|
||||
value: 'real-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const request: Request = {
|
||||
...models.request.init(),
|
||||
_id: 'req_codegen',
|
||||
modified: 1,
|
||||
created: 1,
|
||||
parentId: workspace._id,
|
||||
type: models.request.type,
|
||||
headers: [{ name: 'Authorization', value: 'Bearer {{ _.token }}' }],
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/',
|
||||
};
|
||||
const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id, purpose: 'preview' });
|
||||
expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
});
|
||||
|
||||
it('exportHar (explicit export) uses undefined purpose — confidential env values are masked (same as SECRET, not affected by setting)', async () => {
|
||||
const workspace = await services.workspace.create({ _id: 'wrk_export_sep' });
|
||||
const baseEnv = await services.environment.getOrCreateForParentId(workspace._id);
|
||||
await services.environment.update(baseEnv, {
|
||||
data: { token: 'real-secret' },
|
||||
kvPairData: [
|
||||
{
|
||||
id: 'pair_token',
|
||||
name: 'token',
|
||||
value: 'real-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const request: Request = {
|
||||
...models.request.init(),
|
||||
_id: 'req_export_sep',
|
||||
modified: 1,
|
||||
created: 1,
|
||||
parentId: workspace._id,
|
||||
type: models.request.type,
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/',
|
||||
headers: [],
|
||||
};
|
||||
// explicit export passes no purpose → fail-closed 'mask' → same behaviour as SECRET
|
||||
const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id });
|
||||
expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportHarWithRequest()', () => {
|
||||
it('renders does it correctly', async () => {
|
||||
const workspace = await services.workspace.create();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { createBuilder } from '@develohpanda/fluent-builder';
|
||||
import type { Environment, Workspace } from 'insomnia-data';
|
||||
import { models, services } from 'insomnia-data';
|
||||
import { EnvironmentKvPairDataType, models, services } from 'insomnia-data';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { environmentModelSchema, requestGroupModelSchema } from '../../sync/__schemas__/model-schemas';
|
||||
@@ -30,6 +30,16 @@ const stubContextGetters = {
|
||||
getGlobalEnvironmentId: expect.any(Function),
|
||||
getProjectId: expect.any(Function),
|
||||
};
|
||||
const makeBaseContext = (
|
||||
purpose: 'preview' | 'send' | 'no-render' | 'script',
|
||||
hideSecretValuesInPreviewAndConsole = true,
|
||||
forceReveal?: boolean,
|
||||
) =>
|
||||
({
|
||||
getMeta: () => ({}),
|
||||
getPurpose: () => purpose,
|
||||
getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole, forceReveal }),
|
||||
}) as any;
|
||||
|
||||
describe('render tests', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -103,6 +113,273 @@ describe('render tests', () => {
|
||||
});
|
||||
|
||||
describe('buildRenderContext()', () => {
|
||||
it('preserves legacy vault masking for no-render', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'encrypted-value',
|
||||
type: EnvironmentKvPairDataType.SECRET,
|
||||
enabled: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('no-render'),
|
||||
});
|
||||
|
||||
expect(context.vault.token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
});
|
||||
|
||||
it('masks confidential values in preview while preserving JSON shape and derived variables', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({
|
||||
token: 'top-secret',
|
||||
config: { token: 'nested-secret', enabled: true, retries: 3, empty: null, values: ['secret'] },
|
||||
authorization: 'Bearer {{ token }}',
|
||||
})
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'top-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
{
|
||||
id: 'envPair_config',
|
||||
name: 'config',
|
||||
value: '{}',
|
||||
type: EnvironmentKvPairDataType.JSON,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
|
||||
expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
expect(context.config).toEqual({
|
||||
token: models.environment.vaultEnvironmentMaskValue,
|
||||
enabled: models.environment.vaultEnvironmentMaskValue,
|
||||
retries: models.environment.vaultEnvironmentMaskValue,
|
||||
empty: models.environment.vaultEnvironmentMaskValue,
|
||||
values: [models.environment.vaultEnvironmentMaskValue],
|
||||
});
|
||||
expect(context.authorization).toBe(`Bearer ${models.environment.vaultEnvironmentMaskValue}`);
|
||||
});
|
||||
|
||||
it('uses the last enabled KV row and higher-priority public layers to clear confidentiality', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'root-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_confidential',
|
||||
name: 'token',
|
||||
value: 'root-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
{
|
||||
id: 'envPair_public',
|
||||
name: 'token',
|
||||
value: 'root-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: false,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
const subEnvironment = envBuilder.data({ token: 'sub-public' }).build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
subEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
|
||||
expect(context.token).toBe('sub-public');
|
||||
});
|
||||
|
||||
it('masks a higher-priority confidential value that overrides a public value', async () => {
|
||||
const rootEnvironment = envBuilder.data({ token: 'root-public' }).build();
|
||||
const subEnvironment = envBuilder
|
||||
.data({ token: 'sub-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_sub',
|
||||
name: 'token',
|
||||
value: 'sub-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
subEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
|
||||
expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
});
|
||||
|
||||
it('reveals confidential values in preview when the local setting is disabled', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'top-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'top-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview', false),
|
||||
});
|
||||
|
||||
expect(context.token).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('ignores disabled confidential rows', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'visible' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_disabled',
|
||||
name: 'token',
|
||||
value: 'visible',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: false,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
|
||||
expect(context.token).toBe('visible');
|
||||
});
|
||||
|
||||
it('script purpose reveals confidential values', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'top-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'top-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('script'),
|
||||
});
|
||||
|
||||
expect(context.token).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('forceReveal in preview bypasses hideSecretValuesInPreviewAndConsole', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'top-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'top-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const context = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview', true, true),
|
||||
});
|
||||
|
||||
expect(context.token).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('does not mutate vault data object when preview masking runs', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'encrypted-value',
|
||||
type: EnvironmentKvPairDataType.SECRET,
|
||||
enabled: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
|
||||
expect(rootEnvironment.data[models.environment.vaultEnvironmentPath].token).toBe('encrypted-value');
|
||||
});
|
||||
|
||||
it('does not mutate environment data when preview masking precedes a send render', async () => {
|
||||
const rootEnvironment = envBuilder
|
||||
.data({ token: 'top-secret' })
|
||||
.kvPairData([
|
||||
{
|
||||
id: 'envPair_token',
|
||||
name: 'token',
|
||||
value: 'top-secret',
|
||||
type: EnvironmentKvPairDataType.STRING,
|
||||
enabled: true,
|
||||
isConfidential: true,
|
||||
},
|
||||
])
|
||||
.build();
|
||||
|
||||
const previewContext = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('preview'),
|
||||
});
|
||||
const sendContext = await renderUtils.buildRenderContext({
|
||||
rootEnvironment,
|
||||
baseContext: makeBaseContext('send'),
|
||||
});
|
||||
|
||||
expect(previewContext.token).toBe(models.environment.vaultEnvironmentMaskValue);
|
||||
expect(sendContext.token).toBe('top-secret');
|
||||
expect(rootEnvironment.data.token).toBe('top-secret');
|
||||
expect(rootEnvironment.kvPairData?.[0].value).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('cascades properly', async () => {
|
||||
const ancestors = [
|
||||
reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(),
|
||||
@@ -129,6 +406,25 @@ describe('render tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mutate the ancestors array passed by the caller', async () => {
|
||||
const ancestors = [
|
||||
reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(),
|
||||
reqGroupBuilder.environment({ foo: 'grandparent', ancestor: true }).build(),
|
||||
];
|
||||
const originalOrder = [...ancestors];
|
||||
|
||||
// Calling buildRenderContext twice with the same array (e.g. a prefill
|
||||
// pass followed by the real render pass) must yield the same result
|
||||
// both times. A previous bug reversed `ancestors` in place, so the
|
||||
// second call would see an already-reversed array and flip precedence.
|
||||
const firstContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors });
|
||||
const secondContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors });
|
||||
|
||||
expect(ancestors).toEqual(originalOrder);
|
||||
expect(firstContext.foo).toBe('parent');
|
||||
expect(secondContext.foo).toBe('parent');
|
||||
});
|
||||
|
||||
it('rendered recursive should not infinite loop', async () => {
|
||||
const ancestors = [reqGroupBuilder.environment({ recursive: '{{ recursive }}/hello' }).build()];
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import { version } from '../../package.json';
|
||||
// In the inso CLI and main process, fall back to process.env.
|
||||
const ENV = 'env';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env.
|
||||
const env = typeof window !== 'undefined' && window.env ? window.env : process[ENV];
|
||||
// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`/`typeof process`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env; dedicated Web Workers (e.g. the templating worker) have neither, so fall back to `{}` there instead of throwing.
|
||||
const env = typeof window !== 'undefined' && window.env ? window.env : typeof process !== 'undefined' ? process[ENV] : {};
|
||||
|
||||
export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI;
|
||||
export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clone from 'clone';
|
||||
import type {
|
||||
Environment,
|
||||
EnvironmentKvPairData,
|
||||
GrpcRequest,
|
||||
GrpcRequestBody,
|
||||
McpRequest,
|
||||
@@ -11,9 +12,10 @@ import type {
|
||||
WebSocketRequest,
|
||||
Workspace,
|
||||
} from 'insomnia-data';
|
||||
import { models, services } from 'insomnia-data';
|
||||
import { EnvironmentKvPairDataType, models, services } from 'insomnia-data';
|
||||
import orderedJSON from 'json-order';
|
||||
|
||||
import { type ConfidentialValueKind, getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy';
|
||||
import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '~/common/templating/constants';
|
||||
import { maskOrDecryptVaultDataIfNecessary } from '~/common/templating/mask-or-decrypt-vault-data';
|
||||
import { RenderError } from '~/common/templating/render-error';
|
||||
@@ -74,6 +76,204 @@ function getKeySource(
|
||||
}
|
||||
}
|
||||
|
||||
interface EnvironmentRenderLayer {
|
||||
data: Record<string, any>;
|
||||
kvPairData?: EnvironmentKvPairData[];
|
||||
source: 'global' | 'collection' | 'folder' | 'iteration' | 'transient';
|
||||
// Per-layer source attribution for the "Template tags show variable source
|
||||
// and value" setting; layers that define no variables of their own carry none.
|
||||
meta?: VariableSourceMeta;
|
||||
}
|
||||
|
||||
// Builds a name → ConfidentialValueKind lookup for one environment layer.
|
||||
// SECRET rows are intentionally excluded: they are handled separately by
|
||||
// maskOrDecryptVaultDataIfNecessary and must not be double-processed here.
|
||||
// Disabled rows are excluded to match getDataFromKVPair semantics — they
|
||||
// do not appear in `data` either.
|
||||
// If the same name appears more than once, the last enabled non-SECRET row
|
||||
// wins (consistent with getDataFromKVPair last-wins behaviour).
|
||||
function getConfidentialityByKey(kvPairData?: EnvironmentKvPairData[]) {
|
||||
const confidentialityByKey = new Map<string, ConfidentialValueKind | undefined>();
|
||||
|
||||
for (const pair of kvPairData || []) {
|
||||
if (!pair.enabled || pair.type === EnvironmentKvPairDataType.SECRET) {
|
||||
continue;
|
||||
}
|
||||
confidentialityByKey.set(pair.name, pair.isConfidential ? 'normal' : undefined);
|
||||
}
|
||||
|
||||
return confidentialityByKey;
|
||||
}
|
||||
|
||||
function maskConfidentialValue(value: any): any {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(maskConfidentialValue);
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, maskConfidentialValue(child)]));
|
||||
}
|
||||
|
||||
return models.environment.vaultEnvironmentMaskValue;
|
||||
}
|
||||
|
||||
export function buildEnvironmentRenderLayers({
|
||||
ancestors,
|
||||
rootEnvironment,
|
||||
subEnvironment,
|
||||
rootGlobalEnvironment,
|
||||
subGlobalEnvironment,
|
||||
userUploadEnvironment,
|
||||
transientVariables,
|
||||
workspaceId,
|
||||
}: {
|
||||
ancestors?: RenderContextAncestor[];
|
||||
rootEnvironment?: Environment;
|
||||
subEnvironment?: Environment;
|
||||
rootGlobalEnvironment?: Environment | null;
|
||||
subGlobalEnvironment?: Environment | null;
|
||||
userUploadEnvironment?: UserUploadEnvironment;
|
||||
transientVariables?: Environment;
|
||||
// Collection and folder layers attribute their source to this workspace.
|
||||
workspaceId?: string;
|
||||
}): EnvironmentRenderLayer[] {
|
||||
const environmentLayers: EnvironmentRenderLayer[] = [];
|
||||
|
||||
if (rootGlobalEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
rootGlobalEnvironment.data,
|
||||
rootGlobalEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
kvPairData: rootGlobalEnvironment.kvPairData,
|
||||
source: 'global',
|
||||
meta: {
|
||||
label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name),
|
||||
workspaceId: rootGlobalEnvironment.parentId,
|
||||
environmentId: rootGlobalEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (subGlobalEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
subGlobalEnvironment.data,
|
||||
subGlobalEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
kvPairData: subGlobalEnvironment.kvPairData,
|
||||
source: 'global',
|
||||
meta: {
|
||||
label: formatSourceLabel('Project Environment', subGlobalEnvironment.name),
|
||||
workspaceId: rootGlobalEnvironment?.parentId,
|
||||
environmentId: subGlobalEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get root environment keys in correct order
|
||||
// Then get sub environment keys in correct order
|
||||
// Then get ancestor (folder) environment keys in correct order
|
||||
if (rootEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
rootEnvironment.data,
|
||||
rootEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
kvPairData: rootEnvironment.kvPairData,
|
||||
source: 'collection',
|
||||
meta: {
|
||||
label: formatSourceLabel('Collection Environment', rootEnvironment.name),
|
||||
workspaceId: workspaceId,
|
||||
environmentId: rootEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (subEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
subEnvironment.data,
|
||||
subEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
kvPairData: subEnvironment.kvPairData,
|
||||
source: 'collection',
|
||||
// The active sub environment can be the base environment itself; only a
|
||||
// distinct sub environment carries its own source attribution.
|
||||
meta:
|
||||
subEnvironment._id !== rootEnvironment?._id
|
||||
? {
|
||||
label: formatSourceLabel('Collection Environment', subEnvironment.name),
|
||||
workspaceId: workspaceId,
|
||||
environmentId: subEnvironment._id,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const doc of [...(ancestors || [])].reverse()) {
|
||||
const ancestor: any = doc;
|
||||
const { environment, environmentPropertyOrder, kvPairData } = ancestor;
|
||||
|
||||
if (typeof environment === 'object' && environment !== null) {
|
||||
const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
kvPairData,
|
||||
source: 'folder',
|
||||
// Only folder environments carry source metadata; the request and workspace
|
||||
// ancestors define no variables of their own.
|
||||
meta:
|
||||
isRequestGroup(ancestor) && 'name' in ancestor
|
||||
? {
|
||||
label: formatSourceLabel('Folder Environment', ancestor.name),
|
||||
workspaceId: workspaceId,
|
||||
requestGroupId: ancestor._id,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// user upload env in collection runner has highest priority except local variables
|
||||
if (userUploadEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
userUploadEnvironment.data,
|
||||
userUploadEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
source: 'iteration',
|
||||
meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) },
|
||||
});
|
||||
}
|
||||
|
||||
// script local variables (insomnia.variable.set) has highest priority
|
||||
if (transientVariables) {
|
||||
const ordered = orderedJSON.order(
|
||||
transientVariables.data,
|
||||
transientVariables.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
environmentLayers.push({
|
||||
data: ordered,
|
||||
source: 'transient',
|
||||
meta: { label: formatSourceLabel('Script Variables', transientVariables.name) },
|
||||
});
|
||||
}
|
||||
|
||||
return environmentLayers;
|
||||
}
|
||||
|
||||
export async function buildRenderContext({
|
||||
ancestors,
|
||||
rootEnvironment,
|
||||
@@ -97,122 +297,16 @@ export async function buildRenderContext({
|
||||
// follows the same merge order and winners as the values.
|
||||
const keySource: Record<string, string> = {};
|
||||
const keySourceMeta: Record<string, VariableSourceMeta> = {};
|
||||
const workspaceId = baseContext.getMeta().workspaceId;
|
||||
const envObjects: { data: Record<string, any>; meta?: VariableSourceMeta }[] = [];
|
||||
|
||||
if (rootGlobalEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
rootGlobalEnvironment.data,
|
||||
rootGlobalEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({
|
||||
data: ordered,
|
||||
meta: {
|
||||
label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name),
|
||||
workspaceId: rootGlobalEnvironment.parentId,
|
||||
environmentId: rootGlobalEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (subGlobalEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
subGlobalEnvironment.data,
|
||||
subGlobalEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({
|
||||
data: ordered,
|
||||
meta: {
|
||||
label: formatSourceLabel('Project Environment', subGlobalEnvironment.name),
|
||||
workspaceId: rootGlobalEnvironment?.parentId,
|
||||
environmentId: subGlobalEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get root environment keys in correct order
|
||||
// Then get sub environment keys in correct order
|
||||
// Then get ancestor (folder) environment keys in correct order
|
||||
if (rootEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
rootEnvironment.data,
|
||||
rootEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({
|
||||
data: ordered,
|
||||
meta: {
|
||||
label: formatSourceLabel('Collection Environment', rootEnvironment.name),
|
||||
workspaceId: workspaceId,
|
||||
environmentId: rootEnvironment._id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (subEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
subEnvironment.data,
|
||||
subEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({
|
||||
data: ordered,
|
||||
// The active sub environment can be the base environment itself; only a
|
||||
// distinct sub environment carries its own source attribution.
|
||||
meta:
|
||||
subEnvironment._id !== rootEnvironment?._id
|
||||
? {
|
||||
label: formatSourceLabel('Collection Environment', subEnvironment.name),
|
||||
workspaceId: workspaceId,
|
||||
environmentId: subEnvironment._id,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const doc of (ancestors || []).reverse()) {
|
||||
const ancestor: any = doc;
|
||||
const { environment, environmentPropertyOrder } = ancestor;
|
||||
|
||||
if (typeof environment === 'object' && environment !== null) {
|
||||
const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR);
|
||||
// Only folder environments carry source metadata; the request and workspace
|
||||
// ancestors define no variables of their own.
|
||||
envObjects.push({
|
||||
data: ordered,
|
||||
meta:
|
||||
isRequestGroup(ancestor) && 'name' in ancestor
|
||||
? {
|
||||
label: formatSourceLabel('Folder Environment', ancestor.name),
|
||||
workspaceId: workspaceId,
|
||||
requestGroupId: ancestor._id,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// user upload env in collection runner has highest priority except local variables
|
||||
if (userUploadEnvironment) {
|
||||
const ordered = orderedJSON.order(
|
||||
userUploadEnvironment.data,
|
||||
userUploadEnvironment.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) } });
|
||||
}
|
||||
|
||||
// script local variables (insomnia.variable.set) has highest priority
|
||||
if (transientVariables) {
|
||||
const ordered = orderedJSON.order(
|
||||
transientVariables.data,
|
||||
transientVariables.dataPropertyOrder ?? null,
|
||||
JSON_ORDER_SEPARATOR,
|
||||
);
|
||||
envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Script Variables', transientVariables.name) } });
|
||||
}
|
||||
const environmentLayers = buildEnvironmentRenderLayers({
|
||||
ancestors,
|
||||
rootEnvironment,
|
||||
subEnvironment,
|
||||
rootGlobalEnvironment,
|
||||
subGlobalEnvironment,
|
||||
userUploadEnvironment,
|
||||
transientVariables,
|
||||
workspaceId: baseContext.getMeta().workspaceId,
|
||||
});
|
||||
|
||||
// At this point, environments is a list of environments ordered
|
||||
// from top-most parent to bottom-most child, and they keys in each environment
|
||||
@@ -271,9 +365,43 @@ export async function buildRenderContext({
|
||||
}
|
||||
let finalRenderContext = { ...renderContext };
|
||||
|
||||
for (const { data } of envObjects) {
|
||||
// For every environment render the Objects
|
||||
finalRenderContext = await renderSubContext(data, finalRenderContext);
|
||||
// Cross-layer confidentiality accumulator. Updated on every key as layers are
|
||||
// merged in priority order (low → high). When a higher-priority layer defines
|
||||
// the same key, its confidentiality verdict overwrites the previous one — including
|
||||
// setting it to undefined when the higher-priority layer has no kvPairData entry
|
||||
// for that key (e.g. iteration/transient layers), which intentionally clears the
|
||||
// confidential flag so a public override is not masked.
|
||||
const confidentialityByKey = new Map<string, ConfidentialValueKind | undefined>();
|
||||
const hideSecretValues = renderContext?.getSettings?.().hideSecretValuesInPreviewAndConsole;
|
||||
const forceReveal = renderContext?.getSettings?.().forceReveal;
|
||||
// Hoisted above both loops: purpose/hideSecretValues/forceReveal are invariant for the
|
||||
// entire buildRenderContext call. undefined purpose hits the fail-closed 'mask' branch,
|
||||
// so file-export paths (explicit HAR, etc.) always mask confidential values — consistent
|
||||
// with SECRET behaviour and independent of the hideSecretValuesInPreviewAndConsole setting.
|
||||
const purpose = renderContext?.getPurpose?.();
|
||||
const confidentialPolicy = getConfidentialValuePolicy({ purpose, hideSecretValues, forceReveal });
|
||||
|
||||
for (const environmentLayer of environmentLayers) {
|
||||
// Per-layer lookup built from kvPairData metadata (not from data values).
|
||||
const layerConfidentialityByKey = getConfidentialityByKey(environmentLayer.kvPairData);
|
||||
// Build a masked copy of this layer's data before merging into the context.
|
||||
// Masking must happen here — after this point the values enter renderSubContext
|
||||
// where they may be rendered into other variables, and we must never let a
|
||||
// masked value be subsequently over-written with the real one from a cache or
|
||||
// re-render of the same context.
|
||||
const renderableData: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(environmentLayer.data)) {
|
||||
// Overwrite the accumulated confidentiality with this layer's verdict.
|
||||
// layerConfidentialityByKey.get(key) returns undefined for keys that have
|
||||
// no kvPairData (e.g. raw-JSON layers, iteration data), which is the
|
||||
// correct "public" signal and clears any confidentiality from lower layers.
|
||||
confidentialityByKey.set(key, layerConfidentialityByKey.get(key));
|
||||
const confidentiality = confidentialityByKey.get(key);
|
||||
renderableData[key] = confidentiality && confidentialPolicy === 'mask' ? maskConfidentialValue(value) : value;
|
||||
}
|
||||
|
||||
finalRenderContext = await renderSubContext(renderableData, finalRenderContext);
|
||||
}
|
||||
|
||||
// Key source attribution, registered in the same order the runtime merges the
|
||||
@@ -285,7 +413,7 @@ export async function buildRenderContext({
|
||||
// there for `_.vault.key` chips; the reserved key itself is never exposed.
|
||||
const inKey = NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME;
|
||||
|
||||
for (const { data, meta } of envObjects) {
|
||||
for (const { data, meta } of environmentLayers) {
|
||||
if (!meta) {
|
||||
continue;
|
||||
}
|
||||
@@ -301,7 +429,7 @@ export async function buildRenderContext({
|
||||
}
|
||||
}
|
||||
|
||||
for (const { data, meta } of envObjects) {
|
||||
for (const { data, meta } of environmentLayers) {
|
||||
if (!meta) {
|
||||
continue;
|
||||
}
|
||||
@@ -311,7 +439,9 @@ export async function buildRenderContext({
|
||||
|
||||
const vaultEnvironmentData = await maskOrDecryptVaultDataIfNecessary(
|
||||
finalRenderContext[models.environment.vaultEnvironmentPath],
|
||||
renderContext.getPurpose(),
|
||||
purpose,
|
||||
hideSecretValues,
|
||||
forceReveal,
|
||||
);
|
||||
if (vaultEnvironmentData) {
|
||||
// avoid add undefined data to render context
|
||||
@@ -498,6 +628,7 @@ export async function getRenderContext({
|
||||
ancestors: _ancestors,
|
||||
purpose,
|
||||
extraInfo,
|
||||
forceReveal,
|
||||
}: RenderContextOptions): Promise<BaseRenderContext> {
|
||||
const ancestors = _ancestors || (await getRenderContextAncestors(request));
|
||||
|
||||
@@ -553,7 +684,11 @@ export async function getRenderContext({
|
||||
getGlobalEnvironmentId: () => subGlobalEnvironment?._id || rootGlobalEnvironment?._id,
|
||||
// It is possible for a project to not exist because this code path can be reached via Inso which has no concept of a project.
|
||||
getProjectId: () => project?._id,
|
||||
getSettings: () => ({ dataFolders: settings.dataFolders }),
|
||||
getSettings: () => ({
|
||||
dataFolders: settings.dataFolders,
|
||||
hideSecretValuesInPreviewAndConsole: settings.hideSecretValuesInPreviewAndConsole,
|
||||
forceReveal: forceReveal,
|
||||
}),
|
||||
};
|
||||
|
||||
// Generate the context we need to render
|
||||
|
||||
+19
-3
@@ -15,7 +15,7 @@ const makeContext = () => ({
|
||||
getKeysContext: () => ({ keyContext: {} }),
|
||||
getProjectId: () => 'proj_1',
|
||||
getPurpose: () => 'send',
|
||||
getSettings: () => ({ dataFolders: [] }),
|
||||
getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true }),
|
||||
});
|
||||
|
||||
describe('serializeRenderContext', () => {
|
||||
@@ -41,7 +41,7 @@ describe('serializeRenderContext', () => {
|
||||
keysContext: { keyContext: {} },
|
||||
projectId: 'proj_1',
|
||||
purpose: 'send',
|
||||
settings: { dataFolders: [] },
|
||||
settings: { dataFolders: [], hideSecretValuesInPreviewAndConsole: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,8 +54,24 @@ describe('deserializeRenderContext', () => {
|
||||
expect(context.getMeta()).toEqual({ requestId: 'req_1', workspaceId: 'wrk_1' });
|
||||
expect(context.getProjectId()).toBe('proj_1');
|
||||
expect(context.getEnvironmentId()).toBe('env_1');
|
||||
expect(context.getSettings()).toEqual({ dataFolders: [] });
|
||||
expect(context.getSettings()).toEqual({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true });
|
||||
// environment data survives the round-trip
|
||||
expect(context.foo).toBe('bar');
|
||||
});
|
||||
|
||||
it('preserves forceReveal in settings across the worker boundary', () => {
|
||||
const ctx = {
|
||||
...makeContext(),
|
||||
getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true, forceReveal: true }),
|
||||
};
|
||||
const roundTripped = structuredClone(serializeRenderContext(ctx));
|
||||
const context = deserializeRenderContext(roundTripped);
|
||||
|
||||
expect(context.getSettings()).toEqual({
|
||||
dataFolders: [],
|
||||
hideSecretValuesInPreviewAndConsole: true,
|
||||
forceReveal: true,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type ConfidentialValuePolicy, getConfidentialValuePolicy, shouldMaskExternalVaultTag } from './confidential-value-policy';
|
||||
import type { RenderPurpose } from './types';
|
||||
|
||||
const purposes: (RenderPurpose | undefined)[] = [
|
||||
undefined,
|
||||
'general',
|
||||
'send',
|
||||
'preview',
|
||||
'script',
|
||||
'no-render',
|
||||
];
|
||||
const settings = [true, false, undefined];
|
||||
|
||||
function expectedPolicy(
|
||||
purpose: RenderPurpose | undefined,
|
||||
hideSecretValues: boolean | undefined,
|
||||
): ConfidentialValuePolicy {
|
||||
if (purpose === 'send' || purpose === 'script') {
|
||||
return 'reveal';
|
||||
}
|
||||
|
||||
if (purpose === 'preview') {
|
||||
return hideSecretValues === false ? 'reveal' : 'mask';
|
||||
}
|
||||
|
||||
return 'mask';
|
||||
}
|
||||
|
||||
describe('getConfidentialValuePolicy', () => {
|
||||
it.each(purposes.flatMap(purpose => settings.map(hideSecretValues => ({ purpose, hideSecretValues }))))(
|
||||
'purpose=$purpose hideSecretValues=$hideSecretValues follows the policy matrix',
|
||||
({ purpose, hideSecretValues }) => {
|
||||
expect(getConfidentialValuePolicy({ purpose, hideSecretValues })).toBe(
|
||||
expectedPolicy(purpose, hideSecretValues),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('forceReveal only applies to preview — fallback surfaces ignore it', () => {
|
||||
expect(getConfidentialValuePolicy({ purpose: 'preview', forceReveal: true })).toBe('reveal');
|
||||
expect(getConfidentialValuePolicy({ purpose: undefined, forceReveal: true })).toBe('mask');
|
||||
expect(getConfidentialValuePolicy({ purpose: 'general', forceReveal: true })).toBe('mask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldMaskExternalVaultTag', () => {
|
||||
const PLUGIN = '@kong/insomnia-plugin-external-vault';
|
||||
const TAG = 'vault';
|
||||
|
||||
it('returns false for non-vault plugin', () => {
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: 'other-plugin', tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-vault tag name', () => {
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: 'other-tag', renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-preview purposes — plugin run() handles its own placeholder', () => {
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'send', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'script', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'general', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for preview when setting is ON (default-closed)', () => {
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(true);
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: {} })).toBe(true);
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for preview when setting is OFF — reveal real value', () => {
|
||||
expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for preview when forceReveal is true — eye-button reveal bypasses setting', () => {
|
||||
expect(
|
||||
shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true, forceReveal: true } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants';
|
||||
|
||||
import type { RenderPurpose } from './types';
|
||||
|
||||
export type ConfidentialValueKind = 'normal' | 'secret' | 'external-vault';
|
||||
export type ConfidentialValuePolicy =
|
||||
// Use the real plaintext value (send, script, or preview with setting OFF).
|
||||
| 'reveal'
|
||||
// Replace the value with •••••• before it reaches any display surface or log (preview with setting ON).
|
||||
| 'mask';
|
||||
|
||||
// The string displayed in place of a masked confidential value across all surfaces.
|
||||
export const CONFIDENTIAL_MASK_VALUE = '••••••';
|
||||
|
||||
// Tag name for the bundled external vault plugin, used to intercept tag execution
|
||||
// at the adapter boundary before any provider fetch occurs.
|
||||
const BUNDLED_EXTERNAL_VAULT_TAG = 'vault';
|
||||
|
||||
export function getConfidentialValuePolicy({
|
||||
purpose,
|
||||
hideSecretValues,
|
||||
forceReveal,
|
||||
}: {
|
||||
purpose?: RenderPurpose;
|
||||
hideSecretValues?: boolean;
|
||||
forceReveal?: boolean;
|
||||
}): ConfidentialValuePolicy {
|
||||
if (purpose === 'send' || purpose === 'script') {
|
||||
return 'reveal';
|
||||
}
|
||||
|
||||
if (purpose === 'preview') {
|
||||
// forceReveal is set by the VariableEditor eye-button; it only applies to the preview
|
||||
// surface so that codegen and fallback surfaces remain unaffected.
|
||||
return forceReveal || hideSecretValues === false ? 'reveal' : 'mask';
|
||||
}
|
||||
|
||||
// Fail-closed: any surface that does not explicitly declare its purpose is masked.
|
||||
// All known display surfaces (preview, send, script) are explicit above.
|
||||
return 'mask';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the external vault tag's run() should be skipped and CONFIDENTIAL_MASK_VALUE
|
||||
* returned instead, avoiding any provider fetch.
|
||||
*
|
||||
* Only intercepts the 'preview' purpose. Generate Code and Copy as cURL callers use
|
||||
* purpose:'preview' so they are covered here. For 'general', undefined, and other purposes
|
||||
* the plugin's own run() handles its behavior.
|
||||
*/
|
||||
export function shouldMaskExternalVaultTag({
|
||||
pluginName,
|
||||
tagName,
|
||||
renderPurpose,
|
||||
settings,
|
||||
}: {
|
||||
pluginName: string;
|
||||
tagName?: string;
|
||||
renderPurpose?: RenderPurpose;
|
||||
settings?: { hideSecretValuesInPreviewAndConsole?: boolean; forceReveal?: boolean };
|
||||
}): boolean {
|
||||
if (pluginName !== EXTERNAL_VAULT_PLUGIN_NAME || tagName !== BUNDLED_EXTERNAL_VAULT_TAG) {
|
||||
return false;
|
||||
}
|
||||
if (renderPurpose !== 'preview') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getConfidentialValuePolicy({
|
||||
purpose: renderPurpose,
|
||||
hideSecretValues: settings?.hideSecretValuesInPreviewAndConsole,
|
||||
forceReveal: settings?.forceReveal,
|
||||
}) === 'mask'
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Tag } from 'liquidjs';
|
||||
import type { Plugin } from '~/common/plugins/types';
|
||||
|
||||
import packageJson from '../../../package.json';
|
||||
import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from './confidential-value-policy';
|
||||
import { resolveArg } from './resolve-arg';
|
||||
import { tokenizeArgs } from './tokenize-args';
|
||||
import type {
|
||||
@@ -182,6 +183,13 @@ export function createLiquidTagWorker(
|
||||
},
|
||||
};
|
||||
|
||||
// Short-circuit external vault before any provider fetch when the policy says mask.
|
||||
const renderSettings = renderContext.getSettings?.();
|
||||
if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) {
|
||||
emitter.write(CONFIDENTIAL_MASK_VALUE);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Promise.resolve(ext.run(helperContext, ...args));
|
||||
emitter.write(result == null ? '' : String(result));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { models, services } from 'insomnia-data';
|
||||
|
||||
import { getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy';
|
||||
import type { RenderPurpose } from '~/common/templating/types';
|
||||
import { decryptVaultKeyFromSession } from '~/common/utils/vault';
|
||||
import { getRuntime } from '~/runtimes';
|
||||
@@ -11,14 +12,15 @@ import { getRuntime } from '~/runtimes';
|
||||
*
|
||||
* Given an object the result is always a fresh copy; a non-object input is returned as is.
|
||||
*/
|
||||
export async function maskOrDecryptVaultDataIfNecessary(vaultEnvironmentData: any, renderPurpose?: RenderPurpose) {
|
||||
/**
|
||||
* Decrypt secrets when renderPurpose is one of the following:
|
||||
* - preview: render the template in variable editor to do the live preview
|
||||
* - send: render the template when sending requests
|
||||
* - script: render the template in pre-request or after-response script
|
||||
*/
|
||||
const shouldDecrypt = renderPurpose === 'preview' || renderPurpose === 'send' || renderPurpose === 'script';
|
||||
export async function maskOrDecryptVaultDataIfNecessary(
|
||||
vaultEnvironmentData: any,
|
||||
renderPurpose?: RenderPurpose,
|
||||
hideSecretValues?: boolean,
|
||||
forceReveal?: boolean,
|
||||
) {
|
||||
// getConfidentialValuePolicy is the single decision point for reveal/mask across all render surfaces
|
||||
const shouldDecrypt =
|
||||
getConfidentialValuePolicy({ purpose: renderPurpose, hideSecretValues, forceReveal }) === 'reveal';
|
||||
if (typeof vaultEnvironmentData !== 'object' || vaultEnvironmentData === null) {
|
||||
return vaultEnvironmentData;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
Response,
|
||||
ResponseHeader,
|
||||
Services,
|
||||
Settings,
|
||||
SocketIORequest,
|
||||
UserUploadEnvironment,
|
||||
WebSocketRequest,
|
||||
@@ -63,6 +64,7 @@ export interface SerializedCookie {
|
||||
hostOnly?: boolean;
|
||||
}
|
||||
|
||||
// TODO: check if we still need no-render, since we do not have any entry points that require it
|
||||
export type RenderPurpose = 'send' | 'general' | 'preview' | 'script' | 'no-render';
|
||||
export type PluginToMainAPIPaths =
|
||||
| 'readFile'
|
||||
@@ -139,6 +141,9 @@ export interface BaseRenderContextOptions {
|
||||
purpose?: RenderPurpose;
|
||||
extraInfo?: { requestChain: string[] };
|
||||
ignoreUndefinedEnvVariable?: boolean;
|
||||
// When true the user has explicitly clicked reveal in the VariableEditor;
|
||||
// this bypasses hideSecretValuesInPreviewAndConsole for the current render only.
|
||||
forceReveal?: boolean;
|
||||
}
|
||||
export type RenderContextAncestor =
|
||||
| Request
|
||||
@@ -282,6 +287,7 @@ export interface BaseRenderContext {
|
||||
keyContextMeta?: Record<string, VariableSourceMeta>;
|
||||
};
|
||||
getPurpose: () => RenderPurpose | undefined;
|
||||
getSettings: () => Pick<Settings, 'dataFolders' | 'hideSecretValuesInPreviewAndConsole'> & { forceReveal?: boolean };
|
||||
getExtraInfo: () => { requestChain: string[] } | undefined;
|
||||
getEnvironmentId: () => string | undefined;
|
||||
getGlobalEnvironmentId: () => string | undefined;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { models } from 'insomnia-data';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { getStrictSchema } from './check-schema-mismatch';
|
||||
import { findUnknownFieldRanges, type UnknownFieldRange } from './find-unknown-field-ranges';
|
||||
|
||||
export type SchemaCompatibility =
|
||||
// Passes the schema.
|
||||
| 'match'
|
||||
// Passes the strict schema.
|
||||
| 'strict-match'
|
||||
// Passes the schema but fails the strict schema (contains unknown fields).
|
||||
| 'match-with-unknown-fields'
|
||||
// Fails even the schema: a field is missing, renamed or has a value/format this schema doesn't accept.
|
||||
| 'mismatch';
|
||||
|
||||
// Memoized so getStrictSchema's WeakMap cache keeps hitting instead of rebuilding the strict schema on every call.
|
||||
const modelSchemaCache = new Map<string, z.ZodTypeAny>();
|
||||
|
||||
function getModelSchema(type: unknown): z.ZodTypeAny | undefined {
|
||||
if (typeof type !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const cachedSchema = modelSchemaCache.get(type);
|
||||
if (cachedSchema) {
|
||||
return cachedSchema;
|
||||
}
|
||||
const model = models.getModel(type);
|
||||
// Models that haven't been migrated to zod don't export a schema and are never checked.
|
||||
if (!model || !('schema' in model)) {
|
||||
return undefined;
|
||||
}
|
||||
// Cloud sync deletes DELETE_KEY (`modified`) from a doc before storing its blob. Make it optional to avoid a false mismatch.
|
||||
const schema = model.schema as unknown as z.ZodObject;
|
||||
let docSchema = schema.partial({ [models.DELETE_KEY]: true });
|
||||
// hashDocument also resets a Workspace's `parentId` to null (resetKeys in insomnia-data), the schema expects a string.
|
||||
if (type === models.workspace.type) {
|
||||
docSchema = docSchema.extend({ parentId: z.string().nullable() });
|
||||
}
|
||||
modelSchemaCache.set(type, docSchema);
|
||||
return docSchema;
|
||||
}
|
||||
|
||||
function parseDoc(json?: string): Record<string, unknown> | null {
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const doc = JSON.parse(json);
|
||||
return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDocSchemaCompatibility(schema: z.ZodTypeAny, doc: unknown): SchemaCompatibility {
|
||||
if (schema.safeParse(doc).success) {
|
||||
if (getStrictSchema(schema).safeParse(doc).success) {
|
||||
return 'strict-match';
|
||||
}
|
||||
return 'match-with-unknown-fields';
|
||||
}
|
||||
return 'mismatch';
|
||||
}
|
||||
// Blobs are content-addressed and status is recomputed on every DB change, so remember results per blob content.
|
||||
const MAX_CACHED_RESULTS = 200;
|
||||
// A Map iterates in insertion order, so the first key is always the least recently used one.
|
||||
const docBlobStatusCache = new Map<string, SchemaCompatibility>();
|
||||
|
||||
export function checkDocCompatibilityFromSchema(blobContent?: string): SchemaCompatibility {
|
||||
if (!blobContent) {
|
||||
return 'strict-match';
|
||||
}
|
||||
const cached = docBlobStatusCache.get(blobContent);
|
||||
if (cached !== undefined) {
|
||||
// Re-insert to mark it as most recently used
|
||||
docBlobStatusCache.delete(blobContent);
|
||||
docBlobStatusCache.set(blobContent, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const doc = parseDoc(blobContent);
|
||||
const schema = doc && getModelSchema(doc.type);
|
||||
const result: SchemaCompatibility = schema ? getDocSchemaCompatibility(schema, doc) : 'strict-match';
|
||||
|
||||
if (docBlobStatusCache.size >= MAX_CACHED_RESULTS) {
|
||||
docBlobStatusCache.delete(docBlobStatusCache.keys().next().value!);
|
||||
}
|
||||
docBlobStatusCache.set(blobContent, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Lines of the YAML rendering of a synced doc that hold unknown fields.
|
||||
export function findDocUnknownFieldRanges(blobContent: string | undefined, yamlText: string): UnknownFieldRange[] {
|
||||
if (checkDocCompatibilityFromSchema(blobContent) !== 'match-with-unknown-fields') {
|
||||
return [];
|
||||
}
|
||||
const schema = getModelSchema(parseDoc(blobContent)?.type);
|
||||
return schema ? findUnknownFieldRanges(yamlText, schema) : [];
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function collectIssues(issues: readonly z.core.$ZodIssue[], basePath: PropertyKe
|
||||
// cache the strict schema to avoid rebuilding it multiple times
|
||||
const strictSchemaCache = new WeakMap<z.ZodTypeAny, z.ZodTypeAny>();
|
||||
|
||||
function getStrictSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
export function getStrictSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
|
||||
let strictSchema = strictSchemaCache.get(schema);
|
||||
if (!strictSchema) {
|
||||
strictSchema = deepStrict(schema);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
RunTemplateTagActionArgs,
|
||||
} from '~/common/plugins/bridge-types';
|
||||
import type { GenerateMcpSamplingResponseFunction } from '~/common/plugins/types';
|
||||
import type { RenderedRequest } from '~/common/templating/types';
|
||||
import type { RenderedRequest, RenderPurpose } from '~/common/templating/types';
|
||||
import { invariant } from '~/common/utils/invariant';
|
||||
import { invokeWithNormalizedError } from '~/main/ipc/invoke';
|
||||
import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service';
|
||||
@@ -425,9 +425,9 @@ const main: Window['main'] = {
|
||||
generateCodeSnippet: (options: { har: object; target: string; client: string }) =>
|
||||
invokeWithNormalizedError('generateCodeSnippet', options),
|
||||
getCodeSnippetTargets: () => invokeWithNormalizedError('getCodeSnippetTargets'),
|
||||
exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean }) =>
|
||||
exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) =>
|
||||
invokeWithNormalizedError('exportHarWithRequest', options),
|
||||
exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) =>
|
||||
exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) =>
|
||||
invokeWithNormalizedError('exportHarRequest', options),
|
||||
exportHarCurrentRequest: (options: { requestId: string; responseId: string }) =>
|
||||
invokeWithNormalizedError('exportHarCurrentRequest', options),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { models, services } from 'insomnia-data';
|
||||
import { Cookie as ToughCookie } from 'tough-cookie';
|
||||
|
||||
import { RenderError } from '~/common/templating/render-error';
|
||||
import type { RenderedRequest } from '~/common/templating/types';
|
||||
import type { RenderedRequest, RenderPurpose } from '~/common/templating/types';
|
||||
import { parseGraphQLReqeustBody } from '~/common/utils/graph-ql';
|
||||
import { smartEncodeUrl } from '~/common/utils/url/querystring';
|
||||
import { getAuthHeader } from '~/main/network/get-auth-header';
|
||||
@@ -218,7 +218,7 @@ export async function exportHarResponse(response?: Response) {
|
||||
return harResponse;
|
||||
}
|
||||
|
||||
export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false) {
|
||||
export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false, purpose?: RenderPurpose) {
|
||||
const request = await services.request.getById(requestId);
|
||||
let environmentId: string;
|
||||
if (models.environment.isEnvironmentId(environmentOrWorkspaceId)) {
|
||||
@@ -239,12 +239,12 @@ export async function exportHarRequest(requestId: string, environmentOrWorkspace
|
||||
return null;
|
||||
}
|
||||
|
||||
return exportHarWithRequest(request, environmentId, addContentLength);
|
||||
return exportHarWithRequest(request, environmentId, addContentLength, purpose);
|
||||
}
|
||||
|
||||
export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false) {
|
||||
export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false, purpose?: RenderPurpose) {
|
||||
try {
|
||||
const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId });
|
||||
const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId, purpose });
|
||||
const renderedRequest = await getRuntime().network.applyRequestHooks(renderResult.request, renderResult.context);
|
||||
parseGraphQLReqeustBody(renderedRequest);
|
||||
return exportHarWithRenderedRequest(renderedRequest, addContentLength);
|
||||
|
||||
@@ -32,7 +32,7 @@ import type {
|
||||
MockRouteData,
|
||||
ModelConfig,
|
||||
} from '~/common/plugins/types';
|
||||
import type { RenderedRequest } from '~/common/templating/types';
|
||||
import type { RenderedRequest, RenderPurpose } from '~/common/templating/types';
|
||||
import { bundleSpectralRuleset } from '~/main/bundle-spectral-ruleset';
|
||||
import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization';
|
||||
import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc';
|
||||
@@ -320,11 +320,13 @@ export interface RendererToMainBridgeAPI {
|
||||
requestId: string;
|
||||
environmentId?: string;
|
||||
addContentLength?: boolean;
|
||||
purpose?: RenderPurpose;
|
||||
}) => Promise<any>;
|
||||
exportHarRequest: (options: {
|
||||
requestId: string;
|
||||
environmentOrWorkspaceId: string;
|
||||
addContentLength?: boolean;
|
||||
purpose?: RenderPurpose;
|
||||
}) => Promise<any>;
|
||||
exportHarCurrentRequest: (options: { requestId: string; responseId: string }) => Promise<any>;
|
||||
exportRequestsHAR: (options: { requests: any[]; includePrivateDocs?: boolean }) => Promise<string>;
|
||||
@@ -567,19 +569,19 @@ export function registerMainHandlers() {
|
||||
|
||||
ipcMainHandle(
|
||||
'exportHarWithRequest',
|
||||
async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean }) => {
|
||||
async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) => {
|
||||
const request = await services.request.getById(options.requestId);
|
||||
if (!request) {
|
||||
throw new Error(`Request ${options.requestId} not found`);
|
||||
}
|
||||
return exportHarWithRequest(request, options.environmentId, options.addContentLength);
|
||||
return exportHarWithRequest(request, options.environmentId, options.addContentLength, options.purpose);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMainHandle(
|
||||
'exportHarRequest',
|
||||
async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) => {
|
||||
return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength);
|
||||
async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) => {
|
||||
return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength, options.purpose);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Tag } from 'liquidjs';
|
||||
|
||||
import { jarFromCookies } from '~/common/cookies';
|
||||
import type { Plugin } from '~/common/plugins/types';
|
||||
import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from '~/common/templating/confidential-value-policy';
|
||||
import { resolveArg } from '~/common/templating/resolve-arg';
|
||||
import type { BaseRenderContext, PluginTemplateTag, PluginTemplateTagContext } from '~/common/templating/types';
|
||||
import { decodeEncoding, tokenizeArgs } from '~/common/templating/utils';
|
||||
@@ -104,6 +105,13 @@ export function createLiquidTag(
|
||||
},
|
||||
};
|
||||
|
||||
// Short-circuit external vault before any provider fetch when the policy says mask.
|
||||
const renderSettings = renderContext.getSettings?.();
|
||||
if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) {
|
||||
emitter.write(CONFIDENTIAL_MASK_VALUE);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Promise.resolve(ext.run(helperContext, ...args));
|
||||
emitter.write(result == null ? '' : String(result));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { showModal } from '~/ui/components/modals/index';
|
||||
import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal';
|
||||
import { UpgradeModal } from '~/ui/components/modals/upgrade-modal';
|
||||
import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts';
|
||||
import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks';
|
||||
import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks';
|
||||
import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh';
|
||||
import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source';
|
||||
import { usePlanData } from '~/ui/hooks/use-plan';
|
||||
@@ -49,6 +49,10 @@ import { queryXPath } from '~/ui/utils/xpath/query';
|
||||
import { getCachedEditorState, setCachedEditorState } from './editor-state-cache';
|
||||
import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip';
|
||||
import { normalizeIrregularWhitespace } from './normalize-irregular-whitespace';
|
||||
|
||||
// Stable options object so handleRender reference stays constant across re-renders.
|
||||
// A new object per render would cause initEditor to re-run on every render.
|
||||
const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } };
|
||||
const TAB_SIZE = 4;
|
||||
const MAX_SIZE_FOR_LINTING = 1_000_000; // Around 1MB
|
||||
const LONG_LINE_THRESHOLD = 10_000; // Collapse lines longer than 10,000 characters
|
||||
@@ -293,7 +297,7 @@ export const CodeEditor = memo(
|
||||
}),
|
||||
[indentChars],
|
||||
);
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks();
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS);
|
||||
const openVariableSource = useOpenVariableSource();
|
||||
const isNunjucksEnabled = enableNunjucks && handleRender;
|
||||
const shouldTruncateLongLines = !!readOnly && !!truncateLongLines;
|
||||
|
||||
@@ -31,7 +31,7 @@ import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal';
|
||||
import { UpgradeModal } from '~/ui/components/modals/upgrade-modal';
|
||||
import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts';
|
||||
import { Tooltip } from '~/ui/components/tooltip';
|
||||
import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks';
|
||||
import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks';
|
||||
import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh';
|
||||
import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source';
|
||||
import { usePlanData } from '~/ui/hooks/use-plan';
|
||||
@@ -42,6 +42,10 @@ import { getTagDefinitions } from '~/ui/templating/renderer-safe';
|
||||
import { getCachedEditorState, setCachedEditorState } from './editor-state-cache';
|
||||
import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip';
|
||||
|
||||
// Stable options object so handleRender reference stays constant across re-renders.
|
||||
// A new object per render would cause initEditor to re-run on every render.
|
||||
const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } };
|
||||
|
||||
// Replace the editor's entire value while PRESERVING undo/redo history and the
|
||||
// cursor. Unlike cm.setValue(), which clears history, replaceRange records the
|
||||
// change as a normal, undoable edit. No-ops when the value is unchanged so we
|
||||
@@ -114,7 +118,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
|
||||
);
|
||||
const { settings } = useRootLoaderData()!;
|
||||
const { isOwner, isEnterprisePlan } = usePlanData();
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks();
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS);
|
||||
const openVariableSource = useOpenVariableSource();
|
||||
const isPasswordEditor = type?.toLowerCase() === 'password';
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ export const RequestActionsDropdown = ({
|
||||
const har = await window.main.exportHarRequest({
|
||||
requestId: request._id,
|
||||
environmentOrWorkspaceId: workspaceId,
|
||||
purpose: 'preview',
|
||||
});
|
||||
if (!har) {
|
||||
return;
|
||||
|
||||
@@ -21,3 +21,24 @@ export const UnknownFieldsWarningIcon: FC = () => (
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
|
||||
export const UNSUPPORTED_DOC_MESSAGE =
|
||||
'The remote version of this file is not supported by this version of Insomnia. It has been converted to an older format and committing changes will break functionality for your collaborators.';
|
||||
|
||||
export const UnsupportedDocErrorIcon: FC = () => (
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
slot={null}
|
||||
aria-label="Unsupported item error"
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-danger) ring-1 ring-transparent transition-all focus:ring-(--hl-md) focus:ring-inset"
|
||||
>
|
||||
<Icon icon="circle-exclamation" aria-hidden pointerEvents="none" />
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
{UNSUPPORTED_DOC_MESSAGE}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
|
||||
interface Props {
|
||||
onConfirm?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Warns before committing changes to files whose committed version has fields this
|
||||
// version of Insomnia doesn't recognize, since re-saving them here would drop those fields.
|
||||
export const ConfirmUnknownFieldsCommitModal = ({ onConfirm, onClose }: Props) => {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
isDismissable
|
||||
className="fixed top-[50%] left-0 z-10 flex h-(--visual-viewport-height) w-full translate-y-[-50%] items-center justify-center bg-black/30"
|
||||
>
|
||||
<Modal
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
className="flex w-full max-w-2xl 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 data-loading:animate-pulse">
|
||||
{({ close }) => (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<Heading slot="title" className="flex items-center gap-2 text-2xl">
|
||||
<Icon icon="triangle-exclamation" className="text-(--color-warning)" />
|
||||
Unrecognized Fields
|
||||
</Heading>
|
||||
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="flex aspect-square h-6 shrink-0 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)"
|
||||
onPress={close}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="">
|
||||
The commit will modify files with unknown fields. Committing any changes to them from here may break or
|
||||
remove functionality for your collaborators. Are you sure you want to continue?
|
||||
</div>
|
||||
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
className="h-full gap-2 rounded-md bg-(--color-bg) px-4 py-2 text-sm font-semibold ring-1 ring-transparent transition-all hover:bg-(--hl-xs)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) aria-pressed:opacity-80"
|
||||
onPress={() => close?.()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="unknown-fields-commit-confirm-button"
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
onPress={() => {
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
@@ -48,8 +48,10 @@ interface Props {
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } };
|
||||
|
||||
export const CookiesModal = ({ setIsOpen }: Props) => {
|
||||
const { handleRender } = useNunjucks();
|
||||
const { handleRender } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS);
|
||||
|
||||
const { organizationId, projectId, workspaceId } = useParams() as {
|
||||
organizationId: string;
|
||||
|
||||
@@ -92,6 +92,7 @@ export const GenerateCodeModal = forwardRef<GenerateCodeModalHandle, Props>((pro
|
||||
requestId: request._id,
|
||||
environmentId: props.environmentId,
|
||||
addContentLength,
|
||||
purpose: 'preview',
|
||||
});
|
||||
if (har) {
|
||||
const cmd = await window.main.generateCodeSnippet({
|
||||
|
||||
@@ -58,6 +58,7 @@ import { AnalyticsEvent } from '~/ui/analytics';
|
||||
import { Badge } from '~/ui/components/base/badge';
|
||||
import { GitOauthAuthBanner } from '~/ui/components/git/git-oauth-auth-banner';
|
||||
import { isGitRepoLoadAuthHttp40Error } from '~/ui/components/git/git-oauth-auth-utils';
|
||||
import { ConfirmUnknownFieldsCommitModal } from '~/ui/components/modals/confirm-unknown-fields-commit-modal';
|
||||
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
import { SvgIcon } from '~/ui/components/svg-icon';
|
||||
import { WarningBanner } from '~/ui/components/warning-banner';
|
||||
@@ -2144,72 +2145,6 @@ const CreateBranchAndPushModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Warns before committing changes to files whose committed version has fields this
|
||||
// version of Insomnia doesn't recognize, since re-saving them here would drop those fields.
|
||||
const ConfirmUnknownFieldsCommitModal = ({ onConfirm, onClose }: Omit<ConfirmModalProps, 'message'>) => {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
isDismissable
|
||||
className="fixed top-[50%] left-0 z-10 flex h-(--visual-viewport-height) w-full translate-y-[-50%] items-center justify-center bg-black/30"
|
||||
>
|
||||
<Modal
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
className="flex w-full max-w-2xl 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 data-loading:animate-pulse">
|
||||
{({ close }) => (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<Heading slot="title" className="flex items-center gap-2 text-2xl">
|
||||
<Icon icon="triangle-exclamation" className="text-(--color-warning)" />
|
||||
Unrecognized Fields
|
||||
</Heading>
|
||||
|
||||
<Button
|
||||
aria-label="Close"
|
||||
className="flex aspect-square h-6 shrink-0 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)"
|
||||
onPress={close}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="">
|
||||
The commit will modify files with unknown fields. Committing any changes to them from here may break or
|
||||
remove functionality for your collaborators. Are you sure you want to continue?
|
||||
</div>
|
||||
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
className="h-full gap-2 rounded-md bg-(--color-bg) px-4 py-2 text-sm font-semibold ring-1 ring-transparent transition-all hover:bg-(--hl-xs)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) aria-pressed:opacity-80"
|
||||
onPress={() => close?.()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="unknown-fields-commit-confirm-button"
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
onPress={() => {
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
// Asks the user whether to stage all changes and commit them directly when
|
||||
// nothing is staged (mirrors VSCode, without the "Always"/"Never" options).
|
||||
const ConfirmCommitAllModal = ({ onConfirm, onClose }: Omit<ConfirmModalProps, 'message'>) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { models } from 'insomnia-data';
|
||||
import type { StageEntry, Status, StatusCandidate } from 'insomnia-vcs';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -18,11 +18,19 @@ import {
|
||||
import { useParams } from 'react-router';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import { checkDocCompatibilityFromSchema, findDocUnknownFieldRanges } from '~/common/zod/check-doc-compatibility';
|
||||
import { useInsomniaSyncCreateSnapshotActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot';
|
||||
import { useInsomniaSyncStageActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage';
|
||||
import { useInsomniaSyncUnstageActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage';
|
||||
import { ConfirmUnknownFieldsCommitModal } from '~/ui/components/modals/confirm-unknown-fields-commit-modal';
|
||||
import { ErrorBanner, WarningBanner } from '~/ui/components/warning-banner';
|
||||
|
||||
import { DiffEditor } from '../diff-view-editor';
|
||||
import {
|
||||
UnknownFieldsWarningIcon,
|
||||
UNSUPPORTED_DOC_MESSAGE,
|
||||
UnsupportedDocErrorIcon,
|
||||
} from '../git/unknown-fields-warning';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
interface Props {
|
||||
@@ -32,16 +40,24 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getDiff(previewDiffItem?: StageEntry) {
|
||||
function getPreviousBlobContent(item?: StageEntry) {
|
||||
return item && 'previousBlobContent' in item ? item.previousBlobContent : undefined;
|
||||
}
|
||||
|
||||
function getBlobContent(item?: StageEntry) {
|
||||
return item && 'blobContent' in item ? item.blobContent : undefined;
|
||||
}
|
||||
|
||||
function getDiff(previousBlobContent?: string, blobContent?: string) {
|
||||
let before = '{}';
|
||||
let after = '{}';
|
||||
|
||||
if (previewDiffItem && 'previousBlobContent' in previewDiffItem && previewDiffItem.previousBlobContent) {
|
||||
before = previewDiffItem.previousBlobContent === 'null' ? '' : previewDiffItem.previousBlobContent || '{}';
|
||||
if (previousBlobContent) {
|
||||
before = previousBlobContent === 'null' ? '' : previousBlobContent;
|
||||
}
|
||||
|
||||
if (previewDiffItem && 'blobContent' in previewDiffItem && previewDiffItem.blobContent) {
|
||||
after = previewDiffItem.blobContent === 'null' ? '' : previewDiffItem.blobContent || '{}';
|
||||
if (blobContent) {
|
||||
after = blobContent === 'null' ? '' : blobContent;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -56,9 +72,18 @@ function getDiff(previewDiffItem?: StageEntry) {
|
||||
console.warn('Failed to parse after JSON', e);
|
||||
}
|
||||
|
||||
// Check the compatibility of the original document against the schema only when there is blob content (not being deleted).
|
||||
const originDocCompatibility = !!blobContent && checkDocCompatibilityFromSchema(previousBlobContent);
|
||||
const unknownFieldRanges =
|
||||
originDocCompatibility === 'match-with-unknown-fields'
|
||||
? findDocUnknownFieldRanges(previousBlobContent, before)
|
||||
: [];
|
||||
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
unknownFieldRanges,
|
||||
originStatus: originDocCompatibility,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +123,9 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => {
|
||||
syncItems.find(item => item.key === key)?.document || 'deleted' in entry
|
||||
? { type: getModelTypeById(key) }
|
||||
: undefined,
|
||||
originStatus: getBlobContent(entry)
|
||||
? checkDocCompatibilityFromSchema(getPreviousBlobContent(entry))
|
||||
: 'strict-match',
|
||||
id: `staged-${key}`,
|
||||
}));
|
||||
const unstagedChanges = Object.entries(status.unstaged).map(([key, entry]) => ({
|
||||
@@ -106,6 +134,9 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => {
|
||||
syncItems.find(item => item.key === key)?.document || 'deleted' in entry
|
||||
? { type: getModelTypeById(key) }
|
||||
: undefined,
|
||||
originStatus: getBlobContent(entry)
|
||||
? checkDocCompatibilityFromSchema(getPreviousBlobContent(entry))
|
||||
: 'strict-match',
|
||||
id: `unstaged-${key}`,
|
||||
}));
|
||||
|
||||
@@ -142,275 +173,332 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => {
|
||||
}, [allChangesLength, onClose, createSnapshotFetcher.data?.error]);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>('');
|
||||
const [pendingCommit, setPendingCommit] = useState<{ message: string; push: boolean } | null>(null);
|
||||
|
||||
const hasStagedUnknownFields = stagedChanges.some(item => item.originStatus === 'match-with-unknown-fields');
|
||||
|
||||
const createSnapshot = (message: string, push: boolean) => {
|
||||
createSnapshotFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
message,
|
||||
push,
|
||||
});
|
||||
};
|
||||
|
||||
const previewDiffItem = allChanges.find(item => item.id === selectedItemId);
|
||||
const previewDiffItemName = getPreviewItemName(previewDiffItem);
|
||||
const diff = getDiff(previewDiffItem);
|
||||
const previewPreviousBlobContent = getPreviousBlobContent(previewDiffItem);
|
||||
const previewBlobContent = getBlobContent(previewDiffItem);
|
||||
|
||||
// DiffEditor re-creates its models whenever its props change, so keep `diff` (and its ranges) referentially stable.
|
||||
const diff = useMemo(
|
||||
() => getDiff(previewPreviousBlobContent, previewBlobContent),
|
||||
[previewPreviousBlobContent, previewBlobContent],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose();
|
||||
}}
|
||||
isDismissable
|
||||
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
|
||||
>
|
||||
<Modal
|
||||
<>
|
||||
<ModalOverlay
|
||||
isOpen
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose();
|
||||
}}
|
||||
className="flex h-[calc(100%-var(--padding-xl))] w-[calc(100%-var(--padding-xl))] flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)"
|
||||
isDismissable
|
||||
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
|
||||
>
|
||||
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-hidden">
|
||||
{({ close }) => (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<Heading slot="title" className="text-2xl">
|
||||
Commit changes
|
||||
</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 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)"
|
||||
onPress={close}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid h-full grid-cols-[350px_1fr] gap-4 divide-x divide-solid divide-(--hl-md) overflow-hidden">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden p-2">
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const submitter = e.nativeEvent instanceof SubmitEvent ? e.nativeEvent.submitter : null;
|
||||
const formData = new FormData(e.currentTarget, submitter);
|
||||
const message = formData.get('message')?.toString().trim() || '';
|
||||
const push = Boolean(formData.get('push') === 'true');
|
||||
if (message) {
|
||||
createSnapshotFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
message,
|
||||
push,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
<Modal
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose();
|
||||
}}
|
||||
className="flex h-[calc(100%-var(--padding-xl))] w-[calc(100%-var(--padding-xl))] 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 flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||
<Heading slot="title" className="text-2xl">
|
||||
Commit changes
|
||||
</Heading>
|
||||
<Button
|
||||
className="flex aspect-square h-6 shrink-0 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)"
|
||||
onPress={close}
|
||||
>
|
||||
<TextField className="flex shrink-0 flex-col gap-2">
|
||||
<Label className="font-bold">Message</Label>
|
||||
<TextArea
|
||||
rows={3}
|
||||
name="message"
|
||||
className="resize-none rounded-xs border border-solid border-(--hl-sm) p-2 placeholder:text-(--hl-md)"
|
||||
placeholder="This is a helpful message that describes the changes made in this commit."
|
||||
required
|
||||
/>
|
||||
</TextField>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid h-full grid-cols-[350px_1fr] gap-4 divide-x divide-solid divide-(--hl-md) overflow-hidden">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden p-2">
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const submitter = e.nativeEvent instanceof SubmitEvent ? e.nativeEvent.submitter : null;
|
||||
const formData = new FormData(e.currentTarget, submitter);
|
||||
const message = formData.get('message')?.toString().trim() || '';
|
||||
const push = Boolean(formData.get('push') === 'true');
|
||||
if (message) {
|
||||
if (hasStagedUnknownFields) {
|
||||
setPendingCommit({ message, push });
|
||||
} else {
|
||||
createSnapshot(message, push);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<TextField className="flex shrink-0 flex-col gap-2">
|
||||
<Label className="font-bold">Message</Label>
|
||||
<TextArea
|
||||
rows={3}
|
||||
name="message"
|
||||
className="resize-none rounded-xs border border-solid border-(--hl-sm) p-2 placeholder:text-(--hl-md)"
|
||||
placeholder="This is a helpful message that describes the changes made in this commit."
|
||||
required
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-stretch gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={createSnapshotFetcher.state !== 'idle'}
|
||||
formAction={`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/insomnia-sync/branch/create-snapshot`}
|
||||
className="flex h-8 flex-1 items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) px-4 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={createSnapshotFetcher.state !== 'idle' ? 'spinner' : 'check'}
|
||||
className={`w-5 ${createSnapshotFetcher.state === 'idle' ? '' : 'animate-spin'}`}
|
||||
/>{' '}
|
||||
Commit
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={createSnapshotFetcher.state !== 'idle'}
|
||||
name="push"
|
||||
value="true"
|
||||
className="flex h-8 flex-1 items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) px-4 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={createSnapshotFetcher.state !== 'idle' ? 'spinner' : 'cloud-arrow-up'}
|
||||
className={`w-5 ${createSnapshotFetcher.state !== 'idle' ? 'animate-spin' : ''}`}
|
||||
/>{' '}
|
||||
Commit and push
|
||||
</Button>
|
||||
</div>
|
||||
{createSnapshotFetcher.data?.error && (
|
||||
<p className="rounded-xs bg-(--color-danger)/20 p-2 text-sm text-(--color-font-danger)">
|
||||
<Icon icon="exclamation-triangle" /> {createSnapshotFetcher.data.error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="grid auto-rows-auto gap-2 overflow-y-auto">
|
||||
<div className="flex max-h-96 w-full flex-col gap-2 overflow-hidden">
|
||||
<Heading className="group flex w-full shrink-0 items-center justify-between gap-2 py-1 font-semibold">
|
||||
<span className="flex-1">Staged changes</span>
|
||||
<div className="flex shrink-0 items-center justify-stretch gap-2">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
unstageChanges(stagedChanges.map(item => item.key));
|
||||
}}
|
||||
type="submit"
|
||||
isDisabled={createSnapshotFetcher.state !== 'idle'}
|
||||
formAction={`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/insomnia-sync/branch/create-snapshot`}
|
||||
className="flex h-8 flex-1 items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) px-4 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="minus" />
|
||||
<Icon
|
||||
icon={createSnapshotFetcher.state !== 'idle' ? 'spinner' : 'check'}
|
||||
className={`w-5 ${createSnapshotFetcher.state === 'idle' ? '' : 'animate-spin'}`}
|
||||
/>{' '}
|
||||
Commit
|
||||
</Button>
|
||||
<span className="rounded-full bg-(--hl-sm) px-1 text-xs text-(--hl)">
|
||||
{stagedChanges.length}
|
||||
</span>
|
||||
</Heading>
|
||||
<div className="flex w-full flex-1 overflow-y-auto select-none">
|
||||
<GridList
|
||||
className="w-full"
|
||||
items={stagedChanges.map(entry => ({
|
||||
entry,
|
||||
id: entry.id,
|
||||
key: entry.id,
|
||||
textValue: entry.name || entry.document?.type || '',
|
||||
}))}
|
||||
aria-label="Unstaged changes"
|
||||
onAction={key => {
|
||||
setSelectedItemId(key.toString());
|
||||
}}
|
||||
renderEmptyState={() => (
|
||||
<p className="p-2 text-sm text-(--hl)">Stage your changes to commit them.</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={createSnapshotFetcher.state !== 'idle'}
|
||||
name="push"
|
||||
value="true"
|
||||
className="flex h-8 flex-1 items-center justify-center gap-2 rounded-xs bg-(--hl-xxs) px-4 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)"
|
||||
>
|
||||
{item => {
|
||||
return (
|
||||
<GridListItem className="group flex w-full items-center justify-between overflow-hidden px-2 py-1 text-(--hl) outline-hidden transition-colors select-none hover:bg-(--hl-xs) focus:bg-(--hl-sm) aria-selected:bg-(--hl-sm) aria-selected:text-(--color-font)">
|
||||
<span className="truncate">{item.entry.name || item.entry.document?.type}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
unstageChanges([item.entry.key]);
|
||||
}}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
<TooltipTrigger>
|
||||
<Button className="cursor-default">
|
||||
{'added' in item.entry ? 'U' : 'deleted' in item.entry ? 'D' : 'M'}
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
{'added' in item.entry
|
||||
? 'Untracked'
|
||||
: 'deleted' in item.entry
|
||||
? 'Deleted'
|
||||
: 'Modified'}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
<Icon
|
||||
icon={createSnapshotFetcher.state !== 'idle' ? 'spinner' : 'cloud-arrow-up'}
|
||||
className={`w-5 ${createSnapshotFetcher.state !== 'idle' ? 'animate-spin' : ''}`}
|
||||
/>{' '}
|
||||
Commit and push
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-96 w-full flex-col gap-2 overflow-hidden">
|
||||
<Heading className="group flex w-full shrink-0 items-center justify-between py-1 font-semibold">
|
||||
<span>Changes</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{createSnapshotFetcher.data?.error && (
|
||||
<p className="rounded-xs bg-(--color-danger)/20 p-2 text-sm text-(--color-font-danger)">
|
||||
<Icon icon="exclamation-triangle" /> {createSnapshotFetcher.data.error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="grid auto-rows-auto gap-2 overflow-y-auto">
|
||||
<div className="flex max-h-96 w-full flex-col gap-2 overflow-hidden">
|
||||
<Heading className="group flex w-full shrink-0 items-center justify-between gap-2 py-1 font-semibold">
|
||||
<span className="flex-1">Staged changes</span>
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
stageChanges(unstagedChanges.map(item => item.key));
|
||||
unstageChanges(stagedChanges.map(item => item.key));
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
<span className="rounded-full bg-(--hl-sm) px-1 text-xs text-(--hl)">
|
||||
{unstagedChanges.length}
|
||||
{stagedChanges.length}
|
||||
</span>
|
||||
</div>
|
||||
</Heading>
|
||||
<div className="flex w-full flex-1 overflow-y-auto select-none">
|
||||
<GridList
|
||||
className="w-full"
|
||||
items={unstagedChanges.map(entry => ({
|
||||
entry,
|
||||
id: entry.id,
|
||||
key: entry.id,
|
||||
textValue: entry.name || entry.document?.type || '',
|
||||
}))}
|
||||
aria-label="Unstaged changes"
|
||||
onAction={key => {
|
||||
setSelectedItemId(key.toString());
|
||||
}}
|
||||
>
|
||||
{item => {
|
||||
return (
|
||||
<GridListItem className="group flex w-full items-center justify-between overflow-hidden px-2 py-1 text-(--hl) outline-hidden transition-colors select-none hover:bg-(--hl-xs) focus:bg-(--hl-sm) aria-selected:bg-(--hl-sm) aria-selected:text-(--color-font)">
|
||||
<span className="truncate">{item.entry.name || item.entry.document?.type}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
stageChanges([item.entry.key]);
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
<TooltipTrigger>
|
||||
<Button className="cursor-default">
|
||||
{'added' in item.entry ? 'U' : 'deleted' in item.entry ? 'D' : 'M'}
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
</Heading>
|
||||
<div className="flex w-full flex-1 overflow-y-auto select-none">
|
||||
<GridList
|
||||
className="w-full"
|
||||
items={stagedChanges.map(entry => ({
|
||||
entry,
|
||||
id: entry.id,
|
||||
key: entry.id,
|
||||
textValue: entry.name || entry.document?.type || '',
|
||||
}))}
|
||||
aria-label="Unstaged changes"
|
||||
onAction={key => {
|
||||
setSelectedItemId(key.toString());
|
||||
}}
|
||||
renderEmptyState={() => (
|
||||
<p className="p-2 text-sm text-(--hl)">Stage your changes to commit them.</p>
|
||||
)}
|
||||
>
|
||||
{item => {
|
||||
return (
|
||||
<GridListItem className="group flex w-full items-center justify-between overflow-hidden px-2 py-1 text-(--hl) outline-hidden transition-colors select-none hover:bg-(--hl-xs) focus:bg-(--hl-sm) aria-selected:bg-(--hl-sm) aria-selected:text-(--color-font)">
|
||||
<span className="truncate">{item.entry.name || item.entry.document?.type}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
unstageChanges([item.entry.key]);
|
||||
}}
|
||||
>
|
||||
{'added' in item.entry
|
||||
? 'Untracked'
|
||||
: 'deleted' in item.entry
|
||||
? 'Deleted'
|
||||
: 'Modified'}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
{item.entry.originStatus === 'match-with-unknown-fields' && (
|
||||
<UnknownFieldsWarningIcon />
|
||||
)}
|
||||
{item.entry.originStatus === 'mismatch' && <UnsupportedDocErrorIcon />}
|
||||
<TooltipTrigger>
|
||||
<Button className="cursor-default">
|
||||
{'added' in item.entry ? 'U' : 'deleted' in item.entry ? 'D' : 'M'}
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
{'added' in item.entry
|
||||
? 'Untracked'
|
||||
: 'deleted' in item.entry
|
||||
? 'Deleted'
|
||||
: 'Modified'}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-h-96 w-full flex-col gap-2 overflow-hidden">
|
||||
<Heading className="group flex w-full shrink-0 items-center justify-between py-1 font-semibold">
|
||||
<span>Changes</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
stageChanges(unstagedChanges.map(item => item.key));
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
<span className="rounded-full bg-(--hl-sm) px-1 text-xs text-(--hl)">
|
||||
{unstagedChanges.length}
|
||||
</span>
|
||||
</div>
|
||||
</Heading>
|
||||
<div className="flex w-full flex-1 overflow-y-auto select-none">
|
||||
<GridList
|
||||
className="w-full"
|
||||
items={unstagedChanges.map(entry => ({
|
||||
entry,
|
||||
id: entry.id,
|
||||
key: entry.id,
|
||||
textValue: entry.name || entry.document?.type || '',
|
||||
}))}
|
||||
aria-label="Unstaged changes"
|
||||
onAction={key => {
|
||||
setSelectedItemId(key.toString());
|
||||
}}
|
||||
>
|
||||
{item => {
|
||||
return (
|
||||
<GridListItem className="group flex w-full items-center justify-between overflow-hidden px-2 py-1 text-(--hl) outline-hidden transition-colors select-none hover:bg-(--hl-xs) focus:bg-(--hl-sm) aria-selected:bg-(--hl-sm) aria-selected:text-(--color-font)">
|
||||
<span className="truncate">{item.entry.name || item.entry.document?.type}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className="flex aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-focus-within:opacity-100 group-hover:opacity-100 group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:opacity-100"
|
||||
slot={null}
|
||||
onPress={() => {
|
||||
stageChanges([item.entry.key]);
|
||||
}}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
{item.entry.originStatus === 'match-with-unknown-fields' && (
|
||||
<UnknownFieldsWarningIcon />
|
||||
)}
|
||||
{item.entry.originStatus === 'mismatch' && <UnsupportedDocErrorIcon />}
|
||||
<TooltipTrigger>
|
||||
<Button className="cursor-default">
|
||||
{'added' in item.entry ? 'U' : 'deleted' in item.entry ? 'D' : 'M'}
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="max-h-[85vh] max-w-xs overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-4 py-2 text-sm text-(--color-font) shadow-lg select-none focus:outline-hidden"
|
||||
>
|
||||
{'added' in item.entry
|
||||
? 'Untracked'
|
||||
: 'deleted' in item.entry
|
||||
? 'Deleted'
|
||||
: 'Modified'}
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{previewDiffItem ? (
|
||||
<div className="flex h-full flex-col gap-2 overflow-y-auto pb-0">
|
||||
<Heading className="flex items-center gap-2 font-bold">
|
||||
<Icon icon="code-compare" />
|
||||
{previewDiffItemName}
|
||||
</Heading>
|
||||
{diff.originStatus === 'mismatch' && (
|
||||
<ErrorBanner data-testid="unsupported-doc-diff-error">
|
||||
{UNSUPPORTED_DOC_MESSAGE}
|
||||
<div className="mt-2 font-bold">
|
||||
We strongly recommend upgrading, then deleting and re-syncing it from the remote source
|
||||
before making any changes.
|
||||
</div>
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{diff.unknownFieldRanges.length > 0 && (
|
||||
<WarningBanner icon="triangle-exclamation" data-testid="unknown-fields-diff-warning">
|
||||
The remote version of this file has fields not recognized by this version of Insomnia.
|
||||
Committing changes to this file from this version of Insomnia may break or remove
|
||||
functionality for your collaborators. We recommend upgrading.
|
||||
</WarningBanner>
|
||||
)}
|
||||
{previewDiffItem && (
|
||||
<div className="flex-1 overflow-y-auto rounded-xs bg-(--hl-xs) p-2 text-(--color-font)">
|
||||
<DiffEditor
|
||||
original={diff.before}
|
||||
modified={diff.after}
|
||||
originalHighlights={diff.unknownFieldRanges}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-2">
|
||||
<Heading className="flex items-center justify-center gap-2 text-4xl font-semibold text-(--hl-md)">
|
||||
<Icon icon="code-compare" />
|
||||
View diff
|
||||
</Heading>
|
||||
<p className="text-(--hl)">Select a file to compare changes</p>
|
||||
<p className="text-sm text-(--hl-md)">
|
||||
Changes may include modifications you made and automatic updates like timestamps
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewDiffItem ? (
|
||||
<div className="flex h-full flex-col gap-2 overflow-y-auto pb-0">
|
||||
<Heading className="flex items-center gap-2 font-bold">
|
||||
<Icon icon="code-compare" />
|
||||
{previewDiffItemName}
|
||||
</Heading>
|
||||
{previewDiffItem && (
|
||||
<div className="flex-1 overflow-y-auto rounded-xs bg-(--hl-xs) p-2 text-(--color-font)">
|
||||
<DiffEditor original={diff.before} modified={diff.after} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-2">
|
||||
<Heading className="flex items-center justify-center gap-2 text-4xl font-semibold text-(--hl-md)">
|
||||
<Icon icon="code-compare" />
|
||||
View diff
|
||||
</Heading>
|
||||
<p className="text-(--hl)">Select a file to compare changes</p>
|
||||
<p className="text-sm text-(--hl-md)">
|
||||
Changes may include modifications you made and automatic updates like timestamps
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
{pendingCommit && (
|
||||
<ConfirmUnknownFieldsCommitModal
|
||||
onClose={() => setPendingCommit(null)}
|
||||
onConfirm={() => {
|
||||
createSnapshot(pendingCommit.message, pendingCommit.push);
|
||||
setPendingCommit(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -61,10 +61,12 @@ async function getQueryParamsFromAuth(
|
||||
|
||||
const MAX_URL_LENGTH = 10 * 1024;
|
||||
|
||||
const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } };
|
||||
|
||||
export const RenderedQueryString: FC<Props> = ({ request }) => {
|
||||
const [previewString, setPreviewString] = useState(defaultPreview);
|
||||
const [tooLong, setTooLong] = useState(false);
|
||||
const { handleRender } = useNunjucks();
|
||||
const { handleRender } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { HandleRender } from '~/common/templating/types';
|
||||
|
||||
import { useNunjucks } from '../context/nunjucks/use-nunjucks';
|
||||
|
||||
const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } };
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
render: HandleRender;
|
||||
@@ -71,7 +73,7 @@ class RenderedTextInternal extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
export const RenderedText: FC<Omit<Props, 'render'>> = props => {
|
||||
const { handleRender } = useNunjucks({ requestId: props.requestId });
|
||||
const { handleRender } = useNunjucks({ ...PREVIEW_NUNJUCKS_OPTIONS, requestId: props.requestId });
|
||||
|
||||
return <RenderedTextInternal {...props} render={handleRender} />;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import classnames from 'classnames';
|
||||
import clone from 'clone';
|
||||
import type { BaseModel, CloudProviderCredential, PluginData, Request, RequestGroup, Workspace } from 'insomnia-data';
|
||||
import { models, services } from 'insomnia-data';
|
||||
import React, { type FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Link } from 'react-aria-components';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
@@ -87,7 +87,12 @@ export const TagEditor: FC<Props> = props => {
|
||||
variables: [],
|
||||
vaultPluginData: [],
|
||||
});
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks({ renderContext: { purpose: 'preview' } });
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
const nunjucksOptions = useMemo(
|
||||
() => ({ renderContext: { purpose: 'preview' as const, forceReveal: isRevealed } }),
|
||||
[isRevealed],
|
||||
);
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks(nunjucksOptions);
|
||||
|
||||
const isEditorMountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
@@ -150,6 +155,14 @@ export const TagEditor: FC<Props> = props => {
|
||||
refreshModels();
|
||||
}, [refreshModels]);
|
||||
|
||||
useEffect(() => {
|
||||
const { tagDefinitions, activeTagDefinition, activeTagData } = state;
|
||||
if (activeTagData) {
|
||||
update(tagDefinitions, activeTagDefinition, activeTagData, true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRevealed]);
|
||||
|
||||
async function updateArg(
|
||||
argValue: string | number | boolean,
|
||||
argIndex: number,
|
||||
@@ -720,6 +733,14 @@ export const TagEditor: FC<Props> = props => {
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
<Button
|
||||
aria-label={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
style={{ zIndex: 10, position: 'relative' }}
|
||||
className="txt-sm pull-right icon mr-1 inline-block"
|
||||
onPress={() => setIsRevealed(prev => !prev)}
|
||||
>
|
||||
<Icon icon={isRevealed ? 'eye-slash' : 'eye'} />
|
||||
</Button>
|
||||
<label>
|
||||
Live Preview
|
||||
{previewElement}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { models } from 'insomnia-data';
|
||||
import React, { type FC, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '~/common/templating/constants';
|
||||
import type { RenderPurpose, VariableSourceMeta } from '~/common/templating/types';
|
||||
import type { VariableSourceMeta } from '~/common/templating/types';
|
||||
|
||||
import { useNunjucks } from '../../context/nunjucks/use-nunjucks';
|
||||
|
||||
@@ -14,24 +12,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export const VariableEditor: FC<Props> = ({ onChange, defaultValue, variableSource, onOpenSource }) => {
|
||||
const [purpose, setPurpose] = useState<RenderPurpose | ''>('');
|
||||
const useNunjuckOptions = useMemo(() => {
|
||||
const renderContext = purpose === '' ? {} : { purpose };
|
||||
return { renderContext };
|
||||
}, [purpose]);
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks(useNunjuckOptions);
|
||||
// Always use 'preview' purpose; masking is controlled by the policy layer.
|
||||
// forceReveal lets the user bypass hideSecretValuesInPreviewAndConsole for this
|
||||
// session only — it is orthogonal to purpose and does not affect send/script paths.
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
const nunjucksOptions = useMemo(
|
||||
() => ({ renderContext: { purpose: 'preview' as const, forceReveal: isRevealed } }),
|
||||
[isRevealed],
|
||||
);
|
||||
const { handleRender, handleGetRenderContext } = useNunjucks(nunjucksOptions);
|
||||
const [selected, setSelected] = useState(defaultValue);
|
||||
const [options, setOptions] = useState<{ name: string; value: any }[]>([]);
|
||||
const [preview, setPreview] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const isVaultVariable =
|
||||
selected &&
|
||||
selected
|
||||
.replace('{{', '')
|
||||
.replace('}}', '')
|
||||
.trim()
|
||||
.startsWith(`${NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME}.${models.environment.vaultEnvironmentRuntimePath}`) &&
|
||||
preview === models.environment.vaultEnvironmentMaskValue;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -88,26 +81,21 @@ export const VariableEditor: FC<Props> = ({ onChange, defaultValue, variableSour
|
||||
</div>
|
||||
)}
|
||||
<div className="form-control form-control--outlined">
|
||||
{isVaultVariable && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
zIndex: 10,
|
||||
position: 'relative',
|
||||
}}
|
||||
className="txt-sm pull-right icon inline-block"
|
||||
onClick={() => setPurpose(prevPurpose => (prevPurpose === '' ? 'preview' : ''))}
|
||||
>
|
||||
{purpose === '' ? <i className="fa-regular fa-eye" /> : <i className="fa-regular fa-eye-slash" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
style={{ zIndex: 10, position: 'relative' }}
|
||||
className="txt-sm pull-right icon inline-block"
|
||||
onClick={() => setIsRevealed(prev => !prev)}
|
||||
>
|
||||
{isRevealed ? <i className="fa-regular fa-eye-slash" /> : <i className="fa-regular fa-eye" />}
|
||||
</button>
|
||||
<label>
|
||||
Live Preview
|
||||
<textarea className={`${error ? 'danger' : ''}`} value={preview || error} readOnly />
|
||||
</label>
|
||||
{variableSource && (
|
||||
<div className="mt-1 flex items-center gap-2 text-xs" data-testid="variable-source-row">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide opacity-60">Source</span>
|
||||
<span className="text-[0.7rem] tracking-wide uppercase opacity-60">Source</span>
|
||||
<span className="min-w-0 flex-1 truncate italic" title={variableSource.label}>
|
||||
{variableSource.label}
|
||||
</span>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { twMerge } from 'tailwind-merge';
|
||||
import { Icon } from './icon';
|
||||
|
||||
interface WarningBannerProps {
|
||||
icon?: React.ComponentProps<typeof Icon>['icon'];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
'icon'?: React.ComponentProps<typeof Icon>['icon'];
|
||||
'children': ReactNode;
|
||||
'className'?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -27,3 +27,21 @@ export const WarningBanner = ({
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
|
||||
export const ErrorBanner = ({
|
||||
icon = 'circle-exclamation',
|
||||
children,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: WarningBannerProps) => (
|
||||
<p
|
||||
data-testid={testId}
|
||||
className={twMerge(
|
||||
'flex items-start gap-2 rounded-sm border border-solid border-(--hl-md) bg-[rgba(var(--color-danger-rgb),0.5)] p-(--padding-sm) text-base',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className="mt-1 shrink-0" />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
);
|
||||
@@ -12,7 +12,7 @@ import { useRequestGroupLoaderData } from '~/routes/organization.$organizationId
|
||||
let getRenderContextPromiseCache: any = {};
|
||||
|
||||
export interface UseNunjucksOptions {
|
||||
renderContext?: Pick<Partial<RenderContextOptions>, 'purpose' | 'extraInfo'>;
|
||||
renderContext?: Pick<Partial<RenderContextOptions>, 'purpose' | 'extraInfo' | 'forceReveal'>;
|
||||
requestId?: string;
|
||||
}
|
||||
export const initializeNunjucksRenderPromiseCache = () => {
|
||||
|
||||
@@ -5,18 +5,24 @@ import { useRootLoaderData } from '~/root';
|
||||
|
||||
export const useEditorRefresh = (callback: () => void) => {
|
||||
const { settings } = useRootLoaderData()!;
|
||||
const { showVariableSourceAndValue, nunjucksPowerUserMode } = settings;
|
||||
const { showVariableSourceAndValue, nunjucksPowerUserMode, hideSecretValuesInPreviewAndConsole } = settings;
|
||||
const previousShowVariableSourceAndValue = reactUse.usePrevious(showVariableSourceAndValue);
|
||||
const previousNunjucksPowerUserMode = reactUse.usePrevious(nunjucksPowerUserMode);
|
||||
const previousHideSecretValues = reactUse.usePrevious(hideSecretValuesInPreviewAndConsole);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousShowVariableSourceAndValue === undefined || previousNunjucksPowerUserMode === undefined) {
|
||||
if (
|
||||
previousShowVariableSourceAndValue === undefined ||
|
||||
previousNunjucksPowerUserMode === undefined ||
|
||||
previousHideSecretValues === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previousShowVariableSourceAndValue === showVariableSourceAndValue &&
|
||||
previousNunjucksPowerUserMode === nunjucksPowerUserMode
|
||||
previousNunjucksPowerUserMode === nunjucksPowerUserMode &&
|
||||
previousHideSecretValues === hideSecretValuesInPreviewAndConsole
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -25,8 +31,10 @@ export const useEditorRefresh = (callback: () => void) => {
|
||||
}, [
|
||||
showVariableSourceAndValue,
|
||||
nunjucksPowerUserMode,
|
||||
hideSecretValuesInPreviewAndConsole,
|
||||
previousShowVariableSourceAndValue,
|
||||
previousNunjucksPowerUserMode,
|
||||
previousHideSecretValues,
|
||||
callback,
|
||||
]);
|
||||
};
|
||||
Reference in new issue
Block a user