feat: local filesystem collections (resolves #22)

This commit is contained in:
Anoop M D 2022-10-15 20:14:43 +05:30
parent 91981a48e4
commit 44aa019754
25 changed files with 18800 additions and 12385 deletions

View File

@ -5,6 +5,9 @@ npm i
# run next app # run next app
npm run dev --workspace=packages/bruno-app npm run dev --workspace=packages/bruno-app
# run electron app
npm run dev --workspace=packages/bruno-electron
``` ```
# testing # testing

19449
package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,9 @@
"immer": "^9.0.12", "immer": "^9.0.12",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"nanoid": "^3.1.30", "nanoid": "3.3.4",
"next": "^12.1.0", "next": "^12.1.0",
"path": "^0.12.7",
"qs": "^6.11.0", "qs": "^6.11.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",

View File

@ -1,13 +1,14 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import useIdb from './useIdb'; import useIdb from './useIdb';
import useLocalCollectionTreeSync from './useLocalCollectionTreeSync';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
export const AppContext = React.createContext(); export const AppContext = React.createContext();
export const AppProvider = props => { export const AppProvider = props => {
// boot idb
useIdb(); useIdb();
useLocalCollectionTreeSync();
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
removeCollectionEvent,
localCollectionAddDirectoryEvent,
localCollectionAddFileEvent,
localCollectionChangeFileEvent,
localCollectionUnlinkFileEvent,
localCollectionUnlinkDirectoryEvent
} from 'providers/ReduxStore/slices/collections';
import {
openLocalCollectionEvent
} from 'providers/ReduxStore/slices/collections/actions';
import { isElectron } from 'utils/common/platform';
const useLocalCollectionTreeSync = () => {
const dispatch = useDispatch();
useEffect(() => {
if(!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const _openCollection = (pathname, uid) => {
console.log(`collection uid: ${uid}, pathname: ${pathname}`);
dispatch(openLocalCollectionEvent(uid, pathname));
};
const _collectionTreeUpdated = (type, val) => {
if(type === 'addDir') {
dispatch(localCollectionAddDirectoryEvent({
dir: val
}));
}
if(type === 'addFile') {
dispatch(localCollectionAddFileEvent({
file: val
}));
}
if(type === 'change') {
dispatch(localCollectionChangeFileEvent({
file: val
}));
}
if(type === 'unlink') {
setTimeout(() => {
dispatch(localCollectionUnlinkFileEvent({
file: val
}));
}, 100);
}
if(type === 'unlinkDir') {
dispatch(localCollectionUnlinkDirectoryEvent({
directory: val
}));
}
};
const _collectionRemoved = (pathname) => {
// dispatch(removeCollectionEvent({
// pathname
// }));
};
const removeListener1 = ipcRenderer.on('main:collection-opened', _openCollection);
const removeListener2 = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
const removeListener3 = ipcRenderer.on('main:collection-removed', _collectionRemoved);
return () => {
removeListener1();
removeListener2();
removeListener3();
};
}, [isElectron]);
};
export default useLocalCollectionTreeSync;

View File

@ -1,5 +1,7 @@
import path from 'path';
import axios from 'axios'; import axios from 'axios';
import each from 'lodash/each'; import each from 'lodash/each';
import trim from 'lodash/trim';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
@ -8,11 +10,13 @@ import {
findCollectionByUid, findCollectionByUid,
recursivelyGetAllItemUids, recursivelyGetAllItemUids,
transformCollectionToSaveToIdb, transformCollectionToSaveToIdb,
transformRequestToSaveToFilesystem,
deleteItemInCollection, deleteItemInCollection,
findParentItemInCollection, findParentItemInCollection,
isItemAFolder isItemAFolder,
refreshUidsInItem
} from 'utils/collections'; } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema'; import { collectionSchema, itemSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common'; import { waitForNextTick } from 'utils/common';
import cancelTokens, { saveCancelToken, deleteCancelToken } from 'utils/network/cancelTokens'; import cancelTokens, { saveCancelToken, deleteCancelToken } from 'utils/network/cancelTokens';
import { getCollectionsFromIdb, saveCollectionToIdb, deleteCollectionInIdb } from 'utils/idb'; import { getCollectionsFromIdb, saveCollectionToIdb, deleteCollectionInIdb } from 'utils/idb';
@ -35,6 +39,9 @@ import {
import { closeTabs, addTab } from 'providers/ReduxStore/slices/tabs'; import { closeTabs, addTab } from 'providers/ReduxStore/slices/tabs';
import { addCollectionToWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; import { addCollectionToWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { isLocalCollection, resolveRequestFilename } from 'utils/common/platform';
const PATH_SEPARATOR = path.sep;
export const loadCollectionsFromIdb = () => (dispatch) => { export const loadCollectionsFromIdb = () => (dispatch) => {
getCollectionsFromIdb(window.__idb) getCollectionsFromIdb(window.__idb)
@ -44,6 +51,28 @@ export const loadCollectionsFromIdb = () => (dispatch) => {
.catch(() => toast.error("Error occured while loading collections from IndexedDB")); .catch(() => toast.error("Error occured while loading collections from IndexedDB"));
}; };
export const openLocalCollectionEvent = (uid, pathname) => (dispatch, getState) => {
const localCollection = {
uid: uid,
name: path.basename(pathname),
pathname: pathname,
items: []
};
const state = getState();
const { activeWorkspaceUid } = state.workspaces;
return new Promise((resolve, reject) => {
collectionSchema
.validate(localCollection)
.then(() => dispatch(_createCollection(localCollection)))
.then(waitForNextTick)
.then(() => dispatch(addCollectionToWorkspace(activeWorkspaceUid, localCollection.uid)))
.then(resolve)
.catch(reject);
});
};
export const createCollection = (collectionName) => (dispatch, getState) => { export const createCollection = (collectionName) => (dispatch, getState) => {
const newCollection = { const newCollection = {
uid: uuid(), uid: uuid(),
@ -148,6 +177,24 @@ export const saveRequest = (itemUid, collectionUid) => (dispatch, getState) => {
if(!collection) { if(!collection) {
return reject(new Error('Collection not found')); return reject(new Error('Collection not found'));
} }
if(isLocalCollection(collection)) {
const item = findItemInCollection(collection, itemUid);
if(item) {
const itemToSave = transformRequestToSaveToFilesystem(item);
const { ipcRenderer } = window;
itemSchema
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:save-request', item.pathname, itemToSave))
.then(resolve)
.catch(reject);
} else {
reject(new Error("Not able to locate item"));
}
return;
}
const collectionCopy = cloneDeep(collection); const collectionCopy = cloneDeep(collection);
const collectionToSave = transformCollectionToSaveToIdb(collectionCopy); const collectionToSave = transformCollectionToSaveToIdb(collectionCopy);
@ -208,6 +255,43 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if(!collection) { if(!collection) {
return reject(new Error('Collection not found')); return reject(new Error('Collection not found'));
} }
if(isLocalCollection(collection)) {
if(!itemUid) {
const folderWithSameNameExists = find(collection.items, (i) => i.type === 'folder' && trim(i.name) === trim(folderName));
if(!folderWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`;
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
return reject(new Error("folder with same name already exists"));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
if(currentItem) {
const folderWithSameNameExists = find(currentItem.items, (i) => i.type === 'folder' && trim(i.name) === trim(folderName));
if(!folderWithSameNameExists) {
const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${folderName}`;
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
return reject(new Error("folder with same name already exists"));
}
} else {
return reject(new Error("unable to find parent folder"));
}
}
return;
}
const collectionCopy = cloneDeep(collection); const collectionCopy = cloneDeep(collection);
const item = { const item = {
uid: uuid(), uid: uuid(),
@ -250,8 +334,32 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
return reject(new Error('Collection not found')); return reject(new Error('Collection not found'));
} }
const item = findItemInCollection(collection, itemUid);
if(!item) {
return reject(new Error("Unable to locate item"));
}
if(isLocalCollection(collection)) {
const dirname = path.dirname(item.pathname);
let newPathname = '';
if(item.type === 'folder') {
newPathname = `${dirname}${PATH_SEPARATOR}${trim(newName)}`;
} else {
const filename = resolveRequestFilename(newName);
newPathname = `${dirname}${PATH_SEPARATOR}${filename}`;
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:rename-item', item.pathname, newPathname, newName)
.then(resolve)
.catch(reject);
return;
}
const collectionCopy = cloneDeep(collection); const collectionCopy = cloneDeep(collection);
const item = findItemInCollection(collectionCopy, itemUid);
if(item) { if(item) {
item.name = newName; item.name = newName;
} }
@ -280,18 +388,56 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if(!collection) { if(!collection) {
return reject(new Error('Collection not found')); throw new Error('Collection not found');
} }
const collectionCopy = cloneDeep(collection); const collectionCopy = cloneDeep(collection);
const item = findItemInCollection(collectionCopy, itemUid); const item = findItemInCollection(collectionCopy, itemUid);
if(!item) { if(!item) {
return; throw new Error('Unable to locate item');
} }
if(isItemAFolder(item)) { if(isItemAFolder(item)) {
throw new Error('Cloning folders is not supported yet'); throw new Error('Cloning folders is not supported yet');
} }
if(isLocalCollection(collection)) {
const parentItem = findParentItemInCollection(collectionCopy, itemUid);
const filename = resolveRequestFilename(newName);
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item));
itemToSave.name = trim(newName);
if(!parentItem) {
const reqWithSameNameExists = find(collection.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename));
if(!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
itemSchema
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in collection`));
}
} else {
const reqWithSameNameExists = find(parentItem.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename));
if(!reqWithSameNameExists) {
const dirname = path.dirname(item.pathname);
const fullName = `${dirname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
itemSchema
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in the folder`));
}
}
return;
};
// todo: clone query params // todo: clone query params
const clonedItem = cloneDeep(item); const clonedItem = cloneDeep(item);
clonedItem.name = newName; clonedItem.name = newName;
@ -328,23 +474,38 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if(collection) { if(!collection) {
const collectionCopy = cloneDeep(collection); return reject(new Error('Collection not found'));
deleteItemInCollection(itemUid, collectionCopy);
const collectionToSave = transformCollectionToSaveToIdb(collectionCopy);
collectionSchema
.validate(collectionToSave)
.then(() => saveCollectionToIdb(window.__idb, collectionToSave))
.then(() => {
dispatch(_deleteItem({
itemUid: itemUid,
collectionUid: collectionUid
}));
})
.then(() => resolve())
.catch((error) => reject(error));
} }
if(isLocalCollection(collection)) {
const item = findItemInCollection(collection, itemUid);
if(item) {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type)
.then(() => resolve())
.catch((error) => reject(error));
}
return;
}
const collectionCopy = cloneDeep(collection);
deleteItemInCollection(itemUid, collectionCopy);
const collectionToSave = transformCollectionToSaveToIdb(collectionCopy);
collectionSchema
.validate(collectionToSave)
.then(() => saveCollectionToIdb(window.__idb, collectionToSave))
.then(() => {
dispatch(_deleteItem({
itemUid: itemUid,
collectionUid: collectionUid
}));
})
.then(() => resolve())
.catch((error) => reject(error));
}); });
}; };
@ -384,6 +545,42 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
} }
} }
}; };
if(isLocalCollection(collection)) {
const filename = resolveRequestFilename(requestName);
if(!itemUid) {
const reqWithSameNameExists = find(collection.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename));
if(!reqWithSameNameExists) {
const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-request', fullName, item)
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in collection`));
}
} else {
const currentItem = findItemInCollection(collection, itemUid);
if(currentItem) {
const reqWithSameNameExists = find(currentItem.items, (i) => i.type !== 'folder' && trim(i.filename) === trim(filename));
if(!reqWithSameNameExists) {
const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-request', fullName, item)
.then(resolve)
.catch(reject);
} else {
return reject(new Error(`${requestName} already exists in the folder`));
}
}
}
return;
};
if(!itemUid) { if(!itemUid) {
collectionCopy.items.push(item); collectionCopy.items.push(item);
} else { } else {

View File

@ -1,3 +1,4 @@
import path from 'path';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import find from 'lodash/find'; import find from 'lodash/find';
import concat from 'lodash/concat'; import concat from 'lodash/concat';
@ -9,14 +10,16 @@ import splitOnFirst from 'split-on-first';
import { import {
findCollectionByUid, findCollectionByUid,
findItemInCollection, findItemInCollection,
findItemInCollectionByPathname,
addDepth, addDepth,
collapseCollection, collapseCollection,
deleteItemInCollection, deleteItemInCollection,
isItemARequest isItemARequest
} from 'utils/collections'; } from 'utils/collections';
import { parseQueryParams, stringifyQueryParams } from 'utils/url'; import { parseQueryParams, stringifyQueryParams } from 'utils/url';
import { getSubdirectoriesFromRoot } from 'utils/common/platform';
// todo: errors should be tracked in each slice and displayed as toasts const PATH_SEPARATOR = path.sep;
const initialState = { const initialState = {
collections: [] collections: []
@ -538,6 +541,131 @@ export const collectionsSlice = createSlice({
item.draft.request.method = action.payload.method; item.draft.request.method = action.payload.method;
} }
} }
},
localCollectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if(collection) {
const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
let childItem = currentSubItems.find(f => f.type === 'folder' && f.name === directoryName)
if (!childItem) {
childItem = {
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
name: directoryName,
collapsed: false,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
if (!currentSubItems.find(f => f.name === file.meta.name)) {
// this happens when you rename a file
// the add event might get triggered first, before the unlink event
// this results in duplicate uids causing react renderer to go mad
const currentItem = find(currentSubItems, (i) => i.uid === file.data.uid);
if(currentItem) {
currentItem.name = file.data.name;
currentItem.type = file.data.type;
currentItem.request = file.data.request;
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.draft = null;
} else {
currentSubItems.push({
uid: file.data.uid,
name: file.data.name,
type: file.data.type,
request: file.data.request,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null
});
}
}
addDepth(collection.items);
// sortItems(collection);
}
},
localCollectionAddDirectoryEvent: (state, action) => {
const { dir } = action.payload;
const collection = findCollectionByUid(state.collections, dir.meta.collectionUid);
if(collection) {
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
let childItem = currentSubItems.find(f => f.type === 'folder' && f.name === directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
name: directoryName,
collapsed: false,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
addDepth(collection.items);
// sortItems(collection);
}
},
localCollectionChangeFileEvent: (state, action) => {
const { file } = action.payload;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if(collection) {
const item = findItemInCollection(collection, file.data.uid);
if(item) {
item.name = file.data.name;
item.type = file.data.type;
item.request = file.data.request;
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.draft = null;
}
}
},
localCollectionUnlinkFileEvent: (state, action) => {
const { file } = action.payload;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
if(collection) {
const item = findItemInCollectionByPathname(collection, file.meta.pathname);
if(item) {
deleteItemInCollection(item.uid, collection);
}
}
},
localCollectionUnlinkDirectoryEvent: (state, action) => {
const { directory } = action.payload;
const collection = findCollectionByUid(state.collections, directory.meta.collectionUid);
if(collection) {
const item = findItemInCollectionByPathname(collection, directory.meta.pathname);
if(item) {
deleteItemInCollection(item.uid, collection);
}
}
} }
} }
}); });
@ -574,7 +702,12 @@ export const {
deleteMultipartFormParam, deleteMultipartFormParam,
updateRequestBodyMode, updateRequestBodyMode,
updateRequestBody, updateRequestBody,
updateRequestMethod updateRequestMethod,
localCollectionAddFileEvent,
localCollectionAddDirectoryEvent,
localCollectionChangeFileEvent,
localCollectionUnlinkFileEvent,
localCollectionUnlinkDirectoryEvent
} = collectionsSlice.actions; } = collectionsSlice.actions;
export default collectionsSlice.reducer; export default collectionsSlice.reducer;

View File

@ -134,9 +134,9 @@ export const addCollectionToWorkspace = (workspaceUid, collectionUid) => (dispat
const workspaceCopy = cloneDeep(workspace); const workspaceCopy = cloneDeep(workspace);
if(workspaceCopy.collections && workspace.collections.length) { if(workspaceCopy.collections && workspace.collections.length) {
if(!findCollectionInWorkspace(workspace, collectionUid)) { if(!findCollectionInWorkspace(workspace, collectionUid)) {
workspaceCopy.collections.push([{ workspaceCopy.collections.push({
uid: collectionUid uid: collectionUid
}]); });
} }
} else { } else {
workspaceCopy.collections = [{ workspaceCopy.collections = [{

View File

@ -46,9 +46,9 @@ export const workspacesSlice = createSlice({
if(workspace) { if(workspace) {
if(workspace.collections && workspace.collections.length) { if(workspace.collections && workspace.collections.length) {
if(!findCollectionInWorkspace(workspace, collectionUid)) { if(!findCollectionInWorkspace(workspace, collectionUid)) {
workspace.collections.push([{ workspace.collections.push({
uid: collectionUid uid: collectionUid
}]); });
} }
} else { } else {
workspace.collections = [{ workspace.collections = [{

View File

@ -48,10 +48,10 @@ const updateUidsInCollection = (collection) => {
each(items, (item) => { each(items, (item) => {
item.uid = uuid(); item.uid = uuid();
each(get(item, 'headers'), (header) => header.uid = uuid()); each(get(item, 'request.headers'), (header) => header.uid = uuid());
each(get(item, 'params'), (param) => param.uid = uuid()); each(get(item, 'request.params'), (param) => param.uid = uuid());
each(get(item, 'body.multipartForm'), (param) => param.uid = uuid()); each(get(item, 'request.body.multipartForm'), (param) => param.uid = uuid());
each(get(item, 'body.formUrlEncoded'), (param) => param.uid = uuid()); each(get(item, 'request.body.formUrlEncoded'), (param) => param.uid = uuid());
if(item.items && item.items.length) { if(item.items && item.items.length) {
updateItemUids(item.items); updateItemUids(item.items);

View File

@ -1,9 +1,11 @@
import get from 'lodash/get';
import each from 'lodash/each'; import each from 'lodash/each';
import find from 'lodash/find'; import find from 'lodash/find';
import isString from 'lodash/isString'; import isString from 'lodash/isString';
import map from 'lodash/map'; import map from 'lodash/map';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import sortBy from 'lodash/sortBy'; import sortBy from 'lodash/sortBy';
import { uuid } from 'utils/common';
const replaceTabsWithSpaces = (str, numSpaces = 2) => { const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if(!str || !str.length || !isString(str)) { if(!str || !str.length || !isString(str)) {
@ -77,11 +79,20 @@ export const findItem = (items = [], itemUid) => {
return find(items, (i) => i.uid === itemUid); return find(items, (i) => i.uid === itemUid);
}; };
export const findCollectionByUid = (collections, collectionUid) => { export const findCollectionByUid = (collections, collectionUid) => {
return find(collections, (c) => c.uid === collectionUid); return find(collections, (c) => c.uid === collectionUid);
}; };
export const findItemByPathname = (items = [], pathname) => {
return find(items, (i) => i.pathname === pathname);
};
export const findItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return findItemByPathname(flattenedItems, pathname);
}
export const findItemInCollection = (collection, itemUid) => { export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items); let flattenedItems = flattenItems(collection.items);
@ -224,6 +235,48 @@ export const transformCollectionToSaveToIdb = (collection, options = {}) => {
return collectionToSave; return collectionToSave;
}; };
export const transformRequestToSaveToFilesystem = (item) => {
const _item = item.draft ? item.draft : item;
const itemToSave = {
uid: _item.uid,
type: _item.type,
name: _item.name,
request: {
method: _item.request.method,
url: _item.request.url,
params: [],
headers: [],
body: _item.request.body
}
};
each(_item.request.params, (param) => {
itemToSave.request.params.push({
uid: param.uid,
name: param.name,
value: param.value,
description: param.description,
enabled: param.enabled
});
});
each(_item.request.headers, (header) => {
itemToSave.request.headers.push({
uid: header.uid,
name: header.name,
value: header.value,
description: header.description,
enabled: header.enabled,
});
});
if(itemToSave.request.body.mode === 'json') {
itemToSave.request.body.json = replaceTabsWithSpaces(itemToSave.request.body.json);
}
return itemToSave;
};
// todo: optimize this // todo: optimize this
export const deleteItemInCollection = (itemUid, collection) => { export const deleteItemInCollection = (itemUid, collection) => {
collection.items = filter(collection.items, (i) => i.uid !== itemUid); collection.items = filter(collection.items, (i) => i.uid !== itemUid);
@ -274,3 +327,14 @@ export const humanizeRequestBodyMode = (mode) => {
return label; return label;
}; };
export const refreshUidsInItem = (item) => {
item.uid = uuid();
each(get(item, 'request.headers'), (header) => header.uid = uuid());
each(get(item, 'request.params'), (param) => param.uid = uuid());
each(get(item, 'request.body.multipartForm'), (param) => param.uid = uuid());
each(get(item, 'request.body.formUrlEncoded'), (param) => param.uid = uuid());
return item;
}

View File

@ -0,0 +1,27 @@
import trim from 'lodash/trim';
import path from 'path';
export const isElectron = () => {
if(!window) {
return false;
}
return window.ipcRenderer ? true : false;
};
export const isLocalCollection = (collection) => {
return collection.pathname ? true : false;
};
export const resolveRequestFilename = (name) => {
return `${trim(name)}.json`;
};
export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
if (!path.isAbsolute(pathname)) {
throw new Error('Invalid path!');
}
const relativePath = path.relative(rootPath, pathname);
return relativePath ? relativePath.split(path.sep) : [];
};

File diff suppressed because it is too large Load Diff

View File

@ -18,10 +18,15 @@
}, },
"dependencies": { "dependencies": {
"axios": "^0.26.0", "axios": "^0.26.0",
"chokidar": "^3.5.3",
"electron-is-dev": "^2.0.0", "electron-is-dev": "^2.0.0",
"electron-store": "^8.1.0",
"electron-util": "^0.17.2", "electron-util": "^0.17.2",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"lodash": "^4.17.21" "fs-extra": "^10.1.0",
"is-valid-path": "^0.1.1",
"lodash": "^4.17.21",
"nanoid": "3.3.4"
}, },
"devDependencies": { "devDependencies": {
"electron": "^17.1.0", "electron": "^17.1.0",

View File

@ -0,0 +1,26 @@
const { uuid } = require('../utils/common');
const { dialog } = require('electron');
const { isDirectory, normalizeAndResolvePath } = require('../utils/filesystem');
const openCollection = async (win, watcher) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
});
if (filePaths && filePaths[0]) {
const resolvedPath = normalizeAndResolvePath(filePaths[0]);
if (isDirectory(resolvedPath)) {
if(!watcher.hasWatcher(resolvedPath)) {
const uid = uuid();
win.webContents.send('main:collection-opened', resolvedPath, uid);
watcher.addWatcher(win, resolvedPath, uid);
}
} else {
console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`);
}
}
};
module.exports = {
openCollection
};

View File

@ -5,7 +5,7 @@ const template = [
label: 'Collection', label: 'Collection',
submenu: [ submenu: [
{ {
label: 'Open Collection', label: 'Open Local Collection',
click () { click () {
ipcMain.emit('main:open-collection'); ipcMain.emit('main:open-collection');
} }

View File

@ -0,0 +1,134 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const chokidar = require('chokidar');
const { hasJsonExtension } = require('../utils/filesystem');
const add = async (win, pathname, collectionUid) => {
const isJson = hasJsonExtension(pathname);
console.log(`watcher add: ${pathname}`);
if(isJson) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname),
}
}
try {
const jsonData = fs.readFileSync(pathname, 'utf8');
file.data = JSON.parse(jsonData);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (err) {
console.error(err)
}
}
};
const addDirectory = (win, pathname, collectionUid) => {
console.log(`watcher addDirectory: ${pathname}`);
const directory = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname),
}
};
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
const change = async (win, pathname, collectionUid) => {
console.log(`watcher change: ${pathname}`);
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname),
}
};
try {
const jsonData = fs.readFileSync(pathname, 'utf8');
file.data = await JSON.parse(jsonData);
win.webContents.send('main:collection-tree-updated', 'change', file);
} catch (err) {
console.error(err)
}
};
const unlink = (win, pathname, collectionUid) => {
console.log(`watcher unlink: ${pathname}`);
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
win.webContents.send('main:collection-tree-updated', 'unlink', file);
}
const unlinkDir = (win, pathname, collectionUid) => {
console.log(`watcher unlinkDir: ${pathname}`);
const directory = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
}
class Watcher {
constructor () {
this.watchers = {};
}
addWatcher (win, watchPath, collectionUid) {
if(this.watchers[watchPath]) {
this.watchers[watchPath].close();
}
const self = this;
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
usePolling: false,
ignored: path => ["node_modules", ".git", "bruno.json"].some(s => path.includes(s)),
persistent: true,
ignorePermissionErrors: true,
awaitWriteFinish: {
stabilityThreshold: 80,
pollInterval: 10
},
depth: 20
});
watcher
.on('add', pathname => add(win, pathname, collectionUid))
.on('addDir', pathname => addDirectory(win, pathname, collectionUid))
.on('change', pathname => change(win, pathname, collectionUid))
.on('unlink', pathname => unlink(win, pathname, collectionUid))
.on('unlinkDir', pathname => unlinkDir(win, pathname, collectionUid))
self.watchers[watchPath] = watcher;
}, 100);
}
hasWatcher (watchPath) {
return this.watchers[watchPath];
}
removeWatcher (watchPath, win) {
if(this.watchers[watchPath]) {
this.watchers[watchPath].close();
this.watchers[watchPath] = null;
win.webContents.send('main:collection-removed', watchPath);
}
}
};
module.exports = Watcher;

View File

@ -4,8 +4,10 @@ const { format } = require('url');
const { BrowserWindow, app, Menu } = require('electron'); const { BrowserWindow, app, Menu } = require('electron');
const { setContentSecurityPolicy } = require('electron-util'); const { setContentSecurityPolicy } = require('electron-util');
const menuTemplate = require('./menu-template'); const menuTemplate = require('./app/menu-template');
const registerIpc = require('./ipc'); const registerNetworkIpc = require('./ipc/network');
const registerLocalCollectionsIpc = require('./ipc/local-collection');
const Watcher = require('./app/watcher');
setContentSecurityPolicy(` setContentSecurityPolicy(`
default-src * 'unsafe-inline' 'unsafe-eval'; default-src * 'unsafe-inline' 'unsafe-eval';
@ -20,6 +22,7 @@ const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu); Menu.setApplicationMenu(menu);
let mainWindow; let mainWindow;
let watcher;
// Prepare the renderer once the app is ready // Prepare the renderer once the app is ready
app.on('ready', async () => { app.on('ready', async () => {
@ -42,9 +45,11 @@ app.on('ready', async () => {
}); });
mainWindow.loadURL(url); mainWindow.loadURL(url);
watcher = new Watcher();
// register all ipc handlers // register all ipc handlers
registerIpc(mainWindow); registerNetworkIpc(mainWindow, watcher);
registerLocalCollectionsIpc(mainWindow, watcher);
}); });
// Quit the app once all windows are closed // Quit the app once all windows are closed

View File

@ -0,0 +1,174 @@
const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const { ipcMain } = require('electron');
const {
isValidPathname,
writeFile,
hasJsonExtension,
isDirectory,
browseDirectory,
createDirectory
} = require('../utils/filesystem');
const { uuid, stringifyJson, parseJson } = require('../utils/common');
const { openCollection } = require('../app/collections');
const registerRendererEventHandlers = (mainWindow, watcher) => {
// browse directory
ipcMain.handle('renderer:browse-directory', async (event, pathname, request) => {
try {
const dirPath = await browseDirectory(mainWindow);
return dirPath;
} catch (error) {
return Promise.reject(error);
}
});
// create collection
ipcMain.handle('renderer:create-collection', async (event, collectionName, collectionLocation) => {
try {
const dirPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(dirPath)){
throw new Error(`collection: ${dir} already exists`);
}
if(!isValidPathname(dirPath)) {
throw new Error(`collection: invaid pathname - ${dir}`);
}
await createDirectory(dirPath);
const content = await stringifyJson({
version: '1.0',
type: 'collection'
});
await writeFile(path.join(dirPath, 'bruno.json'), content);
const uid = uuid();
mainWindow.webContents.send('main:collection-opened', dirPath, uid);
watcher.addWatcher(mainWindow, dirPath, uid);
return;
} catch (error) {
return Promise.reject(error);
}
});
// new request
ipcMain.handle('renderer:new-request', async (event, pathname, request) => {
try {
if (fs.existsSync(pathname)){
throw new Error(`path: ${pathname} already exists`);
}
const content = await stringifyJson(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
}
});
// save request
ipcMain.handle('renderer:save-request', async (event, pathname, request) => {
try {
if (!fs.existsSync(pathname)){
throw new Error(`path: ${pathname} does not exist`);
}
const content = await stringifyJson(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
}
});
// rename item
ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
try {
if (!fs.existsSync(oldPath)){
throw new Error(`path: ${oldPath} does not exist`);
}
if (fs.existsSync(newPath)){
throw new Error(`path: ${oldPath} already exists`);
}
// if its directory, rename and return
if(isDirectory(oldPath)) {
return fs.renameSync(oldPath, newPath);
}
const isJson = hasJsonExtension(oldPath);
if(!isJson) {
throw new Error(`path: ${oldPath} is not a json file`);
}
// update name in file and save new copy, then delete old copy
const data = fs.readFileSync(oldPath, 'utf8');
const jsonData = await parseJson(data);
jsonData.name = newName;
const content = await stringifyJson(jsonData);
await writeFile(newPath, content);
await fs.unlinkSync(oldPath);
} catch (error) {
return Promise.reject(error);
}
});
// new folder
ipcMain.handle('renderer:new-folder', async (event, pathname) => {
try {
if (!fs.existsSync(pathname)){
fs.mkdirSync(pathname);
} else {
return Promise.reject(new Error('The directory already exists'));
}
} catch (error) {
return Promise.reject(error);
}
});
// delete file/folder
ipcMain.handle('renderer:delete-item', async (event, pathname, type) => {
try {
if(type === 'folder') {
await fs.rmSync(pathname, { recursive: true, force: true});
} else if (['http-request', 'graphql-request'].includes(type)) {
await fs.unlinkSync(pathname);
} else {
return Promise.reject(error);
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:open-collection', () => {
if(watcher && mainWindow) {
openCollection(mainWindow, watcher);
}
});
ipcMain.handle('renderer:remove-collection', async (event, collectionPath) => {
if(watcher && mainWindow) {
watcher.removeWatcher(collectionPath, mainWindow);
}
});
};
const registerMainEventHandlers = (mainWindow, watcher) => {
ipcMain.on('main:open-collection', () => {
if(watcher && mainWindow) {
openCollection(mainWindow, watcher);
}
});
}
const registerLocalCollectionsIpc = (mainWindow, watcher) => {
registerRendererEventHandlers(mainWindow, watcher);
registerMainEventHandlers(mainWindow, watcher);
}
module.exports = registerLocalCollectionsIpc;

View File

@ -3,8 +3,7 @@ const FormData = require('form-data');
const { ipcMain } = require('electron'); const { ipcMain } = require('electron');
const { forOwn, extend } = require('lodash'); const { forOwn, extend } = require('lodash');
const registerNetworkIpc = () => {
const registerIpc = () => {
// handler for sending http request // handler for sending http request
ipcMain.handle('send-http-request', async (event, request) => { ipcMain.handle('send-http-request', async (event, request) => {
try { try {
@ -44,4 +43,4 @@ const registerIpc = () => {
}); });
}; };
module.exports = registerIpc; module.exports = registerNetworkIpc;

View File

@ -0,0 +1,32 @@
const { customAlphabet } = require('nanoid');
// a customized version of nanoid without using _ and -
const uuid = () => {
// https://github.com/ai/nanoid/blob/main/url-alphabet/index.js
const urlAlphabet = 'useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict';
const customNanoId = customAlphabet (urlAlphabet, 21);
return customNanoId();
};
const stringifyJson = async (str) => {
try {
return JSON.stringify(str, null, 2);
} catch(err) {
return Promise.reject(err);
}
}
const parseJson = async (obj) => {
try {
return JSON.parse(obj);
} catch(err) {
return Promise.reject(err);
}
}
module.exports = {
uuid,
stringifyJson,
parseJson
};

View File

@ -0,0 +1,104 @@
const path = require('path');
const fs = require('fs-extra');
const fsPromises = require('fs/promises');
const { dialog } = require('electron');
const isValidPathname = require('is-valid-path');
const exists = async p => {
try {
await fsPromises.access(p);
return true;
} catch (_) {
return false;
}
};
const isSymbolicLink = filepath => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isSymbolicLink();
} catch (_) {
return false;
}
};
const isFile = filepath => {
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile();
} catch (_) {
return false;
}
};
const isDirectory = dirPath => {
try {
return fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory();
} catch (_) {
return false;
}
};
const normalizeAndResolvePath = pathname => {
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
if (isFile(targetPath) || isDirectory(targetPath)) {
return path.resolve(targetPath);
}
console.error(`Cannot resolve link target "${pathname}" (${targetPath}).`)
return '';
}
return path.resolve(pathname);
};
const writeFile = async (pathname, content) => {
try {
fs.writeFileSync(pathname, content, {
encoding: "utf8"
});
} catch (err) {
return Promise.reject(err);
}
};
const hasJsonExtension = filename => {
if (!filename || typeof filename !== 'string') return false
return ['json'].some(ext => filename.toLowerCase().endsWith(`.${ext}`))
}
const createDirectory = async (dir) => {
if(!dir) {
throw new Error(`directory: path is null`);
}
if (fs.existsSync(dir)){
throw new Error(`directory: ${dir} already exists`);
}
return fs.mkdirSync(dir);
};
const browseDirectory = async (win) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
});
if (!filePaths || !filePaths[0]) {
return false;
}
const resolvedPath = normalizeAndResolvePath(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false;
};
module.exports = {
isValidPathname,
exists,
isSymbolicLink,
isFile,
isDirectory,
normalizeAndResolvePath,
writeFile,
hasJsonExtension,
createDirectory,
browseDirectory
};

View File

@ -43,7 +43,9 @@ const itemSchema = Yup.object({
is: (type) => ['http-request', 'graphql-request'].includes(type), is: (type) => ['http-request', 'graphql-request'].includes(type),
then: (schema) => schema.required('request is required when item-type is request') then: (schema) => schema.required('request is required when item-type is request')
}), }),
items: Yup.lazy(() => Yup.array().of(itemSchema)) items: Yup.lazy(() => Yup.array().of(itemSchema)),
filename: Yup.string().max(1024, 'filename cannot be more than 1024 characters').nullable(),
pathname: Yup.string().max(1024, 'pathname cannot be more than 1024 characters').nullable()
}).noUnknown(true).strict(); }).noUnknown(true).strict();
const collectionSchema = Yup.object({ const collectionSchema = Yup.object({
@ -52,7 +54,8 @@ const collectionSchema = Yup.object({
.min(1, 'name must be atleast 1 characters') .min(1, 'name must be atleast 1 characters')
.max(50, 'name must be 100 characters or less') .max(50, 'name must be 100 characters or less')
.required('name is required'), .required('name is required'),
items: Yup.array().of(itemSchema) items: Yup.array().of(itemSchema),
pathname: Yup.string().max(1024, 'pathname cannot be more than 1024 characters').nullable()
}).noUnknown(true).strict(); }).noUnknown(true).strict();
module.exports = { module.exports = {

View File

@ -1,7 +1,8 @@
const { workspaceSchema } = require("./workspaces"); const { workspaceSchema } = require("./workspaces");
const { collectionSchema } = require("./collections"); const { collectionSchema, itemSchema } = require("./collections");
module.exports = { module.exports = {
itemSchema,
collectionSchema, collectionSchema,
workspaceSchema workspaceSchema
}; };