Filter remote collections by the active space (#3456)

Co-authored-by: David Marby <david@dmarby.se>
This commit is contained in:
Opender SinghandDavid Marby authored and GitHub committed 2021-06-15 04:21:06 +00:00
1 parent d0fac873d0
commit 58160e82bc
24 files changed
+803 -494

No files matched your search

@@ -94,9 +94,12 @@ export const UPDATE_CHANNEL_BETA = 'beta';
export const UPDATE_URL_MAC = 'https://updates.insomnia.rest/builds/check/mac';
export const UPDATE_URL_WINDOWS = 'https://updates.insomnia.rest/updates/win';
// AP
// API
export const API_BASE_URL = 'https://api.insomnia.rest';
// SYNC
export const DEFAULT_BRANCH_NAME = 'master';
// PLUGIN
export const PLUGIN_HUB_BASE = 'https://insomnia.rest/plugins';
export const NPM_PACKAGE_BASE = 'https://www.npmjs.com/package';
@@ -17,6 +17,7 @@ import { Environment } from '../models/environment';
export interface Query {
_id?: string | SpecificQuery;
parentId?: string | null;
remoteId?: string | null;
plugin?: string;
key?: string;
environmentId?: string | null;
+1 -1
View File
@@ -444,6 +444,6 @@ export function snapNumberToLimits(value: number, min?: number, max?: number) {
return value;
}
export function isNotNullOrUndefined(obj: unknown) {
export function isNotNullOrUndefined(obj: any | null | undefined) {
return obj !== null && obj !== undefined;
}
@@ -0,0 +1,21 @@
import { Schema } from '@develohpanda/fluent-builder';
import { BaseModel, workspace } from '..';
import { Workspace } from '../workspace';
export const baseModelSchema: Schema<BaseModel> = {
_id: () => 'id',
created: () => 1234,
isPrivate: () => false,
modified: () => 5678,
name: () => 'name',
parentId: () => '',
type: () => 'base',
};
export const workspaceModelSchema: Schema<Workspace> = {
...baseModelSchema,
type: () => workspace.type,
description: () => '',
certificates: () => undefined,
scope: () => 'collection',
};
@@ -0,0 +1,31 @@
import { createBuilder, Schema } from '@develohpanda/fluent-builder';
import { baseModelSchema } from '../../models/__schemas__/model-schemas';
import { Branch, MergeConflict, Project, StatusCandidate } from '../types';
export const projectSchema: Schema<Project> = {
id: () => 'id',
rootDocumentId: () => 'rootDocumentId',
name: () => 'name',
};
export const branchSchema: Schema<Branch> = {
created: () => new Date(0),
modified: () => new Date(0),
name: () => '',
snapshots: () => [],
};
export const mergeConflictSchema: Schema<MergeConflict> = {
key: () => 'key',
choose: () => null,
mineBlob: () => null,
theirsBlob: () => null,
message: () => 'message',
name: () => 'name',
};
export const statusCandidateSchema: Schema<StatusCandidate> = {
key: () => 'key',
name: () => 'name',
document: () => createBuilder(baseModelSchema).build(),
};
+2 -1
View File
@@ -1,3 +1,4 @@
import { BaseModel } from '../models';
export interface Team {
id: string;
@@ -92,7 +93,7 @@ export type Stage = Record<DocumentKey, StageEntry>;
export interface StatusCandidate {
key: DocumentKey;
name: string;
document: Record<string, any>;
document: BaseModel;
}
export type StatusCandidateMap = Record<DocumentKey, StatusCandidate>;
@@ -0,0 +1,65 @@
import { createBuilder } from '@develohpanda/fluent-builder';
import { baseModelSchema, workspaceModelSchema } from '../../../models/__schemas__/model-schemas';
import { deleteKeys, resetKeys, shouldIgnoreKey } from '../ignore-keys';
const baseModelBuilder = createBuilder(baseModelSchema);
const workspaceModelBuilder = createBuilder(workspaceModelSchema);
describe('shouldIgnoreKey()', () => {
it('should always ignore keys', () => {
const base = baseModelBuilder.build();
const workspace = workspaceModelBuilder.build();
// don't ignore _id
expect(shouldIgnoreKey('_id', base)).toBe(false);
expect(shouldIgnoreKey('_id', workspace)).toBe(false);
// do ignore modified
expect(shouldIgnoreKey('modified', base)).toBe(true);
expect(shouldIgnoreKey('modified', workspace)).toBe(true);
});
it('should always ignore parent id from workspace', () => {
const base = baseModelBuilder.build();
const workspace = workspaceModelBuilder.build();
expect(shouldIgnoreKey('parentId', base)).toBe(false);
expect(shouldIgnoreKey('parentId', workspace)).toBe(true);
});
});
describe('deleteKeys', () => {
it('should always delete the modified key', () => {
const base = baseModelBuilder.build();
const workspace = workspaceModelBuilder.build();
deleteKeys(base);
deleteKeys(workspace);
// Don't delete _id
expect('_id' in base).toBe(true);
expect('_id' in workspace).toBe(true);
// Do delete modified
expect('modified' in base).not.toBe(true);
expect('modified' in workspace).not.toBe(true);
});
});
describe('resetKeys', () => {
it('should clear parentId from the workspace', () => {
const base = baseModelBuilder._id('123').parentId('abc').build();
const workspace = workspaceModelBuilder._id('123').parentId('abc').build();
resetKeys(base);
resetKeys(workspace);
// Don't reset _id
expect(base._id).toBe('123');
expect(workspace._id).toBe('123');
// Do reset parentId for a workspace
expect(base.parentId).toBe('abc');
expect(workspace.parentId).toBeNull();
});
});
@@ -0,0 +1,162 @@
import { createBuilder } from '@develohpanda/fluent-builder';
import { DEFAULT_BRANCH_NAME } from '../../../common/constants';
import * as models from '../../../models';
import { Workspace } from '../../../models/workspace';
import { globalBeforeEach } from '../../../__jest__/before-each';
import { projectSchema } from '../../__schemas__/type-schemas';
import { pullProject } from '../pull-project';
import { mocked } from 'ts-jest/utils';
import MemoryDriver from '../../store/drivers/memory-driver';
import { VCS } from '../vcs';
jest.mock('../vcs');
const project = createBuilder(projectSchema).build();
const newMockedVcs = () => mocked(new VCS(new MemoryDriver()), true);
describe('pullProject()', () => {
let vcs = newMockedVcs();
beforeEach(async () => {
(VCS as jest.MockedClass<typeof VCS>).mockClear();
await globalBeforeEach();
vcs = newMockedVcs();
});
afterEach(() => {
expect(vcs.setProject).toHaveBeenCalledWith(project);
expect(vcs.checkout).toHaveBeenCalledWith([], DEFAULT_BRANCH_NAME);
});
describe('creating a new project', () => {
beforeEach(() => {
vcs.getRemoteBranches.mockResolvedValue([]);
});
afterEach(() => {
expect(vcs.pull).not.toHaveBeenCalledWith([]);
});
it('should insert a workspace with no parent', async () => {
// Arrange
const spaceId = undefined;
// Act
await pullProject({ vcs, project, spaceId });
// Assert
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
const workspace = workspaces[0];
expect(workspace).toStrictEqual(expect.objectContaining<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
// @ts-expect-error parent id is optional for workspaces
parentId: null,
}));
});
it('should insert a workspace with parent', async () => {
// Arrange
const spaceId = 'spaceId';
// Act
await pullProject({ vcs, project, spaceId });
// Assert
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
const workspace = workspaces[0];
expect(workspace).toStrictEqual(expect.objectContaining<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
parentId: spaceId,
}));
});
it('should update a workspace if the name or parentId is different', async () => {
// Arrange
await models.workspace.create({ _id: project.rootDocumentId, name: 'someName' });
const spaceId = 'spaceId';
// Act
await pullProject({ vcs, project, spaceId });
// Assert
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
const workspace = workspaces[0];
expect(workspace).toStrictEqual(expect.objectContaining<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
parentId: spaceId,
}));
});
});
describe('pulling an existing project', () => {
beforeEach(() => {
vcs.getRemoteBranches.mockResolvedValue([DEFAULT_BRANCH_NAME]);
});
afterEach(() => {
expect(vcs.pull).toHaveBeenCalledWith([]);
});
it('should overwrite the parentId only for a workspace with null', async () => {
// Arrange
const spaceId = undefined;
const existingWrk = await models.workspace.create({ _id: project.rootDocumentId, name: project.name });
const existingReq = await models.request.create({ parentId: existingWrk._id });
vcs.allDocuments.mockResolvedValue([existingWrk, existingReq]);
// Act
await pullProject({ vcs, project, spaceId });
// Assert
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
const workspace = workspaces[0];
expect(workspace).toStrictEqual(expect.objectContaining<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
// @ts-expect-error parent id is optional for workspaces
parentId: null,
}));
const requests = await models.request.all();
expect(requests).toHaveLength(1);
const request = requests[0];
expect(request).toStrictEqual(existingReq);
});
it('should overwrite the parentId only for a workspace with the space id', async () => {
// Arrange
const spaceId = 'spaceId';
const existingWrk = await models.workspace.create({ _id: project.rootDocumentId, name: project.name });
const existingReq = await models.request.create({ parentId: existingWrk._id });
vcs.allDocuments.mockResolvedValue([existingWrk, existingReq]);
// Act
await pullProject({ vcs, project, spaceId });
// Assert
const workspaces = await models.workspace.all();
expect(workspaces).toHaveLength(1);
const workspace = workspaces[0];
expect(workspace).toStrictEqual(expect.objectContaining<Partial<Workspace>>({
_id: project.rootDocumentId,
name: project.name,
parentId: spaceId,
}));
const requests = await models.request.all();
expect(requests).toHaveLength(1);
const request = requests[0];
expect(request).toStrictEqual(existingReq);
});
});
});
@@ -1,3 +1,7 @@
import { createBuilder } from '@develohpanda/fluent-builder';
import { baseModelSchema, workspaceModelSchema } from '../../../models/__schemas__/model-schemas';
import { StageEntry } from '../../types';
import { branchSchema, statusCandidateSchema, mergeConflictSchema } from '../../__schemas__/type-schemas';
import {
combinedMapKeys,
compareBranches,
@@ -5,6 +9,7 @@ import {
generateSnapshotStateMap,
getRootSnapshot,
getStagable,
hash,
hashDocument,
preMergeCheck,
stateDelta,
@@ -12,7 +17,74 @@ import {
updateStateWithConflictResolutions,
} from '../util';
const statusCandidateBuilder = createBuilder(statusCandidateSchema);
const baseModelBuilder = createBuilder(baseModelSchema);
const workspaceModelBuilder = createBuilder(workspaceModelSchema);
const mergeConflictBuilder = createBuilder(mergeConflictSchema);
const branchBuilder = createBuilder(branchSchema);
describe('util', () => {
beforeEach(() => {
statusCandidateBuilder.reset();
baseModelBuilder.reset();
mergeConflictBuilder.reset();
branchBuilder.reset();
workspaceModelBuilder.reset();
});
const DA1 = statusCandidateBuilder.reset()
.key('a')
.document(baseModelBuilder.reset()._id('a').name('1').build())
.build();
const DA2 = statusCandidateBuilder.reset()
.key('a')
.document(baseModelBuilder.reset()._id('a').name('2').build())
.build();
const DB1 = statusCandidateBuilder.reset()
.key('b')
.document(baseModelBuilder.reset()._id('b').name('1').build())
.build();
const DB2 = statusCandidateBuilder.reset()
.key('b')
.document(baseModelBuilder.reset()._id('b').name('2').build())
.build();
const DC1 = statusCandidateBuilder.reset()
.key('c')
.document(baseModelBuilder.reset()._id('c').name('1').build())
.build();
const HA1 = hashDocument(DA1.document);
const HA2 = hashDocument(DA2.document);
const HB1 = hashDocument(DB1.document);
const HB2 = hashDocument(DB2.document);
const HC1 = hashDocument(DC1.document);
const A1 = {
key: DA1.key,
blob: HA1.hash,
name: '',
};
const A2 = {
key: DA2.key,
blob: HA2.hash,
name: '',
};
const B1 = {
key: DB1.key,
blob: HB1.hash,
name: '',
};
const B2 = {
key: DB2.key,
blob: HB2.hash,
name: '',
};
const C1 = {
key: DC1.key,
blob: HC1.hash,
name: '',
};
describe('generateSnapshotStateMap()', () => {
it('generates from simple states', async () => {
const snapshot = newSnapshot(1, [
@@ -351,81 +423,15 @@ describe('util', () => {
});
describe('getStagable()', () => {
const A1 = {
key: 'a',
blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c',
name: '',
};
const A2 = {
key: 'a',
blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5',
name: '',
};
const B1 = {
key: 'b',
blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01',
name: '',
};
const B2 = {
key: 'b',
blob: 'eb157a78ebd367a004c5a32a18d6d8c18e60ce00',
name: '',
};
const C1 = {
key: 'c',
blob: 'db7d35b96291b823e674bf23ac59e6e116b1890e',
name: '',
};
const DA1 = {
key: 'a',
document: {
id: 'a',
v: 1,
},
name: '',
};
const DA2 = {
key: 'a',
document: {
id: 'a',
v: 2,
},
name: '',
};
const DB1 = {
key: 'b',
document: {
id: 'b',
v: 1,
},
name: '',
};
const DB2 = {
key: 'b',
document: {
id: 'b',
v: 2,
},
name: '',
};
const DC1 = {
key: 'c',
document: {
id: 'c',
v: 1,
},
name: '',
};
it('works with empty state', () => {
const state = [];
const dsrd = [DA1];
expect(getStagable(state, dsrd)).toEqual([
expect(getStagable(state, dsrd)).toEqual<StageEntry[]>([
{
blobContent: '{"id":"a","v":1}',
blobId: A1.blob,
key: 'a',
name: '',
blobContent: HA1.content,
blobId: HA1.hash,
key: A1.key,
name: DA1.name,
added: true,
},
]);
@@ -442,10 +448,10 @@ describe('util', () => {
const dsrd = [DA1, DB1];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: B1.blob,
blobContent: '{"id":"b","v":1}',
name: '',
key: B1.key,
blobId: HB1.hash,
blobContent: HB1.content,
name: DB1.name,
added: true,
},
]);
@@ -456,10 +462,10 @@ describe('util', () => {
const dsrd = [DB2];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: B2.blob,
blobContent: '{"id":"b","v":2}',
name: '',
key: B2.key,
blobId: HB2.hash,
blobContent: HB2.content,
name: DB2.name,
modified: true,
},
]);
@@ -470,8 +476,8 @@ describe('util', () => {
const dsrd = [];
expect(getStagable(state, dsrd)).toEqual([
{
key: 'b',
blobId: '05ab2ec03c7e32eff27305b4a11c573a3843fa01',
key: B1.key,
blobId: HB1.hash,
name: '',
deleted: true,
},
@@ -483,23 +489,23 @@ describe('util', () => {
const dsrd = [DA2, DB1];
expect(getStagable(state, dsrd)).toEqual([
{
blobContent: '{"id":"a","v":2}',
blobId: A2.blob,
key: 'a',
blobContent: HA2.content,
blobId: HA2.hash,
key: A2.key,
modified: true,
name: '',
name: DA2.name,
},
{
key: 'c',
blobId: 'db7d35b96291b823e674bf23ac59e6e116b1890e',
key: C1.key,
blobId: HC1.hash,
name: '',
deleted: true,
},
{
key: 'b',
blobId: B1.blob,
blobContent: '{"id":"b","v":1}',
name: '',
key: B1.key,
blobId: HB1.hash,
blobContent: HB1.content,
name: DB1.name,
added: true,
},
]);
@@ -548,26 +554,6 @@ describe('util', () => {
});
describe('updateStateWithConflictResolutions()', () => {
const A1 = {
key: 'a',
blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c',
name: '',
};
const A2 = {
key: 'a',
blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5',
name: '',
};
const B1 = {
key: 'b',
blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01',
name: '',
};
const C1 = {
key: 'c',
blob: 'db7d35b96291b823e674bf23ac59e6e116b1890e',
name: '',
};
const D1 = {
key: 'd',
blob: 'f23ac59e6e11db7d35b96291b823e674b6b1890e',
@@ -587,93 +573,40 @@ describe('util', () => {
it('does it', () => {
const state = [A1, B1, E1, F1];
const conflicts = [
{
key: A1.key,
name: A1.name,
mineBlob: A1.blob,
theirsBlob: A2.blob,
choose: A1.blob,
},
{
key: B1.key,
name: B1.name,
mineBlob: B1.blob,
theirsBlob: null,
choose: null,
},
{
key: C1.key,
name: C1.name,
mineBlob: null,
theirsBlob: C1.blob,
choose: C1.blob,
},
{
key: D1.key,
name: D1.name,
mineBlob: D1.blob,
theirsBlob: null,
choose: null,
},
mergeConflictBuilder.reset()
.key(A1.key)
.name(A1.name)
.mineBlob(A1.blob)
.theirsBlob(A2.blob)
.choose(A1.blob)
.build(),
mergeConflictBuilder.reset()
.key(B1.key)
.name(B1.name)
.mineBlob(B1.blob)
.theirsBlob(null)
.choose(null)
.build(),
mergeConflictBuilder.reset()
.key(C1.key)
.name(C1.name)
.mineBlob(null)
.theirsBlob(C1.blob)
.choose(C1.blob)
.build(),
mergeConflictBuilder.reset()
.key(D1.key)
.name(D1.name)
.mineBlob(D1.blob)
.theirsBlob(null)
.choose(null)
.build(),
];
expect(updateStateWithConflictResolutions(state, conflicts)).toEqual([A1, E1, F1, C1]);
});
});
describe('preMergeCheck()', () => {
const A1 = {
key: 'a',
blob: '1932cc749f57a77ba4a231d62559c47ee931ee2c',
name: '',
};
const A2 = {
key: 'a',
blob: 'cf2ec18c2e686b4c966725f30050120d42d4b2e5',
name: '',
};
const B1 = {
key: 'b',
blob: '05ab2ec03c7e32eff27305b4a11c573a3843fa01',
name: '',
};
const B2 = {
key: 'b',
blob: 'eb157a78ebd367a004c5a32a18d6d8c18e60ce00',
name: '',
};
const DA1 = {
key: 'a',
document: {
id: 'a',
v: 1,
},
name: '',
};
const DA2 = {
key: 'a',
document: {
id: 'a',
v: 2,
},
name: '',
};
const DB1 = {
key: 'b',
document: {
id: 'b',
v: 1,
},
name: '',
};
const DC1 = {
key: 'c',
document: {
id: 'c',
v: 1,
},
name: '',
};
it('no changes', () => {
const trunk = [A1];
const other = [A1];
@@ -885,14 +818,14 @@ describe('util', () => {
});
});
describe('hashDocument()', () => {
describe('hash()', () => {
it('ignore object key order', () => {
const result1 = hashDocument({
const result1 = hash({
c: 'c',
a: 'a',
b: 'b',
});
const result2 = hashDocument({
const result2 = hash({
a: 'a',
b: 'b',
c: 'c',
@@ -900,12 +833,12 @@ describe('util', () => {
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).toBe('fed03259e8027e1fab2e2e57b402671bef7f3eb9');
expect(result2.hash).toBe('fed03259e8027e1fab2e2e57b402671bef7f3eb9');
expect(result1.content.toString('utf8')).toBe('{"a":"a","b":"b","c":"c"}');
expect(result2.content.toString('utf8')).toBe('{"a":"a","b":"b","c":"c"}');
expect(result1.content).toBe('{"a":"a","b":"b","c":"c"}');
expect(result2.content).toBe('{"a":"a","b":"b","c":"c"}');
});
it('works on recursive things', () => {
const result1 = hashDocument({
const result1 = hash({
arr: [
{
b: 'b',
@@ -919,7 +852,7 @@ describe('util', () => {
},
},
});
const result2 = hashDocument({
const result2 = hash({
arr: [
{
b: 'b',
@@ -936,79 +869,101 @@ describe('util', () => {
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).toBe('7eaaa8a03bada54b403aeada681aad6892a28ab3');
expect(result2.hash).toBe('7eaaa8a03bada54b403aeada681aad6892a28ab3');
expect(result1.content.toString('utf8')).toBe(
expect(result1.content).toBe(
'{"arr":[{"a":"a","b":"b"}],"obj":{"obj2":{"a":"a","b":"b"}}}',
);
expect(result2.content.toString('utf8')).toBe(
expect(result2.content).toBe(
'{"arr":[{"a":"a","b":"b"}],"obj":{"obj2":{"a":"a","b":"b"}}}',
);
});
it('array order matters', () => {
const result1 = hashDocument(['c', 'a', 'b']);
const result2 = hashDocument(['a', 'b', 'c']);
const result1 = hash(['c', 'a', 'b']);
const result2 = hash(['a', 'b', 'c']);
expect(result1.hash).not.toBe(result2.hash);
expect(result1.hash).toBe('edf94aad27c26bdcfe7477e0ed68991cbaedf8d8');
expect(result2.hash).toBe('e13460afb1e68af030bb9bee8344c274494661fa');
expect(result1.content.toString('utf8')).toBe('["c","a","b"]');
expect(result2.content.toString('utf8')).toBe('["a","b","c"]');
expect(result1.content).toBe('["c","a","b"]');
expect(result2.content).toBe('["a","b","c"]');
});
it('works with strange types', () => {
const sDate1 = hashDocument(new Date(1541178019555));
const sDate2 = hashDocument('2018-11-02T17:00:19.555Z');
const sDate1 = hash(new Date(1541178019555));
const sDate2 = hash('2018-11-02T17:00:19.555Z');
expect(sDate1.hash).toBe(sDate2.hash);
expect(sDate1.hash).toBe('ec0a18c47fb39de00204c57b5b492fd408394a01');
expect(sDate2.hash).toBe('ec0a18c47fb39de00204c57b5b492fd408394a01');
expect(sDate1.content.toString('utf8')).toBe('"2018-11-02T17:00:19.555Z"');
expect(sDate2.content.toString('utf8')).toBe('"2018-11-02T17:00:19.555Z"');
const sNull2 = hashDocument('null');
expect(sDate1.content).toBe('"2018-11-02T17:00:19.555Z"');
expect(sDate2.content).toBe('"2018-11-02T17:00:19.555Z"');
const sNull2 = hash('null');
expect(sNull2.hash).toBe('8c1030365643f1f4b7f00e3d88c0a3c555522b60');
expect(sNull2.content.toString('utf8')).toBe('"null"');
expect(sNull2.content).toBe('"null"');
});
it('skips non-json types', () => {
const sFunc1 = hashDocument({
const sFunc1 = hash({
a: [0, () => null],
fn: () => null,
});
const sFunc2 = hashDocument({
const sFunc2 = hash({
a: [0],
});
expect(sFunc1.hash).toBe(sFunc2.hash);
expect(sFunc1.hash).toBe('38b742facb1034438d82cf1e294d9e71051bf120');
expect(sFunc2.hash).toBe('38b742facb1034438d82cf1e294d9e71051bf120');
expect(sFunc1.content.toString('utf8')).toBe('{"a":[0]}');
expect(sFunc2.content.toString('utf8')).toBe('{"a":[0]}');
expect(sFunc1.content.toString()).toBe('{"a":[0]}');
expect(sFunc2.content.toString()).toBe('{"a":[0]}');
});
it('fails on undefined', () => {
try {
hashDocument(undefined);
hashDocument();
} catch (err) {
return;
}
throw new Error('Expected hashDocument(undefined) to fail');
expect(() => hash(undefined)).toThrowError('Cannot hash undefined value');
expect(() => hash()).toThrowError('Cannot hash undefined value');
});
});
describe('hashDocument()', () => {
it('fails on undefined', () => {
expect(() => hashDocument(undefined)).toThrowError('Cannot hash undefined value');
expect(() => hashDocument()).toThrowError('Cannot hash undefined value');
});
it('ignores object keys that do not matter', () => {
const result1 = hashDocument({
a: 'a',
modified: 456,
});
const result2 = hashDocument({
a: 'a',
modified: 123,
});
const result3 = hashDocument({
a: 'b',
modified: 123,
});
it('ignores global object keys that do not matter', () => {
const result1 = hashDocument(
baseModelBuilder.modified(123).parentId('abc').build(),
);
const result2 = hashDocument(
baseModelBuilder.modified(456).build(),
);
const result3 = hashDocument(
baseModelBuilder.name('abc').modified(456).build(),
);
expect(result1.hash).toBe(result2.hash);
expect(result1.hash).not.toBe(result3.hash);
});
it('shouldnt change the hash of a workspace after a parent id is added and ignored', () => {
// Arrange
// @ts-expect-error parent id should be nullable
const originalSyncedWorkspace = workspaceModelBuilder.parentId(null).build();
// Existing synced workspaces do not have a modified field
// @ts-expect-error delete non-optional
delete originalSyncedWorkspace.modified;
// intentionally hash and not hashDocument, to bypass ignoring logic
const original = hash(originalSyncedWorkspace);
// Act
const withParent = hashDocument(
workspaceModelBuilder.parentId('abc').build(),
);
const unique = hashDocument(
workspaceModelBuilder.name('unique').parentId('abc').build(),
);
// Assert
expect(original.hash).toBe(withParent.hash);
expect(original.hash).not.toBe(unique.hash);
});
});
});
@@ -1032,19 +987,11 @@ function newStateEntry(key, blob) {
};
}
function newCandidate(key, n) {
return {
key,
name: `Candidate ${n}`,
content: `Content for candidate ${key}.${n}`,
};
}
const newCandidate = (key: string, n: number) => statusCandidateBuilder
.reset()
.key(key)
.name(`Candidate ${n}`)
.document(baseModelBuilder.reset().name(`Content for candidate ${key}.${n}`).build())
.build();
function newBranch(snapshots) {
return {
name: '',
created: new Date(0),
modified: new Date(0),
snapshots,
};
}
const newBranch = (snapshots: string[]) => branchBuilder.snapshots(snapshots).build();
@@ -0,0 +1,44 @@
import { BaseModel } from '../../models';
import { isWorkspace } from '../../models/helpers/is-model';
import { Workspace } from '../../models/workspace';
// Key for VCS to delete before computing changes
const DELETE_KEY: keyof BaseModel = 'modified';
type ResetModelKeys<T extends BaseModel> = {
[K in keyof T]?: T[K] | null;
}
// Keys for VCS to reset before computing changes
// We can't always delete keys
// If we delete a key that previously existed (even as null), we then have a different hash because of the deletion
// Therefore, we have to set it back to a default value
const RESET_WORKSPACE_KEYS: ResetModelKeys<Workspace> = {
parentId: null,
};
export const shouldIgnoreKey = <T extends BaseModel>(key: keyof T, doc: T) => {
if (key === DELETE_KEY) {
return true;
}
if (isWorkspace(doc)) {
return key in RESET_WORKSPACE_KEYS;
}
return false;
};
export const deleteKeys = <T extends BaseModel>(doc: T) => {
// @ts-expect-error force delete the key even if it is required
delete doc[DELETE_KEY];
};
export const resetKeys = <T extends BaseModel>(doc: T) => {
if (isWorkspace(doc)) {
Object.keys(RESET_WORKSPACE_KEYS)
.forEach(key => {
doc[key] = RESET_WORKSPACE_KEYS[key];
});
}
};
@@ -0,0 +1,49 @@
import { DEFAULT_BRANCH_NAME } from '../../common/constants';
import { isWorkspace } from '../../models/helpers/is-model';
import { Workspace } from '../../models/workspace';
import { Project } from '../types';
import * as models from '../../models';
import { database } from '../../common/database';
import { VCS } from './vcs';
interface Options {
vcs: VCS;
project: Project;
spaceId?: string;
}
export const pullProject = async ({ vcs, project, spaceId }: Options) => {
// Set project, checkout master, and pull
await vcs.setProject(project);
await vcs.checkout([], DEFAULT_BRANCH_NAME);
const remoteBranches = await vcs.getRemoteBranches();
const defaultBranchMissing = !remoteBranches.includes(DEFAULT_BRANCH_NAME);
const workspaceParentId = spaceId || null;
// The default branch does not exist, so we create it and the workspace locally
if (defaultBranchMissing) {
const workspace: Workspace = await models.initModel(models.workspace.type, {
_id: project.rootDocumentId,
name: project.name,
parentId: workspaceParentId,
});
await database.upsert(workspace);
} else {
await vcs.pull([]); // 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() || [])) {
if (isWorkspace(doc)) {
// @ts-expect-error parent id is optional for workspaces
doc.parentId = workspaceParentId;
}
await database.upsert(doc);
}
await database.flushChanges(flushId);
}
};
+20 -16
View File
@@ -1,5 +1,6 @@
import clone from 'clone';
import crypto from 'crypto';
import { BaseModel } from '../../models';
import { deterministicStringify } from '../lib/deterministicStringify';
import type {
Branch,
@@ -13,8 +14,7 @@ import type {
StatusCandidate,
StatusCandidateMap,
} from '../types';
// Keys for VCS to ignore when computing changes
const IGNORED_KEYS = ['modified'];
import { deleteKeys, resetKeys, shouldIgnoreKey } from './ignore-keys';
export function generateSnapshotStateMap(snapshot: Snapshot | null): SnapshotStateMap {
if (!snapshot) {
@@ -451,24 +451,16 @@ export function preMergeCheck(
};
}
export function hashDocument(
doc: Record<string, any>,
): {
// Intentionally any to handle strange types, see unit tests
export function hash(obj?: any): {
content: string;
hash: string;
} {
if (!doc) {
if (!obj) {
throw new Error('Cannot hash undefined value');
}
// Remove fields we don't care about for sync purposes
const newDoc = clone(doc);
for (const key of IGNORED_KEYS) {
delete newDoc[key];
}
const content = deterministicStringify(newDoc);
const content = deterministicStringify(obj);
const hash = crypto.createHash('sha1').update(content).digest('hex');
return {
hash,
@@ -476,6 +468,18 @@ export function hashDocument(
};
}
export function hashDocument(doc?: BaseModel) {
// Remove fields we don't care about for sync purposes
const newDoc = clone(doc);
if (newDoc) {
deleteKeys(newDoc);
resetKeys(newDoc);
}
return hash(newDoc);
}
export function updateStateWithConflictResolutions(state: SnapshotState, conflicts: MergeConflict[]) {
const newStateMap = generateStateMap(state);
@@ -509,7 +513,7 @@ export function updateStateWithConflictResolutions(state: SnapshotState, conflic
return Object.keys(newStateMap).map(k => newStateMap[k]);
}
export function describeChanges(a: any, b: any): string[] {
export function describeChanges<T extends BaseModel>(a: T, b: T): string[] {
const aT = Object.prototype.toString.call(a);
const bT = Object.prototype.toString.call(b);
@@ -521,7 +525,7 @@ export function describeChanges(a: any, b: any): string[] {
const allKeys = [...Object.keys({ ...a, ...b })];
for (const key of allKeys) {
if (IGNORED_KEYS.includes(key)) {
if (shouldIgnoreKey(key as keyof T, a)) {
continue;
}
+5 -6
View File
@@ -23,6 +23,7 @@ import {
generateCandidateMap,
getRootSnapshot,
getStagable,
hash,
hashDocument,
preMergeCheck,
stateDelta,
@@ -34,6 +35,7 @@ import * as crypt from '../../account/crypt';
import * as session from '../../account/session';
import * as fetch from '../../account/fetch';
import { strings } from '../../common/strings';
import { BaseModel } from '../../models';
const EMPTY_HASH = crypto.createHash('sha1').digest('hex').replace(/./g, '0');
@@ -154,10 +156,7 @@ export class VCS {
return {
stage,
unstaged,
key: hashDocument({
stage,
unstaged,
}).hash,
key: hash({ stage, unstaged }).hash,
};
}
@@ -1541,9 +1540,9 @@ export class VCS {
await this._store.setItem(paths.head(this._projectId()), head);
}
async _getBlob(id: string) {
_getBlob(id: string) {
const p = paths.blob(this._projectId(), id);
return this._store.getItem(p) as Record<string, any> | null;
return this._store.getItem(p) as Promise<BaseModel | null>;
}
async _getBlobs(ids: string[]) {
@@ -1,194 +1,81 @@
import React, { Component } from 'react';
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { AUTOBIND_CFG } from '../../../common/constants';
import * as session from '../../../account/session';
import React, { FC } from 'react';
import { VCS } from '../../../sync/vcs/vcs';
import type { Project } from '../../../sync/types';
import { Dropdown, DropdownDivider, DropdownItem, Button, Tooltip } from 'insomnia-components';
import type { Workspace } from '../../../models/workspace';
import HelpTooltip from '../help-tooltip';
import * as models from '../../../models';
import { database as db } from '../../../common/database';
import { showAlert } from '../modals';
import { strings } from '../../../common/strings';
import { isLoggedIn } from '../../../account/session';
import { useRemoteWorkspaces } from '../../hooks/workspace';
import { useSelector } from 'react-redux';
import { selectActiveSpace } from '../../redux/selectors';
interface Props {
className?: string;
vcs?: VCS | null;
workspaces: Workspace[];
}
interface State {
loading: boolean;
localProjects: Project[];
pullingProjects: Record<string, boolean>;
remoteProjects: Project[];
}
const PullButton: FC<{disabled?: boolean, className?: string}> = ({ disabled, className }) => (
<Button className={className} disabled={disabled}>
Pull
<i className="fa fa-caret-down pad-left-sm" />
</Button>
);
@autoBindMethodsForReact(AUTOBIND_CFG)
class RemoteWorkspacesDropdown extends Component<Props, State> {
state: State = {
loading: false,
localProjects: [],
pullingProjects: {},
remoteProjects: [],
export const RemoteWorkspacesDropdown: FC<Props> = ({ className, vcs }) => {
const {
loading,
refresh,
missingProjects,
pullingProjects,
pull,
} = useRemoteWorkspaces(vcs || undefined);
const isRemoteSpace = Boolean(useSelector(selectActiveSpace)?.remoteId);
// Don't show the pull dropdown if we are not in a remote space
if (!isRemoteSpace) {
return null;
}
async _refreshRemoteWorkspaces() {
const { vcs } = this.props;
if (!vcs) {
return;
}
if (!session.isLoggedIn()) {
return;
}
this.setState({
loading: true,
});
const remoteProjects = await vcs.remoteProjects();
const localProjects = await vcs.localProjects();
this.setState({
remoteProjects,
localProjects,
loading: false,
});
}
async _handlePullRemoteWorkspace(project: Project) {
const { vcs } = this.props;
if (!vcs) {
throw new Error('VCS is not defined');
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: true },
}));
try {
// Clone old VCS so we don't mess anything up while working on other projects
const newVCS = vcs.newInstance();
// Remove all projects for workspace first
await newVCS.removeProjectsForRoot(project.rootDocumentId);
// Set project, checkout master, and pull
const defaultBranch = 'master';
await newVCS.setProject(project);
await newVCS.checkout([], defaultBranch);
const remoteBranches = await newVCS.getRemoteBranches();
const defaultBranchMissing = !remoteBranches.includes(defaultBranch);
// The default branch does not exist, so we create it and the workspace locally
if (defaultBranchMissing) {
const workspace: Workspace = await models.initModel(models.workspace.type, {
_id: project.rootDocumentId,
name: project.name,
});
await db.upsert(workspace);
} else {
await newVCS.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
// @ts-expect-error -- TSCONVERSION
for (const doc of (await newVCS.allDocuments() || [])) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
}
await this._refreshRemoteWorkspaces();
} catch (err) {
// @ts-expect-error -- TSCONVERSION this appears to be a genuine error because there's no _dropdown assigned elsewhere in this class and it doesn't extend another class that has _dropdown and even if it did it's supposed to be private presumably so....
this._dropdown.hide();
showAlert({
title: 'Pull Error',
message: `Failed to pull workspace. ${err.message}`,
});
}
this.setState(state => ({
pullingProjects: { ...state.pullingProjects, [project.id]: false },
}));
}
componentDidUpdate(prevProps: Props) {
// Reload workspaces if we just got a new VCS instance
if (this.props.vcs && !prevProps.vcs) {
this._refreshRemoteWorkspaces();
}
}
componentDidMount() {
this._refreshRemoteWorkspaces();
}
renderButton(disabled = false) {
// Show a disabled button if remote space but not logged in
if (!isLoggedIn()) {
return (
<Button className={this.props.className} disabled={disabled}>
Pull
<i className="fa fa-caret-down pad-left-sm" />
</Button>
<Tooltip message="Please log in to access your remote collections" position="bottom">
<PullButton className={className} disabled />
</Tooltip>
);
}
render() {
const { workspaces } = this.props;
const { loading, remoteProjects, localProjects, pullingProjects } = this.state;
if (!session.isLoggedIn()) {
return (
<Tooltip message="Please log in to access your remote collections" position="bottom">
{this.renderButton(true)}
</Tooltip>
);
}
const missingRemoteProjects = remoteProjects.filter(({ id, rootDocumentId }) => {
const localProjectExists = localProjects.find(p => p.id === id);
const workspaceExists = workspaces.find(w => w._id === rootDocumentId);
// Mark as missing if:
// - the project doesn't yet exists locally
// - the project exists locally but somehow the workspace doesn't anymore
return !(workspaceExists && localProjectExists);
});
const button = this.renderButton();
return (
<Dropdown onOpen={this._refreshRemoteWorkspaces} renderButton={button}>
<DropdownDivider>
Remote {strings.collection.plural}
<HelpTooltip>
These {strings.collection.plural.toLowerCase()} have been shared with you via Insomnia
Sync and do not yet exist on your machine.
</HelpTooltip>{' '}
{loading && <i className="fa fa-spin fa-refresh" />}
</DropdownDivider>
{missingRemoteProjects.length === 0 && (
<DropdownItem disabled>Nothing to pull</DropdownItem>
)}
{missingRemoteProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
onClick={() => this._handlePullRemoteWorkspace(p)}
icon={
pullingProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)
}>
<span>
Pull <strong>{p.name}</strong>
</span>
</DropdownItem>
))}
</Dropdown>
);
}
}
export default RemoteWorkspacesDropdown;
return (
<Dropdown onOpen={refresh} renderButton={<PullButton className={className} />}>
<DropdownDivider>
Remote {strings.collection.plural}
<HelpTooltip>
These {strings.collection.plural.toLowerCase()} have been shared with you via Insomnia
Sync and do not yet exist on your machine.
</HelpTooltip>{' '}
{loading && <i className="fa fa-spin fa-refresh" />}
</DropdownDivider>
{missingProjects.length === 0 && (
<DropdownItem disabled>Nothing to pull</DropdownItem>
)}
{missingProjects.map(p => (
<DropdownItem
key={p.id}
stayOpenAfterClick
value={p}
onClick={pull}
icon={
pullingProjects[p.id] ? (
<i className="fa fa-refresh fa-spin" />
) : (
<i className="fa fa-cloud-download" />
)
}>
<span>
Pull <strong>{p.name}</strong>
</span>
</DropdownItem>
))}
</Dropdown>
);
};
@@ -1,6 +1,6 @@
import React, { Fragment, PureComponent } from 'react';
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { AUTOBIND_CFG } from '../../../common/constants';
import { AUTOBIND_CFG, DEFAULT_BRANCH_NAME } from '../../../common/constants';
import classnames from 'classnames';
import { Dropdown, DropdownButton, DropdownDivider, DropdownItem } from '../base/dropdown';
import type { Workspace } from '../../../models/workspace';
@@ -23,14 +23,16 @@ import { database as db } from '../../../common/database';
import * as models from '../../../models';
import { docsVersionControl } from '../../../common/documentation';
import { strings } from '../../../common/strings';
import { pullProject } from '../../../sync/vcs/pull-project';
import { Space } from '../../../models/space';
// Stop refreshing if user hasn't been active in this long
const REFRESH_USER_ACTIVITY = 1000 * 60 * 10;
// Refresh dropdown periodically
const REFRESH_PERIOD = 1000 * 60 * 1;
const DEFAULT_BRANCH_NAME = 'master';
interface Props {
workspace: Workspace;
space: Space | null;
vcs: VCS;
syncItems: StatusCandidate[];
className?: string;
@@ -286,35 +288,12 @@ class SyncDropdown extends PureComponent<Props, State> {
}
async _handleSetProject(p: Project) {
const { vcs } = this.props;
const { vcs, space } = this.props;
this.setState({
loadingProjectPull: true,
});
await vcs.setProject(p);
await vcs.checkout([], DEFAULT_BRANCH_NAME);
const remoteBranches = await vcs.getRemoteBranches();
const defaultBranchMissing = !remoteBranches.includes(DEFAULT_BRANCH_NAME);
// The default branch does not exist, so we create it and the workspace locally
if (defaultBranchMissing) {
const workspace: Workspace = await models.initModel(models.workspace.type, {
_id: p.rootDocumentId,
name: p.name,
});
await db.upsert(workspace);
} else {
// Pull changes
await vcs.pull([]); // There won't be any existing docs since it's a new pull
const flushId = await db.bufferChanges();
// @ts-expect-error -- TSCONVERSION
for (const doc of await vcs.allDocuments()) {
await db.upsert(doc);
}
await db.flushChanges(flushId);
}
await pullProject({ vcs, project: p, spaceId: space?._id });
await this.refreshMainAttributes({
loadingProjectPull: false,
@@ -13,6 +13,7 @@ import Tooltip from '../tooltip';
import IndeterminateCheckbox from '../base/indeterminate-checkbox';
import { describeChanges } from '../../../sync/vcs/util';
import { strings } from '../../../common/strings';
import { BaseModel } from '../../../models';
interface Props {
workspace: Workspace;
@@ -155,7 +156,7 @@ class SyncStagingModal extends PureComponent<Props, State> {
for (const key of allKeys) {
const item = syncItems.find(si => si.key === key);
const oldDoc: Record<string, any> | null = await vcs.blobFromLastSnapshot(key);
const oldDoc: BaseModel | null = await vcs.blobFromLastSnapshot(key);
const doc = (item && item.document) || oldDoc;
const entry = status.stage[key] || status.unstaged[key];
@@ -58,7 +58,7 @@ interface Props {
class WrapperDebug extends PureComponent<Props> {
_renderPageHeader() {
const { wrapperProps, gitSyncDropdown, handleActivityChange } = this.props;
const { vcs, activeWorkspace, syncItems } = this.props.wrapperProps;
const { vcs, activeWorkspace, activeSpace, syncItems } = this.props.wrapperProps;
const collection = isCollection(activeWorkspace);
const design = isDesign(activeWorkspace);
const share = session.isLoggedIn() && collection && (
@@ -67,7 +67,7 @@ class WrapperDebug extends PureComponent<Props> {
</Button>
);
const betaSync = collection && vcs && isLoggedIn() && (
<SyncDropdown workspace={activeWorkspace} vcs={vcs} syncItems={syncItems} />
<SyncDropdown workspace={activeWorkspace} space={activeSpace} vcs={vcs} syncItems={syncItems} />
);
const gitSync = design && gitSyncDropdown;
const sync = betaSync || gitSync;
@@ -41,7 +41,7 @@ import PageLayout from './page-layout';
import { ForceToWorkspaceKeys } from '../redux/modules/helpers';
import coreLogo from '../images/insomnia-core-logo.png';
import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs';
import RemoteWorkspacesDropdown from './dropdowns/remote-workspaces-dropdown';
import { RemoteWorkspacesDropdown } from './dropdowns/remote-workspaces-dropdown';
import SettingsButton from './buttons/settings-button';
import AccountDropdown from './dropdowns/account-dropdown';
import { strings } from '../../common/strings';
@@ -335,7 +335,7 @@ class WrapperHome extends PureComponent<Props, State> {
}
renderDashboardMenu() {
const { vcs, workspaces } = this.props.wrapperProps;
const { vcs } = this.props.wrapperProps;
return (
<div className="row row--right pad-left wide">
<div
@@ -354,7 +354,7 @@ class WrapperHome extends PureComponent<Props, State> {
<span className="fa fa-search filter-icon" />
</KeydownBinder>
</div>
<RemoteWorkspacesDropdown vcs={vcs} workspaces={workspaces} className="margin-left" />
<RemoteWorkspacesDropdown vcs={vcs} className="margin-left" />
{this.renderCreateMenu()}
</div>
);
@@ -99,6 +99,7 @@ import WrapperAnalytics from './wrapper-analytics';
import { HandleGetRenderContext, HandleRender } from '../../common/render';
import { RequestGroup } from '../../models/request-group';
import SpaceSettingsModal from './modals/space-settings-modal';
import { Space } from '../../models/space';
const spectral = new Spectral();
@@ -173,6 +174,7 @@ export interface WrapperProps {
activeWorkspaceMeta?: WorkspaceMeta;
environments: Environment[];
activeApiSpec: ApiSpec;
activeSpace: Space | null;
activeUnitTestSuite: UnitTestSuite | null;
activeRequestResponses: Response[];
activeWorkspace: Workspace;
@@ -55,6 +55,7 @@ import {
selectActiveRequestMeta,
selectActiveRequestResponses,
selectActiveResponse,
selectActiveSpace,
selectActiveUnitTestResult,
selectActiveUnitTests,
selectActiveUnitTestSuite,
@@ -1686,6 +1687,8 @@ function mapStateToProps(state, props) {
const settings = entitiesLists.settings[0];
// Workspace stuff
// @ts-expect-error -- TSCONVERSION https://github.com/reduxjs/reselect#accessing-react-props-in-selectors
const activeSpace = selectActiveSpace(state, props);
// @ts-expect-error -- TSCONVERSION https://github.com/reduxjs/reselect#accessing-react-props-in-selectors
const workspaces = selectWorkspacesForActiveSpace(state, props);
// @ts-expect-error -- TSCONVERSION https://github.com/reduxjs/reselect#accessing-react-props-in-selectors
@@ -1749,6 +1752,7 @@ function mapStateToProps(state, props) {
const activeUnitTestResult = selectActiveUnitTestResult(state, props);
return Object.assign({}, state, {
activity: activeActivity,
activeSpace,
activeApiSpec,
activeCookieJar,
activeEnvironment,
@@ -28,6 +28,7 @@ export const useRemoteSpaces = (vcs?: VCS) => {
}
}, [vcs]);
// If the refresh callback changes, refresh
useEffect(() => {
(async () => { await refresh(); })();
}, [refresh]);
@@ -0,0 +1,87 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { isLoggedIn } from '../../account/session';
import { Project } from '../../sync/types';
import { VCS } from '../../sync/vcs/vcs';
import { pullProject } from '../../sync/vcs/pull-project';
import { showAlert } from '../components/modals';
import { selectActiveSpace, selectAllWorkspaces } from '../redux/selectors';
export const useRemoteWorkspaces = (vcs?: VCS) => {
// Fetch from redux
const workspaces = useSelector(selectAllWorkspaces);
const activeSpace = useSelector(selectActiveSpace);
const spaceRemoteId = activeSpace?.remoteId || undefined;
const spaceId = activeSpace?._id;
// Local state
const [loading, setLoading] = useState(false);
const [localProjects, setLocalProjects] = useState<Project[]>([]);
const [remoteProjects, setRemoteProjects] = useState<Project[]>([]);
const [pullingProjects, setPullingProjects] = useState<Record<string, boolean>>({});
// Refresh remote spaces
const refresh = useCallback(async () => {
if (!vcs || !isLoggedIn()) {
return;
}
setLoading(true);
const remote = await vcs.remoteProjects(spaceRemoteId);
const local = await vcs.localProjects();
setRemoteProjects(remote);
setLocalProjects(local);
setLoading(false);
},
[spaceRemoteId, vcs]);
// Find remote spaces that haven't been pulled
const missingProjects = useMemo(() => remoteProjects.filter(({ id, rootDocumentId }) => {
const localProjectExists = localProjects.find(p => p.id === id);
const workspaceExists = workspaces.find(w => w._id === rootDocumentId);
// Mark as missing if:
// - the project doesn't yet exists locally
// - the project exists locally but somehow the workspace doesn't anymore
return !(workspaceExists && localProjectExists);
}), [localProjects, remoteProjects, workspaces]);
// Pull a remote space
const pull = useCallback(async (project: Project) => {
if (!vcs) {
throw new Error('VCS is not defined');
}
setPullingProjects(state => ({ ...state, [project.id]: true }));
try {
// Clone old VCS so we don't mess anything up while working on other projects
const newVCS = vcs.newInstance();
// Remove all projects for workspace first
await newVCS.removeProjectsForRoot(project.rootDocumentId);
await pullProject({ vcs: newVCS, project, spaceId });
await refresh();
} catch (err) {
showAlert({
title: 'Pull Error',
message: `Failed to pull workspace. ${err.message}`,
});
} finally {
setPullingProjects(state => ({ ...state, [project.id]: false }));
}
}, [vcs, refresh, spaceId]);
// If the refresh callback changes, refresh
useEffect(() => {
(async () => { await refresh(); })();
}, [refresh]);
return {
loading,
missingProjects,
pullingProjects,
refresh,
pull,
};
};
@@ -1,8 +1,10 @@
import { createSelector } from 'reselect';
import * as models from '../../models';
import { BaseModel } from '../../models';
import { Space } from '../../models/space';
import { UnitTestResult } from '../../models/unit-test-result';
import { Workspace } from '../../models/workspace';
import { StatusCandidate } from '../../sync/types';
// ~~~~~~~~~ //
// Selectors //
@@ -150,7 +152,7 @@ export const selectActiveWorkspaceEntities = createSelector(
selectActiveWorkspace,
selectEntitiesChildrenMap,
(activeWorkspace, childrenMap) => {
const descendants = [activeWorkspace];
const descendants: BaseModel[] = [activeWorkspace];
// @ts-expect-error -- TSCONVERSION
const addChildrenOf = parent => {
@@ -226,6 +228,15 @@ export const selectActiveOAuth2Token = createSelector(
return entities.oAuth2Tokens.find(t => t.parentId === id);
},
);
export const selectAllWorkspaces = createSelector<any, {}, Workspace[]>(
selectEntitiesLists,
entities => {
// @ts-expect-error -- TSCONVERSION
const { workspaces } = entities;
return workspaces;
});
export const selectUnseenWorkspaces = createSelector(selectEntitiesLists, entities => {
// @ts-expect-error -- TSCONVERSION
const { workspaces, workspaceMetas } = entities;
@@ -345,10 +356,15 @@ export const selectActiveUnitTestSuites = createSelector(
return entities.unitTestSuites.filter(s => s.parentId === activeWorkspace._id);
},
);
export const selectSyncItems = createSelector(selectActiveWorkspaceEntities, workspaceEntities =>
workspaceEntities.filter(models.canSync).map(doc => ({
key: doc._id,
name: doc.name || '',
document: doc,
})),
export const selectSyncItems = createSelector(
selectActiveWorkspaceEntities,
workspaceEntities =>
workspaceEntities
.filter(models.canSync)
.map<StatusCandidate>(doc => ({
key: doc._id,
name: doc.name || '',
document: doc,
})),
);
+6 -1
View File
@@ -16,7 +16,12 @@ const { readFile, writeFile } = promises;
// Start build if ran from CLI
if (require.main === module) {
process.nextTick(async () => {
await module.exports.start(false);
try {
await module.exports.start(false);
} catch (err) {
console.log('[build] ERROR:', err);
process.exit(1);
}
});
}