diff --git a/packages/insomnia-app/app/common/constants.ts b/packages/insomnia-app/app/common/constants.ts index 5f03db9b6e..550e8edb29 100644 --- a/packages/insomnia-app/app/common/constants.ts +++ b/packages/insomnia-app/app/common/constants.ts @@ -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'; diff --git a/packages/insomnia-app/app/common/database.ts b/packages/insomnia-app/app/common/database.ts index 5cd1b52f37..ce00da96c9 100644 --- a/packages/insomnia-app/app/common/database.ts +++ b/packages/insomnia-app/app/common/database.ts @@ -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; diff --git a/packages/insomnia-app/app/common/misc.ts b/packages/insomnia-app/app/common/misc.ts index dc5d0b7d80..19f8e9ad4f 100644 --- a/packages/insomnia-app/app/common/misc.ts +++ b/packages/insomnia-app/app/common/misc.ts @@ -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; } diff --git a/packages/insomnia-app/app/models/__schemas__/model-schemas.ts b/packages/insomnia-app/app/models/__schemas__/model-schemas.ts new file mode 100644 index 0000000000..fcc0253e28 --- /dev/null +++ b/packages/insomnia-app/app/models/__schemas__/model-schemas.ts @@ -0,0 +1,21 @@ +import { Schema } from '@develohpanda/fluent-builder'; +import { BaseModel, workspace } from '..'; +import { Workspace } from '../workspace'; + +export const baseModelSchema: Schema = { + _id: () => 'id', + created: () => 1234, + isPrivate: () => false, + modified: () => 5678, + name: () => 'name', + parentId: () => '', + type: () => 'base', +}; + +export const workspaceModelSchema: Schema = { + ...baseModelSchema, + type: () => workspace.type, + description: () => '', + certificates: () => undefined, + scope: () => 'collection', +}; diff --git a/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts b/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts new file mode 100644 index 0000000000..38b774d601 --- /dev/null +++ b/packages/insomnia-app/app/sync/__schemas__/type-schemas.ts @@ -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 = { + id: () => 'id', + rootDocumentId: () => 'rootDocumentId', + name: () => 'name', +}; + +export const branchSchema: Schema = { + created: () => new Date(0), + modified: () => new Date(0), + name: () => '', + snapshots: () => [], +}; + +export const mergeConflictSchema: Schema = { + key: () => 'key', + choose: () => null, + mineBlob: () => null, + theirsBlob: () => null, + message: () => 'message', + name: () => 'name', +}; + +export const statusCandidateSchema: Schema = { + key: () => 'key', + name: () => 'name', + document: () => createBuilder(baseModelSchema).build(), +}; diff --git a/packages/insomnia-app/app/sync/types.ts b/packages/insomnia-app/app/sync/types.ts index c27276846d..dfdc01709e 100644 --- a/packages/insomnia-app/app/sync/types.ts +++ b/packages/insomnia-app/app/sync/types.ts @@ -1,3 +1,4 @@ +import { BaseModel } from '../models'; export interface Team { id: string; @@ -92,7 +93,7 @@ export type Stage = Record; export interface StatusCandidate { key: DocumentKey; name: string; - document: Record; + document: BaseModel; } export type StatusCandidateMap = Record; diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/ignore-keys.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/ignore-keys.test.ts new file mode 100644 index 0000000000..a4e09611f5 --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/__tests__/ignore-keys.test.ts @@ -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(); + }); +}); diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts new file mode 100644 index 0000000000..cfa488d53f --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/__tests__/pull-project.test.ts @@ -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).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>({ + _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>({ + _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>({ + _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>({ + _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>({ + _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); + }); + }); +}); diff --git a/packages/insomnia-app/app/sync/vcs/__tests__/util.test.ts b/packages/insomnia-app/app/sync/vcs/__tests__/util.test.ts index 3e7056f925..2ff322a00b 100644 --- a/packages/insomnia-app/app/sync/vcs/__tests__/util.test.ts +++ b/packages/insomnia-app/app/sync/vcs/__tests__/util.test.ts @@ -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([ { - 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(); diff --git a/packages/insomnia-app/app/sync/vcs/ignore-keys.ts b/packages/insomnia-app/app/sync/vcs/ignore-keys.ts new file mode 100644 index 0000000000..a280b9fd30 --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/ignore-keys.ts @@ -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 = { + [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 = { + parentId: null, +}; + +export const shouldIgnoreKey = (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 = (doc: T) => { + // @ts-expect-error force delete the key even if it is required + delete doc[DELETE_KEY]; +}; + +export const resetKeys = (doc: T) => { + if (isWorkspace(doc)) { + Object.keys(RESET_WORKSPACE_KEYS) + .forEach(key => { + doc[key] = RESET_WORKSPACE_KEYS[key]; + }); + } +}; diff --git a/packages/insomnia-app/app/sync/vcs/pull-project.ts b/packages/insomnia-app/app/sync/vcs/pull-project.ts new file mode 100644 index 0000000000..ba117a871b --- /dev/null +++ b/packages/insomnia-app/app/sync/vcs/pull-project.ts @@ -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); + } +}; diff --git a/packages/insomnia-app/app/sync/vcs/util.ts b/packages/insomnia-app/app/sync/vcs/util.ts index d955b0acd6..48ba4eb2af 100644 --- a/packages/insomnia-app/app/sync/vcs/util.ts +++ b/packages/insomnia-app/app/sync/vcs/util.ts @@ -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, -): { +// 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(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; } diff --git a/packages/insomnia-app/app/sync/vcs/vcs.ts b/packages/insomnia-app/app/sync/vcs/vcs.ts index 2b09ea4cfd..ef14a21493 100644 --- a/packages/insomnia-app/app/sync/vcs/vcs.ts +++ b/packages/insomnia-app/app/sync/vcs/vcs.ts @@ -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 | null; + return this._store.getItem(p) as Promise; } async _getBlobs(ids: string[]) { diff --git a/packages/insomnia-app/app/ui/components/dropdowns/remote-workspaces-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/remote-workspaces-dropdown.tsx index 832c6b4b8f..a56a8ba407 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/remote-workspaces-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/dropdowns/remote-workspaces-dropdown.tsx @@ -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; - remoteProjects: Project[]; -} +const PullButton: FC<{disabled?: boolean, className?: string}> = ({ disabled, className }) => ( + +); -@autoBindMethodsForReact(AUTOBIND_CFG) -class RemoteWorkspacesDropdown extends Component { - state: State = { - loading: false, - localProjects: [], - pullingProjects: {}, - remoteProjects: [], +export const RemoteWorkspacesDropdown: FC = ({ 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 ( - + + + ); } - render() { - const { workspaces } = this.props; - const { loading, remoteProjects, localProjects, pullingProjects } = this.state; - - if (!session.isLoggedIn()) { - return ( - - {this.renderButton(true)} - - ); - } - - 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 ( - - - Remote {strings.collection.plural} - - These {strings.collection.plural.toLowerCase()} have been shared with you via Insomnia - Sync and do not yet exist on your machine. - {' '} - {loading && } - - {missingRemoteProjects.length === 0 && ( - Nothing to pull - )} - {missingRemoteProjects.map(p => ( - this._handlePullRemoteWorkspace(p)} - icon={ - pullingProjects[p.id] ? ( - - ) : ( - - ) - }> - - Pull {p.name} - - - ))} - - ); - } -} - -export default RemoteWorkspacesDropdown; + return ( + }> + + Remote {strings.collection.plural} + + These {strings.collection.plural.toLowerCase()} have been shared with you via Insomnia + Sync and do not yet exist on your machine. + {' '} + {loading && } + + {missingProjects.length === 0 && ( + Nothing to pull + )} + {missingProjects.map(p => ( + + ) : ( + + ) + }> + + Pull {p.name} + + + ))} + + ); +}; diff --git a/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx b/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx index 9f1960c887..4dab393f4d 100644 --- a/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx +++ b/packages/insomnia-app/app/ui/components/dropdowns/sync-dropdown.tsx @@ -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 { } 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, diff --git a/packages/insomnia-app/app/ui/components/modals/sync-staging-modal.tsx b/packages/insomnia-app/app/ui/components/modals/sync-staging-modal.tsx index a137d9b110..1398e31d03 100644 --- a/packages/insomnia-app/app/ui/components/modals/sync-staging-modal.tsx +++ b/packages/insomnia-app/app/ui/components/modals/sync-staging-modal.tsx @@ -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 { for (const key of allKeys) { const item = syncItems.find(si => si.key === key); - const oldDoc: Record | 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]; diff --git a/packages/insomnia-app/app/ui/components/wrapper-debug.tsx b/packages/insomnia-app/app/ui/components/wrapper-debug.tsx index f8103b32fb..6f640a2718 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-debug.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-debug.tsx @@ -58,7 +58,7 @@ interface Props { class WrapperDebug extends PureComponent { _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 { ); const betaSync = collection && vcs && isLoggedIn() && ( - + ); const gitSync = design && gitSyncDropdown; const sync = betaSync || gitSync; diff --git a/packages/insomnia-app/app/ui/components/wrapper-home.tsx b/packages/insomnia-app/app/ui/components/wrapper-home.tsx index 25598ef626..6126dbab83 100644 --- a/packages/insomnia-app/app/ui/components/wrapper-home.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper-home.tsx @@ -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 { } renderDashboardMenu() { - const { vcs, workspaces } = this.props.wrapperProps; + const { vcs } = this.props.wrapperProps; return (
{
- + {this.renderCreateMenu()}
); diff --git a/packages/insomnia-app/app/ui/components/wrapper.tsx b/packages/insomnia-app/app/ui/components/wrapper.tsx index b838a1ecd6..ff3b788e85 100644 --- a/packages/insomnia-app/app/ui/components/wrapper.tsx +++ b/packages/insomnia-app/app/ui/components/wrapper.tsx @@ -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; diff --git a/packages/insomnia-app/app/ui/containers/app.tsx b/packages/insomnia-app/app/ui/containers/app.tsx index 01b7d468be..ea1e246ea4 100644 --- a/packages/insomnia-app/app/ui/containers/app.tsx +++ b/packages/insomnia-app/app/ui/containers/app.tsx @@ -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, diff --git a/packages/insomnia-app/app/ui/hooks/space.ts b/packages/insomnia-app/app/ui/hooks/space.ts index 352b32c87e..41051a4800 100644 --- a/packages/insomnia-app/app/ui/hooks/space.ts +++ b/packages/insomnia-app/app/ui/hooks/space.ts @@ -28,6 +28,7 @@ export const useRemoteSpaces = (vcs?: VCS) => { } }, [vcs]); + // If the refresh callback changes, refresh useEffect(() => { (async () => { await refresh(); })(); }, [refresh]); diff --git a/packages/insomnia-app/app/ui/hooks/workspace.ts b/packages/insomnia-app/app/ui/hooks/workspace.ts new file mode 100644 index 0000000000..de79963530 --- /dev/null +++ b/packages/insomnia-app/app/ui/hooks/workspace.ts @@ -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([]); + const [remoteProjects, setRemoteProjects] = useState([]); + const [pullingProjects, setPullingProjects] = useState>({}); + + // 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, + }; +}; diff --git a/packages/insomnia-app/app/ui/redux/selectors.ts b/packages/insomnia-app/app/ui/redux/selectors.ts index 7443b06225..f97e6f21ba 100644 --- a/packages/insomnia-app/app/ui/redux/selectors.ts +++ b/packages/insomnia-app/app/ui/redux/selectors.ts @@ -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( + 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(doc => ({ + key: doc._id, + name: doc.name || '', + document: doc, + })), ); diff --git a/packages/insomnia-app/scripts/build.ts b/packages/insomnia-app/scripts/build.ts index 91004448be..c24d5ff7fb 100644 --- a/packages/insomnia-app/scripts/build.ts +++ b/packages/insomnia-app/scripts/build.ts @@ -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); + } }); }