chore(Architecture): react router spa mode (#8902)

* first pass

fix tests

move all react things in dev

try-package

build stuff

use http protocol instead of file

handle refresh

fix tests and routeloaderdata apths

fix npm run dev

fix sorts

fix hidden browser window

cleanup files

Typesafe /auth/* routes

typesafe commands route

git-credentials typesafe routes

import typesafe routes

fix types

fix hidden browser window

invite and collaborators typesafe routes

fix types

remove workarounds

fix dashboard test

more types

git typesafe routes

fix runner test

typesafe scratchpad navigation

fix remove unused project route

fix test routes

add space

request typesafe routes

git credentials typescript conspiracy

git typesafe routes

typecheck

debug bundles for inso

fix test

fix request group tab

workspace typesafe routes

feedback

All routes use generated types

Add typed fetchers to actions and loader

Use typed fetchers in the app

move git actions to the root

* fix react-use usage

update import source field

Spawning npm fails the build

Add ~ module resolution to vitest

add initialEntry functionality

fix update environment name requirement

fix settings patch

use loader for fetching the vault key and fix process.env.PLAYWRIGHT issue

fix missing type

Centralize useRouteLoaderData to routes

Use environment for vitest tests that run browser code

Update remaining fetchers to typesafe versions

remove unused fetcher and add callback to sync

Wrap load/submit in useCallback to keep them stable between re-renders

Update deps lists with stable submit functions

fix lint issue

* fix ts issues

* Add toaster to root

* Use shell for running scripts with spawn on Windows

* Move renderer bundling out of the build script

* Fix request-pane test flakiness

* update the url we use for internal purposes

* Increase timeout for release workflow

* fix flaky bundling test

---------

Co-authored-by: jackkav <jackkav@gmail.com>
This commit is contained in:
James Gatz
2025-08-08 11:10:28 +02:00
committed by GitHub
parent 2eca4de150
commit aa838bf39c
455 changed files with 16127 additions and 11382 deletions

View File

@@ -20,7 +20,7 @@ env:
jobs:
publish:
timeout-minutes: 15
timeout-minutes: 30
runs-on: ubuntu-22.04
outputs:
NOTARY_REPOSITORY: ${{ env.NOTARY_REPOSITORY }}

View File

@@ -17,7 +17,7 @@ env:
PR_NUMBER: ${{ github.event.number }}
jobs:
build-and-upload-artifacts:
timeout-minutes: 15
timeout-minutes: 30
# Skip jobs for release PRs
# windows on recurring should be portable
if: ${{ !startsWith(github.head_ref, 'release/') }}
@@ -62,7 +62,7 @@ jobs:
shell: bash
run: NODE_OPTIONS='--max_old_space_size=6144' BUILD_TARGETS='${{ matrix.build-targets }}' npm run app-package
- name: Verify secure wrapper (Windows)
- name: Build and verify secure wrapper (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: bash
run: NODE_OPTIONS='--max_old_space_size=6144' ./build-secure-wrapper.sh

View File

@@ -1,5 +1,5 @@
{
"json.schemas": [],
"typescript.preferences.importModuleSpecifier": "non-relative",
"files.associations": {
"*.db": "ndjson",
"*.jsonl": "ndjson",

View File

@@ -157,6 +157,7 @@ export default tseslint.config(
'**/*.config.js',
'**/*.d.ts',
'**/*.min.js',
'**/*.js.map',
'**/bin/*',
'**/build/*',
'**/coverage/*',
@@ -165,6 +166,7 @@ export default tseslint.config(
'**/docker/*',
'**/electron/index.js',
'**/fixtures',
'**/hidden-window.js',
'**/hidden-window-preload.js',
'**/node_modules/*',
'**/preload.js',
@@ -172,6 +174,7 @@ export default tseslint.config(
'**/traces/*',
'**/verify-pkg.js',
'**/__mocks__/*',
'**/.react-router/*',
],
},
);

1408
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unicorn": "^59.0.1",
"globals": "^16.3.0",
"patch-package": "^8.0.0",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",

View File

@@ -106,3 +106,13 @@ inso -w <INSO_NEDB_PATH>
# using a binary
./packages/insomnia-inso/binaries/insomnia-inso -w <INSO_NEDB_PATH>
```
## How to debug the bundled assets
```bash
DEBUG=1 npm run build
```
This will generate an `artifacts` directory containing information about the bundled assets.
The meta.json can be uploaded to https://esbuild.github.io/analyze/ to visualize the bundle.
The bundle-analysis.log can be used to see the dependency tree of the bundle.

View File

@@ -1,11 +1,14 @@
import { build, type BuildOptions, context } from 'esbuild';
import fs from 'node:fs';
import { analyzeMetafile, build, type BuildOptions, context } from 'esbuild';
const isProd = Boolean(process.env.NODE_ENV === 'production');
const watch = Boolean(process.env.ESBUILD_WATCH);
const isDebug = Boolean(process.env.DEBUG);
const version = process.env.VERSION || 'dev';
const config: BuildOptions = {
outfile: './dist/index.js',
bundle: true,
metafile: isDebug,
platform: 'node',
minify: isProd,
target: 'node22',
@@ -44,5 +47,18 @@ if (watch) {
}
watch();
} else {
if (isDebug) {
async function buildWithDebug() {
const result = await build(config);
if (result.metafile) {
fs.mkdirSync('./artifacts', { recursive: true });
fs.writeFileSync('./artifacts/meta.json', JSON.stringify(result.metafile));
fs.writeFileSync('./artifacts/bundle-analysis.log', await analyzeMetafile(result.metafile));
}
}
buildWithDebug();
}
build(config);
}

View File

@@ -10,15 +10,20 @@
"moduleResolution": "node",
"isolatedModules": true,
"paths": {
"electron": ["../insomnia/send-request/electron"]
"~/*": [
"../insomnia/src/*"
],
"electron": [
"../insomnia/send-request/electron"
]
},
/* remove this once react AlertModal is out of the plugins code path */
"jsx": "react",
/* Transpiling Options */
"module": "CommonJS",
"sourceMap": true,
/* Runs in the DOM NOTE: this is inconsistent with reality */
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"lib": [
"ES2023",
"DOM.Iterable"
],
/* Strictness */
"strict": true,
"noImplicitReturns": true,
@@ -27,7 +32,13 @@
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": [".eslintrc.js", "esbuild.ts", "package.json", "src", "../insomnia/types"],
"include": [
".eslintrc.js",
"esbuild.ts",
"package.json",
"src",
"../insomnia/types",
],
"exclude": [
"**/*.test.ts",
"**/__mocks__/*",

View File

@@ -1,12 +1,12 @@
import { fakerFunctions } from 'insomnia/src/templating/faker-functions';
import { configure, type ConfigureOptions, type Environment as NunjuncksEnv } from 'nunjucks';
import nunjucks, { type ConfigureOptions, type Environment as NunjuncksEnv } from 'nunjucks';
/** @ignore */
class Interpolator {
private engine: NunjuncksEnv;
constructor(config: ConfigureOptions) {
this.engine = configure(config);
this.engine = nunjucks.configure(config);
}
render = (template: string, context: object) => {

View File

@@ -9,6 +9,11 @@
"module": "ES2022",
"moduleResolution": "node",
"isolatedModules": true,
"paths": {
"~/*": [
"../insomnia/src/*"
],
},
/* Strictness */
"strict": true,
"noImplicitReturns": true,
@@ -19,8 +24,17 @@
"verbatimModuleSyntax": true,
"jsx": "react",
/* If your code runs in the DOM: */
"lib": ["es2023", "dom", "dom.iterable"]
"lib": [
"es2023",
"dom",
"dom.iterable"
]
},
"include": ["../insomnia/types"],
"exclude": ["**/__tests__", "node_modules"]
"include": [
"../insomnia/types"
],
"exclude": [
"**/__tests__",
"node_modules"
]
}

View File

@@ -37,6 +37,10 @@ test('can use bundled plugins, node-libcurl, httpsnippet, hidden browser window'
await page.getByRole('button', { name: 'Done' }).click();
await page.getByLabel('Request Collection').getByTestId('sends request with pre-request script').press('Enter');
await expect
.soft(page.getByTestId('request-pane').getByTestId('OneLineEditor').getByText(`http://127.0.0.1:4010/echo`))
.toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await expect.soft(statusTag).toContainText('200 OK');
await page.getByRole('tab', { name: 'Console' }).click();

View File

@@ -1,96 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '../../playwright/test';
interface SegmentRequestData {
batch: {
timestamp: string;
integrations: {};
type: string;
properties: {};
name?: string;
context: {
app: {
name: string;
version: string;
};
os: {
name: string;
version: string;
};
library: {
name: string;
version: string;
};
};
anonymousId: string;
userId: string;
messageId: string;
_metadata: {
nodeVersion: string;
jsRuntime: string;
};
event?: string;
}[];
writeKey: string;
sentAt: string;
}
interface SegmentLog {
url: string;
data: SegmentRequestData[];
}
test('analytics events are sent', async ({ page, app }) => {
await app.evaluate(async ({ session }) => {
// Capture segment requests to a global variable in main process
globalThis.segmentLogs = [];
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
if (details.url.includes('segment')) {
globalThis.segmentLogs.push({ url: details.url, data: details.uploadData });
}
callback({ cancel: false });
});
});
// Create a collection and requests that cause analytics events:
await page.getByRole('button', { name: 'Create document', exact: true }).click();
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByTestId('workspace-debug').click();
for (let i = 0; i < 10; i++) {
await page.getByLabel('Create in collection').click();
await page.getByRole('menuitemradio', { name: 'HTTP Request' }).press('Enter');
}
const segmentLogs = await app.evaluate(() => globalThis.segmentLogs);
const decodedLogs: SegmentLog[] = segmentLogs.map(
(log: { url: string; data: { type: string; bytes: number[] }[] }) => {
return {
url: log.url,
data: log.data.map(data => JSON.parse(Buffer.from(Object.values(data.bytes)).toString('utf-8'))),
};
},
);
const analyticsBatch = decodedLogs[0].data[0].batch;
const [appStartEvent, ...restEvents] = analyticsBatch;
// Analytics need at least 15 events to be sent
expect.soft(analyticsBatch.length).toBeGreaterThanOrEqual(5);
// App start event
expect.soft(appStartEvent.anonymousId).toBeTruthy();
expect.soft(appStartEvent.event).toBe('App Started');
// First event should have userId and anonymousId
expect.soft(restEvents[0].anonymousId).toBeTruthy();
expect.soft(restEvents[0].userId).toBeTruthy();
// Last event should have userId and anonymousId
expect.soft(restEvents.at(-1)?.anonymousId).toBeTruthy();
expect.soft(restEvents.at(-1)?.userId).toBeTruthy();
});

View File

