Universal Insomnia/Git Sync for all files (documents/collections) (#5945)

* share design documents and tests with insomnia sync

* share collection and documents with insomnia/git sync

* checkpoint

* redirect when cloning (pulling) a new remote project

* backend workspace card

* redirect logic

* local changes styles

* loading indicator

* fix sync button styles

* use interval to refetch git

* open the repo modal first before switching to git sync

* use gitRepositoryId from metadata

* fix sync option update

* cleanup fetcher check

* fixes

* cleanup pull-push

* undo logo change

* clean git-vcs

* use cloud icon for insomnia sync

* cleanup card

* more cleanup

* better return type for fetchfetcher

* update git tests

* fix tests

* fix some prerelease tests

* fix cloning without an api spec

* fix some more tests that expect a file to exist

* fix lint error
This commit is contained in:
James Gatz 2023-07-05 18:51:55 +02:00 committed by GitHub
parent ba1f6e4190
commit 5b7f45e910
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 864 additions and 355 deletions

View File

@ -10,7 +10,7 @@ test.describe('Cookie editor', async () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('Collectionsimplejust now').click();
});

View File

@ -7,7 +7,8 @@ test.describe('Dashboard', async () => {
test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms');
test.describe('Projects', async () => {
test('Can create, rename and delete new project', async ({ page }) => {
await expect(page.locator('.app')).toContainText('All Files (1)');
await expect(page.locator('.app')).toContainText('All Files (0)');
await expect(page.locator('.app')).not.toContainText('Git Sync');
await expect(page.locator('.app')).not.toContainText('Setup Git Sync');
// Create new project
@ -41,14 +42,15 @@ test.describe('Dashboard', async () => {
await expect(page.locator('.app')).toContainText('Insomnia');
await expect(page.locator('.app')).not.toContainText('My Project123');
await expect(page.locator('.app')).toContainText('New Document');
await expect(page.locator('.app')).toContainText('All Files (1)');
await expect(page.locator('.app')).toContainText('All Files (0)');
await expect(page.locator('.app')).not.toContainText('Setup Git Sync');
});
});
test.describe('Interactions', async () => { // Not sure about the name here
// TODO(INS-2504) - we don't support importing multiple collections at this time
test.skip('Can filter through multiple collections', async ({ app, page }) => {
await expect(page.locator('.app')).toContainText('All Files (1)');
await expect(page.locator('.app')).toContainText('All Files (0)');
await expect(page.locator('.app')).not.toContainText('Git Sync');
await expect(page.locator('.app')).not.toContainText('Setup Git Sync');
await page.getByRole('button', { name: 'Create' }).click();
@ -57,7 +59,7 @@ test.describe('Dashboard', async () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('CollectionSmoke testsjust now').click();
// Check that 10 new workspaces are imported besides the default one
const workspaceCards = page.locator('.card-badge');
@ -85,7 +87,8 @@ test.describe('Dashboard', async () => {
});
test('Can create, rename and delete a document', async ({ page }) => {
await expect(page.locator('.app')).toContainText('All Files (1)');
await expect(page.locator('.app')).toContainText('All Files (0)');
await expect(page.locator('.app')).not.toContainText('Git Sync');
await expect(page.locator('.app')).not.toContainText('Setup Git Sync');
// Create new document
@ -93,10 +96,10 @@ test.describe('Dashboard', async () => {
await page.getByRole('menuitem', { name: 'Design Document' }).click();
await page.locator('text=Create').nth(1).click();
// Return to dashboardawait expect(page.locator('.app')).toContainText('My Document');
await page.getByTestId('proj_default-project').getByRole('link', { name: 'Insomnia' }).click();
// Rename document
await page.click('text=DocumentMy Documentjust now >> button');
await page.click('text=DocumentNew Documentjust now >> button');
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")');
@ -108,20 +111,21 @@ test.describe('Dashboard', async () => {
await page.locator('input[name="name"]').fill('test123-duplicate');
await page.click('[role="dialog"] button:has-text("Duplicate")');
// Return to dashboardawait expect(page.locator('.app')).toContainText('test123-duplicate');
await page.getByTestId('proj_default-project').getByRole('link', { name: 'Insomnia' }).click();
const workspaceCards = page.locator('.card-badge');
await expect(workspaceCards).toHaveCount(3);
await expect(workspaceCards).toHaveCount(2);
// Delete document
await page.click('text=Documenttest123just now >> button');
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.locator('text=Yes').click();
await expect(workspaceCards).toHaveCount(2);
await expect(workspaceCards).toHaveCount(1);
});
test('Can create, rename and delete a collection', async ({ page }) => {
await expect(page.locator('.app')).toContainText('All Files (1)');
await expect(page.locator('.app')).toContainText('All Files (0)');
await expect(page.locator('.app')).not.toContainText('Git Sync');
await expect(page.locator('.app')).not.toContainText('Setup Git Sync');
// Create new collection
@ -129,7 +133,7 @@ test.describe('Dashboard', async () => {
await page.getByRole('menuitem', { name: 'Request Collection' }).click();
await page.locator('text=Create').nth(1).click();
// Return to dashboardawait expect(page.locator('.app')).toContainText('My Collection');
await page.getByTestId('proj_default-project').getByRole('link', { name: 'Insomnia' }).click();
// Rename collection
await page.click('text=CollectionMy Collectionjust now >> button');
@ -144,15 +148,15 @@ test.describe('Dashboard', async () => {
await page.locator('input[name="name"]').fill('test123-duplicate');
await page.click('[role="dialog"] button:has-text("Duplicate")');
// Return to dashboardawait expect(page.locator('.app')).toContainText('test123-duplicate');
await page.getByTestId('proj_default-project').getByRole('link', { name: 'Insomnia' }).click();
const workspaceCards = page.locator('.card-badge');
await expect(workspaceCards).toHaveCount(3);
await expect(workspaceCards).toHaveCount(2);
// Delete collection
await page.click('text=Collectiontest123just now >> button');
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.locator('text=Yes').click();
await expect(workspaceCards).toHaveCount(2);
await expect(workspaceCards).toHaveCount(1);
});
});
});

View File

@ -12,7 +12,7 @@ test.describe('Debug-Sidebar', async () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('Collectionsimplejust now').click();
});
@ -61,9 +61,9 @@ test.describe('Debug-Sidebar', async () => {
});
test('Open properties of the collection', async ({ page }) => {
await page.getByRole('button', { name: 'Untitled' }).click();
await page.getByRole('menuitem', { name: 'Document Settings' }).click();
await page.getByText('Document Settings').click();
await page.getByRole('button', { name: 'simple' }).click();
await page.getByRole('menuitem', { name: 'Collection Settings' }).click();
await page.getByText('Collection Settings').click();
});
test('Filter by request name', async ({ page }) => {

View File

@ -14,8 +14,8 @@ test.describe('Design interactions', async () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByText('CollectionSmoke testsjust now').click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('unit-test.yaml').click();
// Switch to Test tab
await page.click('a:has-text("Test")');

View File

@ -10,8 +10,8 @@ test.describe('Environment Editor', async () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByText('CollectionSmoke testsjust now').click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('Collectionenvironments').click();
});
test('create a new environment', async ({ page }) => {

View File

@ -13,7 +13,7 @@ test('Clone Repo with bad values', async ({ page }) => {
await page.getByText('Author Email').fill('test');
await page.getByText('Username').fill('test');
await page.getByText('Authentication Token').fill('test');
await page.getByRole('button', { name: 'Clone' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Clone' }).click();
// Basic check repository data is loaded
// Design doc
@ -50,31 +50,6 @@ test('Clone Repo with bad values', async ({ page }) => {
await expect(page.locator('.app')).toContainText('Error Pushing Repository');
});
test('Clone Bitbucket Repo with bad values', async ({ page }) => {
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('menuitem', { name: 'Git Clone' }).click();
await page.getByRole('tab', { name: 'Git' }).nth(2).click();
// Fill in Git Sync details and clone repository
await page.getByText('Git URI (https)').fill('https://bitbucket.org/atlassian/bitbucket-example-plugin.git');
await page.getByText('Author Name').fill('test');
await page.getByText('Author Email').fill('test');
await page.getByText('Username').fill('test');
await page.getByText('Authentication Token').fill('test');
await page.getByRole('button', { name: 'Clone' }).click();
// Create a branch and try to push with bad Git token
await page.getByRole('button', { name: 'master' }).click();
await page.getByRole('menuitem', { name: 'Branches' }).click();
await page.getByPlaceholder('testing-branch').fill('test123');
await page.getByRole('button', { name: '+ Create' }).click();
await page.getByRole('cell', { name: 'test123(current)' }).click();
await page.getByRole('button', { name: 'Done' }).click();
await page.getByRole('button', { name: 'test123' }).click();
await page.getByRole('menuitem', { name: 'Push' }).click();
await expect(page.locator('.app')).toContainText('Error Pushing Repository');
});
test('Clone Gitlab Repo with bad values', async ({ page }) => {
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('menuitem', { name: 'Git Clone' }).click();
@ -86,7 +61,7 @@ test('Clone Gitlab Repo with bad values', async ({ page }) => {
await page.getByText('Author Email').fill('test');
await page.getByText('Username').fill('test');
await page.getByText('Authentication Token').fill('test');
await page.getByRole('button', { name: 'Clone' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Clone' }).click();
// Create a branch and try to push with bad Git token
await page.getByRole('button', { name: 'master' }).click();

View File

@ -19,7 +19,7 @@ test.describe('gRPC interactions', () => {
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.getByText('CollectionPreRelease gRPCjust now').click();
statusTag = page.locator('[data-testid="response-status-tag"]:visible');
responseBody = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', {

View File

@ -18,8 +18,8 @@ test.describe('Plugins', async () => {
});
test('Check Declarative Config and Kong Kubernetes config', async ({ page }) => {
// Switch to design tab
await page.click('text=Design');
await page.getByRole('button', { name: 'New Document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
// Set example OpenAPI spec
await page.click('text=start from an example');

View File

@ -1,5 +1,13 @@
import { test } from '../../playwright/test';
test.beforeEach(async ({ page }) => {
await page.getByRole('button', { name: 'New Collection' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
await page.getByRole('button', { name: ' ' }).press('ArrowDown');
await page.getByRole('menuitem', { name: 'HTTP Request' }).press('Enter');
});
test('Select body dropdown', async ({ page }) => {
await page.getByRole('button', { name: 'Body' }).click();
await page.getByRole('menuitem', { name: 'JSON' }).click();

View File

@ -3,6 +3,7 @@ import { test } from '../../playwright/test';
test('Sign in with GitHub', async ({ app, page }) => {
await page.getByRole('button', { name: 'New Document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
await page.getByRole('button', { name: 'Git Sync' }).click();
await page.getByRole('button', { name: 'Setup Git Sync' }).click();
await page.getByRole('tab', { name: 'Github' }).click();

View File

@ -3,6 +3,7 @@ import { test } from '../../playwright/test';
test('Sign in with Gitlab', async ({ app, page }) => {
await page.getByRole('button', { name: 'New Document' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
await page.getByRole('button', { name: 'Git Sync' }).click();
await page.getByRole('button', { name: 'Setup Git Sync' }).click();
await page.getByRole('tab', { name: 'GitLab' }).click();

View File

@ -10,7 +10,7 @@ export const prefix = 'spc';
export const canDuplicate = true;
export const canSync = false;
export const canSync = true;
export interface BaseApiSpec {
fileName: string;

View File

@ -3,30 +3,12 @@ import { afterAll, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { globalBeforeEach } from '../../../__jest__/before-each';
import * as models from '../../../models';
import MemoryDriver from '../../store/drivers/memory-driver';
import { initializeLocalBackendProjectAndMarkForSync, pushSnapshotOnInitialize } from '../initialize-backend-project';
import { pushSnapshotOnInitialize } from '../initialize-backend-project';
import { VCS } from '../vcs';
describe('initialize-backend-project', () => {
beforeEach(globalBeforeEach);
describe('initializeLocalBackendProjectAndMarkForSync()', () => {
it('should do nothing if not request collection', async () => {
// Arrange
const workspace = await models.workspace.create({ scope: 'design' });
const vcs = new VCS(new MemoryDriver());
const switchAndCreateBackendProjectIfNotExistSpy = jest.spyOn(vcs, 'switchAndCreateBackendProjectIfNotExist');
// Act
await initializeLocalBackendProjectAndMarkForSync({ workspace, vcs });
// Assert
expect(switchAndCreateBackendProjectIfNotExistSpy).not.toHaveBeenCalled();
// const workspaceMeta = await models.workspaceMeta.getByParentId(workspace._id);
// expect(workspaceMeta?.pushSnapshotOnInitialize).toBe(false);
switchAndCreateBackendProjectIfNotExistSpy.mockClear();
});
});
describe('pushSnapshotOnInitialize()', () => {
const vcs = new VCS(new MemoryDriver());

View File

@ -2,18 +2,13 @@ import { database } from '../../common/database';
import * as models from '../../models';
import { getStatusCandidates } from '../../models/helpers/get-status-candidates';
import { Project } from '../../models/project';
import { isCollection, Workspace } from '../../models/workspace';
import { Workspace } from '../../models/workspace';
import { WorkspaceMeta } from '../../models/workspace-meta';
import { VCS } from './vcs';
const blankStage = {};
export const initializeLocalBackendProjectAndMarkForSync = async ({ vcs, workspace }: { vcs: VCS; workspace: Workspace }) => {
if (!isCollection(workspace)) {
// Don't initialize and mark for sync unless we're in a collection
return;
}
// Create local project
await vcs.switchAndCreateBackendProjectIfNotExist(workspace._id, workspace.name);

View File

@ -1,6 +1,6 @@
import { isLoggedIn } from '../../account/session';
import { asyncFilter } from '../../common/async-array-helpers';
import { database } from '../../common/database';
import { isNotNullOrUndefined } from '../../common/misc';
import * as models from '../../models';
import { isRemoteProject, RemoteProject } from '../../models/project';
import { isCollection, Workspace } from '../../models/workspace';
@ -36,7 +36,7 @@ export const migrateCollectionsIntoRemoteProject = async (vcs: VCS) => {
const isNotInRemoteProject = (collection: Workspace) => !Boolean(remoteProjects.find(project => project._id === collection.parentId));
const hasLocalProject = (collection: Workspace) => vcs.hasBackendProjectForRootDocument(collection._id);
const needsMigration = await asyncFilter(collections, async coll => await hasLocalProject(coll) && isNotInRemoteProject(coll));
const needsMigration = (await Promise.all(collections.map(async coll => await hasLocalProject(coll) && isNotInRemoteProject(coll) ? coll : null))).filter(isNotNullOrUndefined);
// If nothing to migrate, exit
if (!needsMigration.length) {

View File

@ -2,7 +2,10 @@ import { DEFAULT_BRANCH_NAME } from '../../common/constants';
import { database } from '../../common/database';
import { RemoteProject } from '../../models/project';
import { isWorkspace } from '../../models/workspace';
import { initializeProjectFromTeam, initializeWorkspaceFromBackendProject } from './initialize-model-from';
import {
initializeProjectFromTeam,
initializeWorkspaceFromBackendProject,
} from './initialize-model-from';
import { BackendProjectWithTeam } from './normalize-backend-project-team';
import { interceptAccessError } from './util';
import { VCS } from './vcs';
@ -13,7 +16,11 @@ interface Options {
remoteProjects: RemoteProject[];
}
export const pullBackendProject = async ({ vcs, backendProject, remoteProjects }: Options) => {
export const pullBackendProject = async ({
vcs,
backendProject,
remoteProjects,
}: Options) => {
// Set backend project, checkout master, and pull
await vcs.setBackendProject(backendProject);
await vcs.checkout([], DEFAULT_BRANCH_NAME);
@ -26,25 +33,35 @@ export const pullBackendProject = async ({ vcs, backendProject, remoteProjects }
const defaultBranchMissing = !remoteBranches.includes(DEFAULT_BRANCH_NAME);
// Find or create the remote project locally
let project = remoteProjects.find(({ remoteId }) => remoteId === backendProject.team.id);
let project = remoteProjects.find(
({ remoteId }) => remoteId === backendProject.team.id
);
if (!project) {
project = await initializeProjectFromTeam(backendProject.team);
await database.upsert(project);
}
let workspaceId;
// The default branch does not exist, so we create it and the workspace locally
if (defaultBranchMissing) {
const workspace = await initializeWorkspaceFromBackendProject(backendProject, project);
const workspace = await initializeWorkspaceFromBackendProject(
backendProject,
project
);
await database.upsert(workspace);
workspaceId = workspace._id;
} else {
await vcs.pull([], project.remoteId); // There won't be any existing docs since it's a new pull
const flushId = await database.bufferChanges();
// @ts-expect-error -- TSCONVERSION
for (const doc of (await vcs.allDocuments() || [])) {
for (const doc of (await vcs.allDocuments()) || []) {
if (isWorkspace(doc)) {
doc.parentId = project._id;
workspaceId = doc._id;
}
await database.upsert(doc);
}
@ -52,5 +69,5 @@ export const pullBackendProject = async ({ vcs, backendProject, remoteProjects }
await database.flushChanges(flushId);
}
return project;
return { project, workspaceId };
};

View File

@ -1,13 +1,16 @@
import classnames from 'classnames';
import React, { FC, Fragment, useEffect, useRef, useState } from 'react';
import { useFetcher, useParams } from 'react-router-dom';
import { useFetcher, useParams, useRevalidator } from 'react-router-dom';
import { useInterval } from 'react-use';
import { docsGitSync } from '../../../common/documentation';
import { GitRepository } from '../../../models/git-repository';
import { deleteGitRepository } from '../../../models/helpers/git-repository-operations';
import { getOauth2FormatName } from '../../../sync/git/utils';
import {
GitFetchLoaderData,
GitRepoLoaderData,
GitStatusResult,
PullFromGitRemoteResult,
PushToGitRemoteResult,
} from '../../routes/git-actions';
@ -27,13 +30,15 @@ import { GitLogModal } from '../modals/git-log-modal';
import { GitRepositorySettingsModal } from '../modals/git-repository-settings-modal';
import { GitStagingModal } from '../modals/git-staging-modal';
import { Button } from '../themed-button';
import { Tooltip } from '../tooltip';
interface Props {
gitRepository: GitRepository | null;
className?: string;
isInsomniaSyncEnabled: boolean;
}
export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
export const GitSyncDropdown: FC<Props> = ({ className, gitRepository, isInsomniaSyncEnabled }) => {
const { organizationId, projectId, workspaceId } = useParams() as {
organizationId: string;
projectId: string;
@ -52,10 +57,12 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
const gitCheckoutFetcher = useFetcher();
const gitRepoDataFetcher = useFetcher<GitRepoLoaderData>();
const gitFetchFetcher = useFetcher<GitFetchLoaderData>();
const gitStatusFetcher = useFetcher<GitStatusResult>();
const loadingPush = gitPushFetcher.state === 'loading';
const loadingPull = gitPullFetcher.state === 'loading';
const loadingFetch = gitFetchFetcher.state === 'loading';
const loadingStatus = gitStatusFetcher.state === 'loading';
useEffect(() => {
if (
@ -77,6 +84,17 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
workspaceId,
]);
// Only fetch the repo status if we have a repo uri and we don't have the status already
const shouldFetchGitRepoStatus = Boolean(gitRepository?.uri && gitRepository?._id && gitStatusFetcher.state === 'idle' && !gitStatusFetcher.data && gitRepoDataFetcher.data);
useEffect(() => {
if (shouldFetchGitRepoStatus) {
gitStatusFetcher.load(
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/status`
);
}
}, [gitStatusFetcher, organizationId, projectId, shouldFetchGitRepoStatus, workspaceId]);
useEffect(() => {
const errors = [...(gitPushFetcher.data?.errors ?? [])];
if (errors.length > 0) {
@ -133,7 +151,7 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
);
}
let iconClassName = '';
let iconClassName = 'fa-brands fa-git-alt';
const providerName = getOauth2FormatName(gitRepository?.credentials);
if (providerName === 'github') {
iconClassName = 'fa fa-github';
@ -148,10 +166,8 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
gitCheckoutFetcher.state === 'loading' ||
gitPushFetcher.state === 'loading' ||
gitPullFetcher.state === 'loading';
const isButton =
!gitRepository ||
(isLoading && !gitRepoDataFetcher.data) ||
(gitRepoDataFetcher.data && 'errors' in gitRepoDataFetcher.data);
const isSynced = Boolean(gitRepository?.uri && !isLoading && gitRepoDataFetcher.data && !('errors' in gitRepoDataFetcher.data));
const { branches, branch: currentBranch } =
gitRepoDataFetcher.data && 'branches' in gitRepoDataFetcher.data
@ -159,6 +175,7 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
: { branches: [], branch: '' };
let dropdown: React.ReactNode = null;
const { revalidate } = useRevalidator();
const currentBranchActions = [
{
@ -212,51 +229,108 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
},
];
if (isButton) {
dropdown = (
<Button
disabled={isLoading}
size="small"
className="btn--clicky-small btn-sync"
onClick={() => setIsGitRepoSettingsModalOpen(true)}
>
<i
className={`fa fa-code-fork space-right ${
isLoading ? 'fa-fade' : ''
}`}
/>
{isLoading ? 'Loading...' : 'Setup Git Sync'}
</Button>
useInterval(() => {
gitFetchFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/fetch`,
method: 'post',
}
);
} else {
}, 1000 * 60 * 5);
const status = gitStatusFetcher.data?.status;
const commitToolTipMsg = status?.localChanges ? 'Local changes made' : 'No local changes made';
if (isSynced) {
dropdown = (
<div className={className}>
<Dropdown
className="wide tall"
ref={dropdownRef}
onOpen={() => {
gitFetchFetcher.submit(
{},
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/git/fetch`,
method: 'post',
}
);
}}
triggerButton={
<DropdownButton className="btn--clicky-small btn-sync">
{iconClassName && (
<i className={classnames('space-right', iconClassName)} />
)}
<div className="ellipsis">{currentBranch}</div>
<i
className={`fa fa-code-fork space-left ${
isLoading ? 'fa-fade' : ''
}`}
/>
<DropdownButton
size="medium"
variant='text'
removePaddings={false}
removeBorderRadius
style={{
width: '100%',
borderRadius: '0',
borderTop: '1px solid var(--hl-md)',
justifyContent: 'flex-start !important',
height: 'var(--line-height-sm)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 'var(--padding-xs)',
width: '100%',
}}
>
{iconClassName && (
<i className={classnames('space-right', iconClassName)} />
)}
<div className="ellipsis">{currentBranch}</div>
<div
style={{
opacity: loadingStatus ? 0.5 : 1,
}}
>
<Tooltip message={commitToolTipMsg}>
<span
style={{
opacity: status?.localChanges ? 1 : 0.5,
color: status?.localChanges ? 'var(--color-notice)' : 'var(--color-hl)',
}}
><i className="fa fa-cube space-left" /></span>
</Tooltip>
</div>
</div>
</DropdownButton>
}
>
<DropdownSection
items={isInsomniaSyncEnabled ? [{
value: 'Use Insomnia Sync',
id: 'use-insomnia-sync',
}] : []}
>
{item => (
<DropdownItem
key={item.id}
textValue='Use Insomnia Sync'
arial-label='Use Insomnia Sync'
>
<Button
variant='contained'
bg='surprise'
onClick={async () => {
if (gitRepository) {
await deleteGitRepository(gitRepository);
revalidate();
}
}}
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 'var(--padding-sm)',
margin: '0 var(--padding-sm)',
}}
>
<i className="fa fa-cloud" /> Use Insomnia Sync
</Button>
</DropdownItem>
)}
</DropdownSection>
<DropdownSection
title={
<span>
@ -345,6 +419,108 @@ export const GitSyncDropdown: FC<Props> = ({ className, gitRepository }) => {
</Dropdown>
</div>
);
} else {
dropdown = (
<div className={className}>
<Dropdown
className="wide tall"
ref={dropdownRef}
triggerButton={
<DropdownButton
size="medium"
variant='text'
removePaddings={false}
removeBorderRadius
style={{
width: '100%',
borderRadius: '0',
borderTop: '1px solid var(--hl-md)',
justifyContent: 'flex-start !important',
height: 'var(--line-height-sm)',
}}
disabled={isLoading}
>
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 'var(--padding-xs)',
width: '100%',
}}
>
{iconClassName && (
<i className={classnames('space-right', iconClassName)} />
)}
<span className="ellipsis">Git Sync</span>
</div>
</DropdownButton>
}
>
<DropdownSection
items={isInsomniaSyncEnabled ? [{
value: 'Use Insomnia Sync',
id: 'use-insomnia-sync',
}] : []}
>
{item => (
<DropdownItem
key={item.id}
arial-label='Use Insomnia Sync'
>
<Button
variant='contained'
bg='surprise'
onClick={async () => {
if (gitRepository) {
await deleteGitRepository(gitRepository);
revalidate();
}
}}
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 'var(--padding-sm)',
margin: '0 var(--padding-sm)',
}}
>
<i className="fa fa-cloud" /> Use Insomnia Sync
</Button>
</DropdownItem>
)}
</DropdownSection>
<DropdownSection
title={
<span>
Git Sync
<HelpTooltip>
Sync and collaborate with Git{' '}
<Link href={docsGitSync}>
<span className="no-wrap">
<br />
Documentation <i className="fa fa-external-link" />
</span>
</Link>
</HelpTooltip>
</span>
}
>
<DropdownItem textValue="Settings">
<ItemContent
icon="wrench"
label="Setup Git Sync"
onClick={() => {
setIsGitRepoSettingsModalOpen(true);
}}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>
</div>
);
}
return (

View File

@ -24,11 +24,13 @@ import { Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent }
import { Link } from '../base/link';
import { HelpTooltip } from '../help-tooltip';
import { showError, showModal } from '../modals';
import { GitRepositorySettingsModal } from '../modals/git-repository-settings-modal';
import { LoginModal } from '../modals/login-modal';
import { SyncBranchesModal } from '../modals/sync-branches-modal';
import { SyncDeleteModal } from '../modals/sync-delete-modal';
import { SyncHistoryModal } from '../modals/sync-history-modal';
import { SyncStagingModal } from '../modals/sync-staging-modal';
import { Button } from '../themed-button';
import { Tooltip } from '../tooltip';
// TODO: handle refetching logic in one place not here in a component
@ -81,7 +83,6 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
const remoteProjects = useSelector(selectRemoteProjects);
const syncItems = useSelector(selectSyncItems);
const workspaceMeta = useSelector(selectActiveWorkspaceMeta);
const refetchRemoteBranch = useCallback(async () => {
if (session.isLoggedIn()) {
try {
@ -125,6 +126,8 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
refetchRemoteBranch();
}, REFRESH_PERIOD);
const [isGitRepoSettingsModalOpen, setIsGitRepoSettingsModalOpen] = useState(false);
useMount(async () => {
setState(state => ({
...state,
@ -162,10 +165,10 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
loadingProjectPull: true,
}));
const pulledIntoProject = await pullBackendProject({ vcs, backendProject, remoteProjects });
if (pulledIntoProject._id !== project._id) {
if (pulledIntoProject.project._id !== project._id) {
// If pulled into a different project, reactivate the workspace
await dispatch(activateWorkspace({ workspaceId: workspace._id }));
logCollectionMovedToProject(workspace, pulledIntoProject);
dispatch(activateWorkspace({ workspaceId: workspace._id }));
logCollectionMovedToProject(workspace, pulledIntoProject.project);
}
await refreshVCSAndRefetchRemote();
setState(state => ({
@ -338,23 +341,61 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
return (
<div>
<Dropdown
style={{
marginLeft: 'var(--padding-md)',
}}
className="wide tall"
onOpen={() => refreshVCSAndRefetchRemote()}
aria-label="Select a project to sync with"
triggerButton={
<DropdownButton
variant='outlined'
size="medium"
removeBorderRadius
disableHoverBehavior={false}
removePaddings={false}
className="btn--clicky-small btn-sync wide text-left overflow-hidden row-spaced"
variant='text'
style={{
width: '100%',
borderRadius: '0',
borderTop: '1px solid var(--hl-md)',
height: 'var(--line-height-sm)',
}}
>
<i className="fa fa-code-fork " /> Setup Sync
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 'var(--padding-xs)',
width: '100%',
}}
>
<i className="fa fa-cloud" /> Setup Sync
</div>
</DropdownButton>
}
>
<DropdownSection>
<DropdownItem
key='gitSync'
arial-label='Setup Git Sync'
>
<Button
variant='contained'
bg='surprise'
onClick={async () => {
setIsGitRepoSettingsModalOpen(true);
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
margin: '0 var(--padding-sm)',
justifyContent: 'flex-start!important',
}}
>
<i className="fa-brands fa-git-alt" /> Use Git Sync
</Button>
</DropdownItem>
</DropdownSection>
<DropdownSection
aria-label='Sync Projects List'
items={remoteBackendProjects.length === 0 ? emptyDropdownItemsArray : []}
@ -363,8 +404,9 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
{p =>
<DropdownItem
key={p.id}
textValue={p.name}
>
<ItemContent {...p} />
<ItemContent {...p} label={p.name} />
</DropdownItem>
}
</DropdownSection>
@ -387,6 +429,11 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
}
</DropdownSection>
</Dropdown>
{isGitRepoSettingsModalOpen && (
<GitRepositorySettingsModal
onHide={() => setIsGitRepoSettingsModalOpen(false)}
/>
)}
</div>
);
}
@ -404,9 +451,8 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
return (
<div>
<Dropdown
aria-label='Select a branch to sync with'
style={{ marginLeft: 'var(--padding-md)' }}
className="wide tall"
aria-label='Select a branch to sync with'
onOpen={() => refreshVCSAndRefetchRemote()}
closeOnSelect={false}
isDisabled={initializing}
@ -414,56 +460,101 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
currentBranch === null ?
<Fragment>Sync</Fragment> :
<DropdownButton
variant='outlined'
size="medium"
removeBorderRadius
disableHoverBehavior={false}
removePaddings={false}
className="btn--clicky-small btn-sync wide text-left overflow-hidden row-spaced"
variant='text'
style={{
width: '100%',
borderRadius: '0',
borderTop: '1px solid var(--hl-md)',
height: 'var(--line-height-sm)',
}}
>
<div className="ellipsis">
<i className="fa fa-code-fork space-right" />{' '}
{initializing ? 'Initializing...' : currentBranch}
</div>
<div className="flex space-left">
<Tooltip message={snapshotToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cube fa--fixed-width', {
'super-duper-faint': !canCreateSnapshot,
})}
/>
</Tooltip>
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 'var(--padding-xs)',
width: '100%',
}}
>
<div className="ellipsis">
<i className="fa fa-cloud space-right" />{' '}
{initializing ? 'Initializing...' : currentBranch}
</div>
<div className="flex space-left">
<Tooltip message={snapshotToolTipMsg} delay={800} position="bottom">
<i
style={{
color: canCreateSnapshot ? 'var(--color-notice)' : 'var(--color-hl)',
}}
className={classnames('fa fa-cube fa--fixed-width', {
'super-duper-faint': !canCreateSnapshot,
})}
/>
</Tooltip>
{/* Only show cloud icons if logged in */}
{session.isLoggedIn() && (
<Fragment>
{loadingPull ? (
loadIcon
) : (
<Tooltip message={pullToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-download fa--fixed-width', {
'super-duper-faint': !canPull,
})}
/>
</Tooltip>
)}
{/* Only show cloud icons if logged in */}
{session.isLoggedIn() && (
<Fragment>
{loadingPull ? (
loadIcon
) : (
<Tooltip message={pullToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-download fa--fixed-width', {
'super-duper-faint': !canPull,
})}
/>
</Tooltip>
)}
{loadingPush ? (
loadIcon
) : (
<Tooltip message={pushToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-upload fa--fixed-width', {
'super-duper-faint': !canPush,
})}
/>
</Tooltip>
)}
</Fragment>
)}
{loadingPush ? (
loadIcon
) : (
<Tooltip message={pushToolTipMsg} delay={800} position="bottom">
<i
className={classnames('fa fa-cloud-upload fa--fixed-width', {
'super-duper-faint': !canPush,
})}
/>
</Tooltip>
)}
</Fragment>
)}
</div>
</div>
</DropdownButton>
}
>
<DropdownSection>
<DropdownItem
key='gitSync'
arial-label='Setup Git Sync'
>
<Button
variant='contained'
bg='surprise'
onClick={async () => {
setIsGitRepoSettingsModalOpen(true);
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
margin: '0 var(--padding-sm)',
justifyContent: 'flex-start!important',
}}
>
<i className="fa-brands fa-git-alt" /> Use Git Sync
</Button>
</DropdownItem>
</DropdownSection>
<DropdownSection
aria-label='Sync Branches List'
title={syncMenuHeader}
@ -576,6 +667,11 @@ export const SyncDropdown: FC<Props> = ({ vcs, workspace, project }) => {
</DropdownItem>
</DropdownSection>
</Dropdown>
{isGitRepoSettingsModalOpen && (
<GitRepositorySettingsModal
onHide={() => setIsGitRepoSettingsModalOpen(false)}
/>
)}
</div>
);
};

View File

@ -0,0 +1,41 @@
import { FC } from 'react';
import React from 'react';
import { useRouteLoaderData } from 'react-router-dom';
import { isRemoteProject } from '../../../models/project';
import { useVCS } from '../../hooks/use-vcs';
import { WorkspaceLoaderData } from '../../routes/workspace';
import { GitSyncDropdown } from './git-sync-dropdown';
import { SyncDropdown } from './sync-dropdown';
export const WorkspaceSyncDropdown: FC = () => {
const {
activeProject,
activeWorkspace,
gitRepository,
activeWorkspaceMeta,
} = useRouteLoaderData(
':workspaceId'
) as WorkspaceLoaderData;
const vcs = useVCS({
workspaceId: activeWorkspace?._id,
});
if (isRemoteProject(activeProject) && vcs && !activeWorkspaceMeta?.gitRepositoryId) {
return (
<SyncDropdown
key={activeWorkspace?._id}
workspace={activeWorkspace}
project={activeProject}
vcs={vcs}
/>
);
}
if (activeWorkspaceMeta?.gitRepositoryId || !isRemoteProject(activeProject)) {
return <GitSyncDropdown isInsomniaSyncEnabled={isRemoteProject(activeProject)} gitRepository={gitRepository} />;
}
return null;
};

View File

@ -1,5 +1,6 @@
import React, { FC, forwardRef, ReactNode, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRevalidator } from 'react-router-dom';
import styled from 'styled-components';
import { ACTIVITY_HOME } from '../../../common/constants';
@ -12,7 +13,7 @@ import * as models from '../../../models/index';
import { isRequest } from '../../../models/request';
import { invariant } from '../../../utils/invariant';
import { setActiveActivity } from '../../redux/modules/global';
import { selectActiveApiSpec, selectActiveWorkspace, selectActiveWorkspaceClientCertificates, selectActiveWorkspaceName } from '../../redux/selectors';
import { selectActiveApiSpec, selectActiveWorkspace, selectActiveWorkspaceClientCertificates, selectActiveWorkspaceMeta, selectActiveWorkspaceName } from '../../redux/selectors';
import { FileInputButton } from '../base/file-input-button';
import { type ModalHandle, Modal, ModalProps } from '../base/modal';
import { ModalBody } from '../base/modal-body';
@ -57,6 +58,7 @@ const CertificateField: FC<{
</span>
);
};
export interface WorkspaceSettingsModalOptions {
showAddCertificateForm: boolean;
host: string;
@ -86,10 +88,13 @@ export const WorkspaceSettingsModal = forwardRef<WorkspaceSettingsModalHandle, M
defaultPreviewMode: false,
});
const { revalidate } = useRevalidator();
const workspace = useSelector(selectActiveWorkspace);
const apiSpec = useSelector(selectActiveApiSpec);
const activeWorkspaceName = useSelector(selectActiveWorkspaceName);
const clientCertificates = useSelector(selectActiveWorkspaceClientCertificates);
const workspaceMeta = useSelector(selectActiveWorkspaceMeta);
const [caCert, setCaCert] = useState<CaCertificate | null>(null);
useEffect(() => {
if (!workspace) {
@ -223,6 +228,7 @@ export const WorkspaceSettingsModal = forwardRef<WorkspaceSettingsModalHandle, M
showDescription,
defaultPreviewMode,
} = state;
return (
<Modal ref={modalRef}>
{workspace ?
@ -491,6 +497,47 @@ export const WorkspaceSettingsModal = forwardRef<WorkspaceSettingsModalHandle, M
)}
</PanelContainer>
</TabItem>
<TabItem key="git-sybc" title="Git Sync">
<PanelContainer className="pad">
<div className="form-control form-control--outlined">
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
}}
>
<input
type="checkbox"
checked={Boolean(workspaceMeta?.gitRepositoryId)}
onChange={async () => {
if (workspaceMeta?.gitRepositoryId) {
await models.workspaceMeta.update(workspaceMeta, {
gitRepositoryId: null,
});
} else {
invariant(workspaceMeta, 'Workspace meta not found');
const repo = await models.gitRepository.create({
uri: '',
});
await models.workspaceMeta.update(workspaceMeta, {
gitRepositoryId: repo._id,
});
}
revalidate();
}}
/>
Enable Git Sync
</label>
<p>
By enabling Git Sync, you can sync your workspace with a Git repository. This will disable the ability to sync with Insomnia Sync.
</p>
</div>
</PanelContainer>
</TabItem>
</Tabs>
</ModalBody> : null}
</Modal>

View File

@ -187,6 +187,10 @@ const Pane = forwardRef<HTMLElement, { position: string; children: ReactNode }>(
}
);
export const SidebarFooter = styled.div({
gridRowStart: 6,
});
interface Props {
renderPageSidebar?: ReactNode;
renderPaneOne?: ReactNode;

View File

@ -27,20 +27,12 @@ const KongLink = styled.a({
});
export const StatusBar: FC = () => {
return <Bar>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
color: 'var(--color-font)',
fontSize: 'var(--font-size-xs)',
}}
>
return (
<Bar>
<SettingsButton />
</div>
<KongLink className="made-with-love" href="https://konghq.com/">
Made with&nbsp; <SvgIcon icon="heart" /> &nbsp;by Kong
</KongLink>
</Bar>;
<KongLink className="made-with-love" href="https://konghq.com/">
Made with&nbsp; <SvgIcon icon="heart" /> &nbsp;by Kong
</KongLink>
</Bar>
);
};

View File

@ -1,36 +1,27 @@
import React, { FC, Fragment } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { GitRepository } from '../../models/git-repository';
import { isRemoteProject, Project } from '../../models/project';
import { isCollection, isDesign, Workspace } from '../../models/workspace';
import { useVCS } from '../hooks/use-vcs';
import { Project } from '../../models/project';
import { isDesign, Workspace } from '../../models/workspace';
import { ActivityToggle } from './activity-toggle';
import { Breadcrumb } from './breadcrumb';
import { GitSyncDropdown } from './dropdowns/git-sync-dropdown';
import { SyncDropdown } from './dropdowns/sync-dropdown';
import { WorkspaceDropdown } from './dropdowns/workspace-dropdown';
export const WorkspaceHeader: FC<{
gitRepository: GitRepository | null;
activeProject: Project;
activeWorkspace: Workspace;
}> = ({
gitRepository,
activeProject,
activeWorkspace,
}) => {
const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>();
const vcs = useVCS({
workspaceId,
});
}> = ({ activeProject, activeWorkspace }) => {
const { organizationId, projectId } = useParams<{
organizationId: string;
projectId: string;
}>();
const navigate = useNavigate();
const crumbs = [
{
onClick: () => navigate(`/organization/${organizationId}/project/${projectId}`),
onClick: () =>
navigate(`/organization/${organizationId}/project/${projectId}`),
id: activeProject._id,
label: activeProject.name,
node: <span data-testid="project">{activeProject.name}</span>,
@ -46,8 +37,6 @@ export const WorkspaceHeader: FC<{
<Fragment>
<Breadcrumb crumbs={crumbs} />
{isDesign(activeWorkspace) && <ActivityToggle />}
{isDesign(activeWorkspace) && <GitSyncDropdown gitRepository={gitRepository} />}
{isCollection(activeWorkspace) && isRemoteProject(activeProject) && vcs && <SyncDropdown key={workspaceId} workspace={activeWorkspace} project={activeProject} vcs={vcs} />}
</Fragment>
);
};

View File

@ -286,6 +286,10 @@ const router = createMemoryRouter(
{
path: 'git',
children: [
{
path: 'status',
loader: async (...args) => (await import('./routes/git-actions')).gitStatusLoader(...args),
},
{
path: 'changes',
loader: async (...args) => (await import('./routes/git-actions')).gitChangesLoader(...args),

View File

@ -112,13 +112,13 @@ export const createNewWorkspaceAction: ActionFunction = async ({
// Create default env, cookie jar, and meta
await models.environment.getOrCreateForParentId(workspace._id);
await models.cookieJar.getOrCreateForParentId(workspace._id);
await models.workspaceMeta.getOrCreateByParentId(workspace._id);
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspace._id);
await database.flushChanges(flushId);
if (session.isLoggedIn() && isRemoteProject(project) && isCollection(workspace)) {
if (session.isLoggedIn() && isRemoteProject(project) && !workspaceMeta.gitRepositoryId) {
const vcs = getVCS();
if (vcs) {
initializeLocalBackendProjectAndMarkForSync({
await initializeLocalBackendProjectAndMarkForSync({
vcs,
workspace,
});

View File

@ -15,6 +15,7 @@ import { isWebSocketRequest } from '../../models/websocket-request';
import { invariant } from '../../utils/invariant';
import { SegmentEvent, trackSegmentEvent } from '../analytics';
import { EnvironmentsDropdown } from '../components/dropdowns/environments-dropdown';
import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown';
import { ErrorBoundary } from '../components/error-boundary';
import { useDocBodyKeyboardShortcuts } from '../components/keydown-binder';
import { showModal } from '../components/modals';
@ -61,6 +62,7 @@ export interface GrpcRequestState {
methods: GrpcMethodInfo[];
reloadMethods: boolean;
}
const INITIAL_GRPC_REQUEST_STATE = {
running: false,
requestMessages: [],
@ -70,6 +72,7 @@ const INITIAL_GRPC_REQUEST_STATE = {
methods: [],
reloadMethods: true,
};
export const Debug: FC = () => {
const activeEnvironment = useSelector(selectActiveEnvironment);
const activeRequest = useSelector(selectActiveRequest);
@ -264,6 +267,7 @@ export const Debug: FC = () => {
<SidebarChildren
filter={sidebarFilter || ''}
/>
<WorkspaceSyncDropdown />
</Fragment>
: null}
renderPaneOne={activeWorkspace ?

View File

@ -24,6 +24,7 @@ import {
CodeEditorHandle,
} from '../components/codemirror/code-editor';
import { DesignEmptyState } from '../components/design-empty-state';
import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown';
import { ErrorBoundary } from '../components/error-boundary';
import { Notice, NoticeTable } from '../components/notice-table';
import { SidebarLayout } from '../components/sidebar-layout';
@ -256,9 +257,25 @@ const Design: FC = () => {
apiSpec={apiSpec}
handleSetSelection={handleScrollToSelection}
/>
<div
style={{
gridRowStart: 6,
}}
>
<WorkspaceSyncDropdown />
</div>
</ErrorBoundary>
) : (
<EmptySpaceHelper>A spec navigator will render here</EmptySpaceHelper>
<Fragment>
<EmptySpaceHelper>A spec navigator will render here</EmptySpaceHelper>
<div
style={{
gridRowStart: 6,
}}
>
<WorkspaceSyncDropdown />
</div>
</Fragment>
)
}
renderPaneTwo={showRightPane && <SwaggerUIDiv text={apiSpec.contents} />}

View File

@ -1,4 +1,3 @@
import { invariant } from '@remix-run/router';
import { fromUrl } from 'hosted-git-info';
import { Errors } from 'isomorphic-git';
import path from 'path';
@ -34,18 +33,27 @@ import {
addDotGit,
getOauth2FormatName,
} from '../../sync/git/utils';
import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../analytics';
import { invariant } from '../../utils/invariant';
import {
SegmentEvent,
trackSegmentEvent,
vcsSegmentEventProperties,
} from '../analytics';
// Loaders
export type GitRepoLoaderData = {
branch: string;
branches: string[];
gitRepository: GitRepository | null;
} | {
errors: string[];
};
export type GitRepoLoaderData =
| {
branch: string;
branches: string[];
gitRepository: GitRepository | null;
}
| {
errors: string[];
};
export const gitRepoLoader: LoaderFunction = async ({ params }): Promise<GitRepoLoaderData> => {
export const gitRepoLoader: LoaderFunction = async ({
params,
}): Promise<GitRepoLoaderData> => {
try {
const { workspaceId, projectId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
@ -54,89 +62,103 @@ export const gitRepoLoader: LoaderFunction = async ({ params }): Promise<GitRepo
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
if (!workspaceMeta?.gitRepositoryId) {
invariant(workspaceMeta, 'Workspace meta not found');
if (!workspaceMeta.gitRepositoryId) {
return {
errors: ['Workspace is not linked to a git repository'],
};
}
const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId);
const gitRepository = await models.gitRepository.getById(
workspaceMeta.gitRepositoryId
);
invariant(gitRepository, 'Git Repository not found');
if (!GitVCS.isInitializedForRepo(gitRepository._id)) {
const baseDir = path.join(
process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'),
`version-control/git/${gitRepository._id}`,
);
if (GitVCS.isInitializedForRepo(gitRepository._id)) {
return {
branch: await GitVCS.getBranch(),
branches: await GitVCS.listBranches(),
gitRepository: gitRepository,
};
}
// All app data is stored within a namespaced GIT_INSOMNIA_DIR directory at the root of the repository and is read/written from the local NeDB database
const neDbClient = NeDBClient.createClient(workspaceId, projectId);
const baseDir = path.join(
process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'),
`version-control/git/${gitRepository._id}`
);
// All git metadata in the GIT_INTERNAL_DIR directory is stored in a git/ directory on the filesystem
const gitDataClient = fsClient(baseDir);
// All app data is stored within a namespaced GIT_INSOMNIA_DIR directory at the root of the repository and is read/written from the local NeDB database
const neDbClient = NeDBClient.createClient(workspaceId, projectId);
// All data outside the directories listed below will be stored in an 'other' directory. This is so we can support files that exist outside the ones the app is specifically in charge of.
const otherDatClient = fsClient(path.join(baseDir, 'other'));
// All git metadata in the GIT_INTERNAL_DIR directory is stored in a git/ directory on the filesystem
const gitDataClient = fsClient(baseDir);
// The routable FS client directs isomorphic-git to read/write from the database or from the correct directory on the file system while performing git operations.
const routableFS = routableFSClient(otherDatClient, {
[GIT_INSOMNIA_DIR]: neDbClient,
[GIT_INTERNAL_DIR]: gitDataClient,
// All data outside the directories listed below will be stored in an 'other' directory. This is so we can support files that exist outside the ones the app is specifically in charge of.
const otherDatClient = fsClient(path.join(baseDir, 'other'));
// The routable FS client directs isomorphic-git to read/write from the database or from the correct directory on the file system while performing git operations.
const routableFS = routableFSClient(otherDatClient, {
[GIT_INSOMNIA_DIR]: neDbClient,
[GIT_INTERNAL_DIR]: gitDataClient,
});
// Init VCS
const { credentials, uri } = gitRepository;
if (gitRepository.needsFullClone) {
await GitVCS.initFromClone({
repoId: gitRepository._id,
url: uri,
gitCredentials: credentials,
directory: GIT_CLONE_DIR,
fs: routableFS,
gitDirectory: GIT_INTERNAL_DIR,
});
// Init VCS
const { credentials, uri } = gitRepository;
if (gitRepository.needsFullClone) {
await GitVCS.initFromClone({
repoId: gitRepository._id,
url: uri,
gitCredentials: credentials,
directory: GIT_CLONE_DIR,
fs: routableFS,
gitDirectory: GIT_INTERNAL_DIR,
});
await models.gitRepository.update(gitRepository, {
needsFullClone: false,
});
} else {
await GitVCS.init({
repoId: gitRepository._id,
uri,
directory: GIT_CLONE_DIR,
fs: routableFS,
gitDirectory: GIT_INTERNAL_DIR,
gitCredentials: credentials,
});
}
// Configure basic info
const { author, uri: gitUri } = gitRepository;
await GitVCS.setAuthor(author.name, author.email);
await GitVCS.addRemote(gitUri);
await models.gitRepository.update(gitRepository, {
needsFullClone: false,
});
} else {
await GitVCS.init({
repoId: gitRepository._id,
uri,
directory: GIT_CLONE_DIR,
fs: routableFS,
gitDirectory: GIT_INTERNAL_DIR,
gitCredentials: credentials,
});
}
// Configure basic info
const { author, uri: gitUri } = gitRepository;
await GitVCS.setAuthor(author.name, author.email);
await GitVCS.addRemote(gitUri);
return {
branch: await GitVCS.getBranch(),
branches: await GitVCS.listBranches(),
gitRepository: gitRepository,
};
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Error while fetching git repository.';
const errorMessage =
e instanceof Error ? e.message : 'Error while fetching git repository.';
return {
errors: [errorMessage],
};
}
};
export type GitBranchesLoaderData = {
branches: string[];
remoteBranches: string[];
} | {
errors: string[];
};
export type GitBranchesLoaderData =
| {
branches: string[];
remoteBranches: string[];
}
| {
errors: string[];
};
export const gitBranchesLoader: LoaderFunction = async ({ params }): Promise<GitBranchesLoaderData> => {
export const gitBranchesLoader: LoaderFunction = async ({
params,
}): Promise<GitBranchesLoaderData> => {
const { workspaceId, projectId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
invariant(typeof projectId === 'string', 'Project Id is required');
@ -144,13 +166,16 @@ export const gitBranchesLoader: LoaderFunction = async ({ params }): Promise<Git
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
if (!workspaceMeta?.gitRepositoryId) {
invariant(workspaceMeta, 'Workspace meta not found');
if (!workspaceMeta.gitRepositoryId) {
return {
errors: ['Workspace is not linked to a git repository'],
};
}
const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId);
const gitRepository = await models.gitRepository.getById(
workspaceMeta.gitRepositoryId
);
invariant(gitRepository, 'Git Repository not found');
const branches = await GitVCS.listBranches();
@ -163,11 +188,13 @@ export const gitBranchesLoader: LoaderFunction = async ({ params }): Promise<Git
};
};
export type GitFetchLoaderData = {
errors: string[];
} | {};
export interface GitFetchLoaderData {
errors: string[];
}
export const gitFetchAction: ActionFunction = async ({ params }): Promise<GitFetchLoaderData> => {
export const gitFetchAction: ActionFunction = async ({
params,
}): Promise<GitFetchLoaderData> => {
const { workspaceId, projectId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
invariant(typeof projectId === 'string', 'Project Id is required');
@ -175,17 +202,24 @@ export const gitFetchAction: ActionFunction = async ({ params }): Promise<GitFet
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
if (!workspaceMeta?.gitRepositoryId) {
invariant(workspaceMeta, 'Workspace meta not found');
if (!workspaceMeta.gitRepositoryId) {
return {
errors: ['Workspace is not linked to a git repository'],
};
}
const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId);
const gitRepository = await models.gitRepository.getById(
workspaceMeta.gitRepositoryId
);
invariant(gitRepository, 'Git Repository not found');
try {
await GitVCS.fetch({ singleBranch: true, depth: 1, credentials: gitRepository?.credentials });
await GitVCS.fetch({
singleBranch: true,
depth: 1,
credentials: gitRepository.credentials,
});
} catch (e) {
console.error(e);
return {
@ -193,16 +227,22 @@ export const gitFetchAction: ActionFunction = async ({ params }): Promise<GitFet
};
}
return {};
return {
errors: [],
};
};
export type GitLogLoaderData = {
log: GitLogEntry[];
} | {
errors: string[];
};
export type GitLogLoaderData =
| {
log: GitLogEntry[];
}
| {
errors: string[];
};
export const gitLogLoader: LoaderFunction = async ({ params }): Promise<GitLogLoaderData> => {
export const gitLogLoader: LoaderFunction = async ({
params,
}): Promise<GitLogLoaderData> => {
const { workspaceId, projectId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
invariant(typeof projectId === 'string', 'Project Id is required');
@ -210,13 +250,16 @@ export const gitLogLoader: LoaderFunction = async ({ params }): Promise<GitLogLo
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const workspaceMeta = await models.workspaceMeta.getByParentId(workspaceId);
if (!workspaceMeta?.gitRepositoryId) {
invariant(workspaceMeta, 'Workspace meta not found');
if (!workspaceMeta.gitRepositoryId) {
return {
errors: ['Workspace is not linked to a git repository'],
};
}
const gitRepository = await models.gitRepository.getById(workspaceMeta?.gitRepositoryId);
const gitRepository = await models.gitRepository.getById(
workspaceMeta.gitRepositoryId
);
invariant(gitRepository, 'Git Repository not found');
const log = await GitVCS.log({ depth: 35 });
@ -232,7 +275,9 @@ export interface GitChangesLoaderData {
statusNames: Record<string, string>;
}
export const gitChangesLoader: LoaderFunction = async ({ params }): Promise<GitChangesLoaderData> => {
export const gitChangesLoader: LoaderFunction = async ({
params,
}): Promise<GitChangesLoaderData> => {
const { workspaceId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
@ -261,9 +306,11 @@ export const gitChangesLoader: LoaderFunction = async ({ params }): Promise<GitC
};
// Actions
type CloneGitActionResult = Response | {
errors?: string[];
};
type CloneGitActionResult =
| Response
| {
errors?: string[];
};
export function parseGitToHttpsURL(s: string) {
// try to convert any git URL to https URL
@ -289,7 +336,7 @@ export function parseGitToHttpsURL(s: string) {
export const cloneGitRepoAction: ActionFunction = async ({
request,
params,
}): Promise<CloneGitActionResult> => {
}): Promise<CloneGitActionResult> => {
const { organizationId, projectId } = params;
invariant(typeof projectId === 'string', 'ProjectId is required.');
@ -317,7 +364,10 @@ export const cloneGitRepoAction: ActionFunction = async ({
// Git Credentials
const oauth2format = formData.get('oauth2format');
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
invariant(
oauth2format === 'gitlab' || oauth2format === 'github',
'OAuth2 format is required'
);
const token = formData.get('token');
invariant(typeof token === 'string', 'Token is required');
const username = formData.get('username');
@ -382,7 +432,9 @@ export const cloneGitRepoAction: ActionFunction = async ({
providerName,
});
return {
errors: ['Error Cloning Repository: failed to clone with and without `.git` suffix'],
errors: [
'Error Cloning Repository: failed to clone with and without `.git` suffix',
],
};
}
}
@ -419,6 +471,7 @@ export const cloneGitRepoAction: ActionFunction = async ({
parentId: project._id,
description: `Insomnia Workspace for ${repoSettingsPatch.uri}}`,
});
await models.apiSpec.getOrCreateForParentId(workspace._id);
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'no directory found'),
providerName,
@ -446,7 +499,11 @@ export const cloneGitRepoAction: ActionFunction = async ({
if (workspaces.length > 1) {
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'multiple workspaces found'),
...vcsSegmentEventProperties(
'git',
'clone',
'multiple workspaces found'
),
providerName,
});
@ -464,11 +521,17 @@ export const cloneGitRepoAction: ActionFunction = async ({
if (existingWorkspace) {
trackSegmentEvent(SegmentEvent.vcsSyncComplete, {
...vcsSegmentEventProperties('git', 'clone', 'workspace already exists'),
...vcsSegmentEventProperties(
'git',
'clone',
'workspace already exists'
),
providerName,
});
return {
errors: [`Workspace ${existingWorkspace.name} already exists. Please delete it before cloning.`],
errors: [
`Workspace ${existingWorkspace.name} already exists. Please delete it before cloning.`,
],
};
}
@ -505,7 +568,9 @@ export const cloneGitRepoAction: ActionFunction = async ({
invariant(workspaceId, 'Workspace ID is required');
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}`);
return redirect(
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_SPEC}`
);
};
export const updateGitRepoAction: ActionFunction = async ({
@ -541,7 +606,10 @@ export const updateGitRepoAction: ActionFunction = async ({
// Git Credentials
const oauth2format = formData.get('oauth2format');
if (oauth2format) {
invariant(oauth2format === 'gitlab' || oauth2format === 'github', 'OAuth2 format is required');
invariant(
oauth2format === 'gitlab' || oauth2format === 'github',
'OAuth2 format is required'
);
const token = formData.get('token');
invariant(typeof token === 'string', 'Token is required');
const username = formData.get('username');
@ -581,9 +649,7 @@ export const updateGitRepoAction: ActionFunction = async ({
return null;
};
export const resetGitRepoAction: ActionFunction = async ({
params,
}) => {
export const resetGitRepoAction: ActionFunction = async ({ params }) => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
@ -661,15 +727,21 @@ export const commitToGitRepoAction: ActionFunction = async ({
});
for (const item of changesToCommit) {
item.status.includes('deleted') ? await GitVCS.remove(item.path) : await GitVCS.add(item.path);
item.status.includes('deleted')
? await GitVCS.remove(item.path)
: await GitVCS.add(item.path);
}
await GitVCS.commit(message);
const providerName = getOauth2FormatName(repo?.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'commit'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'commit'),
providerName,
});
} catch (e) {
const message = e instanceof Error ? e.message : 'Error while committing changes';
const message =
e instanceof Error ? e.message : 'Error while committing changes';
return { errors: [message] };
}
@ -677,10 +749,13 @@ export const commitToGitRepoAction: ActionFunction = async ({
};
export interface CreateNewGitBranchResult {
errors?: string[];
errors?: string[];
}
export const createNewGitBranchAction: ActionFunction = async ({ request, params }): Promise<CreateNewGitBranchResult> => {
export const createNewGitBranchAction: ActionFunction = async ({
request,
params,
}): Promise<CreateNewGitBranchResult> => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
@ -703,9 +778,15 @@ export const createNewGitBranchAction: ActionFunction = async ({ request, params
try {
const providerName = getOauth2FormatName(repo?.credentials);
await GitVCS.checkout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'create_branch'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'create_branch'),
providerName,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Something went wrong while creating a new branch';
const errorMessage =
err instanceof Error
? err.message
: 'Something went wrong while creating a new branch';
return {
errors: [errorMessage],
};
@ -716,7 +797,7 @@ export const createNewGitBranchAction: ActionFunction = async ({ request, params
export interface CheckoutGitBranchResult {
errors?: string[];
}
}
export const checkoutGitBranchAction: ActionFunction = async ({
request,
params,
@ -774,7 +855,10 @@ export interface MergeGitBranchResult {
errors?: string[];
}
export const mergeGitBranchAction: ActionFunction = async ({ request, params }): Promise<MergeGitBranchResult> => {
export const mergeGitBranchAction: ActionFunction = async ({
request,
params,
}): Promise<MergeGitBranchResult> => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
@ -802,12 +886,18 @@ export const mergeGitBranchAction: ActionFunction = async ({ request, params }):
const bufferId = await database.bufferChanges();
await GitVCS.checkout(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'checkout_branch'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'checkout_branch'),
providerName,
});
await database.flushChanges(bufferId, true);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'merge_branch'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'merge_branch'),
providerName,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
return { errors: [errorMessage] };
return { errors: [errorMessage] };
}
return {};
@ -817,7 +907,10 @@ export interface DeleteGitBranchResult {
errors?: string[];
}
export const deleteGitBranchAction: ActionFunction = async ({ request, params }): Promise<DeleteGitBranchResult> => {
export const deleteGitBranchAction: ActionFunction = async ({
request,
params,
}): Promise<DeleteGitBranchResult> => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
@ -842,10 +935,13 @@ export const deleteGitBranchAction: ActionFunction = async ({ request, params })
try {
const providerName = getOauth2FormatName(repo?.credentials);
await GitVCS.deleteBranch(branch);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'delete_branch'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'delete_branch'),
providerName,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
return { errors: [errorMessage] };
return { errors: [errorMessage] };
}
return {};
@ -858,7 +954,7 @@ export interface PushToGitRemoteResult {
export const pushToGitRemoteAction: ActionFunction = async ({
request,
params,
}): Promise<PushToGitRemoteResult> => {
}): Promise<PushToGitRemoteResult> => {
const { workspaceId } = params;
invariant(workspaceId, 'Workspace ID is required');
const workspace = await models.workspace.getById(workspaceId);
@ -896,11 +992,17 @@ export const pushToGitRemoteAction: ActionFunction = async ({
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
await GitVCS.push(gitRepository.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', force ? 'force_push' : 'push'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', force ? 'force_push' : 'push'),
providerName,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown Error';
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'push', errorMessage), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'push', errorMessage),
providerName,
});
if (err instanceof Errors.PushRejectedError) {
return {
@ -919,7 +1021,7 @@ export const pushToGitRemoteAction: ActionFunction = async ({
};
export interface PullFromGitRemoteResult {
errors?: string[];
errors?: string[];
}
export const pullFromGitRemoteAction: ActionFunction = async ({
@ -945,17 +1047,27 @@ export const pullFromGitRemoteAction: ActionFunction = async ({
const providerName = getOauth2FormatName(gitRepository.credentials);
try {
await GitVCS.fetch({ singleBranch: true, depth: 1, credentials: gitRepository?.credentials });
await GitVCS.fetch({
singleBranch: true,
depth: 1,
credentials: gitRepository?.credentials,
});
} catch (e) {
console.warn('Error fetching from remote');
}
try {
await GitVCS.pull(gitRepository.credentials);
trackSegmentEvent(SegmentEvent.vcsAction, { ...vcsSegmentEventProperties('git', 'pull'), providerName });
trackSegmentEvent(SegmentEvent.vcsAction, {
...vcsSegmentEventProperties('git', 'pull'),
providerName,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown Error';
trackSegmentEvent(SegmentEvent.vcsAction, vcsSegmentEventProperties('git', 'pull', errorMessage));
trackSegmentEvent(
SegmentEvent.vcsAction,
vcsSegmentEventProperties('git', 'pull', errorMessage)
);
return {
errors: [`${errorMessage}`],
@ -1006,8 +1118,10 @@ async function getGitChanges(vcs: typeof GitVCS, workspace: Workspace) {
const statusNames: Record<string, string> = {};
for (const doc of docs) {
const name = (isApiSpec(doc) && doc.fileName) || doc.name || '';
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.json`)] = name;
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.yml`)] = name;
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.json`)] =
name;
statusNames[path.join(GIT_INSOMNIA_DIR_NAME, doc.type, `${doc._id}.yml`)] =
name;
}
// Create status items
const items: Record<string, GitChange> = {};
@ -1063,7 +1177,10 @@ export interface GitRollbackChangesResult {
errors?: string[];
}
export const gitRollbackChangesAction: ActionFunction = async ({ params, request }): Promise<GitRollbackChangesResult> => {
export const gitRollbackChangesAction: ActionFunction = async ({
params,
request,
}): Promise<GitRollbackChangesResult> => {
const { workspaceId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
@ -1088,7 +1205,11 @@ export const gitRollbackChangesAction: ActionFunction = async ({ params, request
const { changes } = await getGitChanges(GitVCS, workspace);
const files = changes
.filter(i => changeType === 'modified' ? !i.status.includes('added') : i.status.includes('added'))
.filter(i =>
changeType === 'modified'
? !i.status.includes('added')
: i.status.includes('added')
)
// only rollback if editable
.filter(i => i.editable)
// only rollback if in selected path or for all paths
@ -1100,7 +1221,8 @@ export const gitRollbackChangesAction: ActionFunction = async ({ params, request
await gitRollback(GitVCS, files);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Error while rolling back changes';
const errorMessage =
e instanceof Error ? e.message : 'Error while rolling back changes';
return {
errors: [errorMessage],
};
@ -1108,3 +1230,28 @@ export const gitRollbackChangesAction: ActionFunction = async ({ params, request
return {};
};
export interface GitStatusResult {
status: {
localChanges: number;
};
}
export const gitStatusLoader: LoaderFunction = async ({
params,
}): Promise<GitStatusResult> => {
const { workspaceId } = params;
invariant(typeof workspaceId === 'string', 'Workspace Id is required');
const workspace = await models.workspace.getById(workspaceId);
invariant(workspace, 'Workspace not found');
const { changes } = await getGitChanges(GitVCS, workspace);
const localChanges = changes.filter(i => i.editable).length;
return {
status: {
localChanges,
},
};
};

View File

@ -1,4 +1,4 @@
import { ActionFunction, LoaderFunction } from 'react-router-dom';
import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom';
import { database } from '../../common/database';
import { isNotNullOrUndefined } from '../../common/misc';
@ -9,7 +9,8 @@ import { pullBackendProject } from '../../sync/vcs/pull-backend-project';
import { getVCS } from '../../sync/vcs/vcs';
import { invariant } from '../../utils/invariant';
export const pullRemoteCollectionAction: ActionFunction = async ({ request }) => {
export const pullRemoteCollectionAction: ActionFunction = async ({ request, params }) => {
const { organizationId, projectId } = params;
const formData = await request.formData();
const backendProjectId = formData.get('backendProjectId');
@ -37,7 +38,11 @@ export const pullRemoteCollectionAction: ActionFunction = async ({ request }) =>
// Remove all backend projects for workspace first
await newVCS.removeBackendProjectsForRoot(backendProject.rootDocumentId);
await pullBackendProject({ vcs: newVCS, backendProject, remoteProjects });
const { workspaceId } = await pullBackendProject({ vcs: newVCS, backendProject, remoteProjects });
if (workspaceId) {
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug`);
}
return null;
};

View File

@ -23,7 +23,7 @@ export const indexLoader: LoaderFunction = async ({ params }) => {
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${testSuiteId}/test-result/${testResult._id}`);
}
return;
return null;
};
export const loader: LoaderFunction = async ({

View File

@ -221,7 +221,7 @@ export const indexLoader: LoaderFunction = async ({ params }) => {
`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/test/test-suite/${unitTestSuites[0]._id}`
);
}
return;
return null;
};
interface LoaderData {

View File

@ -15,9 +15,10 @@ import * as models from '../../models';
import type { UnitTestSuite } from '../../models/unit-test-suite';
import { invariant } from '../../utils/invariant';
import { Dropdown, DropdownButton, DropdownItem, ItemContent } from '../components/base/dropdown';
import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown';
import { ErrorBoundary } from '../components/error-boundary';
import { showPrompt } from '../components/modals';
import { SidebarLayout } from '../components/sidebar-layout';
import { SidebarFooter, SidebarLayout } from '../components/sidebar-layout';
import { Button } from '../components/themed-button';
import { TestRunStatus } from './test-results';
import TestSuiteRoute from './test-suite';
@ -161,6 +162,9 @@ const TestRoute: FC = () => {
))}
</ul>
</div>
<SidebarFooter>
<WorkspaceSyncDropdown />
</SidebarFooter>
</ErrorBoundary>
}
renderPaneOne={