Skip to content

Commit 19418d4

Browse files
committed
feat: adds refresh ruleset logic to invalidate stale data
1 parent 7357374 commit 19418d4

6 files changed

Lines changed: 114 additions & 18 deletions

packages/insomnia/src/entry.preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ const main: Window['main'] = {
282282
cancelCurlRequest: options => ipcRenderer.send('cancelCurlRequest', options),
283283
writeFile: options => invokeWithNormalizedError('writeFile', options),
284284
deleteCompiledRuleset: options => invokeWithNormalizedError('deleteCompiledRuleset', options),
285+
refreshCompiledRuleset: options => invokeWithNormalizedError('refreshCompiledRuleset', options),
285286
writeResponseBodyToFile: options => invokeWithNormalizedError('writeResponseBodyToFile', options),
286287
getAuthHeader: (renderedRequest: RenderedRequest, url: string): Promise<RequestHeader | undefined> =>
287288
invokeWithNormalizedError('getAuthHeader', renderedRequest, url),

packages/insomnia/src/main/ipc/electron.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ export type HandleChannels =
170170
| 'timeline.getPath'
171171
| 'writeFile'
172172
| 'deleteCompiledRuleset'
173+
| 'refreshCompiledRuleset'
173174
| 'writeResponseBodyToFile'
174175
| 'vault.encryptSecretValue'
175176
| 'vault.decryptSecretValue';

packages/insomnia/src/main/ipc/main.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { convert } from '~/main/importers/convert';
2929
import { getCurrentConfig, type LLMConfigServiceAPI } from '~/main/llm-config-service';
3030
import { multipartBufferToArray, type Part } from '~/main/multipart-buffer-to-array';
3131
import { insecureReadFile, insecureReadFileWithEncoding, isPathAllowed, secureReadFile } from '~/main/secure-read-file';
32-
import { deleteCompiledRuleset, writeCompiledRuleset } from '~/main/spectral-ruleset-cache';
32+
import { deleteCompiledRuleset, invalidateCompiledRulesetCache, writeCompiledRuleset } from '~/main/spectral-ruleset-cache';
3333
import type {
3434
GenerateCommitsFromDiffFunction,
3535
GenerateMcpSamplingResponseFunction,
@@ -190,6 +190,7 @@ export interface RendererToMainBridgeAPI {
190190
multipartBufferToArray: (options: { bodyBuffer: Buffer; contentType: string }) => Promise<Part[]>;
191191
writeFile: (options: { path: string; content: string | Buffer }) => Promise<string>;
192192
deleteCompiledRuleset: (options: { projectId: string }) => Promise<void>;
193+
refreshCompiledRuleset: (options: { projectId: string; rulesetContent: string }) => Promise<{ compiledPath: string }>;
193194
writeResponseBodyToFile: (options: {
194195
sourcePath: string;
195196
destinationPath: string;
@@ -386,6 +387,10 @@ export function registerMainHandlers() {
386387
ipcMainHandle('deleteCompiledRuleset', async (_, options: { projectId: string }) => {
387388
await deleteCompiledRuleset(options.projectId);
388389
});
390+
ipcMainHandle('refreshCompiledRuleset', async (_, options: { projectId: string; rulesetContent: string }) => {
391+
invalidateCompiledRulesetCache(options.projectId);
392+
return writeCompiledRuleset(options.projectId, options.rulesetContent);
393+
});
389394
ipcMainHandle('writeResponseBodyToFile', writeResponseBodyToFile);
390395
ipcMainHandle('getAuthHeader', (_, renderedRequest: RenderedRequest, url: string) => {
391396
return getAuthHeaderInMain(renderedRequest, url);

packages/insomnia/src/main/spectral-ruleset-cache.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export async function writeCompiledRuleset(
4646
return { compiledPath };
4747
}
4848

49+
// Clears the in-memory hash cache for a project without touching the disk.
50+
// Forces the next writeCompiledRuleset call to recompile.
51+
export function invalidateCompiledRulesetCache(projectId: string): void {
52+
lastWrittenHash.delete(projectId);
53+
}
54+
4955
// Deletes the compiled ruleset file for a project and clears the in-memory hash cache,
5056
// so the next writeCompiledRuleset call always recompiles from scratch.
5157
export async function deleteCompiledRuleset(projectId: string): Promise<void> {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { services } from 'insomnia-data';
2+
import { href } from 'react-router';
3+
4+
import { invariant } from '~/utils/invariant';
5+
import { createFetcherSubmitHook } from '~/utils/router';
6+
7+
import type { Route } from './+types/organization.$organizationId.project.$projectId.refresh-ruleset';
8+
9+
export async function clientAction({ params }: Route.ClientActionArgs) {
10+
const { projectId } = params;
11+
12+
const project = await services.project.get(projectId);
13+
invariant(project, 'Project not found');
14+
15+
// Touch the record so `modified` reflects when the ruleset was last recompiled.
16+
await services.projectLintRuleset.upsert(projectId, {});
17+
18+
return null;
19+
}
20+
21+
export const useRefreshProjectRulesetActionFetcher = createFetcherSubmitHook(
22+
submit =>
23+
({ organizationId, projectId }: { organizationId: string; projectId: string }) => {
24+
return submit(null, {
25+
action: href('/organization/:organizationId/project/:projectId/refresh-ruleset', {
26+
organizationId,
27+
projectId,
28+
}),
29+
method: 'POST',
30+
});
31+
},
32+
);

packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { debounce } from '~/common/misc';
3333
import { selectFileOrFolder } from '~/common/select-file-or-folder';
3434
import { useRootLoaderData } from '~/root';
3535
import { useDeleteProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.delete-ruleset';
36+
import { useRefreshProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.refresh-ruleset';
3637
import { useUpdateProjectRulesetActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.update-ruleset';
3738
import {
3839
useWorkspaceLoaderData,
@@ -101,6 +102,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
101102
// For git, the RepoFileWatcher keeps .spectral.yaml in sync with this record.
102103
const projectLintRuleset = await services.projectLintRuleset.getByParentId(projectId);
103104
const rulesetContent = projectLintRuleset?.rulesetContent || '';
105+
const rulesetLastCompiledAt = projectLintRuleset?.modified ?? null;
104106

105107
let parsedSpec: OpenAPIV3.Document | undefined;
106108

@@ -114,6 +116,7 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
114116
isConnectedGitProject,
115117
parsedSpec,
116118
rulesetContent,
119+
rulesetLastCompiledAt,
117120
};
118121
}
119122

@@ -192,7 +195,7 @@ const Component = ({ params }: Route.ComponentProps) => {
192195

193196
const { isGenerateMockServersWithAIEnabled } = useAIFeatureStatus();
194197

195-
const { apiSpec, gitSyncRulesetPath, isConnectedGitProject, parsedSpec, rulesetContent } =
198+
const { apiSpec, gitSyncRulesetPath, isConnectedGitProject, parsedSpec, rulesetContent, rulesetLastCompiledAt } =
196199
useLoaderData<typeof clientLoader>();
197200

198201
const [lintMessages, setLintMessages] = useState<LintMessage[]>([]);
@@ -201,6 +204,8 @@ const Component = ({ params }: Route.ComponentProps) => {
201204
const { submit: updateApiSpec } = useSpecUpdateActionFetcher();
202205
const { submit: updateProjectRuleset } = useUpdateProjectRulesetActionFetcher();
203206
const { submit: deleteProjectRuleset } = useDeleteProjectRulesetActionFetcher();
207+
const { submit: refreshProjectRuleset } = useRefreshProjectRulesetActionFetcher();
208+
const [isRefreshing, setIsRefreshing] = useState(false);
204209
const generateRequestCollectionFetcher = useSpecGenerateRequestCollectionActionFetcher();
205210
const gitVersion = useGitVCSVersion();
206211
const [isLintPaneOpen, setIsLintPaneOpen] = useState(false);
@@ -454,6 +459,25 @@ const Component = ({ params }: Route.ComponentProps) => {
454459
setSelectedRulesetPath(gitSyncRulesetPath || rulesetWritePath);
455460
};
456461

462+
const handleRefreshRuleset = async () => {
463+
if (!rulesetContent) {
464+
return;
465+
}
466+
setIsRefreshing(true);
467+
try {
468+
await window.main.refreshCompiledRuleset({ projectId, rulesetContent });
469+
refreshProjectRuleset({ organizationId, projectId });
470+
editor.current?.tryToSetOption('lint', { ...lintOptions });
471+
} catch (err) {
472+
showError({
473+
title: 'Refresh Failed',
474+
message: `Failed to refresh ruleset: ${err instanceof Error ? err.message : String(err)}`,
475+
});
476+
} finally {
477+
setIsRefreshing(false);
478+
}
479+
};
480+
457481
const handleUnselectSpectralFile = async () => {
458482
showModal(AskModal, {
459483
title: 'Remove Ruleset File',
@@ -1111,22 +1135,49 @@ const Component = ({ params }: Route.ComponentProps) => {
11111135
)}
11121136
</span>
11131137
{selectedRulesetPath ? (
1114-
<TooltipTrigger delay={0}>
1115-
<Button
1116-
aria-label="Remove custom ruleset"
1117-
onPress={handleUnselectSpectralFile}
1118-
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)"
1119-
>
1120-
<Icon icon="xmark" />
1121-
</Button>
1122-
<Tooltip
1123-
placement="top end"
1124-
offset={8}
1125-
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"
1126-
>
1127-
<p>Clear custom ruleset and use default OAS ruleset</p>
1128-
</Tooltip>
1129-
</TooltipTrigger>
1138+
<>
1139+
<TooltipTrigger delay={0}>
1140+
<Button
1141+
aria-label="Refresh ruleset from remote sources"
1142+
isDisabled={isRefreshing}
1143+
onPress={handleRefreshRuleset}
1144+
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 disabled:opacity-50 aria-pressed:bg-(--hl-sm)"
1145+
>
1146+
<Icon
1147+
icon={isRefreshing ? 'spinner' : 'rotate'}
1148+
className={isRefreshing ? 'animate-spin' : ''}
1149+
/>
1150+
</Button>
1151+
<Tooltip
1152+
placement="top end"
1153+
offset={8}
1154+
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"
1155+
>
1156+
<p>Recompile ruleset, including re-fetching any referenced remote entries.</p>
1157+
{rulesetLastCompiledAt && (
1158+
<p className="mt-1">
1159+
{`Last updated ${new Date(rulesetLastCompiledAt).toLocaleString()}`}.
1160+
</p>
1161+
)}
1162+
</Tooltip>
1163+
</TooltipTrigger>
1164+
<TooltipTrigger delay={0}>
1165+
<Button
1166+
aria-label="Remove custom ruleset"
1167+
onPress={handleUnselectSpectralFile}
1168+
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)"
1169+
>
1170+
<Icon icon="xmark" />
1171+
</Button>
1172+
<Tooltip
1173+
placement="top end"
1174+
offset={8}
1175+
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"
1176+
>
1177+
<p>Clear custom ruleset and use default OAS ruleset</p>
1178+
</Tooltip>
1179+
</TooltipTrigger>
1180+
</>
11301181
) : (
11311182
<Button
11321183
aria-label="Upload custom ruleset"

0 commit comments

Comments
 (0)