@@ -69,7 +69,7 @@ test.describe('Dashboard', () => {
await page.getByLabel('Files').getByLabel('My Design Document').getByRole('button').click();
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.locator('text=Rename DocumentName Rename >> input[type="text"]').fill('test123');
await page.click('#root button:has-text("Rename")');
await page.getByRole('button', { name: 'Rename' }).click();
await expect.soft(page.locator('.app')).toContainText('test123');
// Duplicate document
@@ -92,7 +92,7 @@ test.describe('Dashboard', () => {
await page.click('text=CollectionMy Collectionjust now >> button');
await page.getByRole('menuitem', { name: 'Rename' }).click();
await page.locator('text=Rename CollectionName Rename >> input[type="text"]').fill('collection123');
await page.click('#root button:has-text("Rename")');
await page.getByRole('button', { name: 'Rename' }).click();
await expect.soft(page.locator('.app')).toContainText('collection123');
// Duplicate collection

View File

@@ -15,7 +15,6 @@ test.describe('Debug-Sidebar', () => {
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByLabel('simple').click();
//Open Properties in Request Sidebar
const requestLocator = page.getByLabel('Request Collection').getByRole('row', { name: 'example http' });
await page.getByLabel('Request Collection').getByRole('row', { name: 'example http' }).click();
await page
.getByLabel('Request Collection')

View File

@@ -32,13 +32,13 @@ test.describe('test hidden window handling', () => {
await page.click('text=Request was cancelled');
await page.getByText('Special template tag format').click();
await expect.soft(page.getByText(`{{ _['examplehost']}}`)).toBeVisible();
await expect.soft(page.getByText(`_['examplehost']`)).toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await page.getByText('200 OK').click();
await page.getByText('Multiple template tags format').click();
await expect.soft(page.getByText(`{{_['a']['b']['c']['url']}}`)).toBeVisible();
await expect.soft(page.getByText(`_['a']['b']['c']['url']`)).toBeVisible();
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await page.getByText('200 OK').click();

View File

@@ -4,8 +4,6 @@ test('Request tabs', async ({ page }) => {
// Create new collection
await page.getByRole('button', { name: 'Create request collection', exact: true }).click();
await page.getByLabel('Create in collection').click();
await page.getByRole('menuitemradio', { name: 'HTTP Request' }).press('Enter');
await page.getByRole('tab', { name: 'Body' }).click();
await page.getByRole('button', { name: 'Body' }).click();
await page.getByRole('option', { name: 'JSON' }).click();

View File

@@ -4,4 +4,4 @@ build
# Generated
src/*.js
src/*.js.map
.react-router/

View File

@@ -22,7 +22,7 @@ export default async function build(options: Options) {
const env: Record<string, string> = __DEV__
? {
'process.env.APP_RENDER_URL': JSON.stringify(`http://localhost:${PORT}/index.html`),
'process.env.APP_RENDER_URL': JSON.stringify(`http://localhost:${PORT}`),
'process.env.HIDDEN_BROWSER_WINDOW_URL': JSON.stringify(`http://localhost:${PORT}/hidden-window.html`),
'process.env.NODE_ENV': JSON.stringify('development'),
'process.env.INSOMNIA_ENV': JSON.stringify('development'),
@@ -59,6 +59,22 @@ export default async function build(options: Options) {
},
};
const hiddenBrowserWindowBuildOptions: BuildOptions = {
entryPoints: ['./src/hidden-window.ts'],
// TODO: make all of these outputs use a .min.js convention to simplify ignore files
outfile: path.join(outdir, 'hidden-window.js'),
target: 'esnext',
bundle: true,
platform: 'node',
sourcemap: true,
format: 'cjs',
// TODO: remove below, This indicates that libcurl is being imported when it shouldn't be
external: ['electron'],
loader: {
'.node': 'copy',
},
};
const mainBuildOptions: BuildOptions = {
entryPoints: ['./src/main.development.ts'],
outfile: path.join(outdir, 'main.min.js'),
@@ -114,6 +130,10 @@ export default async function build(options: Options) {
...preloadBuildOptions,
plugins: [restartElectronPlugin('preload')],
});
const hiddenBrowserWindowContext = await esbuild.context({
...hiddenBrowserWindowBuildOptions,
plugins: [restartElectronPlugin('hidden-browser-window')],
});
const mainContext = await esbuild.context({
...mainBuildOptions,
plugins: [restartElectronPlugin('main')],
@@ -138,14 +158,16 @@ export default async function build(options: Options) {
};
const preloadWatch = await preloadContext.watch();
const hiddenWindowWatch = await hiddenBrowserWindowContext.watch();
const mainWatch = await mainContext.watch();
const hiddenWindowWatch = await hiddenPreloadContext.watch();
return Promise.all([preloadWatch, mainWatch, hiddenWindowWatch]);
const hiddenWindowPreloadWatch = await hiddenPreloadContext.watch();
return Promise.all([preloadWatch, hiddenWindowPreloadWatch, mainWatch, hiddenWindowWatch]);
}
const preload = esbuild.build(preloadBuildOptions);
const hiddenBrowserWindow = esbuild.build(hiddenBrowserWindowBuildOptions);
const hiddenBrowserWindowPreload = esbuild.build(hiddenBrowserWindowPreloadBuildOptions);
const main = esbuild.build(mainBuildOptions);
return Promise.all([main, preload, hiddenBrowserWindowPreload]).catch(err => {
return Promise.all([main, preload, hiddenBrowserWindow, hiddenBrowserWindowPreload]).catch(err => {
console.error('[Build] Build failed:', err);
});
}

View File

@@ -18,7 +18,8 @@
"main": "src/main.min.js",
"scripts": {
"verify-bundle-plugins": "esr --cache ./scripts/verify-bundle-plugins.ts",
"build": "esr --cache ./scripts/build.ts --noErrorTruncation",
"build": "react-router build && esr --cache ./scripts/build.ts --noErrorTruncation",
"build:react-router": "react-router build",
"generate:schema": "esr ./src/schema.ts",
"build:main.min.js": "cross-env NODE_ENV=development esr esbuild.main.ts",
"lint": "eslint . --ext .js,.ts,.tsx --cache",
@@ -31,8 +32,7 @@
"start:electron": "cross-env NODE_ENV=development esr esbuild.main.ts && electron --inspect=5858 .",
"start:electron:autoRestart": "cross-env NODE_ENV=development esr esbuild.main.ts --autoRestart",
"test": "vitest run",
"electron:dev-build": "electron ./build/main.min.js",
"type-check": "tsc --noEmit --project tsconfig.json",
"type-check": "react-router typegen && tsc --noEmit --project tsconfig.json",
"type-check:watch": "npm run type-check -- --watch",
"convert-svg": "npm_config_yes=true npx @svgr/cli@6.4.0 --no-index --config-file svgr.config.js --out-dir src/ui/components/assets/svgr src/ui/components/assets/"
},
@@ -78,6 +78,7 @@
"https-proxy-agent": "^7.0.5",
"httpsnippet": "^2.0.0",
"iconv-lite": "^0.6.3",
"isbot": "^5",
"js-yaml": "^4.1.0",
"jsdom": "^25.0.1",
"jshint": "^2.13.6",
@@ -106,6 +107,10 @@
"@fortawesome/react-fontawesome": "^0.2.2",
"@getinsomnia/api-client": "0.0.10",
"@getinsomnia/srp-js": "1.0.0-alpha.1",
"@react-router/node": "^7.7.0",
"@react-router/serve": "^7.7.0",
"@react-router/dev": "^7.7.0",
"@react-router/fs-routes": "^7.7.0",
"@sentry/electron": "^6.5.0",
"@stoplight/spectral-core": "^1.20.0",
"@stoplight/spectral-formats": "^1.8.2",
@@ -180,11 +185,11 @@
"react-aria-components": "^1.8.0",
"react-dom": "^18.3.1",
"react-resizable-panels": "^2.1.7",
"react-router": "^7.6.0",
"react-router": "^7.7.0",
"react-stately": "3.37.0",
"react-use": "^17.6.0",
"tailwindcss": "^3.4.17",
"tinykeys": "^2.1.0",
"tinykeys": "^3.0.0",
"type-fest": "^4.40.0",
"typescript": "^5.8.3",
"vite": "^6.3.1",

View File

@@ -0,0 +1,7 @@
import { type Config } from '@react-router/dev/config';
export default {
appDirectory: 'src',
ssr: false,
serverModuleFormat: 'cjs',
} satisfies Config;

View File

@@ -1,9 +1,6 @@
import childProcess from 'node:child_process';
import { cp, mkdir, rm } from 'node:fs/promises';
import { cp, mkdir } from 'node:fs/promises';
import path from 'node:path';
import * as vite from 'vite';
import buildMainAndPreload from '../esbuild.main';
// Start build if ran from CLI
@@ -21,8 +18,7 @@ if (require.main === module) {
export const start = async () => {
console.log('[build] Starting build');
console.log(`[build] npm: ${childProcess.spawnSync('npm', ['--version']).stdout}`.trim());
console.log(`[build] node: ${childProcess.spawnSync('node', ['--version']).stdout}`.trim());
console.log(`[build] node: ${process.version}`.trim());
if (process.version.indexOf('v22.') !== 0) {
console.log('[build] Node 22.x.x is required to build');
@@ -31,21 +27,11 @@ export const start = async () => {
const buildFolder = path.join('../build');
// Remove folders first
console.log('[build] Removing existing directories');
await rm(path.resolve(__dirname, buildFolder), { recursive: true, force: true });
console.log('[build] Building main.min.js and preload');
await buildMainAndPreload({
mode: 'production',
});
console.log('[build] Building renderer');
await vite.build({
configFile: path.join(__dirname, '..', 'vite.config.ts'),
});
// Copy necessary files
console.log('[build] Copying files');
const copyFiles = async (relSource: string, relDest: string) => {
@@ -58,6 +44,7 @@ export const start = async () => {
await copyFiles('../src/static', path.join(buildFolder, 'static'));
await copyFiles('../src/icons', buildFolder);
await copyFiles('../src/main/lint-process.mjs', path.join(buildFolder, 'main/lint-process.mjs'));
await copyFiles('../src/hidden-window.html', path.join(buildFolder, 'hidden-window.html'));
console.log('[build] Complete!');
};

View File

@@ -116,7 +116,7 @@ export async function logout() {
}
}
_unsetSessionData();
await _unsetSessionData();
window.main.loginStateChange();
}

View File

@@ -2,10 +2,6 @@ import { describe, expect, it } from 'vitest';
import type { MockServer } from '../../models/mock-server';
import {
ACTIVITY_DEBUG,
ACTIVITY_HOME,
ACTIVITY_SPEC,
ACTIVITY_UNIT_TEST,
FLEXIBLE_URL_REGEX,
getContentTypeName,
getMockServiceBinURL,
@@ -41,22 +37,22 @@ describe('URL Regex', () => {
describe('isWorkspaceActivity', () => {
it('should return true', () => {
expect(isWorkspaceActivity(ACTIVITY_SPEC)).toBe(true);
expect(isWorkspaceActivity(ACTIVITY_DEBUG)).toBe(true);
expect(isWorkspaceActivity(ACTIVITY_UNIT_TEST)).toBe(true);
expect(isWorkspaceActivity('spec')).toBe(true);
expect(isWorkspaceActivity('debug')).toBe(true);
expect(isWorkspaceActivity('unittest')).toBe(true);
});
it('should return false', () => {
expect(isWorkspaceActivity(ACTIVITY_HOME)).toBe(false);
expect(isWorkspaceActivity('home')).toBe(false);
});
});
describe('isValidActivity', () => {
it('should return true', () => {
expect(isValidActivity(ACTIVITY_SPEC)).toBe(true);
expect(isValidActivity(ACTIVITY_DEBUG)).toBe(true);
expect(isValidActivity(ACTIVITY_UNIT_TEST)).toBe(true);
expect(isValidActivity(ACTIVITY_HOME)).toBe(true);
expect(isValidActivity('spec')).toBe(true);
expect(isValidActivity('debug')).toBe(true);
expect(isValidActivity('unittest')).toBe(true);
expect(isValidActivity('home')).toBe(true);
});
it('should return false', () => {

View File

@@ -176,23 +176,19 @@ export const DEFAULT_SIDEBAR_SIZE = 25;
// Activities
export type GlobalActivity = 'spec' | 'debug' | 'unittest' | 'home';
export const ACTIVITY_SPEC: GlobalActivity = 'spec';
export const ACTIVITY_DEBUG: GlobalActivity = 'debug';
export const ACTIVITY_UNIT_TEST: GlobalActivity = 'unittest';
export const ACTIVITY_HOME: GlobalActivity = 'home';
export const isWorkspaceActivity = (activity?: string): activity is GlobalActivity =>
isDesignActivity(activity) || isCollectionActivity(activity);
export const isDesignActivity = (activity?: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_SPEC:
case ACTIVITY_DEBUG:
case ACTIVITY_UNIT_TEST: {
case 'spec':
case 'debug':
case 'unittest': {
return true;
}
case ACTIVITY_HOME:
case 'home':
default: {
return false;
}
@@ -201,13 +197,13 @@ export const isDesignActivity = (activity?: string): activity is GlobalActivity
export const isCollectionActivity = (activity?: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_DEBUG: {
case 'debug': {
return true;
}
case ACTIVITY_SPEC:
case ACTIVITY_UNIT_TEST:
case ACTIVITY_HOME:
case 'spec':
case 'unittest':
case 'home':
default: {
return false;
}
@@ -216,10 +212,10 @@ export const isCollectionActivity = (activity?: string): activity is GlobalActiv
export const isValidActivity = (activity: string): activity is GlobalActivity => {
switch (activity) {
case ACTIVITY_SPEC:
case ACTIVITY_DEBUG:
case ACTIVITY_UNIT_TEST:
case ACTIVITY_HOME: {
case 'spec':
case 'debug':
case 'unittest':
case 'home': {
return true;
}

View File

@@ -1,5 +1,7 @@
import { readFile } from 'node:fs/promises';
import type { CurrentPlan } from '~/models/organization';
import { type ApiSpec, isApiSpec } from '../models/api-spec';
import { type CookieJar, isCookieJar } from '../models/cookie-jar';
import { type BaseEnvironment, type Environment, isEnvironment } from '../models/environment';
@@ -15,7 +17,6 @@ import { isUnitTest, type UnitTest } from '../models/unit-test';
import { isUnitTestSuite, type UnitTestSuite } from '../models/unit-test-suite';
import { isWebSocketRequest, type WebSocketRequest } from '../models/websocket-request';
import { isWorkspace, type Workspace } from '../models/workspace';
import type { CurrentPlan } from '../ui/organization-utils';
import { convert, type InsomniaImporter } from '../utils/importers/convert';
import type { ImportEntry } from '../utils/importers/entities';
import { id as postmanEnvImporterId } from '../utils/importers/importers/postman-env';

View File

@@ -0,0 +1,104 @@
import './ui/rendererListeners';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import type { SessionData } from 'react-router';
import { HydratedRouter } from 'react-router/dom';
import { migrateFromLocalStorage, setSessionData, setVaultSessionData } from './account/session';
import { getInsomniaSession, getInsomniaVaultKey, getInsomniaVaultSalt, getSkipOnboarding } from './common/constants';
import { database } from './common/database';
import { initializeLogging } from './common/log';
import { settings } from './models';
import { initNewOAuthSession } from './network/o-auth-2/get-token';
import { init as initPlugins } from './plugins';
import { applyColorScheme } from './plugins/misc';
import { HtmlElementWrapper } from './ui/components/html-element-wrapper';
import { showModal } from './ui/components/modals';
import { AlertModal } from './ui/components/modals/alert-modal';
import { PromptModal } from './ui/components/modals/prompt-modal';
import { WrapperModal } from './ui/components/modals/wrapper-modal';
import { initializeSentry } from './ui/sentry';
import { getInitialEntry } from './utils/router';
initializeSentry();
initializeLogging();
try {
window.showAlert = options => showModal(AlertModal, options);
window.showPrompt = options =>
showModal(PromptModal, {
...options,
title: options?.title || '',
});
window.showWrapper = options =>
showModal(WrapperModal, {
...options,
title: options?.title || '',
body: <HtmlElementWrapper el={options?.body} onUnmount={options?.onHide} />,
});
// In order to run playwight tests that simulate a logged in user
// we need to inject state into localStorage
const skipOnboarding = getSkipOnboarding();
if (skipOnboarding) {
window.localStorage.setItem('hasSeenOnboardingV11', skipOnboarding.toString());
window.localStorage.setItem('hasUserLoggedInBefore', skipOnboarding.toString());
}
} catch (e) {
console.log('[onboarding] Failed to parse session data', e);
}
await database.initClient();
await initPlugins();
await migrateFromLocalStorage();
// Check if there is a Session provided by an env variable and use this
const insomniaSession = getInsomniaSession();
const insomniaVaultKey = getInsomniaVaultKey() || '';
const insomniaVaultSalt = getInsomniaVaultSalt() || '';
if (insomniaSession) {
try {
const session = JSON.parse(insomniaSession) as SessionData;
await setSessionData(
session.id,
session.accountId,
session.firstName,
session.lastName,
session.email,
session.symmetricKey,
session.publicKey,
session.encPrivateKey,
);
if (insomniaVaultSalt || insomniaVaultKey) {
await setVaultSessionData(insomniaVaultSalt, insomniaVaultKey);
}
} catch (e) {
console.log('[init] Failed to parse session data', e);
}
}
const appSettings = await settings.getOrCreate();
if (appSettings.clearOAuth2SessionOnRestart) {
initNewOAuthSession();
}
await applyColorScheme(appSettings);
const initialEntry = await getInitialEntry();
if (typeof initialEntry === 'string' && window.location.pathname !== initialEntry) {
console.log('[entry.client] Initial entry:', initialEntry);
window.location.pathname = initialEntry;
}
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});

View File

@@ -0,0 +1,65 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
// loadContext: AppLoadContext
// If you have middleware enabled:
// loadContext: unstable_RouterContextProvider
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
});
// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}

View File

@@ -11,6 +11,6 @@
<body>
<h1>Hidden Browser Window</h1>
<script src="./hidden-window.ts" type="module"></script>
<script src="./hidden-window.js"></script>
</body>
</html>

View File

@@ -1,131 +0,0 @@
<!doctype html>
<html lang="en-US" class="h-full w-full overflow-hidden">
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="
font-src
'self'
data:
;
connect-src
'self'
data:
insomnia-event-source:
insomnia-templating-worker-database:
https:
http:
;
default-src
*
insomnia://*
;
img-src
blob:
data:
*
insomnia://*
;
script-src
'self'
'unsafe-eval'
;
style-src
'self'
'unsafe-inline'
;
media-src
blob:
data:
mediastream:
*
insomnia://*
;
"
/>
<link rel="stylesheet" href="./ui/css/styles.css" />
</head>
<body class="h-full w-full">
<div id="root" class="h-full w-full">
<div id="app-loading-indicator" class="fixed left-0 top-0 flex h-full w-full items-center justify-center">
<div class="relative">
<svg
viewBox="0 0 378 378"
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
width="100"
>
<circle
cx="36"
cy="36"
r="36"
fill="none"
stroke="var(--hl, rgb(130, 130, 130))"
stroke-opacity="0.1"
stroke-width="4px"
transform="translate(-323 -111) translate(359.016 147.016) scale(4.24956)"
></circle>
<circle
cx="36"
cy="36"
r="36"
fill="none"
stroke="var(--hl, rgb(130, 130, 130))"
stroke-opacity="0.8"
stroke-width="4px"
stroke-dasharray="56,172,0,0"
transform="translate(-323 -111) translate(359.016 147.016) scale(4.24956)"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 36 36"
to="360 36 36"
dur="0.8s"
repeatCount="indefinite"
additive="sum"
/>
</circle>
<path
d="M19 37.033c9.96 0 18.033-8.073 18.033-18.033S28.96.967 19 .967.967 9.04.967 19 9.04 37.033 19 37.033z"
fill="#fff"
fill-rule="nonzero"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
></path>
<path
d="M19 0C8.506 0 0 8.506 0 19s8.506 19 19 19 19-8.506 19-19S29.494 0 19 0zm0 1.932c9.426 0 17.068 7.642 17.068 17.068 0 9.426-7.642 17.068-17.068 17.068-9.426 0-17.068-7.642-17.068-17.068C1.932 9.574 9.574 1.932 19 1.932z"
fill="#4000bf"
fill-rule="nonzero"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
></path>
<path
d="M19.214 5.474c7.47 0 13.525 6.057 13.525 13.526 0 7.469-6.055 13.526-13.525 13.526-7.47 0-13.526-6.057-13.526-13.526 0-1.825.362-3.567 1.019-5.156a5.266 5.266 0 004.243 2.15c2.885 0 5.26-2.375 5.26-5.261a5.263 5.263 0 00-2.15-4.242 13.5 13.5 0 015.154-1.017z"
fill="url(#_Linear1)"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
></path>
<defs>
<linearGradient
id="_Linear1"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(-90 25.87 6.655) scale(27.0508)"
>
<stop offset="0" stop-color="#7400e1"></stop>
<stop offset="1" stop-color="#4000bf"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
</div>
<div id="graphql-explorer-container"></div>
<div id="hints-container" class="theme--dropdown__menu"></div>
<script src="./renderer.ts" type="module"></script>
</body>
</html>

View File

@@ -1,3 +1,4 @@
import path from 'node:path';
import { Readable } from 'node:stream';
import { parse as urlParse } from 'node:url';
@@ -178,6 +179,15 @@ export async function registerInsomniaProtocols() {
if (!protocol.isProtocolHandled(httpsScheme)) {
protocol.handle(httpsScheme, async request => {
const url = new URL(request.url);
if (url.hostname === 'insomnia-app.local') {
const rootDir = path.resolve(__dirname, 'client');
const filePath = path.join(rootDir, url.pathname.startsWith('/assets') ? url.pathname : 'index.html');
console.log(`Loading index for: ${url.pathname} from: ${filePath}`);
return await net.fetch(`file://${filePath}`, { bypassCustomProtocolHandlers: true });
}
return net.fetch(request, { bypassCustomProtocolHandlers: true });
});
}

View File

@@ -8,6 +8,8 @@ import { Errors, type HeadStatus, type PromiseFsClient, type StageStatus, type W
import { v4 } from 'uuid';
import YAML, { parse } from 'yaml';
import { type GitCredentials } from '~/models/git-repository';
import {
getApiBaseURL,
getAppWebsiteBaseURL,
@@ -31,7 +33,6 @@ import GitVCS, {
GIT_INSOMNIA_DIR,
GIT_INSOMNIA_DIR_NAME,
GIT_INTERNAL_DIR,
type GitCredentials,
MergeConflictError,
} from '../sync/git/git-vcs';
import { MemClient } from '../sync/git/mem-client';
@@ -593,18 +594,14 @@ export const initGitRepoCloneAction = async ({
uri,
authorName,
authorEmail,
token,
username,
oauth2format,
credentials,
ref,
}: {
organizationId: string;
uri: string;
authorName: string;
authorEmail: string;
token: string;
username: string;
oauth2format?: string;
credentials: GitCredentials;
ref?: string;
}): Promise<
| {
@@ -630,24 +627,20 @@ export const initGitRepoCloneAction = async ({
};
// Git Credentials
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
repoSettingsPatch.credentials = {
username,
token,
oauth2format,
};
if ('oauth2format' in credentials) {
invariant(
credentials.oauth2format === 'gitlab' || credentials.oauth2format === 'github',
'OAuth2 format is required',
);
} else if ('password' in credentials) {
invariant(typeof credentials.username === 'string', 'Username is required');
invariant(typeof credentials.password === 'string', 'Password is required');
} else {
invariant(typeof token === 'string', 'Token is required');
invariant(typeof username === 'string', 'Username is required');
repoSettingsPatch.credentials = {
password: token,
username,
};
throw new Error('Invalid credentials');
}
repoSettingsPatch.credentials = credentials;
repoSettingsPatch.needsFullClone = true;
const inMemoryFsClient = MemClient.createClient();
@@ -704,53 +697,41 @@ export const cloneGitRepoAction = async ({
cloneIntoProjectId,
name,
uri,
authorName,
authorEmail,
token,
username,
oauth2format,
author,
credentials,
ref,
}: {
organizationId: string;
projectId?: string;
cloneIntoProjectId?: string;
author: {
name: string;
email: string;
};
credentials: GitCredentials;
name?: string;
uri: string;
authorName: string;
authorEmail: string;
token: string;
username: string;
oauth2format?: string;
ref?: string;
}) => {
try {
const repoSettingsPatch: Partial<GitRepository> = {};
repoSettingsPatch.uri = parseGitToHttpsURL(uri);
repoSettingsPatch.author = author;
// Git Credentials
if ('oauth2format' in credentials) {
invariant(
credentials.oauth2format === 'gitlab' || credentials.oauth2format === 'github',
'OAuth2 format is required',
);
} else if ('password' in credentials) {
invariant(typeof credentials.password === 'string', 'Password is required');
invariant(typeof credentials.username === 'string', 'Username is required');
}
repoSettingsPatch.credentials = credentials;
if (!projectId) {
const repoSettingsPatch: Partial<GitRepository> = {};
repoSettingsPatch.uri = parseGitToHttpsURL(uri);
repoSettingsPatch.author = {
name: authorName,
email: authorEmail,
};
// Git Credentials
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
repoSettingsPatch.credentials = {
username,
token,
oauth2format,
};
} else {
invariant(typeof token === 'string', 'Token is required');
invariant(typeof username === 'string', 'Username is required');
repoSettingsPatch.credentials = {
password: token,
username,
};
}
trackSegmentEvent(SegmentEvent.vcsSyncStart, vcsSegmentEventProperties('git', 'clone'));
repoSettingsPatch.needsFullClone = true;
@@ -881,32 +862,6 @@ export const cloneGitRepoAction = async ({
const project = await models.project.getById(projectId);
invariant(project, 'Project not found');
const repoSettingsPatch: Partial<GitRepository> = {};
repoSettingsPatch.uri = parseGitToHttpsURL(uri);
repoSettingsPatch.author = {
name: authorName,
email: authorEmail,
};
// Git Credentials
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
repoSettingsPatch.credentials = {
username,
token,
oauth2format,
};
} else {
invariant(typeof token === 'string', 'Token is required');
invariant(typeof username === 'string', 'Username is required');
repoSettingsPatch.credentials = {
password: token,
username,
};
}
trackSegmentEvent(SegmentEvent.vcsSyncStart, vcsSegmentEventProperties('git', 'clone'));
repoSettingsPatch.needsFullClone = true;
@@ -1099,22 +1054,19 @@ export const cloneGitRepoAction = async ({
export const updateGitRepoAction = async ({
projectId,
workspaceId,
authorEmail,
authorName,
author,
credentials,
uri,
oauth2format,
username,
token,
ref,
}: {
projectId: string;
workspaceId?: string;
authorName: string;
authorEmail: string;
author: {
name: string;
email: string;
};
credentials: GitCredentials;
uri: string;
oauth2format?: string;
username: string;
token: string;
ref?: string;
}) => {
try {
@@ -1138,27 +1090,22 @@ export const updateGitRepoAction = async ({
repoSettingsPatch.uri = parseGitToHttpsURL(uri);
// Author
repoSettingsPatch.author = {
name: authorName,
email: authorEmail,
};
repoSettingsPatch.author = author;
// Git Credentials
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
repoSettingsPatch.credentials = {
username,
token,
oauth2format,
};
} else {
repoSettingsPatch.credentials = {
password: token,
username,
};
// Git Credentials
if ('oauth2format' in credentials) {
invariant(
credentials.oauth2format === 'gitlab' || credentials.oauth2format === 'github',
'OAuth2 format is required',
);
} else if ('password' in credentials) {
invariant(typeof credentials.password === 'string', 'Password is required');
invariant(typeof credentials.username === 'string', 'Username is required');
}
repoSettingsPatch.credentials = credentials;
async function setupGitRepository() {
if (gitRepositoryId && gitRepositoryId !== 'empty') {
const gitRepository = await models.gitRepository.getById(gitRepositoryId);
@@ -1494,7 +1441,7 @@ export const checkoutGitBranchAction = async ({
};
}
const errorMessage = err instanceof Error ? err.message : err;
const errorMessage = err instanceof Error ? err.message : err.toString();
return {
errors: [errorMessage],

View File

@@ -104,6 +104,7 @@ export interface RendererToMainBridgeAPI {
updateLatestStepName: (options: { requestId: string; stepName: string }) => void;
extractJsonFileFromPostmanDataDumpArchive: (archivePath: string) => Promise<any>;
}
export function registerMainHandlers() {
ipcMainOn('addExecutionStep', (_, options: { requestId: string; stepName: string }) => {
addExecutionStep(options.requestId, options.stepName);

View File

@@ -1,7 +1,6 @@
import fs from 'node:fs';
import * as os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
app,
@@ -82,6 +81,7 @@ export async function createHiddenBrowserWindow() {
// if window crashed
const windowWasClosedUnexpectedly = hiddenWindowIsBusy && !isRunning;
if (windowWasClosedUnexpectedly) {
console.log('[main] hidden window was closed unexpectedly');
hiddenWindowIsBusy = false;
}
@@ -94,11 +94,12 @@ export async function createHiddenBrowserWindow() {
// if window froze
const isRunningButUnhealthy = isRunning && !isHealthy;
if (isRunningButUnhealthy) {
console.log('[main] hidden window is busy, stopping it');
// stop and wait for window close event and sync the map and busy status
await stopAndWaitForHiddenBrowserWindow(runningHiddenBrowserWindow);
}
console.log('[main] hidden window is down, restarting');
console.log('[main] hidden window is not running, starting it');
const hiddenBrowserWindow = new BrowserWindow({
show: false,
title: 'HiddenBrowserWindow',
@@ -126,9 +127,8 @@ export async function createHiddenBrowserWindow() {
});
const hiddenBrowserWindowPath = path.resolve(__dirname, 'hidden-window.html');
const hiddenBrowserWindowUrl = process.env.HIDDEN_BROWSER_WINDOW_URL || pathToFileURL(hiddenBrowserWindowPath).href;
hiddenBrowserWindow.loadURL(hiddenBrowserWindowUrl);
console.log(`[main] Loading ${hiddenBrowserWindowUrl}`);
hiddenBrowserWindow.loadFile(hiddenBrowserWindowPath);
console.log(`[main] Loading ${hiddenBrowserWindowPath}`);
ipcMain.removeHandler('renderer-listener-ready');
const hiddenWinListenerReady = new Promise<void>(resolve => {
@@ -254,8 +254,7 @@ export function createWindow(): ElectronBrowserWindow {
});
// Load the html of the app.
const appPath = path.resolve(__dirname, './index.html');
const appUrl = process.env.APP_RENDER_URL || pathToFileURL(appPath).href;
const appUrl = process.env.APP_RENDER_URL || 'https://insomnia-app.local';
console.log(`[main] Loading ${appUrl}`);

View File

@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { CONTENT_TYPE_GRAPHQL } from '../../common/constants';

View File

@@ -144,7 +144,7 @@ export const decryptSecretValue = (encryptedValue: string, symmetricKey: JsonWeb
return encryptedValue;
}
try {
const jsonWebKey = base64decode(encryptedValue, true);
const jsonWebKey = base64decode(encryptedValue, true) as crypt.AESMessage;
return crypt.decryptAES(symmetricKey, jsonWebKey);
} catch (error) {
// return origin value if failed to decrypt

View File

@@ -1,5 +1,4 @@
import { database as db } from '../common/database';
import type { GitCredentials } from '../sync/git/git-vcs';
import type { BaseModel } from './index';
export type OauthProviderName = 'gitlab' | 'github' | 'custom';
@@ -78,3 +77,32 @@ export function remove(repo: GitRepository) {
export function all() {
return db.all<GitRepository>(type);
}
export interface GitAuthor {
name: string;
email: string;
}
export interface GitRemoteConfig {
remote: string;
url: string;
}
interface GitCredentialsBase {
username: string;
password: string;
}
interface GitCredentialsOAuth {
/**
* Supported OAuth formats.
* This is needed by isomorphic-git to be able to push/pull using an oauth2 token.
* https://isomorphic-git.org/docs/en/authentication.html
*/
oauth2format?: 'github' | 'gitlab';
username: string;
token: string;
}
export type GitCredentials = GitCredentialsBase | GitCredentialsOAuth;
export const isGitCredentialsOAuth = (credentials: GitCredentials): credentials is GitCredentialsOAuth => {
return 'oauth2format' in credentials;
};

View File

@@ -14,6 +14,14 @@ export interface Organization {
branding?: Branding;
metadata: Metadata;
}
export interface StorageRules {
enableCloudSync: boolean;
enableLocalVault: boolean;
enableGitSync: boolean;
isOverridden: boolean;
}
export const SCRATCHPAD_ORGANIZATION_ID = 'org_scratchpad';
export const isScratchpadOrganizationId = (organizationId: string) => organizationId === SCRATCHPAD_ORGANIZATION_ID;
export const isPersonalOrganization = (organization: Organization) =>
@@ -30,3 +38,60 @@ export const findPersonalOrganization = (organizations: Organization[], accountI
}),
);
};
export interface OrganizationsResponse {
start: number;
limit: number;
length: number;
total: number;
next: string;
organizations: Organization[];
}
export interface UserProfileResponse {
id: string;
email: string;
name: string;
picture: string;
bio: string;
github: string;
linkedin: string;
twitter: string;
identities: any;
given_name: string;
family_name: string;
}
export type PersonalPlanType = 'free' | 'individual' | 'team' | 'enterprise' | 'enterprise-member';
export const formatCurrentPlanType = (type: PersonalPlanType) => {
switch (type) {
case 'free': {
return 'Hobby';
}
case 'individual': {
return 'Individual';
}
case 'team': {
return 'Pro';
}
case 'enterprise': {
return 'Enterprise';
}
case 'enterprise-member': {
return 'Enterprise Member';
}
default: {
return 'Free';
}
}
};
type PaymentSchedules = 'month' | 'year';
export interface CurrentPlan {
isActive: boolean;
period: PaymentSchedules;
planId: string;
price: number;
quantity: number;
type: PersonalPlanType;
planName: string;
}

View File

@@ -1,6 +1,7 @@
import type { StorageRules } from '~/models/organization';
import { database as db } from '../common/database';
import { generateId } from '../common/misc';
import type { StorageRules } from '../ui/organization-utils';
import { type BaseModel } from './index';
export const name = 'Project';

View File

@@ -1,6 +1,5 @@
import type { RequestTestResult } from '../../../insomnia-scripting-environment/src/objects';
import { database as db } from '../common/database';
import type { RunnerSource } from '../ui/routes/$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import type { BaseModel } from './index';
export const name = 'Runner Test Result';
@@ -29,7 +28,7 @@ export interface ResponseInfo {
export type RunnerResultPerRequestPerIteration = RunnerResultPerRequest[][];
export interface BaseRunnerTestResult {
source: RunnerSource;
source: 'runner';
iterations: number;
duration: number; // millisecond
avgRespTime: number; // millisecond

View File

@@ -108,9 +108,9 @@ export async function update(settings: Settings, patch: Partial<Settings>) {
return updatedSettings;
}
export async function patch(patch: Partial<Settings>) {
export async function patch(settingsPatch: Partial<Settings>) {
const settings = await getOrCreate();
const updatedSettings = await db.docUpdate<Settings>(settings, patch);
const updatedSettings = await db.docUpdate<Settings>(settings, settingsPatch);
return updatedSettings;
}

View File

@@ -1,6 +1,5 @@
import type { Merge } from 'type-fest';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../common/constants';
import { database as db } from '../common/database';
import { strings } from '../common/strings';
import type { BaseModel } from './index';
@@ -169,10 +168,10 @@ export function isScratchpad(workspace?: Workspace) {
export const scopeToActivity = (scope: WorkspaceScope) => {
switch (scope) {
case WorkspaceScopeKeys.collection: {
return ACTIVITY_DEBUG;
return 'debug';
}
case WorkspaceScopeKeys.design: {
return ACTIVITY_SPEC;
return 'spec';
}
case WorkspaceScopeKeys.mockServer: {
return 'mock-server';
@@ -181,7 +180,7 @@ export const scopeToActivity = (scope: WorkspaceScope) => {
return 'environment';
}
default: {
return ACTIVITY_DEBUG;
return 'debug';
}
}
};

View File

@@ -53,7 +53,6 @@ import * as plugins from '../plugins/index';
import { RenderError } from '../templating/render-error';
import type { RenderedRequest, RenderPurpose } from '../templating/types';
import { maskOrDecryptVaultDataIfNecessary } from '../templating/utils';
import { type SendActionRuntime } from '../ui/routes/$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import { invariant } from '../utils/invariant';
import { serializeNDJSON } from '../utils/ndjson';
import { buildQueryStringFromParams, joinUrlAndQueryString, smartEncodeUrl } from '../utils/url/querystring';
@@ -63,6 +62,10 @@ import { filterClientCertificates } from './certificate';
import { runScriptConcurrently, type TransformedExecuteScriptContext } from './concurrency';
import { addSetCookiesToToughCookieJar } from './set-cookie-util';
export interface SendActionRuntime {
appendTimeline: (timelinePath: string, logs: string[]) => Promise<void>;
}
export const getOrInheritAuthentication = ({
request,
requestGroups,

View File

@@ -6,7 +6,7 @@ import * as plugin from '../app';
describe('init()', () => {
it('initializes correctly', async () => {
const result = plugin.init();
expect(Object.keys(result)).toEqual(['app', '__private']);
expect(Object.keys(result)).toEqual(['app']);
expect(Object.keys(result.app).sort()).toEqual(
['alert', 'clipboard', 'dialog', 'getPath', 'getInfo', 'prompt', 'showSaveDialog'].sort(),
);

View File

@@ -1,22 +1,11 @@
import { getAppPlatform, getAppVersion } from 'insomnia/src/common/constants';
import type { AppContext, RenderPurpose } from 'insomnia/src/templating/types';
import { invariant } from 'insomnia/src/utils/invariant';
import type React from 'react';
import type ReactDOM from 'react-dom';
export interface PrivateProperties {
loadRendererModules: () => Promise<
| {
ReactDOM: typeof ReactDOM;
React: typeof React;
}
| {}
>;
}
// TODO: consider how this would work in a webworker context
const isRenderer = process.type === 'renderer';
export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContext; __private: PrivateProperties } => ({
export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContext } => ({
app: {
alert: (title: string, message?: string) => {
if (isRenderer) {
@@ -32,9 +21,9 @@ export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContex
});
}
},
prompt: (title, options = {}) => {
prompt: (title, options) => {
if (!isRenderer) {
return Promise.resolve(options.defaultValue || '');
return Promise.resolve(options?.defaultValue || '');
}
// This custom promise converts the prompt modal from being callback-based to reject when the modal is cancelled and resolve when the modal is submitted and hidden
return new Promise<string>((resolve, reject) => {
@@ -78,20 +67,4 @@ export const init = (renderPurpose: RenderPurpose = 'general'): { app: AppContex
clear: () => window.clipboard.clear(),
},
},
__private: {
// Provide modules that can be used in the renderer process
async loadRendererModules() {
if (globalThis.document === undefined) {
return {};
}
const ReactDOM = await import('react-dom');
const React = await import('react');
return {
ReactDOM,
React,
};
},
},
});

View File

@@ -0,0 +1,556 @@
import '~/ui/css/styles.css';
import type { IpcRendererEvent } from 'electron';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Button } from 'react-aria-components';
import {
href,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigate,
useParams,
useRouteLoaderData,
} from 'react-router';
import { isRouteErrorResponse, useNavigation } from 'react-router';
import { EXTERNAL_VAULT_PLUGIN_NAME, isDevelopment } from '~/common/constants';
import * as models from '~/models';
import type { Settings } from '~/models/settings';
import type { UserSession } from '~/models/user-session';
import { executePluginMainAction, reloadPlugins } from '~/plugins';
import { createPlugin } from '~/plugins/create';
import { setTheme } from '~/plugins/misc';
import { useAuthorizeActionFetcher } from '~/routes/auth.authorize';
import { useDefaultBrowserRedirectActionFetcher } from '~/routes/auth.default-browser-redirect';
import { useLogoutFetcher } from '~/routes/auth.logout';
import { useCreateCloudCredentialActionFetcher } from '~/routes/cloud-credentials.create';
import { useGithubCompleteSignInFetcher } from '~/routes/git-credentials.github.complete-sign-in';
import { useGitLabCompleteSignInFetcher } from '~/routes/git-credentials.gitlab.complete-sign-in';
import { SegmentEvent } from '~/ui/analytics';
import { getLoginUrl } from '~/ui/auth-session-provider.client';
import { CopyButton } from '~/ui/components/base/copy-button';
import { Link } from '~/ui/components/base/link';
import { ErrorBoundary as ErrorView } from '~/ui/components/error-boundary';
import { Icon } from '~/ui/components/icon';
import { showError, showModal } from '~/ui/components/modals';
import { AlertModal } from '~/ui/components/modals/alert-modal';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { ImportModal } from '~/ui/components/modals/import-modal/import-modal';
import {
SettingsModal,
TAB_CLOUD_CREDENTIAL,
TAB_INDEX_PLUGINS,
TAB_INDEX_THEMES,
} from '~/ui/components/modals/settings-modal';
import { Toaster } from '~/ui/components/toast-notification';
import { AppHooks } from '~/ui/containers/app-hooks';
import { NunjucksEnabledProvider } from '~/ui/context/nunjucks/nunjucks-enabled-context';
import { useThemeChange } from '~/ui/hooks/use-theme-change';
import Modals from '~/ui/modals';
import type { Route } from './+types/root';
export const links: Route.LinksFunction = () => {
return [
{ rel: 'icon', href: '/favicon.ico' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
{ rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#5bbad5' },
];
};
export const ErrorBoundary: FC<Route.ErrorBoundaryProps> = ({ error }) => {
useThemeChange();
const getErrorMessage = (err: any) => {
if (isRouteErrorResponse(err)) {
return err.data;
}
if (err?.message) {
return err?.message;
}
return 'Unknown error';
};
const getErrorStack = (err: any) => {
if ('error' in err) {
return err.error?.stack;
}
return err?.stack;
};
const navigate = useNavigate();
const navigation = useNavigation();
const errorMessage = getErrorMessage(error);
const logoutFetcher = useLogoutFetcher();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 overflow-hidden">
<h1 className="flex items-center gap-2 text-2xl text-[--color-font]">
<Icon className="text-[--color-danger]" icon="exclamation-triangle" /> Application Error
</h1>
<p className="text-[--color-font]">
Failed to render. Please report to{' '}
<a className="font-bold underline" href="https://github.com/Kong/insomnia/issues">
our Github Issues
</a>
</p>
<div className="p-6 text-[--color-font]">
<code className="break-words p-2">{errorMessage}</code>
</div>
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-base font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
onPress={() => navigate('/organization')}
>
Try to reload the app{' '}
<span>{navigation.state === 'loading' ? <Icon icon="spinner" className="animate-spin" /> : null}</span>
</Button>
<Button
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-base font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
onPress={() => logoutFetcher.submit()}
>
Logout{' '}
<span>{logoutFetcher.state === 'loading' ? <Icon icon="spinner" className="animate-spin" /> : null}</span>
</Button>
</div>
<div className="overflow-y-auto p-6 text-[--color-font]">
<code className="break-all p-2">{getErrorStack(error)}</code>
</div>
</div>
);
};
export interface RootLoaderData {
settings: Settings;
workspaceCount: number;
userSession: UserSession;
}
export const useRootLoaderData = () => {
return useRouteLoaderData<typeof clientLoader>('root');
};
export async function clientLoader(_args: Route.ClientLoaderArgs) {
const settings = await models.settings.get();
const workspaceCount = await models.workspace.count();
const userSession = await models.userSession.getOrCreate();
const cloudCredentials = await models.cloudCredential.all();
return {
settings,
workspaceCount,
userSession,
cloudCredentials,
};
}
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en" className="size-full">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
httpEquiv="Content-Security-Policy"
content="
font-src
'self'
data:
;
connect-src
'self'
data:
insomnia-event-source:
insomnia-templating-worker-database:
https:
http:
;
default-src
*
insomnia://*
;
img-src
blob:
data:
*
insomnia://*
;
script-src
'self'
'unsafe-eval'
'unsafe-inline'
;
style-src
'self'
'unsafe-inline'
;
media-src
blob:
data:
mediastream:
*
insomnia://*
;
"
/>
<Meta />
<Links />
</head>
<body className="size-full">
{children}
<ScrollRestoration />
<Scripts />
<div id="graphql-explorer-container" />
<div id="hints-container" className="theme--dropdown__menu" />
</body>
</html>
);
};
export const HydrateFallback = () => {
return (
<div id="app-loading-indicator" className="fixed left-0 top-0 flex h-full w-full items-center justify-center">
<div className="relative">
<svg viewBox="0 0 378 378" xmlns="http://www.w3.org/2000/svg" fillRule="evenodd" clipRule="evenodd" width="100">
<circle
cx="36"
cy="36"
r="36"
fill="none"
stroke="var(--hl, rgb(130, 130, 130))"
strokeOpacity="0.1"
strokeWidth="4px"
transform="translate(-323 -111) translate(359.016 147.016) scale(4.24956)"
/>
<circle
cx="36"
cy="36"
r="36"
fill="none"
stroke="var(--hl, rgb(130, 130, 130))"
strokeOpacity="0.8"
strokeWidth="4px"
strokeDasharray="56,172,0,0"
transform="translate(-323 -111) translate(359.016 147.016) scale(4.24956)"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 36 36"
to="360 36 36"
dur="0.8s"
repeatCount="indefinite"
additive="sum"
/>
</circle>
<path
d="M19 37.033c9.96 0 18.033-8.073 18.033-18.033S28.96.967 19 .967.967 9.04.967 19 9.04 37.033 19 37.033z"
fill="#fff"
fillRule="nonzero"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
/>
<path
d="M19 0C8.506 0 0 8.506 0 19s8.506 19 19 19 19-8.506 19-19S29.494 0 19 0zm0 1.932c9.426 0 17.068 7.642 17.068 17.068 0 9.426-7.642 17.068-17.068 17.068-9.426 0-17.068-7.642-17.068-17.068C1.932 9.574 9.574 1.932 19 1.932z"
fill="#4000bf"
fillRule="nonzero"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
/>
<path
d="M19.214 5.474c7.47 0 13.525 6.057 13.525 13.526 0 7.469-6.055 13.526-13.525 13.526-7.47 0-13.526-6.057-13.526-13.526 0-1.825.362-3.567 1.019-5.156a5.266 5.266 0 004.243 2.15c2.885 0 5.26-2.375 5.26-5.261a5.263 5.263 0 00-2.15-4.242 13.5 13.5 0 015.154-1.017z"
fill="url(#_Linear1)"
transform="translate(-323 -111) translate(431.258 219.258) scale(4.24956)"
/>
<defs>
<linearGradient
id="_Linear1"
x1="0"
y1="0"
x2="1"
y2="0"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(-90 25.87 6.655) scale(27.0508)"
>
<stop offset="0" stopColor="#7400e1" />
<stop offset="1" stopColor="#4000bf" />
</linearGradient>
</defs>
</svg>
</div>
</div>
);
};
const Root = () => {
const { organizationId, projectId } = useParams() as {
organizationId: string;
projectId: string;
};
const [importUri, setImportUri] = useState('');
const { submit: createCloudCredentials } = useCreateCloudCredentialActionFetcher();
const { submit: authorizeSubmit } = useAuthorizeActionFetcher();
const { submit: logoutSubmit } = useLogoutFetcher();
const { submit: githubCompleteSignInSubmit } = useGithubCompleteSignInFetcher();
const { submit: gitLabCompleteSignInSubmit } = useGitLabCompleteSignInFetcher();
const { submit: redirectToDefaultBrowserSubmit } = useDefaultBrowserRedirectActionFetcher();
const navigate = useNavigate();
useEffect(() => {
return window.main.on('shell:open', async (_: IpcRendererEvent, url: string) => {
// Get the url without params
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
console.log('[deep-link] Invalid args, expected insomnia://x/y/z', url);
return;
}
let urlWithoutParams = url.slice(0, Math.max(0, url.indexOf('?'))) || url;
const params = Object.fromEntries(parsedUrl.searchParams);
// Change protocol for dev redirects to match switch case
if (isDevelopment()) {
urlWithoutParams = urlWithoutParams.replace('insomniadev://', 'insomnia://');
}
if (urlWithoutParams === 'insomnia://app/alert') {
return showModal(AlertModal, {
title: params.title,
message: params.message,
});
}
if (urlWithoutParams === 'insomnia://app/auth/login') {
if (params.message) {
window.localStorage.setItem('logoutMessage', params.message);
}
return logoutSubmit();
}
if (urlWithoutParams === 'insomnia://app/import') {
window.main.trackSegmentEvent({
event: SegmentEvent.importStarted,
properties: {
source: 'import-url',
},
});
return setImportUri(params.uri);
}
if (urlWithoutParams === 'insomnia://plugins/install') {
if (!params.name || params.name.trim() === '') {
return showError({
title: 'Plugin Install',
message: 'Plugin name is required',
});
}
return showModal(AskModal, {
title: 'Plugin Install',
message: (
<p className="text-[--hl]">
Do you want to install <i className="font-bold text-[--hl]">{params.name}</i>?
</p>
),
yesText: 'Install',
noText: 'Cancel',
onDone: async (isYes: boolean) => {
if (isYes) {
try {
// TODO (pavkout): Remove second parameter when we will decide about the @scoped packages name validation
await window.main.installPlugin(params.name.trim(), true);
showModal(SettingsModal, { tab: TAB_INDEX_PLUGINS });
} catch (err) {
showError({
title: 'Plugin Install',
message: 'Failed to install plugin',
error: err.message,
});
}
}
},
});
}
if (urlWithoutParams === 'insomnia://plugins/theme') {
const parsedTheme = JSON.parse(decodeURIComponent(params.theme));
showModal(AskModal, {
title: 'Install Theme',
message: (
<>
Do you want to install <code>{parsedTheme.displayName}</code>?
</>
),
yesText: 'Install',
noText: 'Cancel',
onDone: async (isYes: boolean) => {
if (isYes) {
const mainJsContent = `module.exports.themes = [${JSON.stringify(parsedTheme, null, 2)}];`;
await createPlugin(`theme-${parsedTheme.name}`, mainJsContent);
const settings = await models.settings.get();
await models.settings.update(settings, {
theme: parsedTheme.name,
});
await reloadPlugins();
await setTheme(parsedTheme.name);
showModal(SettingsModal, { tab: TAB_INDEX_THEMES });
}
},
});
}
if (
urlWithoutParams === 'insomnia://oauth/github/authenticate' ||
urlWithoutParams === 'insomnia://oauth/github-app/authenticate'
) {
const { code, state } = params;
return githubCompleteSignInSubmit({
code,
state,
});
}
if (urlWithoutParams === 'insomnia://oauth/gitlab/authenticate') {
const { code, state } = params;
return gitLabCompleteSignInSubmit({
code,
state,
});
}
if (urlWithoutParams === 'insomnia://app/auth/finish') {
return authorizeSubmit({
code: params.box,
});
}
if (urlWithoutParams === 'insomnia://app/open/organization') {
// if user is logged out, navigate to authorize instead
// gracefully handle open org in app from browser
const userSession = await models.userSession.getOrCreate();
if (!userSession.id || userSession.id === '') {
const url = new URL(getLoginUrl());
window.main.openInBrowser(url.toString());
window.localStorage.setItem('specificOrgRedirectAfterAuthorize', params.organizationId);
return navigate(href('/auth/authorize'));
}
return navigate(`/organization/${params.organizationId}`);
}
if (urlWithoutParams === 'insomnia://system-browser-oauth/redirect') {
const { url: redirectUrl } = params;
return redirectToDefaultBrowserSubmit({
redirectUrl,
});
}
if (urlWithoutParams === 'insomnia://oauth/azure/authenticate') {
const { code, ...restParams } = params;
if (code && typeof code === 'string') {
const authResult = await executePluginMainAction({
pluginName: EXTERNAL_VAULT_PLUGIN_NAME,
actionName: 'exchangeCode',
params: { provider: 'azure', code },
});
const { success, result, error } = authResult;
if (success) {
const { account, uniqueId } = result!;
const name = account?.username || uniqueId;
createCloudCredentials({
name,
credentials: result,
provider: 'azure',
isAuthenticated: true,
});
const closeModalBtn = document.getElementById('close-add-cloud-credential-modal');
if (closeModalBtn) {
// close the modal to hint user Azure oauth url if exists
closeModalBtn.click();
}
showModal(SettingsModal, { tab: TAB_CLOUD_CREDENTIAL });
} else {
showError({
title: 'Azure Authorization Failed',
message: error?.errorMessage,
});
}
} else {
const errorDetailKeys = Object.keys(restParams);
const { error, error_description, error_uri } = restParams;
if (error && error_description) {
showError({
title: 'Azure Authorization Failed',
message: (
<div className="flex flex-col gap-1 text-left">
<span className="text-lg font-bold">{error}</span>
<span className="whitespace-normal">{error_description}</span>
{error_uri && (
<div className="mt-2 flex items-center justify-center">
<Link button className="btn btn--clicky w-80" href={error_uri}>
View Document <i className="fa fa-external-link" />
</Link>
</div>
)}
<CopyButton
size="small"
className="absolute right-[--padding-sm] top-[--padding-sm]"
content={error_description}
title="Copy Description"
style={{ borderWidth: 0 }}
>
<i className="fa fa-copy" />
</CopyButton>
</div>
),
});
} else {
showError({
title: 'Azure Authorization Failed',
message: (
<div className="flex flex-col gap-1 text-left">
{errorDetailKeys.length > 0
? errorDetailKeys.map(k => (
<span key={k} className="whitespace-normal">
{k}: {restParams[k]}
</span>
))
: 'Unknown error'}
</div>
),
});
}
}
}
console.log(`Unknown deep link: ${url}`);
});
}, [
authorizeSubmit,
createCloudCredentials,
gitLabCompleteSignInSubmit,
githubCompleteSignInSubmit,
logoutSubmit,
navigate,
redirectToDefaultBrowserSubmit,
]);
return (
<NunjucksEnabledProvider>
<ErrorView>
<div className="app">
<Outlet />
<Toaster />
</div>
<Modals />
<AppHooks />
{/* triggered by insomnia://app/import */}
{importUri && (
<ImportModal
onHide={() => setImportUri('')}
projectName="Insomnia"
defaultProjectId={projectId}
organizationId={organizationId}
from={{ type: 'uri', defaultValue: importUri }}
/>
)}
</ErrorView>
</NunjucksEnabledProvider>
);
};
export default Root;

View File

@@ -0,0 +1,3 @@
import { flatRoutes } from '@react-router/fs-routes';
export default flatRoutes();

View File

@@ -0,0 +1,5 @@
import { redirect } from 'react-router';
export async function clientLoader() {
return redirect('/organization');
}

View File

@@ -1,26 +1,29 @@
import React, { Fragment } from 'react';
import { Fragment, useCallback } from 'react';
import { Button, Heading } from 'react-aria-components';
import { type ActionFunction, redirect, useFetcher, useFetchers, useNavigate } from 'react-router';
import { href, redirect, useFetcher, useFetchers, useNavigate } from 'react-router';
import { userSession as sessionModel } from '../../models';
import { invariant } from '../../utils/invariant';
import { getVaultKeyFromStorage } from '../../utils/vault';
import { SegmentEvent } from '../analytics';
import { getLoginUrl, submitAuthCode } from '../auth-session-provider';
import { Icon } from '../components/icon';
import { insomniaFetch } from '../insomniaFetch';
import { validateVaultKey } from '../vault-key';
import { userSession as sessionModel } from '~/models';
import { SegmentEvent } from '~/ui/analytics';
import { getLoginUrl, submitAuthCode } from '~/ui/auth-session-provider.client';
import { Icon } from '~/ui/components/icon';
import { insomniaFetch } from '~/ui/insomniaFetch';
import { validateVaultKey } from '~/ui/vault-key.client';
import { invariant } from '~/utils/invariant';
import { getVaultKeyFromStorage } from '~/utils/vault';
export const action: ActionFunction = async ({ request }) => {
import type { Route } from './+types/auth.authorize';
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = await request.json();
invariant(typeof data?.code === 'string', 'Expected code to be a string');
const error = await submitAuthCode(data.code);
if (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
const humanReadableError =
error?.message === 'Failed to fetch'
errorMessage === 'Failed to fetch'
? 'Network failed, please try again. If the problem persists, check your network and proxy settings.'
: error?.message;
: errorMessage;
return {
errors: {
message: humanReadableError,
@@ -64,19 +67,39 @@ export const action: ActionFunction = async ({ request }) => {
}
return redirect('/organization');
};
}
const Authorize = () => {
export function useAuthorizeActionFetcher(args: { key?: string } = {}) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { code: string }) => {
fetcherSubmit(data, {
action: href('/auth/authorize'),
method: 'POST',
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}
const Component = () => {
const url = getLoginUrl();
const copyUrl = () => {
window.clipboard.writeText(url);
};
const authorizeFetcher = useFetcher();
const authorizeFetcher = useAuthorizeActionFetcher();
const navigate = useNavigate();
const allFetchers = useFetchers();
const authFetchers = allFetchers.filter(f => f.formAction === '/auth/authorize');
const authFetchers = allFetchers.filter(f => f.formAction === href('/auth/authorize'));
const isAuthenticating = authFetchers.some(f => f.state !== 'idle');
// 1 first time sign up
@@ -124,15 +147,9 @@ const Authorize = () => {
const code = data.get('code');
invariant(typeof code === 'string', 'Expected code to be a string');
authorizeFetcher.submit(
{
code,
},
{
method: 'POST',
encType: 'application/json',
},
);
authorizeFetcher.submit({
code,
});
}}
>
<div className="form-control form-control--outlined no-pad-top" style={{ display: 'flex' }}>
@@ -163,7 +180,7 @@ const Authorize = () => {
<Button
className="flex items-center gap-2"
onPress={() => {
navigate('/auth/login');
navigate(href('/auth/login'));
}}
>
<Icon icon="arrow-left" />
@@ -174,4 +191,4 @@ const Authorize = () => {
);
};
export default Authorize;
export default Component;

View File

@@ -1,12 +1,15 @@
import { ipcRenderer } from 'electron';
import type { ActionFunctionArgs } from 'react-router';
import electron from 'electron';
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { userSession as sessionModel } from '../../models';
import { removeAllSecrets } from '../../models/environment';
import type { ToastNotification } from '../components/toast';
import { insomniaFetch } from '../insomniaFetch';
import { userSession as sessionModel } from '~/models';
import { removeAllSecrets } from '~/models/environment';
import type { ToastNotification } from '~/ui/components/toast';
import { insomniaFetch } from '~/ui/insomniaFetch';
export async function action({ request }: ActionFunctionArgs) {
import type { Route } from './+types/auth.clear-vault-key';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { organizations = [], sessionId: resetVaultClientSessionId } = await request.json();
const userSession = await sessionModel.getOrCreate();
@@ -33,8 +36,27 @@ export async function action({ request }: ActionFunctionArgs) {
key: 'Vault key reset',
message: 'Your vault key has been reset, all you local secrets have been deleted.',
};
ipcRenderer.emit('show-notification', null, notification);
electron.ipcRenderer.emit('show-notification', null, notification);
return true;
}
return false;
}
export function useClearVaultKeyFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { organizations: string[]; sessionId: string }) => {
fetcherSubmit(data, {
action: href('/auth/clear-vault-key'),
method: 'POST',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { createVaultKey } from '~/ui/vault-key.client';
import type { Route } from './+types/auth.create-vault-key';
export async function clientAction(_args: Route.ClientActionArgs) {
return createVaultKey('create');
}
export function useCreateVaultKeyFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(() => {
fetcherSubmit({}, { action: href('/auth/create-vault-key'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,28 @@
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/auth.default-browser-redirect';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { redirectUrl } = (await request.json()) as { redirectUrl: string };
window.main.onDefaultBrowserOAuthRedirect({
url: redirectUrl,
});
return null;
}
export function useDefaultBrowserRedirectActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const fetcher = useFetcher<typeof clientAction>(args);
function submit(data: { redirectUrl: string }) {
return fetcher.submit(JSON.stringify(data), {
method: 'POST',
action: href('/auth/default-browser-redirect'),
encType: 'application/json',
});
}
return {
...fetcher,
submit,
};
}

View File

@@ -1,10 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Button } from 'react-aria-components';
import { type ActionFunction, redirect, useFetcher, useNavigate } from 'react-router';
import { href, redirect, useFetcher, useNavigate } from 'react-router';
import { SegmentEvent } from '../analytics';
import { getLoginUrl } from '../auth-session-provider';
import { Icon } from '../components/icon';
import { SCRATCHPAD_ORGANIZATION_ID } from '~/models/organization';
import { SCRATCHPAD_PROJECT_ID } from '~/models/project';
import { SCRATCHPAD_WORKSPACE_ID } from '~/models/workspace';
import { SegmentEvent } from '~/ui/analytics';
import { getLoginUrl } from '~/ui/auth-session-provider.client';
import { Icon } from '~/ui/components/icon';
import type { Route } from './+types/auth.login';
const GoogleIcon = (props: React.ReactSVGElement['props']) => {
return (
@@ -29,7 +34,7 @@ const GoogleIcon = (props: React.ReactSVGElement['props']) => {
);
};
export const action: ActionFunction = async ({ request }) => {
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = await request.formData();
const provider = data.get('provider');
const url = new URL(getLoginUrl());
@@ -40,24 +45,36 @@ export const action: ActionFunction = async ({ request }) => {
window.main.openInBrowser(url.toString());
return redirect('/auth/authorize');
};
return redirect(href('/auth/authorize'));
}
const Login = () => {
const loginFetcher = useFetcher();
export function useLoginActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { provider: string }) => {
fetcherSubmit(data, {
action: href('/auth/login'),
method: 'POST',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}
const Component = () => {
const loginFetcher = useLoginActionFetcher();
const navigate = useNavigate();
const [message, setMessage] = useState<string | null>(null);
const login = (provider: string) => {
loginFetcher.submit(
{
provider,
},
{
action: '/auth/login',
method: 'POST',
},
);
loginFetcher.submit({
provider,
});
};
const logoutMessage = window.localStorage.getItem('logoutMessage');
@@ -161,7 +178,13 @@ const Login = () => {
window.main.trackSegmentEvent({
event: SegmentEvent.selectScratchpad,
});
navigate('/organization/org_scratchpad/project/proj_scratchpad/workspace/wrk_scratchpad/debug');
navigate(
href('/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug', {
organizationId: SCRATCHPAD_ORGANIZATION_ID,
projectId: SCRATCHPAD_PROJECT_ID,
workspaceId: SCRATCHPAD_WORKSPACE_ID,
}),
);
}}
aria-label="Use the Scratch Pad"
className="flex justify-center gap-[--padding-xs] text-sm text-[rgba(var(--color-font-rgb),0.8)] outline-none transition-colors hover:text-[--color-font] focus:text-[--color-font]"
@@ -176,4 +199,4 @@ const Login = () => {
);
};
export default Login;
export default Component;

View File

@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import { href, redirect, useFetcher } from 'react-router';
import { logout } from '~/account/session';
import type { Route } from './+types/auth.logout';
export async function clientAction(_args: Route.ClientActionArgs) {
await logout();
return redirect(href('/auth/login'));
}
export function useLogoutFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/auth/logout'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { type ActionFunctionArgs, href, useFetcher } from 'react-router';
import { createVaultKey } from '~/ui/vault-key.client';
export async function clientAction(_args: ActionFunctionArgs) {
return createVaultKey('reset');
}
export function useResetVaultKeyFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(() => {
fetcherSubmit({}, { action: href('/auth/reset-vault-key'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -1,16 +1,16 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Button, Link, Tooltip, TooltipTrigger } from 'react-aria-components';
import { Outlet } from 'react-router';
import { Hotkey } from '../components/hotkey';
import { Icon } from '../components/icon';
import { InsomniaLogo } from '../components/insomnia-icon';
import { showSettingsModal } from '../components/modals/settings-modal';
import { TrailLinesContainer } from '../components/trail-lines-container';
import { useRootLoaderData } from './root';
import { useRootLoaderData } from '~/root';
import { Hotkey } from '~/ui/components/hotkey';
import { Icon } from '~/ui/components/icon';
import { InsomniaLogo } from '~/ui/components/insomnia-icon';
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
import { TrailLinesContainer } from '~/ui/components/trail-lines-container';
const Auth = () => {
const { userSession, settings } = useRootLoaderData();
const Component = () => {
const { userSession, settings } = useRootLoaderData()!;
const [status, setStatus] = useState<'online' | 'offline'>('online');
useEffect(() => {
const handleOnline = () => setStatus('online');
@@ -118,4 +118,4 @@ const Auth = () => {
);
};
export default Auth;
export default Component;

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { type ActionFunctionArgs, href, useFetcher } from 'react-router';
import { userSession as sessionModel } from '~/models';
import { insomniaFetch } from '~/ui/insomniaFetch';
export async function clientAction(_args: ActionFunctionArgs) {
const userSession = await sessionModel.getOrCreate();
const { id: sessionId } = userSession;
const { salt: vaultSalt } = await insomniaFetch<{
salt?: string;
error?: string;
}>({
method: 'GET',
path: '/v1/user/vault',
sessionId,
});
if (vaultSalt) {
await sessionModel.update(userSession, { vaultSalt });
}
return vaultSalt;
}
export function useUpdateVaultSaltFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/auth/update-vault-salt'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,52 @@
import { useCallback } from 'react';
import { type ActionFunctionArgs, href, useFetcher } from 'react-router';
import { userSession as sessionModel } from '~/models';
import { saveVaultKey, validateVaultKey } from '~/ui/vault-key.client';
export async function clientAction({ request }: ActionFunctionArgs) {
const { vaultKey, saveVaultKey: saveVaultKeyLocally = false } = await request.json();
const userSession = await sessionModel.getOrCreate();
const { vaultSalt, accountId } = userSession;
if (!vaultSalt) {
return { error: 'Please generate a vault key from preference first' };
}
try {
const validateResult = await validateVaultKey(userSession, vaultKey, vaultSalt);
if (!validateResult) {
return { error: 'Invalid vault key, please check and input again' };
}
if (saveVaultKeyLocally) {
await saveVaultKey(accountId, vaultKey);
}
return { vaultKey, srpK: validateResult };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
return { error: errorMessage };
}
}
export function useValidateVaultKeyActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
({ vaultKey, saveVaultKey = false }: { vaultKey: string; saveVaultKey?: boolean }) => {
const url = href('/auth/validate-vault-key');
return fetcherSubmit(JSON.stringify({ vaultKey, saveVaultKey }), {
action: url,
method: 'POST',
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import * as models from '~/models';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/cloud-credentials.$cloudCredentialId.delete';
export async function clientAction({ params }: Route.ClientActionArgs) {
const { cloudCredentialId } = params;
invariant(typeof cloudCredentialId === 'string', 'Cloud Credential ID is required');
const cloudCredential = await models.cloudCredential.getById(cloudCredentialId);
invariant(cloudCredential, 'Cloud Credential not found');
await models.cloudCredential.remove(cloudCredential);
return null;
}
export function useDeleteCloudCredentialActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcher } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
function submit({ cloudCredentialId }: { cloudCredentialId: string }) {
return fetcherSubmit(
{},
{
method: 'POST',
action: href('/cloud-credentials/:cloudCredentialId/delete', {
cloudCredentialId,
}),
encType: 'application/json',
},
);
},
[fetcherSubmit],
);
return {
...fetcher,
submit,
};
}

View File

@@ -0,0 +1,66 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants';
import * as models from '~/models';
import type { BaseCloudCredential } from '~/models/cloud-credential';
import { executePluginMainAction } from '~/plugins';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/cloud-credentials.$cloudCredentialId.update';
export async function clientAction({ params, request }: Route.ClientActionArgs) {
const { cloudCredentialId } = params;
invariant(typeof cloudCredentialId === 'string', 'Credential ID is required');
const patch = (await request.json()) as BaseCloudCredential;
const { name, provider, credentials } = patch;
invariant(name && typeof name === 'string', 'Name is required');
invariant(provider, 'Cloud Provider name is required');
invariant(credentials, 'Credentials are required');
const authenticateResponse = await executePluginMainAction({
pluginName: EXTERNAL_VAULT_PLUGIN_NAME,
actionName: 'authenticate',
params: { provider, credentials },
});
const { success, error, result } = authenticateResponse;
if (error) {
return {
error: `${error.errorMessage}`,
};
}
if (success) {
const originCredential = await models.cloudCredential.getById(cloudCredentialId);
invariant(originCredential, 'No Cloud Credential found');
if (provider === 'hashicorp') {
// update access token and expires_at
const { access_token, expires_at } = result as { access_token: string; expires_at: number };
patch.credentials['access_token'] = access_token;
patch.credentials['expires_at'] = expires_at;
}
await models.cloudCredential.update(originCredential, patch);
return result as { access_token: string; expires_at: number };
}
return { error: 'Unexpected response from ' + provider };
}
export function useUpdateCloudCredentialActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcher } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
function submit({ cloudCredentialId, patch }: { cloudCredentialId: string; patch: BaseCloudCredential }) {
return fetcherSubmit(JSON.stringify(patch), {
method: 'POST',
action: href('/cloud-credentials/:cloudCredentialId/update', {
cloudCredentialId,
}),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcher,
submit,
};
}

View File

@@ -0,0 +1,72 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants';
import * as models from '~/models';
import type { BaseCloudCredential } from '~/models/cloud-credential';
import { executePluginMainAction } from '~/plugins';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/cloud-credentials.create';
type CreateCloudCredentialsData = BaseCloudCredential & { isAuthenticated?: boolean; provider: string };
export async function clientAction({ request }: Route.ClientActionArgs) {
const patch = await request.json();
const { name, provider, credentials, isAuthenticated } = patch as CreateCloudCredentialsData;
invariant(name && typeof name === 'string', 'Name is required');
invariant(provider, 'Cloud Provider name is required');
invariant(credentials, 'Credentials are required');
if (isAuthenticated) {
// find credential with same name for oauth authenticated cloud service
const existingCredential = await models.cloudCredential.getByName(name, provider);
if (existingCredential.length === 0) {
await models.cloudCredential.create(patch);
} else {
await models.cloudCredential.update(existingCredential[0], patch);
}
return credentials;
}
const authenticateResponse = await executePluginMainAction({
pluginName: EXTERNAL_VAULT_PLUGIN_NAME,
actionName: 'authenticate',
params: { provider, credentials },
});
const { success, error, result } = authenticateResponse!;
if (error) {
return {
error: `${error.errorMessage}`,
};
}
if (success) {
if (provider === 'hashicorp') {
// update access token and expires_at
const { access_token, expires_at } = result as { access_token: string; expires_at: number };
patch.credentials['access_token'] = access_token;
patch.credentials['expires_at'] = expires_at;
}
await models.cloudCredential.create(patch);
return result as { access_token: string; expires_at: number };
}
return { error: 'Unexpected response from ' + provider };
}
export function useCreateCloudCredentialActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcher } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
function submit(data: CreateCloudCredentialsData) {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/cloud-credentials/create'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcher,
submit,
};
}

View File

@@ -1,7 +1,8 @@
import type { LoaderFunction } from 'react-router';
import { useCallback } from 'react';
import { useFetcher } from 'react-router';
import { database } from '../../common/database';
import { fuzzyMatch } from '../../common/misc';
import { database } from '~/common/database';
import { fuzzyMatch } from '~/common/misc';
import {
environment,
grpcRequest,
@@ -11,40 +12,20 @@ import {
userSession,
webSocketRequest,
workspace,
} from '../../models';
import type { Environment } from '../../models/environment';
import type { GrpcRequest } from '../../models/grpc-request';
import { isScratchpadOrganizationId, type Organization } from '../../models/organization';
import { isRemoteProject, type Project } from '../../models/project';
import type { Request } from '../../models/request';
import type { RequestGroup } from '../../models/request-group';
import type { WebSocketRequest } from '../../models/websocket-request';
import { scopeToActivity, type Workspace } from '../../models/workspace';
import { invariant } from '../../utils/invariant';
} from '~/models';
import type { Environment } from '~/models/environment';
import type { GrpcRequest } from '~/models/grpc-request';
import { isScratchpadOrganizationId, type Organization } from '~/models/organization';
import { isRemoteProject, type Project } from '~/models/project';
import type { Request } from '~/models/request';
import type { RequestGroup } from '~/models/request-group';
import type { WebSocketRequest } from '~/models/websocket-request';
import { scopeToActivity, type Workspace } from '~/models/workspace';
import { invariant } from '~/utils/invariant';
export interface CommandItem<TItem> {
id: string;
url: string;
name: string;
organizationName: string;
projectName: string;
workspaceName?: string;
item: TItem;
}
import type { Route } from './+types/commands';
export interface LoaderResult {
current: {
requests: CommandItem<Request | GrpcRequest | WebSocketRequest>[];
files: CommandItem<Workspace & { teamProjectId: string }>[];
environments: Environment[];
};
other: {
requests: CommandItem<Request | GrpcRequest | WebSocketRequest>[];
files: CommandItem<Workspace & { teamProjectId: string }>[];
};
}
export const loader: LoaderFunction = async args => {
export async function clientLoader(args: Route.ClientLoaderArgs) {
const searchParams = new URL(args.request.url).searchParams;
const organizationId = searchParams.get('organizationId');
invariant(organizationId, 'organizationId is required');
@@ -304,4 +285,42 @@ export const loader: LoaderFunction = async args => {
}),
},
};
};
}
export function useCommandsLoaderFetcher() {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>();
const load = useCallback(
({
organizationId,
projectId,
workspaceId,
filter,
}: {
organizationId: string;
projectId: string;
workspaceId?: string;
filter?: string;
}) => {
const params = new URLSearchParams();
params.set('organizationId', organizationId);
params.set('projectId', projectId);
if (workspaceId) {
params.set('workspaceId', workspaceId);
}
if (filter) {
params.set('filter', filter);
}
return fetcherLoad(`/commands?${params.toString()}`, {
flushSync: true,
});
},
[fetcherLoad],
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.github.complete-sign-in';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { code, state } = (await request.json()) as { code: string; state: string; path: string };
await window.main.git.completeSignInToGitHub({
code,
state,
});
return null;
}
export function useGithubCompleteSignInFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { code: string; state: string }) => {
return fetcherSubmit(data, { action: href('/git-credentials/github/complete-sign-in'), method: 'POST' });
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.github.init-sign-in';
export async function clientAction(_args: Route.ClientActionArgs) {
await window.main.git.initSignInToGitHub();
return null;
}
export function useInitSignInToGitHubFetcher() {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>();
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/git-credentials/github/init-sign-in'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.github.sign-out';
export async function clientAction(_args: Route.ClientActionArgs) {
await window.main.git.signOutOfGitHub();
return null;
}
export function useGithubSignOutFetcher() {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>();
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/git-credentials/github/sign-out'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,25 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { gitCredentials } from '~/models';
import type { Route } from './+types/git-credentials.github';
export async function clientLoader(_args: Route.ClientActionArgs) {
const credentials = await gitCredentials.getByProvider('github');
return credentials;
}
export function useGitHubCredentialsFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>(args);
const load = useCallback(() => {
return fetcherLoad(href('/git-credentials/github'));
}, [fetcherLoad]);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,30 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.gitlab.complete-sign-in';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { code, state } = (await request.json()) as { code: string; state: string; path: string };
await window.main.git.completeSignInToGitLab({
code,
state,
});
return null;
}
export function useGitLabCompleteSignInFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { code: string; state: string }) => {
return fetcherSubmit(data, { action: href('/git-credentials/gitlab/complete-sign-in'), method: 'POST' });
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.gitlab.init-sign-in';
export async function clientAction(_args: Route.ClientActionArgs) {
await window.main.git.initSignInToGitLab();
return null;
}
export function useInitSignInToGitLabFetcher() {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>();
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/git-credentials/gitlab/init-sign-in'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git-credentials.gitlab.sign-out';
export async function clientAction(_args: Route.ClientActionArgs) {
await window.main.git.signOutOfGitLab();
return null;
}
export function useGitLabSignOutFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(() => {
return fetcherSubmit({}, { action: href('/git-credentials/gitlab/sign-out'), method: 'POST' });
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,25 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { gitCredentials } from '~/models';
import type { Route } from './+types/git-credentials.gitlab';
export async function clientLoader(_args: Route.ClientActionArgs) {
const credentials = await gitCredentials.getByProvider('gitlab');
return credentials;
}
export function useGitLabCredentialsFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>(args);
const load = useCallback(() => {
return fetcherLoad(href('/git-credentials/gitlab'));
}, [fetcherLoad]);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,9 @@
import type { LoaderFunctionArgs } from 'react-router';
import { gitCredentials } from '~/models';
export async function clientLoader(_args: LoaderFunctionArgs) {
const credentials = await gitCredentials.all();
return credentials;
}

View File

@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/git.branch.checkout';
interface CheckoutGitBranchData {
branch: string;
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as CheckoutGitBranchData;
invariant(typeof data.branch === 'string', 'Branch is required');
return window.main.git.checkoutGitBranch(data);
}
export function useGitProjectCheckoutBranchActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: CheckoutGitBranchData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/branch/checkout'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,39 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { invariant } from '../utils/invariant';
import type { Route } from './+types/git.branch.delete';
interface DeleteGitBranchData {
branch: string;
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as DeleteGitBranchData;
invariant(typeof data.branch === 'string', 'Branch is required');
return window.main.git.deleteGitBranch(data);
}
export function useGitProjectDeleteBranchActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: DeleteGitBranchData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/branch/delete'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/git.branch.new';
interface NewGitBranchData {
branch: string;
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as NewGitBranchData;
invariant(typeof data.branch === 'string', 'Branch is required');
return window.main.git.createNewGitBranch(data);
}
export function useGitProjectNewBranchActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: NewGitBranchData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/branch/new'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.branches';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const workspaceId = params.workspaceId;
const projectId = params.projectId;
return window.main.git.getGitBranches({
projectId,
workspaceId,
});
}
export function useGitProjectBranchesLoaderFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
load: fetcherLoad,
...fetcherRest
} = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ projectId, workspaceId }: { workspaceId?: string; projectId: string }) => {
const searchParams = new URLSearchParams();
if (workspaceId) {
searchParams.set('workspaceId', workspaceId);
}
searchParams.set('projectId', projectId);
return fetcherLoad(`${href('/git/branches')}?${searchParams.toString()}`);
},
[fetcherLoad]
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.changes';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const workspaceId = params.workspaceId;
const projectId = params.projectId;
return window.main.git.gitChangesLoader({
projectId,
workspaceId,
});
}
export function useGitProjectChangesFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
load: fetcherLoad,
...fetcherRest
} = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ projectId, workspaceId }: { projectId: string; workspaceId?: string }) => {
const searchParams = new URLSearchParams();
if (workspaceId) {
searchParams.set('workspaceId', workspaceId);
}
searchParams.set('projectId', projectId);
return fetcherLoad(`${href('/git/changes')}?${searchParams.toString()}`);
},
[fetcherLoad]
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,57 @@
import { useCallback } from 'react';
import { href, redirect, useFetcher } from 'react-router';
import type { GitCredentials } from '~/models/git-repository';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/git.clone';
interface CloneGitRepoData {
organizationId: string;
projectId?: string;
uri: string;
author: {
name: string;
email: string;
};
credentials: GitCredentials;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as CloneGitRepoData;
const { errors, projectId } = await window.main.git.cloneGitRepo(data);
if (errors) {
return { errors };
}
invariant(projectId, 'Project ID is required');
return redirect(
href(`/organization/:organizationId/project/:projectId`, {
organizationId: data.organizationId,
projectId,
}),
);
}
export function useGitCloneActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((data: CloneGitRepoData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/clone'),
encType: 'application/json',
});
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/git.commit';
interface CommitGitRepoData {
projectId: string;
workspaceId?: string;
message: string;
push?: boolean;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as CommitGitRepoData;
invariant(typeof data.message === 'string', 'Message is required');
if (data.push) {
return window.main.git.commitAndPushToGitRepo(data);
}
return window.main.git.commitToGitRepo(data);
}
export function useGitProjectCommitActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((data: CommitGitRepoData) => {
return fetcherSubmit(JSON.stringify(data), {
action: href('/git/commit'),
method: 'POST',
encType: 'application/json',
});
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { invariant } from '~/utils/invariant';
import type { Route } from './+types/git.diff';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const filepath = url.searchParams.get('filepath');
invariant(filepath, 'Filepath is required');
const staged = url.searchParams.get('staged') === 'true';
const projectId = url.searchParams.get('projectId');
invariant(projectId, 'Project ID is required');
const workspaceId = url.searchParams.get('workspaceId') || undefined;
return window.main.git.diffFileLoader({ filepath, staged, projectId, workspaceId });
}
export function useGitProjectDiffLoaderFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
load: fetcherLoad,
...fetcherRest
} = useFetcher<typeof clientLoader>(args);
const load = useCallback((
{
workspaceId,
projectId,
filePath,
staged,
}: {
workspaceId?: string;
projectId: string;
filePath: string;
staged: boolean;
}
) => {
const params = new URLSearchParams();
params.set('filepath', filePath);
params.set('staged', staged ? 'true' : 'false');
if (workspaceId) {
params.set('workspaceId', workspaceId);
}
params.set('projectId', projectId);
return fetcherLoad(`${href('/git/diff')}?${params.toString()}`);
}, [fetcherLoad]);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.discard';
interface DiscardGitChangesData {
paths: string[];
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as DiscardGitChangesData;
return window.main.git.discardChanges(data);
}
export function useGitProjectDiscardActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((data: DiscardGitChangesData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/discard'),
encType: 'application/json',
});
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.fetch';
interface FetchGitData {
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
console.log('Client action for git fetch', request);
const data = (await request.json()) as FetchGitData;
return window.main.git.gitFetchAction(data);
}
export function useGitProjectFetchActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((data: FetchGitData) => {
console.log('Submitting git fetch action', data);
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/fetch'),
encType: 'application/json',
});
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,50 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { GitCredentials } from '~/models/git-repository';
import type { Route } from './+types/git.init-clone';
interface RepoInitCloneData {
organizationId: string;
projectId?: string;
uri: string;
authorName: string;
authorEmail: string;
credentials: Required<GitCredentials>;
ref?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as RepoInitCloneData;
const initCloneResult = await window.main.git.initGitRepoClone(data);
if ('errors' in initCloneResult) {
return { errors: initCloneResult.errors };
}
return {
files: initCloneResult.files,
};
}
export function useGitProjectInitCloneActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((data: RepoInitCloneData) => {
return fetcherSubmit(JSON.stringify(data), {
action: href('/git/init-clone'),
method: 'POST',
encType: 'application/json',
});
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.log';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const workspaceId = params.workspaceId;
const projectId = params.projectId;
return window.main.git.gitLogLoader({ workspaceId, projectId });
}
export function useGitProjectLogLoaderFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
load: fetcherLoad,
...fetcherRest
} = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ workspaceId, projectId }: { workspaceId?: string; projectId: string }) => {
const searchParams = new URLSearchParams();
if (workspaceId) {
searchParams.set('workspaceId', workspaceId);
}
searchParams.set('projectId', projectId);
return fetcherLoad(`${href('/git/log')}?${searchParams.toString()}`);
},
[fetcherLoad]
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.migrate-legacy-insomnia-folder-to-file';
export async function clientAction({ request }: Route.ClientActionArgs) {
const { projectId } = (await request.json()) as {
projectId: string;
};
return window.main.git.migrateLegacyInsomniaFolderToFile({ projectId });
}
export function useGitProjectMigrateLegacyInsomniaFolderActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback(({ projectId }: { projectId: string }) => {
return fetcherSubmit(
{
projectId,
},
{
method: 'POST',
action: href('/git/migrate-legacy-insomnia-folder-to-file'),
},
);
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.push';
interface PushGitData {
projectId: string;
workspaceId?: string;
force?: boolean;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as PushGitData;
return window.main.git.pushToGitRemote(data);
}
export function useGitProjectPushActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: PushGitData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/push'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return { ...fetcherRest, submit };
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { GitCredentials } from '~/models/git-repository';
import type { Route } from './+types/git.remote-branches';
interface FetchRemoteBranchesData {
uri: string;
credentials: GitCredentials;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as FetchRemoteBranchesData;
return window.main.git.fetchGitRemoteBranches(data);
}
export function useGitRemoteBranchesActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: FetchRemoteBranchesData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href(`/git/remote-branches`),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.repo';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const workspaceId = params.workspaceId;
const projectId = params.projectId;
return window.main.git.loadGitRepository({ workspaceId, projectId });
}
export function useGitProjectRepoFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ workspaceId, projectId }: { workspaceId?: string; projectId: string }) => {
const searchParams = new URLSearchParams();
if (workspaceId) {
searchParams.set('workspaceId', workspaceId);
}
searchParams.set('projectId', projectId);
return fetcherLoad(`${href('/git/repo')}?${searchParams.toString()}`);
},
[fetcherLoad],
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.repository-tree';
export async function clientLoader({ request }: Route.ClientLoaderArgs) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const projectId = params.projectId;
return window.main.git.getRepositoryDirectoryTree({ projectId });
}
export function useGitProjectRepositoryTreeLoaderFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ projectId }: { projectId: string }) => {
const searchParams = new URLSearchParams();
searchParams.set('projectId', projectId);
return fetcherLoad(`${href('/git/repository-tree')}?${searchParams.toString()}`);
},
[fetcherLoad],
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.reset';
interface ResetGitRepoParams {
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as ResetGitRepoParams;
return window.main.git.resetGitRepo(data);
}
export function useGitProjectResetActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: ResetGitRepoParams) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/reset'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.stage';
interface StageGitChangesData {
paths: string[];
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as StageGitChangesData;
return window.main.git.stageChanges(data);
}
export function useGitProjectStageActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: StageGitChangesData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/stage'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.status';
interface GitStatusData {
workspaceId?: string;
projectId: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as GitStatusData;
return window.main.git.gitStatus(data);
}
export function useGitProjectStatusActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: GitStatusData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/status'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { Route } from './+types/git.unstage';
interface UnstageGitChangesData {
paths: string[];
projectId: string;
workspaceId?: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as UnstageGitChangesData;
return window.main.git.unstageChanges(data);
}
export function useGitProjectUnstageActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: UnstageGitChangesData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href('/git/unstage'),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import type { GitCredentials } from '~/models/git-repository';
import type { Route } from './+types/git.update';
interface UpdateGitRepoData {
author: {
email: string;
name: string;
};
credentials: GitCredentials;
uri: string;
workspaceId?: string;
projectId: string;
}
export async function clientAction({ request }: Route.ClientActionArgs) {
const data = (await request.json()) as UpdateGitRepoData;
return window.main.git.updateGitRepo(data);
}
export function useGitProjectUpdateActionFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: UpdateGitRepoData) => {
return fetcherSubmit(JSON.stringify(data), {
method: 'POST',
action: href(`/git/update`),
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -1,21 +1,19 @@
import type { ActionFunctionArgs } from 'react-router';
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { importResourcesToProject, importResourcesToWorkspace } from '../../common/import';
import * as models from '../../models';
import { isRemoteProject } from '../../models/project';
import type { Workspace } from '../../models/workspace';
import { importResourcesToProject, importResourcesToWorkspace } from '~/common/import';
import * as models from '~/models';
import { isRemoteProject } from '~/models/project';
import type { Workspace } from '~/models/workspace';
import {
initializeLocalBackendProjectAndMarkForSync,
pushSnapshotOnInitialize,
} from '../../sync/vcs/initialize-backend-project';
import { VCSInstance } from '../../sync/vcs/insomnia-sync';
import { invariant } from '../../utils/invariant';
import { fetchAndCacheOrganizationStorageRule } from '../organization-utils';
} from '~/sync/vcs/initialize-backend-project';
import { VCSInstance } from '~/sync/vcs/insomnia-sync';
import { fetchAndCacheOrganizationStorageRule } from '~/ui/organization-utils';
import { invariant } from '~/utils/invariant';
export interface ImportResourcesActionResult {
errors?: string[];
done: boolean;
}
import type { Route } from './+types/import.resources';
export const importScannedResources = async ({
organizationId,
@@ -44,26 +42,53 @@ export const importScannedResources = async ({
}
};
export async function action({ request }: ActionFunctionArgs) {
export async function clientAction({ request }: Route.ClientActionArgs) {
try {
const formData = await request.formData();
const data = (await request.json()) as {
organizationId: string;
projectId: string;
workspaceId?: string;
};
const organizationId = data.organizationId;
const projectId = data.projectId;
const workspaceId = data.workspaceId;
invariant(typeof organizationId === 'string', 'OrganizationId is required.');
invariant(typeof projectId === 'string', 'ProjectId is required.');
await importScannedResources({
organizationId: formData.get('organizationId') as string,
projectId: formData.get('projectId') as string,
workspaceId: formData.get('workspaceId') as string | undefined,
organizationId,
projectId,
workspaceId,
});
// TODO: find more elegant way to wait for import to finish
return { done: true };
} catch (error) {
console.error('Failed to import resources:', error);
return {
errors: [error instanceof Error ? error.message : 'An unknown error occurred'],
done: false,
errors: ['Failed to import resources.'],
};
}
}
export function useImportResourcesFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: { organizationId: string; projectId: string; workspaceId?: string }) => {
fetcherSubmit(JSON.stringify(data), {
action: href('/import/resources'),
method: 'POST',
encType: 'application/json',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}
// The reason why we put this function here is because this function indirectly depends on some modules that can only run in a browser environment.
// If we put this function in import.ts which is depended by Inso CLI, Inso CLI will fail to build because it doesn't have access to the browser environment.
// So we put this function here and pass it to importResourcesToProject func to avoid the dependency issue.
@@ -92,9 +117,10 @@ export async function syncNewWorkspaceIfNeeded(newWorkspace: Workspace) {
workspace: newWorkspace,
project,
});
} catch (e) {
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
console.warn(
`Failed to initialize sync to insomnia cloud for workspace ${newWorkspace._id}. This will be retried when the workspace is opened on the app. ${e.message}`,
`Failed to initialize sync to insomnia cloud for workspace ${newWorkspace._id}. This will be retried when the workspace is opened on the app. ${errorMessage}`,
);
}
}

View File

@@ -1,12 +1,13 @@
import path from 'node:path';
import type { ActionFunctionArgs } from 'react-router';
import { useCallback } from 'react';
import { type ActionFunctionArgs, href, useFetcher } from 'react-router';
import type { ScanResult } from '../../common/import';
import { fetchImportContentFromURI, getFilesFromPostmanExportedDataDump, scanResources } from '../../common/import';
import type { ImportEntry } from '../../utils/importers/entities';
import { invariant } from '../../utils/invariant';
import { SegmentEvent } from '../analytics';
import type { ScanResult } from '~/common/import';
import { fetchImportContentFromURI, getFilesFromPostmanExportedDataDump, scanResources } from '~/common/import';
import { SegmentEvent } from '~/ui/analytics';
import type { ImportEntry } from '~/utils/importers/entities';
import { invariant } from '~/utils/invariant';
type SourceType = 'file' | 'uri' | 'clipboard';
@@ -129,21 +130,43 @@ export const scanImportResources = async (data: {
return result;
};
export async function action({ request }: ActionFunctionArgs) {
interface ImportScanInputData {
source: SourceType;
uri?: string;
filePaths?: string | string[];
postmanArchiveFile?: string | null;
}
export async function clientAction({ request }: ActionFunctionArgs) {
try {
const formData = await request.formData();
const data = Object.fromEntries(formData.entries()) as unknown as ImportScanInputData;
return await scanImportResources({
source: formData.get('importFrom') as SourceType,
uri: formData.get('uri') as string | undefined,
filePaths: formData.get('filePaths') as string | string[] | undefined,
postmanArchiveFile: formData.get('postmanArchiveFile') as string | null,
});
return await scanImportResources(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
return [
{
errors: [err.message],
errors: [errorMessage],
},
];
}
}
export function useScanResourcesFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { submit: fetcherSubmit, ...fetcherRest } = useFetcher<typeof clientAction>(args);
const submit = useCallback(
(data: FormData | HTMLFormElement) => {
return fetcherSubmit(data, {
action: href('/import/scan'),
method: 'POST',
});
},
[fetcherSubmit],
);
return {
...fetcherRest,
submit,
};
}

View File

@@ -1,26 +1,23 @@
import React from 'react';
import { Button, Heading, Radio, RadioGroup } from 'react-aria-components';
import { type ActionFunction, type LoaderFunction, redirect, useFetcher } from 'react-router';
import { href, redirect, useFetcher } from 'react-router';
import { shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { invariant } from '../../utils/invariant';
import { Icon } from '../components/icon';
import { InsomniaLogo } from '../components/insomnia-icon';
import { TrailLinesContainer } from '../components/trail-lines-container';
import { shouldMigrateProjectUnderOrganization } from '~/sync/vcs/migrate-projects-into-organization';
import { Icon } from '~/ui/components/icon';
import { InsomniaLogo } from '~/ui/components/insomnia-icon';
import { TrailLinesContainer } from '~/ui/components/trail-lines-container';
import { invariant } from '~/utils/invariant';
export const loader: LoaderFunction = async () => {
import type { Route } from './+types/onboarding.migrate';
export async function clientLoader(_args: Route.ClientLoaderArgs) {
if (!(await shouldMigrateProjectUnderOrganization())) {
return redirect('/organization');
return redirect(href('/organization'));
}
return null;
};
interface MigrationActionData {
error?: string;
}
export const action: ActionFunction = async ({ request }) => {
export async function clientAction({ request }: Route.ClientActionArgs) {
const formData = await request.formData();
const type = formData.get('type');
invariant(type === 'local' || type === 'remote', 'Expected type to be either local or remote');
@@ -28,10 +25,10 @@ export const action: ActionFunction = async ({ request }) => {
localStorage.setItem('prefers-project-type', type);
return redirect('/organization');
};
}
export const Migrate = () => {
const { Form, state } = useFetcher<MigrationActionData>();
const Component = () => {
const { Form, state } = useFetcher<typeof clientAction>();
return (
<div className="relative flex h-full w-full bg-[--color-bg] text-left text-base">
@@ -103,3 +100,5 @@ export const Migrate = () => {
</div>
);
};
export default Component;

View File

@@ -1,12 +1,11 @@
import type { IconName } from '@fortawesome/fontawesome-svg-core';
import React from 'react';
import { Link, Route, Routes, useLocation } from 'react-router';
import { InsomniaLogo } from '../components/insomnia-icon';
import { TrailLinesContainer } from '../components/trail-lines-container';
import git_projects from '../images/onboarding/git_projects.png';
import multiple_tabs from '../images/onboarding/multiple_tabs.png';
import secret_vaults from '../images/onboarding/secret_vaults.png';
import { InsomniaLogo } from '~/ui/components/insomnia-icon';
import { TrailLinesContainer } from '~/ui/components/trail-lines-container';
import git_projects from '~/ui/images/onboarding/git_projects.png';
import multiple_tabs from '~/ui/images/onboarding/multiple_tabs.png';
import secret_vaults from '~/ui/images/onboarding/secret_vaults.png';
const features = [
{
@@ -123,7 +122,7 @@ const FeatureWizardView = () => {
);
};
const Onboarding = () => {
const Component = () => {
const location = useLocation();
return (
@@ -165,4 +164,4 @@ const Onboarding = () => {
);
};
export default Onboarding;
export default Component;

View File

@@ -0,0 +1,18 @@
import { redirect } from 'react-router';
import { syncProjects } from '~/ui/organization-utils';
import { getInitialRouteForOrganization } from '~/utils/router';
import type { Route } from './+types/organization.$organizationId._index';
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { organizationId } = params;
try {
await syncProjects(organizationId);
} catch {
console.log('[project] Could not fetch remote projects.');
}
const initialOrganizationRoute = await getInitialRouteForOrganization({ organizationId });
return redirect(initialOrganizationRoute);
}

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import { userSession } from '~/models';
import { insomniaFetch } from '~/ui/insomniaFetch';
import type { Route } from './+types/organization.$organizationId.collaborators-search';
type CollaboratorType = 'invite' | 'member' | 'group';
interface CollaboratorSearchResultItem {
id: string;
picture: string;
type: CollaboratorType;
name: string;
}
export async function clientLoader({ params, request }: Route.ClientLoaderArgs) {
const { id: sessionId } = await userSession.get();
const { organizationId } = params;
try {
const requestUrl = new URL(request.url);
const searchParams = Object.fromEntries(requestUrl.searchParams.entries());
const collaboratorsSearchList = await insomniaFetch<CollaboratorSearchResultItem[]>({
method: 'GET',
path: `/v1/desktop/organizations/${organizationId}/collaborators/search/${searchParams.query}`,
sessionId,
});
return collaboratorsSearchList;
} catch {
return [];
}
}
export function useCollaboratorsSearchLoaderFetcher(args?: Parameters<typeof useFetcher>[0]) {
const { load: fetcherLoad, ...fetcherRest } = useFetcher<typeof clientLoader>(args);
const load = useCallback(
({ organizationId, query }: { organizationId: string; query?: string }) => {
return fetcherLoad(
`${href(`/organization/:organizationId/collaborators-search`, { organizationId })}?${encodeURIComponent(query || '')}`,
);
},
[fetcherLoad],
);
return {
...fetcherRest,
load,
};
}

View File

@@ -0,0 +1,53 @@
import { useCallback } from 'react';
import { href, useFetcher } from 'react-router';
import * as models from '~/models';
import { insomniaFetch } from '~/ui/insomniaFetch';
import type { Route } from './+types/organization.$organizationId.collaborators.invites.$invitationId.reinvite';
export async function clientAction({ params }: Route.ClientActionArgs) {
const { organizationId, invitationId } = params;
try {
const user = await models.userSession.getOrCreate();
const sessionId = user.id;
const response = await insomniaFetch<{ enabled: boolean }>({
method: 'POST',
path: `/v1/organizations/${organizationId}/invites/${invitationId}/reinvite`,
sessionId,
});
return response;
} catch {
throw new Error('Failed to reinvite member. Please try again.');
}
}
export function useReinviteFetcher(args?: Parameters<typeof useFetcher>[0]) {
const {
submit: fetcherSubmit,
...fetcherRest
} = useFetcher<typeof clientAction>(args);
const submit = useCallback((
{ organizationId, invitationId }: { organizationId: string; invitationId: string }
) => {
return fetcherSubmit(
{},
{
action: href(`/organization/:organizationId/collaborators/invites/:invitationId/reinvite`, {
organizationId,
invitationId,
}),
method: 'POST',
},
);
}, [fetcherSubmit]);
return {
...fetcherRest,
submit,
};
}

Some files were not shown because too many files have changed in this diff Show More