feat: initial desktop app commit
@@ -93,13 +93,11 @@ declare module 'vue' {
|
||||
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
|
||||
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
|
||||
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
|
||||
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
|
||||
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
|
||||
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
||||
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
|
||||
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
|
||||
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
|
||||
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
|
||||
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
||||
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
|
||||
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
|
||||
@@ -146,7 +144,6 @@ declare module 'vue' {
|
||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
@@ -156,10 +153,8 @@ declare module 'vue' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
||||
@@ -192,7 +187,6 @@ declare module 'vue' {
|
||||
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
|
||||
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
|
||||
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
|
||||
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
|
||||
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
|
||||
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
|
||||
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
|
||||
|
||||
24
packages/hoppscotch-selfhost-desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: http://localhost:3000/sitemap.xml
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>http://localhost:3000/settings</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/realtime</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/realtime/websocket</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/realtime/sse</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/realtime/socketio</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/realtime/mqtt</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/profile</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/join-team</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/import</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/graphql</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>http://localhost:3000/enter</loc><lastmod>2023-10-11T06:31:17.965Z</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url></urlset>
|
||||
7
packages/hoppscotch-selfhost-desktop/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
16
packages/hoppscotch-selfhost-desktop/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Tauri + Vue 3 + TypeScript
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
## Type Support For `.vue` Imports in TS
|
||||
|
||||
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
|
||||
|
||||
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
|
||||
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
|
||||
26
packages/hoppscotch-selfhost-desktop/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Hoppscotch - Open source API development ecosystem</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
// Shims to make swagger-parser package work
|
||||
window.global = window
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Buffer } from "buffer"
|
||||
import process from "process"
|
||||
|
||||
// // Shims to make postman-collection work
|
||||
window.Buffer = Buffer
|
||||
window.process = process
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
118
packages/hoppscotch-selfhost-desktop/meta.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { IHTMLTag } from "vite-plugin-html-config"
|
||||
|
||||
export const APP_INFO = {
|
||||
name: "Hoppscotch",
|
||||
shortDescription: "Open source API development ecosystem",
|
||||
description:
|
||||
"Helps you create requests faster, saving precious time on development.",
|
||||
keywords:
|
||||
"hoppscotch, hopp scotch, hoppscotch online, hoppscotch app, postwoman, postwoman chrome, postwoman online, postwoman for mac, postwoman app, postwoman for windows, postwoman google chrome, postwoman chrome app, get postwoman, postwoman web, postwoman android, postwoman app for chrome, postwoman mobile app, postwoman web app, api, request, testing, tool, rest, websocket, sse, graphql, socketio",
|
||||
app: {
|
||||
background: "#202124",
|
||||
},
|
||||
social: {
|
||||
twitter: "@hoppscotch_io",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const META_TAGS = (env: Record<string, string>): IHTMLTag[] => [
|
||||
{
|
||||
name: "keywords",
|
||||
content: APP_INFO.keywords,
|
||||
},
|
||||
{
|
||||
name: "X-UA-Compatible",
|
||||
content: "IE=edge, chrome=1",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: APP_INFO.description,
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
content: `${env.VITE_BASE_URL}/banner.png`,
|
||||
},
|
||||
// Open Graph tags
|
||||
{
|
||||
name: "og:title",
|
||||
content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
|
||||
},
|
||||
{
|
||||
name: "og:description",
|
||||
content: APP_INFO.description,
|
||||
},
|
||||
{
|
||||
name: "og:image",
|
||||
content: `${env.VITE_BASE_URL}/banner.png`,
|
||||
},
|
||||
// Twitter tags
|
||||
{
|
||||
name: "twitter:card",
|
||||
content: "summary_large_image",
|
||||
},
|
||||
{
|
||||
name: "twitter:site",
|
||||
content: APP_INFO.social.twitter,
|
||||
},
|
||||
{
|
||||
name: "twitter:creator",
|
||||
content: APP_INFO.social.twitter,
|
||||
},
|
||||
{
|
||||
name: "twitter:title",
|
||||
content: `${APP_INFO.name} • ${APP_INFO.shortDescription}`,
|
||||
},
|
||||
{
|
||||
name: "twitter:description",
|
||||
content: APP_INFO.description,
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
content: `${env.VITE_BASE_URL}/banner.png`,
|
||||
},
|
||||
// Add to homescreen for Chrome on Android. Fallback for PWA (handled by nuxt)
|
||||
{
|
||||
name: "application-name",
|
||||
content: APP_INFO.name,
|
||||
},
|
||||
// Windows phone tile icon
|
||||
{
|
||||
name: "msapplication-TileImage",
|
||||
content: `${env.VITE_BASE_URL}/icon.png`,
|
||||
},
|
||||
{
|
||||
name: "msapplication-TileColor",
|
||||
content: APP_INFO.app.background,
|
||||
},
|
||||
{
|
||||
name: "msapplication-tap-highlight",
|
||||
content: "no",
|
||||
},
|
||||
// iOS Safari
|
||||
{
|
||||
name: "apple-mobile-web-app-title",
|
||||
content: APP_INFO.name,
|
||||
},
|
||||
{
|
||||
name: "apple-mobile-web-app-capable",
|
||||
content: "yes",
|
||||
},
|
||||
{
|
||||
name: "apple-mobile-web-app-status-bar-style",
|
||||
content: "black-translucent",
|
||||
},
|
||||
// PWA
|
||||
{
|
||||
name: "theme-color",
|
||||
content: APP_INFO.app.background,
|
||||
},
|
||||
{
|
||||
name: "mask-icon",
|
||||
content: "/icon.png",
|
||||
color: APP_INFO.app.background,
|
||||
},
|
||||
]
|
||||
63
packages/hoppscotch-selfhost-desktop/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@hoppscotch/desktop",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node --max_old_space_size=16384 ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hoppscotch/common": "workspace:^",
|
||||
"@platform/auth": "^0.1.106",
|
||||
"@tauri-apps/api": "^1.3.0",
|
||||
"@tauri-apps/cli": "^1.3.0",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"axios": "^0.21.4",
|
||||
"buffer": "^6.0.3",
|
||||
"environments.api": "link:@platform/environments/environments.api",
|
||||
"event": "link:@tauri-apps/api/event",
|
||||
"fp-ts": "^2.16.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"process": "^0.11.10",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell": "link:@tauri-apps/api/shell",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tauri": "link:@tauri-apps/api/tauri",
|
||||
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
|
||||
"util": "^0.12.4",
|
||||
"vue": "^3.2.45",
|
||||
"workbox-window": "^6.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@types/node": "^18.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
||||
"@typescript-eslint/parser": "^5.19.0",
|
||||
"@vitejs/plugin-legacy": "^2.3.0",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.5.1",
|
||||
"typescript": "^4.9.5",
|
||||
"unplugin-icons": "^0.14.9",
|
||||
"unplugin-vue-components": "^0.21.0",
|
||||
"vite": "^4.2.1",
|
||||
"vite-plugin-fonts": "^0.6.0",
|
||||
"vite-plugin-html-config": "^1.0.10",
|
||||
"vite-plugin-inspect": "^0.7.4",
|
||||
"vite-plugin-pages": "^0.26.0",
|
||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
||||
"vite-plugin-pwa": "^0.13.1",
|
||||
"vite-plugin-static-copy": "^0.12.0",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
"vite-plugin-windicss": "^1.8.8",
|
||||
"vue-tsc": "^1.0.11",
|
||||
"windicss": "^3.5.6"
|
||||
}
|
||||
}
|
||||
6
packages/hoppscotch-selfhost-desktop/public/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
packages/hoppscotch-selfhost-desktop/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
4
packages/hoppscotch-selfhost-desktop/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
4217
packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.lock
generated
Normal file
35
packages/hoppscotch-selfhost-desktop/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "hoppscotch-desktop"
|
||||
version = "0.0.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "1.4.1", features = ["http-all", "os-all", "shell-open", "window-start-dragging", "http-multipart"] }
|
||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
|
||||
tauri-plugin-window-state = "0.1.0"
|
||||
reqwest = "0.11.20"
|
||||
serde_json = "1.0.107"
|
||||
url = "2.4.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.25.0"
|
||||
objc = "0.2.7"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# windows = { version = "0.51.1", features = ["Win32_Graphics_Dwm", "Win32_Foundation", "Win32_UI_Controls"] }
|
||||
hex_color = "2.0.0"
|
||||
|
||||
[features]
|
||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||
# DO NOT REMOVE!!
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
19
packages/hoppscotch-selfhost-desktop/src-tauri/Info.plist
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<!-- Obviously needs to be replaced with your app's bundle identifier -->
|
||||
<string>io.hoppscotch.desktop</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<!-- register the myapp:// and myscheme:// schemes -->
|
||||
<string>hoppscotch</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
3
packages/hoppscotch-selfhost-desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
packages/hoppscotch-selfhost-desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
BIN
packages/hoppscotch-selfhost-desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
BIN
packages/hoppscotch-selfhost-desktop/src-tauri/icons/icon.icns
Normal file
BIN
packages/hoppscotch-selfhost-desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
packages/hoppscotch-selfhost-desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
pub mod window;
|
||||
345
packages/hoppscotch-selfhost-desktop/src-tauri/src/mac/window.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
use tauri::{App, Manager, Runtime, Window};
|
||||
|
||||
// If anything breaks on macOS, this should be the place which is broken
|
||||
// We have to override Tauri (Tao) 's built-in NSWindowDelegate implementation with a
|
||||
// custom implementation so we can emit events on full screen mode changes.
|
||||
// Our custom implementation tries to mock the Tauri implementation. So please do refer to the relevant parts
|
||||
|
||||
// Apple's NSWindowDelegate reference: https://developer.apple.com/documentation/appkit/nswindowdelegate?language=objc
|
||||
// Tao's Window Delegate Implementation: https://github.com/tauri-apps/tao/blob/dev/src/platform_impl/macos/window_delegate.rs
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum ToolbarThickness {
|
||||
Thick,
|
||||
Medium,
|
||||
Thin,
|
||||
}
|
||||
|
||||
const WINDOW_CONTROL_PAD_X: f64 = 15.0;
|
||||
const WINDOW_CONTROL_PAD_Y: f64 = 23.0;
|
||||
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn set_transparent_titlebar(id: cocoa::base::id) {
|
||||
use cocoa::appkit::NSWindow;
|
||||
|
||||
id.setTitlebarAppearsTransparent_(cocoa::base::YES);
|
||||
id.setTitleVisibility_(cocoa::appkit::NSWindowTitleVisibility::NSWindowTitleHidden);
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_window_controls_pos(window: cocoa::base::id, x: f64, y: f64) {
|
||||
use cocoa::{appkit::{NSWindow, NSWindowButton, NSView}, foundation::NSRect};
|
||||
|
||||
unsafe {
|
||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize =
|
||||
window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between);
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for Window<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self) {
|
||||
unsafe {
|
||||
let id = self.ns_window().unwrap() as cocoa::base::id;
|
||||
|
||||
set_transparent_titlebar(id);
|
||||
|
||||
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug)]
|
||||
struct HoppAppState {
|
||||
window: Window,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn setup_mac_window(app: &mut App) {
|
||||
use cocoa::appkit::NSWindow;
|
||||
use cocoa::base::{id, BOOL};
|
||||
use cocoa::foundation::NSUInteger;
|
||||
use objc::runtime::{Object, Sel};
|
||||
use std::ffi::c_void;
|
||||
|
||||
fn with_hopp_app<F: FnOnce(&mut HoppAppState) -> T, T>(this: &Object, func: F) {
|
||||
let ptr = unsafe {
|
||||
let x: *mut c_void = *this.get_ivar("hoppApp");
|
||||
&mut *(x as *mut HoppAppState)
|
||||
};
|
||||
func(ptr);
|
||||
}
|
||||
|
||||
let window = app.get_window("main").unwrap();
|
||||
|
||||
unsafe {
|
||||
let ns_win = window.ns_window().unwrap() as id;
|
||||
|
||||
let current_delegate: id = ns_win.delegate();
|
||||
|
||||
extern "C" fn on_window_should_close(this: &Object, _cmd: Sel, sender: id) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, windowShouldClose: sender]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_close(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillClose: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_resize(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
with_hopp_app(&*this, |state| {
|
||||
let id = state.window.ns_window().unwrap() as id;
|
||||
|
||||
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidResize: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_move(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidMove: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_change_backing_properties(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_become_key(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidBecomeKey: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_resign_key(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidResignKey: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_dragging_entered(this: &Object, _cmd: Sel, notification: id) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, draggingEntered: notification]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_prepare_for_drag_operation(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, prepareForDragOperation: notification]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_perform_drag_operation(this: &Object, _cmd: Sel, sender: id) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, performDragOperation: sender]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_conclude_drag_operation(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, concludeDragOperation: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_dragging_exited(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, draggingExited: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_use_full_screen_presentation_options(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
window: id,
|
||||
proposed_options: NSUInteger,
|
||||
) -> NSUInteger {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_enter_full_screen(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
with_hopp_app(&*this, |state| {
|
||||
state.window.emit("did-enter-fullscreen", ()).unwrap();
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_enter_full_screen(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
with_hopp_app(&*this, |state| {
|
||||
state.window.emit("will-enter-fullscreen", ()).unwrap();
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_exit_full_screen(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
with_hopp_app(&*this, |state| {
|
||||
state.window.emit("did-exit-fullscreen", ()).unwrap();
|
||||
|
||||
let id = state.window.ns_window().unwrap() as id;
|
||||
set_window_controls_pos(id, WINDOW_CONTROL_PAD_X, WINDOW_CONTROL_PAD_Y);
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidExitFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_exit_full_screen(this: &Object, _cmd: Sel, notification: id) {
|
||||
unsafe {
|
||||
with_hopp_app(&*this, |state| {
|
||||
state.window.emit("will-exit-fullscreen", ()).unwrap();
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_fail_to_enter_full_screen(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
window: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_effective_appearance_did_change(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_effective_appearance_did_changed_on_main_thread(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![
|
||||
super_del,
|
||||
effectiveAppearanceDidChangedOnMainThread: notification
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// extern fn on_dealloc(this: &Object, cmd: Sel) {
|
||||
// unsafe {
|
||||
// let super_del: id = *this.get_ivar("super_delegate");
|
||||
// let _: () = msg_send![super_del, dealloc];
|
||||
// }
|
||||
// }
|
||||
|
||||
// extern fn on_mark_is_checking_zoomed_in(this: &Object, cmd: Sel) {
|
||||
// unsafe {
|
||||
// let super_del: id = *this.get_ivar("super_delegate");
|
||||
// let _: () = msg_send![super_del, markIsCheckingZoomedIn];
|
||||
// }
|
||||
// }
|
||||
|
||||
// extern fn on_clear_is_checking_zoomed_in(this: &Object, cmd: Sel) {
|
||||
// unsafe {
|
||||
// let super_del: id = *this.get_ivar("super_delegate");
|
||||
// let _: () = msg_send![super_del, clearIsCheckingZoomedIn];
|
||||
// }
|
||||
// }
|
||||
|
||||
// Are we deallocing this properly ? (I miss safe Rust :( )
|
||||
let app_state = HoppAppState {
|
||||
window,
|
||||
};
|
||||
let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
|
||||
|
||||
ns_win.setDelegate_(delegate!("MainWindowDelegate", {
|
||||
window: id = ns_win,
|
||||
hoppApp: *mut c_void = app_box,
|
||||
toolbar: id = cocoa::base::nil,
|
||||
super_delegate: id = current_delegate,
|
||||
// (dealloc) => on_dealloc as extern fn(&Object, Sel),
|
||||
// (markIsCheckingZoomedIn) => on_mark_is_checking_zoomed_in as extern fn(&Object, Sel),
|
||||
// (clearIsCheckingZoomedIn) => on_clear_is_checking_zoomed_in as extern fn(&Object, Sel),
|
||||
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
|
||||
(windowDidResize:) => on_window_did_resize as extern fn(&Object, Sel, id),
|
||||
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
|
||||
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
|
||||
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
|
||||
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
|
||||
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
|
||||
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
|
||||
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
|
||||
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen as extern fn(&Object, Sel, id),
|
||||
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen as extern fn(&Object, Sel, id),
|
||||
(windowDidExitFullScreen:) => on_window_did_exit_full_screen as extern fn(&Object, Sel, id),
|
||||
(windowWillExitFullScreen:) => on_window_will_exit_full_screen as extern fn(&Object, Sel, id),
|
||||
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
|
||||
}))
|
||||
}
|
||||
|
||||
app.get_window("main")
|
||||
.unwrap()
|
||||
.set_transparent_titlebar();
|
||||
}
|
||||
48
packages/hoppscotch-selfhost-desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate cocoa;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
fn main() {
|
||||
tauri_plugin_deep_link::prepare("io.hoppscotch.desktop");
|
||||
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
if cfg!(target_os = "macos") {
|
||||
use mac::window::setup_mac_window;
|
||||
|
||||
setup_mac_window(app);
|
||||
} else if cfg!(target_os = "windows") {
|
||||
#[cfg(target_os = "windows")]
|
||||
setup_win_window(app);
|
||||
}
|
||||
|
||||
let handle = app.handle();
|
||||
tauri_plugin_deep_link::register(
|
||||
"hoppscotch",
|
||||
move |request| {
|
||||
println!("{:?}", request);
|
||||
handle.emit_all("scheme-request-received", request).unwrap();
|
||||
},
|
||||
).unwrap();
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod window;
|
||||
@@ -0,0 +1,86 @@
|
||||
use hex_color::HexColor;
|
||||
use tauri::App;
|
||||
use tauri::Manager;
|
||||
|
||||
use std::mem::transmute;
|
||||
use std::{ptr, ffi::c_void, mem::size_of};
|
||||
|
||||
use windows::Win32::UI::Controls::{WTA_NONCLIENT, WTNCA_NODRAWICON, WTNCA_NOSYSMENU, WTNCA_NOMIRRORHELP};
|
||||
|
||||
use windows::Win32::UI::Controls::SetWindowThemeAttribute;
|
||||
use windows::Win32::UI::Controls::WTNCA_NODRAWCAPTION;
|
||||
use windows::Win32::Graphics::Dwm::DWMWA_CAPTION_COLOR;
|
||||
use windows::Win32::Foundation::COLORREF;
|
||||
use windows::Win32::Foundation::BOOL;
|
||||
use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::Graphics::Dwm::{DWMWA_USE_IMMERSIVE_DARK_MODE};
|
||||
|
||||
fn hex_color_to_colorref(color: HexColor) -> COLORREF {
|
||||
// TODO: Remove this unsafe, This operation doesn't need to be unsafe!
|
||||
unsafe {
|
||||
COLORREF(transmute::<[u8; 4], u32>([color.r, color.g, color.b, 0]))
|
||||
}
|
||||
}
|
||||
|
||||
struct WinThemeAttribute {
|
||||
flag: u32,
|
||||
mask: u32
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn update_bg_color(hwnd: &HWND, bg_color: HexColor) {
|
||||
|
||||
let use_dark_mode = BOOL::from(true);
|
||||
|
||||
let final_color = hex_color_to_colorref(bg_color);
|
||||
|
||||
unsafe {
|
||||
DwmSetWindowAttribute(
|
||||
HWND(hwnd.0),
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
ptr::addr_of!(use_dark_mode) as *const c_void,
|
||||
size_of::<BOOL>().try_into().unwrap()
|
||||
).unwrap();
|
||||
|
||||
DwmSetWindowAttribute(
|
||||
HWND(hwnd.0),
|
||||
DWMWA_CAPTION_COLOR,
|
||||
ptr::addr_of!(final_color) as *const c_void,
|
||||
size_of::<COLORREF>().try_into().unwrap()
|
||||
).unwrap();
|
||||
|
||||
let flags = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON;
|
||||
let mask = WTNCA_NODRAWCAPTION | WTNCA_NODRAWICON | WTNCA_NOSYSMENU | WTNCA_NOMIRRORHELP;
|
||||
let options = WinThemeAttribute { flag: flags, mask };
|
||||
|
||||
SetWindowThemeAttribute(
|
||||
HWND(hwnd.0),
|
||||
WTA_NONCLIENT,
|
||||
ptr::addr_of!(options) as *const c_void,
|
||||
size_of::<WinThemeAttribute>().try_into().unwrap()
|
||||
).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn setup_win_window(app: &mut App) {
|
||||
|
||||
let window = app.get_window("main").unwrap();
|
||||
let win_handle = window.hwnd().unwrap();
|
||||
|
||||
let win_clone = win_handle.clone();
|
||||
|
||||
app.listen_global("hopp-bg-changed", move |ev| {
|
||||
|
||||
let payload = serde_json::from_str::<&str>(ev.payload().unwrap())
|
||||
.unwrap()
|
||||
.trim();
|
||||
|
||||
let color = HexColor::parse_rgb(payload).unwrap();
|
||||
|
||||
update_bg_color(&HWND(win_clone.0), color);
|
||||
});
|
||||
|
||||
update_bg_color(&HWND(win_handle.0), HexColor::rgb(23, 23, 23));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devPath": "http://localhost:3000",
|
||||
"distDir": "../dist",
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"package": {
|
||||
"productName": "hoppscotch-desktop",
|
||||
"version": "0.0.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"os": {
|
||||
"all": true
|
||||
},
|
||||
"http": {
|
||||
"all": true,
|
||||
"request": true,
|
||||
"scope": [
|
||||
"http://*",
|
||||
"https://*",
|
||||
"wss://*"
|
||||
]
|
||||
},
|
||||
"window": {
|
||||
"startDragging": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"identifier": "io.hoppscotch.desktop",
|
||||
"targets": "all"
|
||||
},
|
||||
"security": {
|
||||
"csp": "none"
|
||||
},
|
||||
"updater": {
|
||||
"active": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"fullscreen": false,
|
||||
"resizable": true,
|
||||
"title": "Hoppscotch",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
1857
packages/hoppscotch-selfhost-desktop/src/api/generated/graphql.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation ClearGlobalEnvironments($id: ID!) {
|
||||
clearGlobalEnvironments(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation CreateGQLChildUserCollection(
|
||||
$title: String!
|
||||
$parentUserCollectionID: ID!
|
||||
) {
|
||||
createGQLChildUserCollection(
|
||||
title: $title
|
||||
parentUserCollectionID: $parentUserCollectionID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation CreateGQLRootUserCollection($title: String!) {
|
||||
createGQLRootUserCollection(title: $title) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mutation CreateGQLUserRequest(
|
||||
$title: String!
|
||||
$request: String!
|
||||
$collectionID: ID!
|
||||
) {
|
||||
createGQLUserRequest(
|
||||
title: $title
|
||||
request: $request
|
||||
collectionID: $collectionID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation CreateRESTChildUserCollection(
|
||||
$title: String!
|
||||
$parentUserCollectionID: ID!
|
||||
) {
|
||||
createRESTChildUserCollection(
|
||||
title: $title
|
||||
parentUserCollectionID: $parentUserCollectionID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation CreateRESTRootUserCollection($title: String!) {
|
||||
createRESTRootUserCollection(title: $title) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mutation CreateRESTUserRequest(
|
||||
$collectionID: ID!
|
||||
$title: String!
|
||||
$request: String!
|
||||
) {
|
||||
createRESTUserRequest(
|
||||
collectionID: $collectionID
|
||||
title: $title
|
||||
request: $request
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation CreateUserEnvironment($name: String!, $variables: String!) {
|
||||
createUserEnvironment(name: $name, variables: $variables) {
|
||||
id
|
||||
userUid
|
||||
name
|
||||
variables
|
||||
isGlobal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation CreateUserGlobalEnvironment($variables: String!) {
|
||||
createUserGlobalEnvironment(variables: $variables) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mutation CreateUserHistory(
|
||||
$reqData: String!
|
||||
$resMetadata: String!
|
||||
$reqType: ReqType!
|
||||
) {
|
||||
createUserHistory(
|
||||
reqData: $reqData
|
||||
resMetadata: $resMetadata
|
||||
reqType: $reqType
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation CreateUserSettings($properties: String!) {
|
||||
createUserSettings(properties: $properties) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation DeleteAllUserHistory($reqType: ReqType!) {
|
||||
deleteAllUserHistory(reqType: $reqType) {
|
||||
count
|
||||
reqType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteUserCollection($userCollectionID: ID!) {
|
||||
deleteUserCollection(userCollectionID: $userCollectionID)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteUserEnvironment($id: ID!) {
|
||||
deleteUserEnvironment(id: $id)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation DeleteUserRequest($requestID: ID!) {
|
||||
deleteUserRequest(id: $requestID)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mutation MoveUserCollection($destCollectionID: ID, $userCollectionID: ID!) {
|
||||
moveUserCollection(
|
||||
destCollectionID: $destCollectionID
|
||||
userCollectionID: $userCollectionID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
mutation MoveUserRequest(
|
||||
$sourceCollectionID: ID!
|
||||
$requestID: ID!
|
||||
$destinationCollectionID: ID!
|
||||
$nextRequestID: ID
|
||||
) {
|
||||
moveUserRequest(
|
||||
sourceCollectionID: $sourceCollectionID
|
||||
requestID: $requestID
|
||||
destinationCollectionID: $destinationCollectionID
|
||||
nextRequestID: $nextRequestID
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation RemoveRequestFromHistory($id: ID!) {
|
||||
removeRequestFromHistory(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mutation RenameUserCollection($userCollectionID: ID!, $newTitle: String!) {
|
||||
renameUserCollection(
|
||||
userCollectionID: $userCollectionID
|
||||
newTitle: $newTitle
|
||||
) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation ToggleHistoryStarStatus($id: ID!) {
|
||||
toggleHistoryStarStatus(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation UpdateGQLUserRequest($id: ID!, $request: String!, $title: String) {
|
||||
updateGQLUserRequest(id: $id, request: $request, title: $title) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation UpdateRESTUserRequest($id: ID!, $title: String!, $request: String!) {
|
||||
updateRESTUserRequest(id: $id, title: $title, request: $request) {
|
||||
id
|
||||
collectionID
|
||||
request
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mutation UpdateUserCollectionOrder($collectionID: ID!, $nextCollectionID: ID) {
|
||||
updateUserCollectionOrder(
|
||||
collectionID: $collectionID
|
||||
nextCollectionID: $nextCollectionID
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation UpdateUserEnvironment($id: ID!, $name: String!, $variables: String!) {
|
||||
updateUserEnvironment(id: $id, name: $name, variables: $variables) {
|
||||
id
|
||||
userUid
|
||||
name
|
||||
variables
|
||||
isGlobal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation UpdateUserSession(
|
||||
$currentSession: String!
|
||||
$sessionType: SessionType!
|
||||
) {
|
||||
updateUserSessions(
|
||||
currentSession: $currentSession
|
||||
sessionType: $sessionType
|
||||
) {
|
||||
currentRESTSession
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation UpdateUserSettings($properties: String!) {
|
||||
updateUserSettings(properties: $properties) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation CreateUserEnvironment($name: String!, $variables: String!) {
|
||||
createUserEnvironment(name: $name, variables: $variables) {
|
||||
id
|
||||
userUid
|
||||
name
|
||||
variables
|
||||
isGlobal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
query ExportUserCollectionsToJSON(
|
||||
$collectionID: ID
|
||||
$collectionType: ReqType!
|
||||
) {
|
||||
exportUserCollectionsToJSON(
|
||||
collectionID: $collectionID
|
||||
collectionType: $collectionType
|
||||
) {
|
||||
collectionType
|
||||
exportedCollection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
query GetCurrentRESTSession {
|
||||
me {
|
||||
currentRESTSession
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query GetGlobalEnvironments {
|
||||
me {
|
||||
globalEnvironments {
|
||||
id
|
||||
isGlobal
|
||||
name
|
||||
userUid
|
||||
variables
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
query GetRESTUserHistory {
|
||||
me {
|
||||
RESTHistory {
|
||||
id
|
||||
userUid
|
||||
reqType
|
||||
request
|
||||
responseMetadata
|
||||
isStarred
|
||||
executedOn
|
||||
}
|
||||
|
||||
GQLHistory {
|
||||
id
|
||||
userUid
|
||||
reqType
|
||||
request
|
||||
responseMetadata
|
||||
isStarred
|
||||
executedOn
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
query GetGQLRootUserCollections {
|
||||
# the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once
|
||||
rootGQLUserCollections(take: 99999) {
|
||||
id
|
||||
title
|
||||
type
|
||||
childrenGQL {
|
||||
id
|
||||
title
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
query GetUserEnvironments {
|
||||
me {
|
||||
environments {
|
||||
id
|
||||
isGlobal
|
||||
name
|
||||
userUid
|
||||
variables
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
query GetUserRootCollections {
|
||||
# the frontend doesnt paginate right now, so giving take a big enough value to get all collections at once
|
||||
rootRESTUserCollections(take: 99999) {
|
||||
id
|
||||
title
|
||||
type
|
||||
childrenREST {
|
||||
id
|
||||
title
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
query GetUserSettings {
|
||||
me {
|
||||
settings {
|
||||
id
|
||||
properties
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
subscription UserCollectionCreated {
|
||||
userCollectionCreated {
|
||||
parent {
|
||||
id
|
||||
}
|
||||
id
|
||||
title
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserCollectionMoved {
|
||||
userCollectionMoved {
|
||||
id
|
||||
parent {
|
||||
id
|
||||
}
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
subscription UserCollectionOrderUpdated {
|
||||
userCollectionOrderUpdated {
|
||||
userCollection {
|
||||
id
|
||||
parent {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
nextUserCollection {
|
||||
id
|
||||
parent {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
subscription UserCollectionRemoved {
|
||||
userCollectionRemoved {
|
||||
id
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
subscription userCollectionUpdated {
|
||||
userCollectionUpdated {
|
||||
id
|
||||
title
|
||||
type
|
||||
parent {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserEnvironmentCreated {
|
||||
userEnvironmentCreated {
|
||||
id
|
||||
isGlobal
|
||||
name
|
||||
userUid
|
||||
variables
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription UserEnvironmentDeleted {
|
||||
userEnvironmentDeleted {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserEnvironmentUpdated {
|
||||
userEnvironmentUpdated {
|
||||
id
|
||||
userUid
|
||||
name
|
||||
variables
|
||||
isGlobal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
subscription UserHistoryCreated {
|
||||
userHistoryCreated {
|
||||
id
|
||||
reqType
|
||||
request
|
||||
responseMetadata
|
||||
isStarred
|
||||
executedOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
subscription userHistoryDeleted {
|
||||
userHistoryDeleted {
|
||||
id
|
||||
reqType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
subscription UserHistoryDeletedMany {
|
||||
userHistoryDeletedMany {
|
||||
count
|
||||
reqType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
subscription UserHistoryUpdated {
|
||||
userHistoryUpdated {
|
||||
id
|
||||
reqType
|
||||
request
|
||||
responseMetadata
|
||||
isStarred
|
||||
executedOn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserRequestCreated {
|
||||
userRequestCreated {
|
||||
id
|
||||
collectionID
|
||||
title
|
||||
request
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserRequestDeleted {
|
||||
userRequestDeleted {
|
||||
id
|
||||
collectionID
|
||||
title
|
||||
request
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
subscription UserRequestMoved {
|
||||
userRequestMoved {
|
||||
request {
|
||||
id
|
||||
collectionID
|
||||
type
|
||||
}
|
||||
nextRequest {
|
||||
id
|
||||
collectionID
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
subscription UserRequestUpdated {
|
||||
userRequestUpdated {
|
||||
id
|
||||
collectionID
|
||||
title
|
||||
request
|
||||
type
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
subscription UserSettingsUpdated {
|
||||
userSettingsUpdated {
|
||||
id
|
||||
properties
|
||||
}
|
||||
}
|
||||
102
packages/hoppscotch-selfhost-desktop/src/lib/sync/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Observable } from "rxjs"
|
||||
import DispatchingStore from "@hoppscotch/common/newstore/DispatchingStore"
|
||||
|
||||
export type DispatchersOf<T extends DispatchingStore<any, any>> =
|
||||
T extends DispatchingStore<any, infer U>
|
||||
? U extends Record<infer D, any>
|
||||
? D
|
||||
: never
|
||||
: never
|
||||
|
||||
export type StoreSyncDefinitionOf<T extends DispatchingStore<any, any>> = {
|
||||
[x in DispatchersOf<T>]?: T extends DispatchingStore<any, infer U>
|
||||
? U extends Record<x, any>
|
||||
? U[x] extends (x: any, y: infer Y) => any
|
||||
? (payload: Y) => void
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
}
|
||||
|
||||
let _isRunningDispatchWithoutSyncing = true
|
||||
|
||||
export function runDispatchWithOutSyncing(func: () => void) {
|
||||
_isRunningDispatchWithoutSyncing = false
|
||||
func()
|
||||
_isRunningDispatchWithoutSyncing = true
|
||||
}
|
||||
|
||||
export const getSyncInitFunction = <T extends DispatchingStore<any, any>>(
|
||||
store: T,
|
||||
storeSyncDefinition: StoreSyncDefinitionOf<T>,
|
||||
shouldSyncValue: () => boolean,
|
||||
shouldSyncObservable?: Observable<boolean>
|
||||
) => {
|
||||
let startSubscriptions: () => () => void | undefined
|
||||
let stopSubscriptions: () => void | undefined
|
||||
|
||||
let oldSyncStatus = shouldSyncValue()
|
||||
|
||||
// Start and stop the subscriptions according to the sync settings from profile
|
||||
shouldSyncObservable &&
|
||||
shouldSyncObservable.subscribe((newSyncStatus) => {
|
||||
if (oldSyncStatus === true && newSyncStatus === false) {
|
||||
stopListeningToSubscriptions()
|
||||
} else if (oldSyncStatus === false && newSyncStatus === true) {
|
||||
startListeningToSubscriptions()
|
||||
}
|
||||
|
||||
oldSyncStatus = newSyncStatus
|
||||
})
|
||||
|
||||
function startStoreSync() {
|
||||
store.dispatches$.subscribe((actionParams) => {
|
||||
// typescript cannot understand that the dispatcher can be the index, so casting to any
|
||||
if ((storeSyncDefinition as any)[actionParams.dispatcher]) {
|
||||
const dispatcher = actionParams.dispatcher
|
||||
const payload = actionParams.payload
|
||||
|
||||
const operationMapperFunction = (storeSyncDefinition as any)[dispatcher]
|
||||
|
||||
if (
|
||||
operationMapperFunction &&
|
||||
_isRunningDispatchWithoutSyncing &&
|
||||
shouldSyncValue()
|
||||
) {
|
||||
operationMapperFunction(payload)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setupSubscriptions(func: () => () => void) {
|
||||
startSubscriptions = func
|
||||
}
|
||||
|
||||
function startListeningToSubscriptions() {
|
||||
if (!startSubscriptions) {
|
||||
console.warn(
|
||||
"We don't have a function to start subscriptions. Please use `setupSubscriptions` to setup the start function."
|
||||
)
|
||||
}
|
||||
|
||||
stopSubscriptions = startSubscriptions()
|
||||
}
|
||||
|
||||
function stopListeningToSubscriptions() {
|
||||
if (!stopSubscriptions) {
|
||||
console.warn(
|
||||
"We don't have a function to unsubscribe. make sure you return the unsubscribe function when using setupSubscriptions"
|
||||
)
|
||||
}
|
||||
|
||||
stopSubscriptions()
|
||||
}
|
||||
|
||||
return {
|
||||
startStoreSync,
|
||||
setupSubscriptions,
|
||||
startListeningToSubscriptions,
|
||||
stopListeningToSubscriptions,
|
||||
}
|
||||
}
|
||||
42
packages/hoppscotch-selfhost-desktop/src/lib/sync/mapper.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export const createMapper = <
|
||||
LocalIDType extends string | number,
|
||||
BackendIDType extends string | number
|
||||
>() => {
|
||||
const backendIDByLocalIDMap = new Map<
|
||||
LocalIDType,
|
||||
BackendIDType | undefined
|
||||
>()
|
||||
const localIDByBackendIDMap = new Map<
|
||||
BackendIDType,
|
||||
LocalIDType | undefined
|
||||
>()
|
||||
|
||||
return {
|
||||
addEntry(localIdentifier: LocalIDType, backendIdentifier: BackendIDType) {
|
||||
backendIDByLocalIDMap.set(localIdentifier, backendIdentifier)
|
||||
localIDByBackendIDMap.set(backendIdentifier, localIdentifier)
|
||||
},
|
||||
getValue() {
|
||||
return backendIDByLocalIDMap
|
||||
},
|
||||
getBackendIDByLocalID(localIdentifier: LocalIDType) {
|
||||
return backendIDByLocalIDMap.get(localIdentifier)
|
||||
},
|
||||
getLocalIDByBackendID(backendId: BackendIDType) {
|
||||
return localIDByBackendIDMap.get(backendId)
|
||||
},
|
||||
removeEntry(backendId?: BackendIDType, index?: LocalIDType) {
|
||||
if (backendId) {
|
||||
const index = localIDByBackendIDMap.get(backendId)
|
||||
|
||||
localIDByBackendIDMap.delete(backendId)
|
||||
index && backendIDByLocalIDMap.delete(index)
|
||||
} else if (index) {
|
||||
const backendId = backendIDByLocalIDMap.get(index)
|
||||
|
||||
backendIDByLocalIDMap.delete(index)
|
||||
backendId && localIDByBackendIDMap.delete(backendId)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
114
packages/hoppscotch-selfhost-desktop/src/main.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createHoppApp } from "@hoppscotch/common"
|
||||
import { def as authDef } from "./platform/auth"
|
||||
import { def as environmentsDef } from "./platform/environments/environments.platform"
|
||||
import { def as collectionsDef } from "./platform/collections/collections.platform"
|
||||
import { def as settingsDef } from "./platform/settings/settings.platform"
|
||||
import { def as historyDef } from "./platform/history/history.platform"
|
||||
import { def as tabStateDef } from "./platform/tabState/tabState.platform"
|
||||
import { localclientInterceptor } from "./platform/std/interceptors/localclient"
|
||||
import { browserInterceptor } from "@hoppscotch/common/platform/std/interceptors/browser"
|
||||
import { proxyInterceptor } from "@hoppscotch/common/platform/std/interceptors/proxy"
|
||||
import { ExtensionInspectorService } from "@hoppscotch/common/platform/std/inspections/extension.inspector"
|
||||
import { ExtensionInterceptorService } from "@hoppscotch/common/platform/std/interceptors/extension"
|
||||
import { nextTick, ref, watch } from "vue"
|
||||
import { emit, listen } from "@tauri-apps/api/event"
|
||||
import { type } from "@tauri-apps/api/os"
|
||||
import { useSettingStatic } from "@hoppscotch/common/composables/settings"
|
||||
import { appWindow } from "@tauri-apps/api/window"
|
||||
import { stdFooterItems } from "@hoppscotch/common/platform/std/ui/footerItem"
|
||||
import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/supportOptionsItem"
|
||||
import { useMousePressed } from "@vueuse/core"
|
||||
|
||||
const headerPaddingLeft = ref("0px")
|
||||
const headerPaddingTop = ref("0px")
|
||||
|
||||
createHoppApp("#app", {
|
||||
ui: {
|
||||
additionalFooterMenuItems: stdFooterItems,
|
||||
additionalSupportOptionsMenuItems: stdSupportOptionItems,
|
||||
appHeader: {
|
||||
paddingLeft: headerPaddingLeft,
|
||||
paddingTop: headerPaddingTop,
|
||||
},
|
||||
},
|
||||
auth: authDef,
|
||||
sync: {
|
||||
environments: environmentsDef,
|
||||
collections: collectionsDef,
|
||||
settings: settingsDef,
|
||||
history: historyDef,
|
||||
tabState: tabStateDef,
|
||||
},
|
||||
interceptors: {
|
||||
default: "localclient",
|
||||
interceptors: [
|
||||
{ type: "standalone", interceptor: localclientInterceptor },
|
||||
{ type: "standalone", interceptor: browserInterceptor },
|
||||
{ type: "standalone", interceptor: proxyInterceptor },
|
||||
{ type: "service", service: ExtensionInterceptorService },
|
||||
],
|
||||
},
|
||||
additionalInspectors: [
|
||||
{ type: "service", service: ExtensionInspectorService },
|
||||
],
|
||||
platformFeatureFlags: {
|
||||
exportAsGIST: false,
|
||||
hasTelemetry: false,
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
useSettingStatic("BG_COLOR")[0],
|
||||
async () => {
|
||||
await nextTick()
|
||||
|
||||
await emit(
|
||||
"hopp-bg-changed",
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
"--primary-color"
|
||||
)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
;(async () => {
|
||||
const platform = await type()
|
||||
|
||||
if (platform === "Darwin") {
|
||||
listen("will-enter-fullscreen", () => {
|
||||
headerPaddingTop.value = "0px"
|
||||
headerPaddingLeft.value = "0px"
|
||||
})
|
||||
|
||||
listen("will-exit-fullscreen", () => {
|
||||
headerPaddingTop.value = "2px"
|
||||
headerPaddingLeft.value = "70px"
|
||||
})
|
||||
|
||||
headerPaddingTop.value = "2px"
|
||||
headerPaddingLeft.value = "70px"
|
||||
}
|
||||
})()
|
||||
|
||||
const { pressed } = useMousePressed()
|
||||
|
||||
document.addEventListener("mousemove", (ev) => {
|
||||
const { clientX, clientY } = ev
|
||||
|
||||
const el = document.querySelector("header")
|
||||
|
||||
if (!el) return
|
||||
|
||||
const { left, top, width, height } = el.getBoundingClientRect()
|
||||
|
||||
if (
|
||||
clientX >= left &&
|
||||
clientX <= left + width &&
|
||||
clientY >= top &&
|
||||
clientY <= top + height
|
||||
) {
|
||||
if (pressed.value) {
|
||||
appWindow.startDragging()
|
||||
}
|
||||
}
|
||||
})
|
||||
373
packages/hoppscotch-selfhost-desktop/src/platform/auth.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import axios from "axios"
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthPlatformDef,
|
||||
HoppUser,
|
||||
} from "@hoppscotch/common/platform/auth"
|
||||
import { BehaviorSubject, Subject } from "rxjs"
|
||||
import {
|
||||
getLocalConfig,
|
||||
removeLocalConfig,
|
||||
setLocalConfig,
|
||||
} from "@hoppscotch/common/newstore/localpersistence"
|
||||
import { Ref, ref, watch } from "vue"
|
||||
import { open } from '@tauri-apps/api/shell'
|
||||
import { Body, getClient } from '@tauri-apps/api/http'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { Store } from "tauri-plugin-store-api";
|
||||
import { P } from "@tauri-apps/api/event-41a9edf5"
|
||||
|
||||
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
|
||||
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||
|
||||
async function logout() {
|
||||
let client = await getClient();
|
||||
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
|
||||
|
||||
const store = new Store("/Users/vivek/.creds.dat")
|
||||
await store.set("refresh_token", {})
|
||||
await store.set("access_token", {})
|
||||
await store.save()
|
||||
}
|
||||
|
||||
async function signInUserWithGithubFB() {
|
||||
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/github?redirect_uri=desktop`);
|
||||
}
|
||||
|
||||
async function signInUserWithGoogleFB() {
|
||||
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
|
||||
}
|
||||
|
||||
async function signInUserWithMicrosoftFB() {
|
||||
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/microsoft?redirect_uri=desktop`);
|
||||
}
|
||||
|
||||
async function getInitialUserDetails() {
|
||||
const store = new Store("/Users/vivek/.creds.dat");
|
||||
|
||||
try {
|
||||
const accessToken = await store.get("access_token")
|
||||
let client = await getClient()
|
||||
let body = {
|
||||
query: `query Me {
|
||||
me {
|
||||
uid
|
||||
displayName
|
||||
email
|
||||
photoURL
|
||||
isAdmin
|
||||
createdOn
|
||||
}
|
||||
}`}
|
||||
|
||||
let res = await client.post(`${import.meta.env.VITE_BACKEND_GQL_URL}`,
|
||||
Body.json(body), {
|
||||
headers: {
|
||||
"Cookie": `access_token=${accessToken.value}`,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return res.data
|
||||
} catch (error) {
|
||||
let res = {
|
||||
error: "auth/cookies_not_found"
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
const isGettingInitialUser: Ref<null | boolean> = ref(null)
|
||||
|
||||
function setUser(user: HoppUser | null) {
|
||||
currentUser$.next(user)
|
||||
probableUser$.next(user)
|
||||
|
||||
setLocalConfig("login_state", JSON.stringify(user))
|
||||
}
|
||||
|
||||
async function setInitialUser() {
|
||||
isGettingInitialUser.value = true
|
||||
const res = await getInitialUserDetails()
|
||||
|
||||
const error = res.errors && res.errors[0]
|
||||
|
||||
// no cookies sent. so the user is not logged in
|
||||
if (error && error.message === "auth/cookies_not_found") {
|
||||
setUser(null)
|
||||
isGettingInitialUser.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (error && error.message === "user/not_found") {
|
||||
setUser(null)
|
||||
isGettingInitialUser.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// cookies sent, but it is expired, we need to refresh the token
|
||||
if (error && error.message === "Unauthorized") {
|
||||
const isRefreshSuccess = await refreshToken()
|
||||
|
||||
if (isRefreshSuccess) {
|
||||
setInitialUser()
|
||||
} else {
|
||||
setUser(null)
|
||||
isGettingInitialUser.value = false
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// no errors, we have a valid user
|
||||
if (res.data && res.data.me) {
|
||||
const hoppBackendUser = res.data.me
|
||||
|
||||
const hoppUser: HoppUser = {
|
||||
uid: hoppBackendUser.uid,
|
||||
displayName: hoppBackendUser.displayName,
|
||||
email: hoppBackendUser.email,
|
||||
photoURL: hoppBackendUser.photoURL,
|
||||
// all our signin methods currently guarantees the email is verified
|
||||
emailVerified: true,
|
||||
}
|
||||
|
||||
setUser(hoppUser)
|
||||
|
||||
isGettingInitialUser.value = false
|
||||
|
||||
authEvents$.next({
|
||||
event: "login",
|
||||
user: hoppUser,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
const store = new Store("/Users/vivek/.creds.dat");
|
||||
try {
|
||||
const refreshToken = await store.get("refresh_token")
|
||||
|
||||
let client = await getClient()
|
||||
let res = await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/refresh`, {
|
||||
headers: { "Cookie": `refresh_token=${refreshToken.value}` }
|
||||
})
|
||||
|
||||
setAuthCookies(res.rawHeaders)
|
||||
|
||||
const isSuccessful = res.status === 200
|
||||
|
||||
if (isSuccessful) {
|
||||
authEvents$.next({
|
||||
event: "token_refresh",
|
||||
})
|
||||
}
|
||||
|
||||
return isSuccessful
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMagicLink(email: string) {
|
||||
const client = await getClient();
|
||||
let url = `${import.meta.env.VITE_BACKEND_API_URL}/auth/signin?origin=desktop`;
|
||||
|
||||
const res = await client.post(url, Body.json({ email }));
|
||||
|
||||
if (res.data && res.data.deviceIdentifier) {
|
||||
setLocalConfig("deviceIdentifier", res.data.deviceIdentifier)
|
||||
} else {
|
||||
throw new Error("test: does not get device identifier")
|
||||
}
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function setAuthCookies(rawHeaders: Array<String>) {
|
||||
let cookies = rawHeaders['set-cookie'].join("|")
|
||||
|
||||
const accessTokenMatch = cookies.match(/access_token=([^;]+)/);
|
||||
const refreshTokenMatch = cookies.match(/refresh_token=([^;]+)/);
|
||||
|
||||
const store = new Store("/Users/vivek/.creds.dat")
|
||||
|
||||
if (accessTokenMatch) {
|
||||
const accessToken = accessTokenMatch[1];
|
||||
await store.set("access_token", { value: accessToken })
|
||||
}
|
||||
|
||||
if (refreshTokenMatch) {
|
||||
const refreshToken = refreshTokenMatch[1];
|
||||
await store.set("refresh_token", { value: refreshToken })
|
||||
}
|
||||
|
||||
await store.save()
|
||||
}
|
||||
|
||||
|
||||
export const def: AuthPlatformDef = {
|
||||
getCurrentUserStream: () => currentUser$,
|
||||
getAuthEventsStream: () => authEvents$,
|
||||
getProbableUserStream: () => probableUser$,
|
||||
|
||||
getCurrentUser: () => currentUser$.value,
|
||||
getProbableUser: () => probableUser$.value,
|
||||
|
||||
getBackendHeaders() {
|
||||
return {}
|
||||
},
|
||||
getGQLClientOptions() {
|
||||
return {
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* it is not possible for us to know if the current cookie is expired because we cannot access http-only cookies from js
|
||||
* hence just returning if the currentUser$ has a value associated with it
|
||||
*/
|
||||
willBackendHaveAuthError() {
|
||||
return !currentUser$.value
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
onBackendGQLClientShouldReconnect(func) {
|
||||
authEvents$.subscribe((event) => {
|
||||
if (
|
||||
event.event == "login" ||
|
||||
event.event == "logout" ||
|
||||
event.event == "token_refresh"
|
||||
) {
|
||||
func()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* we cannot access our auth cookies from javascript, so leaving this as null
|
||||
*/
|
||||
getDevOptsBackendIDToken() {
|
||||
return null
|
||||
},
|
||||
async performAuthInit() {
|
||||
const probableUser = JSON.parse(getLocalConfig("login_state") ?? "null")
|
||||
probableUser$.next(probableUser)
|
||||
await setInitialUser()
|
||||
|
||||
await listen('scheme-request-received', async (event: any) => {
|
||||
let deep_link = event.payload as string;
|
||||
|
||||
const params = new URLSearchParams(deep_link.split('?')[1]);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const token = params.get('token');
|
||||
|
||||
function isNotNullOrUndefined(x: any) {
|
||||
return x !== null && x !== undefined;
|
||||
}
|
||||
|
||||
if (isNotNullOrUndefined(accessToken) && isNotNullOrUndefined(refreshToken)) {
|
||||
const store = new Store("/Users/vivek/.creds.dat")
|
||||
|
||||
await store.set("access_token", { value: accessToken });
|
||||
await store.set("refresh_token", { value: refreshToken } );
|
||||
await store.save()
|
||||
|
||||
window.location.href = "/"
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNotNullOrUndefined(token)) {
|
||||
setLocalConfig("verifyToken", token)
|
||||
await this.signInWithEmailLink("", "")
|
||||
await setInitialUser()
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
waitProbableLoginToConfirm() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (this.getCurrentUser()) {
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (!probableUser$.value) reject(new Error("no_probable_user"))
|
||||
|
||||
const unwatch = watch(isGettingInitialUser, (val) => {
|
||||
if (val === true || val === false) {
|
||||
resolve()
|
||||
unwatch()
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async signInWithEmail(email: string) {
|
||||
await sendMagicLink(email)
|
||||
},
|
||||
|
||||
async verifyEmailAddress() {
|
||||
return
|
||||
},
|
||||
async signInUserWithGoogle() {
|
||||
await signInUserWithGoogleFB()
|
||||
},
|
||||
async signInUserWithGithub() {
|
||||
await signInUserWithGithubFB()
|
||||
return undefined
|
||||
},
|
||||
async signInUserWithMicrosoft() {
|
||||
await signInUserWithMicrosoftFB()
|
||||
},
|
||||
async signInWithEmailLink(_email, _url) {
|
||||
const deviceIdentifier = getLocalConfig("deviceIdentifier")
|
||||
|
||||
if (!deviceIdentifier) {
|
||||
throw new Error(
|
||||
"Device Identifier not found, you can only signin from the browser you generated the magic link"
|
||||
)
|
||||
}
|
||||
|
||||
let verifyToken = getLocalConfig("verifyToken")
|
||||
|
||||
const client = await getClient();
|
||||
let res = await client.post(`${import.meta.env.VITE_BACKEND_API_URL}/auth/verify`, Body.json({
|
||||
token: verifyToken,
|
||||
deviceIdentifier
|
||||
}));
|
||||
|
||||
setAuthCookies(res.rawHeaders)
|
||||
|
||||
removeLocalConfig("deviceIdentifier")
|
||||
removeLocalConfig("verifyToken")
|
||||
window.location.href = "/"
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async setEmailAddress(_email: string) {
|
||||
return
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async setDisplayName(name: string) {
|
||||
return
|
||||
},
|
||||
|
||||
async signOutUser() {
|
||||
// if (!currentUser$.value) throw new Error("No user has logged in")
|
||||
|
||||
await logout()
|
||||
|
||||
probableUser$.next(null)
|
||||
currentUser$.next(null)
|
||||
removeLocalConfig("login_state")
|
||||
|
||||
authEvents$.next({
|
||||
event: "logout",
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import {
|
||||
runGQLQuery,
|
||||
runGQLSubscription,
|
||||
runMutation,
|
||||
} from "@hoppscotch/common/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateRestRootUserCollectionDocument,
|
||||
CreateRestRootUserCollectionMutation,
|
||||
CreateRestRootUserCollectionMutationVariables,
|
||||
CreateRestUserRequestMutation,
|
||||
CreateRestUserRequestMutationVariables,
|
||||
CreateRestUserRequestDocument,
|
||||
CreateRestChildUserCollectionMutation,
|
||||
CreateRestChildUserCollectionMutationVariables,
|
||||
CreateRestChildUserCollectionDocument,
|
||||
DeleteUserCollectionMutation,
|
||||
DeleteUserCollectionMutationVariables,
|
||||
DeleteUserCollectionDocument,
|
||||
RenameUserCollectionMutation,
|
||||
RenameUserCollectionMutationVariables,
|
||||
RenameUserCollectionDocument,
|
||||
MoveUserCollectionMutation,
|
||||
MoveUserCollectionMutationVariables,
|
||||
MoveUserCollectionDocument,
|
||||
DeleteUserRequestMutation,
|
||||
DeleteUserRequestMutationVariables,
|
||||
DeleteUserRequestDocument,
|
||||
MoveUserRequestDocument,
|
||||
MoveUserRequestMutation,
|
||||
MoveUserRequestMutationVariables,
|
||||
UpdateUserCollectionOrderMutation,
|
||||
UpdateUserCollectionOrderMutationVariables,
|
||||
UpdateUserCollectionOrderDocument,
|
||||
GetUserRootCollectionsQuery,
|
||||
GetUserRootCollectionsQueryVariables,
|
||||
GetUserRootCollectionsDocument,
|
||||
UserCollectionCreatedDocument,
|
||||
UserCollectionUpdatedDocument,
|
||||
UserCollectionRemovedDocument,
|
||||
UserCollectionMovedDocument,
|
||||
UserCollectionOrderUpdatedDocument,
|
||||
ExportUserCollectionsToJsonQuery,
|
||||
ExportUserCollectionsToJsonQueryVariables,
|
||||
ExportUserCollectionsToJsonDocument,
|
||||
UserRequestCreatedDocument,
|
||||
UserRequestUpdatedDocument,
|
||||
UserRequestMovedDocument,
|
||||
UserRequestDeletedDocument,
|
||||
UpdateRestUserRequestMutation,
|
||||
UpdateRestUserRequestMutationVariables,
|
||||
UpdateRestUserRequestDocument,
|
||||
CreateGqlRootUserCollectionMutation,
|
||||
CreateGqlRootUserCollectionMutationVariables,
|
||||
CreateGqlRootUserCollectionDocument,
|
||||
CreateGqlUserRequestMutation,
|
||||
CreateGqlUserRequestMutationVariables,
|
||||
CreateGqlUserRequestDocument,
|
||||
CreateGqlChildUserCollectionMutation,
|
||||
CreateGqlChildUserCollectionMutationVariables,
|
||||
CreateGqlChildUserCollectionDocument,
|
||||
UpdateGqlUserRequestMutation,
|
||||
UpdateGqlUserRequestMutationVariables,
|
||||
UpdateGqlUserRequestDocument,
|
||||
GetGqlRootUserCollectionsQuery,
|
||||
GetGqlRootUserCollectionsQueryVariables,
|
||||
GetGqlRootUserCollectionsDocument,
|
||||
ReqType,
|
||||
} from "../../api/generated/graphql"
|
||||
|
||||
export const createRESTRootUserCollection = (title: string) =>
|
||||
runMutation<
|
||||
CreateRestRootUserCollectionMutation,
|
||||
CreateRestRootUserCollectionMutationVariables,
|
||||
""
|
||||
>(CreateRestRootUserCollectionDocument, {
|
||||
title,
|
||||
})()
|
||||
|
||||
export const createGQLRootUserCollection = (title: string) =>
|
||||
runMutation<
|
||||
CreateGqlRootUserCollectionMutation,
|
||||
CreateGqlRootUserCollectionMutationVariables,
|
||||
""
|
||||
>(CreateGqlRootUserCollectionDocument, {
|
||||
title,
|
||||
})()
|
||||
|
||||
export const createRESTUserRequest = (
|
||||
title: string,
|
||||
request: string,
|
||||
collectionID: string
|
||||
) =>
|
||||
runMutation<
|
||||
CreateRestUserRequestMutation,
|
||||
CreateRestUserRequestMutationVariables,
|
||||
""
|
||||
>(CreateRestUserRequestDocument, {
|
||||
title,
|
||||
request,
|
||||
collectionID,
|
||||
})()
|
||||
|
||||
export const createGQLUserRequest = (
|
||||
title: string,
|
||||
request: string,
|
||||
collectionID: string
|
||||
) =>
|
||||
runMutation<
|
||||
CreateGqlUserRequestMutation,
|
||||
CreateGqlUserRequestMutationVariables,
|
||||
""
|
||||
>(CreateGqlUserRequestDocument, {
|
||||
title,
|
||||
request,
|
||||
collectionID,
|
||||
})()
|
||||
|
||||
export const createRESTChildUserCollection = (
|
||||
title: string,
|
||||
parentUserCollectionID: string
|
||||
) =>
|
||||
runMutation<
|
||||
CreateRestChildUserCollectionMutation,
|
||||
CreateRestChildUserCollectionMutationVariables,
|
||||
""
|
||||
>(CreateRestChildUserCollectionDocument, {
|
||||
title,
|
||||
parentUserCollectionID,
|
||||
})()
|
||||
|
||||
export const createGQLChildUserCollection = (
|
||||
title: string,
|
||||
parentUserCollectionID: string
|
||||
) =>
|
||||
runMutation<
|
||||
CreateGqlChildUserCollectionMutation,
|
||||
CreateGqlChildUserCollectionMutationVariables,
|
||||
""
|
||||
>(CreateGqlChildUserCollectionDocument, {
|
||||
title,
|
||||
parentUserCollectionID,
|
||||
})()
|
||||
|
||||
export const deleteUserCollection = (userCollectionID: string) =>
|
||||
runMutation<
|
||||
DeleteUserCollectionMutation,
|
||||
DeleteUserCollectionMutationVariables,
|
||||
""
|
||||
>(DeleteUserCollectionDocument, {
|
||||
userCollectionID,
|
||||
})()
|
||||
|
||||
export const renameUserCollection = (
|
||||
userCollectionID: string,
|
||||
newTitle: string
|
||||
) =>
|
||||
runMutation<
|
||||
RenameUserCollectionMutation,
|
||||
RenameUserCollectionMutationVariables,
|
||||
""
|
||||
>(RenameUserCollectionDocument, { userCollectionID, newTitle })()
|
||||
|
||||
export const moveUserCollection = (
|
||||
sourceCollectionID: string,
|
||||
destinationCollectionID?: string
|
||||
) =>
|
||||
runMutation<
|
||||
MoveUserCollectionMutation,
|
||||
MoveUserCollectionMutationVariables,
|
||||
""
|
||||
>(MoveUserCollectionDocument, {
|
||||
userCollectionID: sourceCollectionID,
|
||||
destCollectionID: destinationCollectionID,
|
||||
})()
|
||||
|
||||
export const editUserRequest = (
|
||||
requestID: string,
|
||||
title: string,
|
||||
request: string
|
||||
) =>
|
||||
runMutation<
|
||||
UpdateRestUserRequestMutation,
|
||||
UpdateRestUserRequestMutationVariables,
|
||||
""
|
||||
>(UpdateRestUserRequestDocument, {
|
||||
id: requestID,
|
||||
request,
|
||||
title,
|
||||
})()
|
||||
|
||||
export const editGQLUserRequest = (
|
||||
requestID: string,
|
||||
title: string,
|
||||
request: string
|
||||
) =>
|
||||
runMutation<
|
||||
UpdateGqlUserRequestMutation,
|
||||
UpdateGqlUserRequestMutationVariables,
|
||||
""
|
||||
>(UpdateGqlUserRequestDocument, {
|
||||
id: requestID,
|
||||
request,
|
||||
title,
|
||||
})()
|
||||
|
||||
export const deleteUserRequest = (requestID: string) =>
|
||||
runMutation<
|
||||
DeleteUserRequestMutation,
|
||||
DeleteUserRequestMutationVariables,
|
||||
""
|
||||
>(DeleteUserRequestDocument, {
|
||||
requestID,
|
||||
})()
|
||||
|
||||
export const moveUserRequest = (
|
||||
sourceCollectionID: string,
|
||||
destinationCollectionID: string,
|
||||
requestID: string,
|
||||
nextRequestID?: string
|
||||
) =>
|
||||
runMutation<MoveUserRequestMutation, MoveUserRequestMutationVariables, "">(
|
||||
MoveUserRequestDocument,
|
||||
{
|
||||
sourceCollectionID,
|
||||
destinationCollectionID,
|
||||
requestID,
|
||||
nextRequestID,
|
||||
}
|
||||
)()
|
||||
|
||||
export const updateUserCollectionOrder = (
|
||||
collectionID: string,
|
||||
nextCollectionID?: string
|
||||
) =>
|
||||
runMutation<
|
||||
UpdateUserCollectionOrderMutation,
|
||||
UpdateUserCollectionOrderMutationVariables,
|
||||
""
|
||||
>(UpdateUserCollectionOrderDocument, {
|
||||
collectionID,
|
||||
nextCollectionID,
|
||||
})()
|
||||
|
||||
export const getUserRootCollections = () =>
|
||||
runGQLQuery<
|
||||
GetUserRootCollectionsQuery,
|
||||
GetUserRootCollectionsQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: GetUserRootCollectionsDocument,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
export const getGQLRootUserCollections = () =>
|
||||
runGQLQuery<
|
||||
GetGqlRootUserCollectionsQuery,
|
||||
GetGqlRootUserCollectionsQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: GetGqlRootUserCollectionsDocument,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
export const exportUserCollectionsToJSON = (
|
||||
collectionID?: string,
|
||||
collectionType: ReqType.Rest | ReqType.Gql = ReqType.Rest
|
||||
) =>
|
||||
runGQLQuery<
|
||||
ExportUserCollectionsToJsonQuery,
|
||||
ExportUserCollectionsToJsonQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: ExportUserCollectionsToJsonDocument,
|
||||
variables: { collectionID, collectionType },
|
||||
})
|
||||
|
||||
export const runUserCollectionCreatedSubscription = () =>
|
||||
runGQLSubscription({ query: UserCollectionCreatedDocument, variables: {} })
|
||||
|
||||
export const runUserCollectionUpdatedSubscription = () =>
|
||||
runGQLSubscription({ query: UserCollectionUpdatedDocument, variables: {} })
|
||||
|
||||
export const runUserCollectionRemovedSubscription = () =>
|
||||
runGQLSubscription({ query: UserCollectionRemovedDocument, variables: {} })
|
||||
|
||||
export const runUserCollectionMovedSubscription = () =>
|
||||
runGQLSubscription({ query: UserCollectionMovedDocument, variables: {} })
|
||||
|
||||
export const runUserCollectionOrderUpdatedSubscription = () =>
|
||||
runGQLSubscription({
|
||||
query: UserCollectionOrderUpdatedDocument,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
export const runUserRequestCreatedSubscription = () =>
|
||||
runGQLSubscription({ query: UserRequestCreatedDocument, variables: {} })
|
||||
|
||||
export const runUserRequestUpdatedSubscription = () =>
|
||||
runGQLSubscription({ query: UserRequestUpdatedDocument, variables: {} })
|
||||
|
||||
export const runUserRequestMovedSubscription = () =>
|
||||
runGQLSubscription({ query: UserRequestMovedDocument, variables: {} })
|
||||
|
||||
export const runUserRequestDeletedSubscription = () =>
|
||||
runGQLSubscription({ query: UserRequestDeletedDocument, variables: {} })
|
||||
@@ -0,0 +1,789 @@
|
||||
import { authEvents$, def as platformAuth } from "@platform/auth"
|
||||
import { CollectionsPlatformDef } from "@hoppscotch/common/platform/collections"
|
||||
import { runDispatchWithOutSyncing } from "../../lib/sync"
|
||||
|
||||
import {
|
||||
exportUserCollectionsToJSON,
|
||||
runUserCollectionCreatedSubscription,
|
||||
runUserCollectionMovedSubscription,
|
||||
runUserCollectionOrderUpdatedSubscription,
|
||||
runUserCollectionRemovedSubscription,
|
||||
runUserCollectionUpdatedSubscription,
|
||||
runUserRequestCreatedSubscription,
|
||||
runUserRequestDeletedSubscription,
|
||||
runUserRequestMovedSubscription,
|
||||
runUserRequestUpdatedSubscription,
|
||||
} from "./collections.api"
|
||||
import { collectionsSyncer, getStoreByCollectionType } from "./collections.sync"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
addRESTCollection,
|
||||
setRESTCollections,
|
||||
editRESTCollection,
|
||||
removeRESTCollection,
|
||||
moveRESTFolder,
|
||||
updateRESTCollectionOrder,
|
||||
saveRESTRequestAs,
|
||||
navigateToFolderWithIndexPath,
|
||||
editRESTRequest,
|
||||
removeRESTRequest,
|
||||
moveRESTRequest,
|
||||
updateRESTRequestOrder,
|
||||
addRESTFolder,
|
||||
editRESTFolder,
|
||||
removeRESTFolder,
|
||||
addGraphqlFolder,
|
||||
addGraphqlCollection,
|
||||
editGraphqlFolder,
|
||||
editGraphqlCollection,
|
||||
removeGraphqlFolder,
|
||||
removeGraphqlCollection,
|
||||
saveGraphqlRequestAs,
|
||||
editGraphqlRequest,
|
||||
moveGraphqlRequest,
|
||||
removeGraphqlRequest,
|
||||
setGraphqlCollections,
|
||||
restCollectionStore,
|
||||
} from "@hoppscotch/common/newstore/collections"
|
||||
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { gqlCollectionsSyncer } from "./gqlCollections.sync"
|
||||
import { ReqType } from "../../api/generated/graphql"
|
||||
|
||||
function initCollectionsSync() {
|
||||
const currentUser$ = platformAuth.getCurrentUserStream()
|
||||
collectionsSyncer.startStoreSync()
|
||||
collectionsSyncer.setupSubscriptions(setupSubscriptions)
|
||||
|
||||
gqlCollectionsSyncer.startStoreSync()
|
||||
|
||||
loadUserCollections("REST")
|
||||
loadUserCollections("GQL")
|
||||
|
||||
// TODO: test & make sure the auth thing is working properly
|
||||
currentUser$.subscribe(async (user) => {
|
||||
if (user) {
|
||||
loadUserCollections("REST")
|
||||
loadUserCollections("GQL")
|
||||
}
|
||||
})
|
||||
|
||||
authEvents$.subscribe((event) => {
|
||||
if (event.event == "login" || event.event == "token_refresh") {
|
||||
collectionsSyncer.startListeningToSubscriptions()
|
||||
}
|
||||
|
||||
if (event.event == "logout") {
|
||||
collectionsSyncer.stopListeningToSubscriptions()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type ExportedUserCollectionREST = {
|
||||
id?: string
|
||||
folders: ExportedUserCollectionREST[]
|
||||
requests: Array<HoppRESTRequest & { id: string }>
|
||||
name: string
|
||||
}
|
||||
|
||||
type ExportedUserCollectionGQL = {
|
||||
id?: string
|
||||
folders: ExportedUserCollectionGQL[]
|
||||
requests: Array<HoppGQLRequest & { id: string }>
|
||||
name: string
|
||||
}
|
||||
|
||||
function exportedCollectionToHoppCollection(
|
||||
collection: ExportedUserCollectionREST | ExportedUserCollectionGQL,
|
||||
collectionType: "REST" | "GQL"
|
||||
): HoppCollection<HoppRESTRequest | HoppGQLRequest> {
|
||||
if (collectionType == "REST") {
|
||||
const restCollection = collection as ExportedUserCollectionREST
|
||||
|
||||
return {
|
||||
id: restCollection.id,
|
||||
v: 1,
|
||||
name: restCollection.name,
|
||||
folders: restCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
),
|
||||
requests: restCollection.requests.map(
|
||||
({
|
||||
id,
|
||||
v,
|
||||
auth,
|
||||
body,
|
||||
endpoint,
|
||||
headers,
|
||||
method,
|
||||
name,
|
||||
params,
|
||||
preRequestScript,
|
||||
testScript,
|
||||
}) => ({
|
||||
id,
|
||||
v,
|
||||
auth,
|
||||
body,
|
||||
endpoint,
|
||||
headers,
|
||||
method,
|
||||
name,
|
||||
params,
|
||||
preRequestScript,
|
||||
testScript,
|
||||
})
|
||||
),
|
||||
}
|
||||
} else {
|
||||
const gqlCollection = collection as ExportedUserCollectionGQL
|
||||
|
||||
return {
|
||||
id: gqlCollection.id,
|
||||
v: 1,
|
||||
name: gqlCollection.name,
|
||||
folders: gqlCollection.folders.map((folder) =>
|
||||
exportedCollectionToHoppCollection(folder, collectionType)
|
||||
),
|
||||
requests: gqlCollection.requests.map(
|
||||
({ v, auth, headers, name, id }) => ({
|
||||
id,
|
||||
v,
|
||||
auth,
|
||||
headers,
|
||||
name,
|
||||
})
|
||||
) as HoppGQLRequest[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserCollections(collectionType: "REST" | "GQL") {
|
||||
const res = await exportUserCollectionsToJSON(
|
||||
undefined,
|
||||
collectionType == "REST" ? ReqType.Rest : ReqType.Gql
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const collectionsJSONString =
|
||||
res.right.exportUserCollectionsToJSON.exportedCollection
|
||||
const exportedCollections = (
|
||||
JSON.parse(collectionsJSONString) as Array<
|
||||
ExportedUserCollectionGQL | ExportedUserCollectionREST
|
||||
>
|
||||
).map((collection) => ({ v: 1, ...collection }))
|
||||
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "REST"
|
||||
? setRESTCollections(
|
||||
exportedCollections.map(
|
||||
(collection) =>
|
||||
exportedCollectionToHoppCollection(
|
||||
collection,
|
||||
"REST"
|
||||
) as HoppCollection<HoppRESTRequest>
|
||||
)
|
||||
)
|
||||
: setGraphqlCollections(
|
||||
exportedCollections.map(
|
||||
(collection) =>
|
||||
exportedCollectionToHoppCollection(
|
||||
collection,
|
||||
"GQL"
|
||||
) as HoppCollection<HoppGQLRequest>
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setupSubscriptions() {
|
||||
let subs: ReturnType<typeof runGQLSubscription>[1][] = []
|
||||
|
||||
const userCollectionCreatedSub = setupUserCollectionCreatedSubscription()
|
||||
const userCollectionUpdatedSub = setupUserCollectionUpdatedSubscription()
|
||||
const userCollectionRemovedSub = setupUserCollectionRemovedSubscription()
|
||||
const userCollectionMovedSub = setupUserCollectionMovedSubscription()
|
||||
const userCollectionOrderUpdatedSub =
|
||||
setupUserCollectionOrderUpdatedSubscription()
|
||||
const userRequestCreatedSub = setupUserRequestCreatedSubscription()
|
||||
const userRequestUpdatedSub = setupUserRequestUpdatedSubscription()
|
||||
const userRequestDeletedSub = setupUserRequestDeletedSubscription()
|
||||
const userRequestMovedSub = setupUserRequestMovedSubscription()
|
||||
|
||||
subs = [
|
||||
userCollectionCreatedSub,
|
||||
userCollectionUpdatedSub,
|
||||
userCollectionRemovedSub,
|
||||
userCollectionMovedSub,
|
||||
userCollectionOrderUpdatedSub,
|
||||
userRequestCreatedSub,
|
||||
userRequestUpdatedSub,
|
||||
userRequestDeletedSub,
|
||||
userRequestMovedSub,
|
||||
]
|
||||
|
||||
return () => {
|
||||
subs.forEach((sub) => sub.unsubscribe())
|
||||
}
|
||||
}
|
||||
|
||||
function setupUserCollectionCreatedSubscription() {
|
||||
const [userCollectionCreated$, userCollectionCreatedSub] =
|
||||
runUserCollectionCreatedSubscription()
|
||||
|
||||
userCollectionCreated$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const collectionType = res.right.userCollectionCreated.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(collectionType)
|
||||
|
||||
const userCollectionBackendID = res.right.userCollectionCreated.id
|
||||
const parentCollectionID = res.right.userCollectionCreated.parent?.id
|
||||
|
||||
const userCollectionLocalID = getCollectionPathFromCollectionID(
|
||||
userCollectionBackendID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
// collection already exists in store ( this instance created it )
|
||||
if (userCollectionLocalID) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentCollectionPath =
|
||||
parentCollectionID &&
|
||||
getCollectionPathFromCollectionID(
|
||||
parentCollectionID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
// only folders will have parent collection id
|
||||
if (parentCollectionID && parentCollectionPath) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "GQL"
|
||||
? addGraphqlFolder(
|
||||
res.right.userCollectionCreated.title,
|
||||
parentCollectionPath
|
||||
)
|
||||
: addRESTFolder(
|
||||
res.right.userCollectionCreated.title,
|
||||
parentCollectionPath
|
||||
)
|
||||
|
||||
const parentCollection = navigateToFolderWithIndexPath(
|
||||
collectionStore.value.state,
|
||||
parentCollectionPath
|
||||
.split("/")
|
||||
.map((pathIndex) => parseInt(pathIndex))
|
||||
)
|
||||
|
||||
if (parentCollection) {
|
||||
const folderIndex = parentCollection.folders.length - 1
|
||||
|
||||
const addedFolder = parentCollection.folders[folderIndex]
|
||||
addedFolder.id = userCollectionBackendID
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// root collections won't have parentCollectionID
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "GQL"
|
||||
? addGraphqlCollection({
|
||||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 1,
|
||||
})
|
||||
: addRESTCollection({
|
||||
name: res.right.userCollectionCreated.title,
|
||||
folders: [],
|
||||
requests: [],
|
||||
v: 1,
|
||||
})
|
||||
|
||||
const localIndex = collectionStore.value.state.length - 1
|
||||
|
||||
const addedCollection = collectionStore.value.state[localIndex]
|
||||
addedCollection.id = userCollectionBackendID
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userCollectionCreatedSub
|
||||
}
|
||||
|
||||
function setupUserCollectionUpdatedSubscription() {
|
||||
const [userCollectionUpdated$, userCollectionUpdatedSub] =
|
||||
runUserCollectionUpdatedSubscription()
|
||||
|
||||
userCollectionUpdated$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const collectionType = res.right.userCollectionUpdated.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(collectionType)
|
||||
|
||||
const updatedCollectionBackendID = res.right.userCollectionUpdated.id
|
||||
const updatedCollectionLocalPath = getCollectionPathFromCollectionID(
|
||||
updatedCollectionBackendID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
const isFolder =
|
||||
updatedCollectionLocalPath &&
|
||||
updatedCollectionLocalPath.split("/").length > 1
|
||||
|
||||
// updated collection is a folder
|
||||
if (isFolder) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "REST"
|
||||
? editRESTFolder(updatedCollectionLocalPath, {
|
||||
name: res.right.userCollectionUpdated.title,
|
||||
})
|
||||
: editGraphqlFolder(updatedCollectionLocalPath, {
|
||||
name: res.right.userCollectionUpdated.title,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// updated collection is a root collection
|
||||
if (updatedCollectionLocalPath && !isFolder) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "REST"
|
||||
? editRESTCollection(parseInt(updatedCollectionLocalPath), {
|
||||
name: res.right.userCollectionUpdated.title,
|
||||
})
|
||||
: editGraphqlCollection(parseInt(updatedCollectionLocalPath), {
|
||||
name: res.right.userCollectionUpdated.title,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userCollectionUpdatedSub
|
||||
}
|
||||
|
||||
function setupUserCollectionMovedSubscription() {
|
||||
const [userCollectionMoved$, userCollectionMovedSub] =
|
||||
runUserCollectionMovedSubscription()
|
||||
|
||||
userCollectionMoved$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const movedMetadata = res.right.userCollectionMoved
|
||||
|
||||
const sourcePath = getCollectionPathFromCollectionID(
|
||||
movedMetadata.id,
|
||||
restCollectionStore.value.state
|
||||
)
|
||||
|
||||
let destinationPath: string | undefined
|
||||
|
||||
if (movedMetadata.parent?.id) {
|
||||
destinationPath =
|
||||
getCollectionPathFromCollectionID(
|
||||
movedMetadata.parent?.id,
|
||||
restCollectionStore.value.state
|
||||
) ?? undefined
|
||||
}
|
||||
|
||||
sourcePath &&
|
||||
runDispatchWithOutSyncing(() => {
|
||||
moveRESTFolder(sourcePath, destinationPath ?? null)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return userCollectionMovedSub
|
||||
}
|
||||
|
||||
function setupUserCollectionRemovedSubscription() {
|
||||
const [userCollectionRemoved$, userCollectionRemovedSub] =
|
||||
runUserCollectionRemovedSubscription()
|
||||
|
||||
userCollectionRemoved$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const removedCollectionBackendID = res.right.userCollectionRemoved.id
|
||||
const collectionType = res.right.userCollectionRemoved.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(collectionType)
|
||||
|
||||
const removedCollectionLocalPath = getCollectionPathFromCollectionID(
|
||||
removedCollectionBackendID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
const isFolder =
|
||||
removedCollectionLocalPath &&
|
||||
removedCollectionLocalPath.split("/").length > 1
|
||||
|
||||
if (removedCollectionLocalPath && isFolder) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "REST"
|
||||
? removeRESTFolder(removedCollectionLocalPath)
|
||||
: removeGraphqlFolder(removedCollectionLocalPath)
|
||||
})
|
||||
}
|
||||
|
||||
if (removedCollectionLocalPath && !isFolder) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
collectionType == "REST"
|
||||
? removeRESTCollection(parseInt(removedCollectionLocalPath))
|
||||
: removeGraphqlCollection(parseInt(removedCollectionLocalPath))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userCollectionRemovedSub
|
||||
}
|
||||
|
||||
function setupUserCollectionOrderUpdatedSubscription() {
|
||||
const [userCollectionOrderUpdated$, userCollectionOrderUpdatedSub] =
|
||||
runUserCollectionOrderUpdatedSubscription()
|
||||
|
||||
userCollectionOrderUpdated$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const { userCollection, nextUserCollection } =
|
||||
res.right.userCollectionOrderUpdated
|
||||
|
||||
const sourceCollectionID = userCollection.id
|
||||
const destinationCollectionID = nextUserCollection?.id
|
||||
|
||||
const sourcePath = getCollectionPathFromCollectionID(
|
||||
sourceCollectionID,
|
||||
restCollectionStore.value.state
|
||||
)
|
||||
|
||||
let destinationPath: string | null | undefined
|
||||
|
||||
if (destinationCollectionID) {
|
||||
destinationPath = getCollectionPathFromCollectionID(
|
||||
destinationCollectionID,
|
||||
restCollectionStore.value.state
|
||||
)
|
||||
}
|
||||
|
||||
runDispatchWithOutSyncing(() => {
|
||||
if (sourcePath) {
|
||||
updateRESTCollectionOrder(sourcePath, destinationPath ?? null)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return userCollectionOrderUpdatedSub
|
||||
}
|
||||
|
||||
function setupUserRequestCreatedSubscription() {
|
||||
const [userRequestCreated$, userRequestCreatedSub] =
|
||||
runUserRequestCreatedSubscription()
|
||||
|
||||
userRequestCreated$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const collectionID = res.right.userRequestCreated.collectionID
|
||||
const request = JSON.parse(res.right.userRequestCreated.request)
|
||||
const requestID = res.right.userRequestCreated.id
|
||||
|
||||
const requestType = res.right.userRequestCreated.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(requestType)
|
||||
|
||||
const hasAlreadyHappened = getRequestPathFromRequestID(
|
||||
requestID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
if (!!hasAlreadyHappened) {
|
||||
return
|
||||
}
|
||||
|
||||
const collectionPath = getCollectionPathFromCollectionID(
|
||||
collectionID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
if (collectionID && collectionPath) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
requestType == "REST"
|
||||
? saveRESTRequestAs(collectionPath, request)
|
||||
: saveGraphqlRequestAs(collectionPath, request)
|
||||
|
||||
const target = navigateToFolderWithIndexPath(
|
||||
collectionStore.value.state,
|
||||
collectionPath.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const targetRequest = target?.requests[target?.requests.length - 1]
|
||||
|
||||
if (targetRequest) {
|
||||
targetRequest.id = requestID
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userRequestCreatedSub
|
||||
}
|
||||
|
||||
function setupUserRequestUpdatedSubscription() {
|
||||
const [userRequestUpdated$, userRequestUpdatedSub] =
|
||||
runUserRequestUpdatedSubscription()
|
||||
|
||||
userRequestUpdated$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const requestType = res.right.userRequestUpdated.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(requestType)
|
||||
|
||||
const requestPath = getRequestPathFromRequestID(
|
||||
res.right.userRequestUpdated.id,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
const collectionPath = requestPath?.collectionPath
|
||||
const requestIndex = requestPath?.requestIndex
|
||||
|
||||
;(requestIndex || requestIndex == 0) &&
|
||||
collectionPath &&
|
||||
runDispatchWithOutSyncing(() => {
|
||||
requestType == "REST"
|
||||
? editRESTRequest(
|
||||
collectionPath,
|
||||
requestIndex,
|
||||
JSON.parse(res.right.userRequestUpdated.request)
|
||||
)
|
||||
: editGraphqlRequest(
|
||||
collectionPath,
|
||||
requestIndex,
|
||||
JSON.parse(res.right.userRequestUpdated.request)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return userRequestUpdatedSub
|
||||
}
|
||||
|
||||
function setupUserRequestMovedSubscription() {
|
||||
const [userRequestMoved$, userRequestMovedSub] =
|
||||
runUserRequestMovedSubscription()
|
||||
|
||||
userRequestMoved$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const { request, nextRequest } = res.right.userRequestMoved
|
||||
|
||||
const {
|
||||
collectionID: destinationCollectionID,
|
||||
id: sourceRequestID,
|
||||
type: requestType,
|
||||
} = request
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(requestType)
|
||||
|
||||
const sourceRequestPath = getRequestPathFromRequestID(
|
||||
sourceRequestID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
const destinationCollectionPath = getCollectionPathFromCollectionID(
|
||||
destinationCollectionID,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
const destinationRequestIndex = destinationCollectionPath
|
||||
? (() => {
|
||||
const requestsLength = navigateToFolderWithIndexPath(
|
||||
collectionStore.value.state,
|
||||
destinationCollectionPath
|
||||
.split("/")
|
||||
.map((index) => parseInt(index))
|
||||
)?.requests.length
|
||||
|
||||
return requestsLength || requestsLength == 0
|
||||
? requestsLength - 1
|
||||
: undefined
|
||||
})()
|
||||
: undefined
|
||||
|
||||
// there is no nextRequest, so request is moved
|
||||
if (
|
||||
(destinationRequestIndex || destinationRequestIndex == 0) &&
|
||||
destinationCollectionPath &&
|
||||
sourceRequestPath &&
|
||||
!nextRequest
|
||||
) {
|
||||
runDispatchWithOutSyncing(() => {
|
||||
requestType == "REST"
|
||||
? moveRESTRequest(
|
||||
sourceRequestPath.collectionPath,
|
||||
sourceRequestPath.requestIndex,
|
||||
destinationCollectionPath
|
||||
)
|
||||
: moveGraphqlRequest(
|
||||
sourceRequestPath.collectionPath,
|
||||
sourceRequestPath.requestIndex,
|
||||
destinationCollectionPath
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// there is nextRequest, so request is reordered
|
||||
if (
|
||||
(destinationRequestIndex || destinationRequestIndex == 0) &&
|
||||
destinationCollectionPath &&
|
||||
nextRequest &&
|
||||
// we don't have request reordering for graphql yet
|
||||
requestType == "REST"
|
||||
) {
|
||||
const { collectionID: nextCollectionID, id: nextRequestID } =
|
||||
nextRequest
|
||||
|
||||
const nextCollectionPath =
|
||||
getCollectionPathFromCollectionID(
|
||||
nextCollectionID,
|
||||
collectionStore.value.state
|
||||
) ?? undefined
|
||||
|
||||
const nextRequestIndex = nextCollectionPath
|
||||
? getRequestIndex(
|
||||
nextRequestID,
|
||||
nextCollectionPath,
|
||||
collectionStore.value.state
|
||||
)
|
||||
: undefined
|
||||
|
||||
nextRequestIndex &&
|
||||
nextCollectionPath &&
|
||||
sourceRequestPath &&
|
||||
runDispatchWithOutSyncing(() => {
|
||||
updateRESTRequestOrder(
|
||||
sourceRequestPath?.requestIndex,
|
||||
nextRequestIndex,
|
||||
nextCollectionPath
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return userRequestMovedSub
|
||||
}
|
||||
|
||||
function setupUserRequestDeletedSubscription() {
|
||||
const [userRequestDeleted$, userRequestDeletedSub] =
|
||||
runUserRequestDeletedSubscription()
|
||||
|
||||
userRequestDeleted$.subscribe((res) => {
|
||||
if (E.isRight(res)) {
|
||||
const requestType = res.right.userRequestDeleted.type
|
||||
|
||||
const { collectionStore } = getStoreByCollectionType(requestType)
|
||||
|
||||
const deletedRequestPath = getRequestPathFromRequestID(
|
||||
res.right.userRequestDeleted.id,
|
||||
collectionStore.value.state
|
||||
)
|
||||
|
||||
;(deletedRequestPath?.requestIndex ||
|
||||
deletedRequestPath?.requestIndex == 0) &&
|
||||
deletedRequestPath.collectionPath &&
|
||||
runDispatchWithOutSyncing(() => {
|
||||
requestType == "REST"
|
||||
? removeRESTRequest(
|
||||
deletedRequestPath.collectionPath,
|
||||
deletedRequestPath.requestIndex
|
||||
)
|
||||
: removeGraphqlRequest(
|
||||
deletedRequestPath.collectionPath,
|
||||
deletedRequestPath.requestIndex
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return userRequestDeletedSub
|
||||
}
|
||||
|
||||
export const def: CollectionsPlatformDef = {
|
||||
initCollectionsSync,
|
||||
}
|
||||
|
||||
function getCollectionPathFromCollectionID(
|
||||
collectionID: string,
|
||||
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[],
|
||||
parentPath?: string
|
||||
): string | null {
|
||||
for (const collectionIndex in collections) {
|
||||
if (collections[collectionIndex].id == collectionID) {
|
||||
return parentPath
|
||||
? `${parentPath}/${collectionIndex}`
|
||||
: `${collectionIndex}`
|
||||
} else {
|
||||
const collectionPath = getCollectionPathFromCollectionID(
|
||||
collectionID,
|
||||
collections[collectionIndex].folders,
|
||||
parentPath ? `${parentPath}/${collectionIndex}` : `${collectionIndex}`
|
||||
)
|
||||
|
||||
if (collectionPath) return collectionPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getRequestPathFromRequestID(
|
||||
requestID: string,
|
||||
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[],
|
||||
parentPath?: string
|
||||
): { collectionPath: string; requestIndex: number } | null {
|
||||
for (const collectionIndex in collections) {
|
||||
const requestIndex = collections[collectionIndex].requests.findIndex(
|
||||
(request) => request.id == requestID
|
||||
)
|
||||
|
||||
if (requestIndex != -1) {
|
||||
return {
|
||||
collectionPath: parentPath
|
||||
? `${parentPath}/${collectionIndex}`
|
||||
: `${collectionIndex}`,
|
||||
requestIndex,
|
||||
}
|
||||
} else {
|
||||
const requestPath = getRequestPathFromRequestID(
|
||||
requestID,
|
||||
collections[collectionIndex].folders,
|
||||
parentPath ? `${parentPath}/${collectionIndex}` : `${collectionIndex}`
|
||||
)
|
||||
|
||||
if (requestPath) return requestPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getRequestIndex(
|
||||
requestID: string,
|
||||
parentCollectionPath: string,
|
||||
collections: HoppCollection<HoppRESTRequest | HoppGQLRequest>[]
|
||||
) {
|
||||
const collection = navigateToFolderWithIndexPath(
|
||||
collections,
|
||||
parentCollectionPath?.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const requestIndex = collection?.requests.findIndex(
|
||||
(request) => request.id == requestID
|
||||
)
|
||||
|
||||
return requestIndex
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import {
|
||||
graphqlCollectionStore,
|
||||
navigateToFolderWithIndexPath,
|
||||
removeDuplicateRESTCollectionOrFolder,
|
||||
restCollectionStore,
|
||||
} from "@hoppscotch/common/newstore/collections"
|
||||
import {
|
||||
getSettingSubject,
|
||||
settingsStore,
|
||||
} from "@hoppscotch/common/newstore/settings"
|
||||
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
|
||||
import { getSyncInitFunction } from "../../lib/sync"
|
||||
|
||||
import { StoreSyncDefinitionOf } from "../../lib/sync"
|
||||
import { createMapper } from "../../lib/sync/mapper"
|
||||
import {
|
||||
createRESTChildUserCollection,
|
||||
createRESTRootUserCollection,
|
||||
createRESTUserRequest,
|
||||
deleteUserCollection,
|
||||
deleteUserRequest,
|
||||
editUserRequest,
|
||||
moveUserCollection,
|
||||
moveUserRequest,
|
||||
renameUserCollection,
|
||||
updateUserCollectionOrder,
|
||||
} from "./collections.api"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
// restCollectionsMapper uses the collectionPath as the local identifier
|
||||
export const restCollectionsMapper = createMapper<string, string>()
|
||||
|
||||
// restRequestsMapper uses the collectionPath/requestIndex as the local identifier
|
||||
export const restRequestsMapper = createMapper<string, string>()
|
||||
|
||||
// temp implementation untill the backend implements an endpoint that accepts an entire collection
|
||||
// TODO: use importCollectionsJSON to do this
|
||||
const recursivelySyncCollections = async (
|
||||
collection: HoppCollection<HoppRESTRequest>,
|
||||
collectionPath: string,
|
||||
parentUserCollectionID?: string
|
||||
) => {
|
||||
let parentCollectionID = parentUserCollectionID
|
||||
|
||||
// if parentUserCollectionID does not exist, create the collection as a root collection
|
||||
if (!parentUserCollectionID) {
|
||||
const res = await createRESTRootUserCollection(collection.name)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
parentCollectionID = res.right.createRESTRootUserCollection.id
|
||||
|
||||
collection.id = parentCollectionID
|
||||
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
|
||||
} else {
|
||||
parentCollectionID = undefined
|
||||
}
|
||||
} else {
|
||||
// if parentUserCollectionID exists, create the collection as a child collection
|
||||
const res = await createRESTChildUserCollection(
|
||||
collection.name,
|
||||
parentUserCollectionID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const childCollectionId = res.right.createRESTChildUserCollection.id
|
||||
|
||||
collection.id = childCollectionId
|
||||
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
childCollectionId,
|
||||
`${collectionPath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// create the requests
|
||||
if (parentCollectionID) {
|
||||
collection.requests.forEach(async (request) => {
|
||||
const res =
|
||||
parentCollectionID &&
|
||||
(await createRESTUserRequest(
|
||||
request.name,
|
||||
JSON.stringify(request),
|
||||
parentCollectionID
|
||||
))
|
||||
|
||||
if (res && E.isRight(res)) {
|
||||
const requestId = res.right.createRESTUserRequest.id
|
||||
|
||||
request.id = requestId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// create the folders aka child collections
|
||||
if (parentCollectionID)
|
||||
collection.folders.forEach(async (folder, index) => {
|
||||
recursivelySyncCollections(
|
||||
folder,
|
||||
`${collectionPath}/${index}`,
|
||||
parentCollectionID
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: generalize this
|
||||
// TODO: ask backend to send enough info on the subscription to not need this
|
||||
export const collectionReorderOrMovingOperations: {
|
||||
sourceCollectionID: string
|
||||
destinationCollectionID?: string
|
||||
reorderOperation: {
|
||||
fromPath: string
|
||||
toPath?: string
|
||||
}
|
||||
}[] = []
|
||||
|
||||
type OperationStatus = "pending" | "completed"
|
||||
|
||||
type OperationCollectionRemoved = {
|
||||
type: "COLLECTION_REMOVED"
|
||||
collectionBackendID: string
|
||||
status: OperationStatus
|
||||
}
|
||||
|
||||
export const restCollectionsOperations: Array<OperationCollectionRemoved> = []
|
||||
|
||||
export const storeSyncDefinition: StoreSyncDefinitionOf<
|
||||
typeof restCollectionStore
|
||||
> = {
|
||||
appendCollections({ entries }) {
|
||||
let indexStart = restCollectionStore.value.state.length - entries.length
|
||||
|
||||
entries.forEach((collection) => {
|
||||
recursivelySyncCollections(collection, `${indexStart}`)
|
||||
indexStart++
|
||||
})
|
||||
},
|
||||
async addCollection({ collection }) {
|
||||
const lastCreatedCollectionIndex =
|
||||
restCollectionStore.value.state.length - 1
|
||||
|
||||
recursivelySyncCollections(collection, `${lastCreatedCollectionIndex}`)
|
||||
},
|
||||
async removeCollection({ collectionID }) {
|
||||
if (collectionID) {
|
||||
await deleteUserCollection(collectionID)
|
||||
}
|
||||
},
|
||||
editCollection({ partialCollection: collection, collectionIndex }) {
|
||||
const collectionID = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
[collectionIndex]
|
||||
)?.id
|
||||
|
||||
if (collectionID && collection.name) {
|
||||
renameUserCollection(collectionID, collection.name)
|
||||
}
|
||||
},
|
||||
async addFolder({ name, path }) {
|
||||
const parentCollection = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const parentCollectionBackendID = parentCollection?.id
|
||||
|
||||
if (parentCollectionBackendID) {
|
||||
const foldersLength = parentCollection.folders.length
|
||||
|
||||
const res = await createRESTChildUserCollection(
|
||||
name,
|
||||
parentCollectionBackendID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const { id } = res.right.createRESTChildUserCollection
|
||||
|
||||
if (foldersLength) {
|
||||
parentCollection.folders[foldersLength - 1].id = id
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
id,
|
||||
`${path}/${foldersLength - 1}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editFolder({ folder, path }) {
|
||||
const folderID = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)?.id
|
||||
|
||||
const folderName = folder.name
|
||||
|
||||
if (folderID && folderName) {
|
||||
renameUserCollection(folderID, folderName)
|
||||
}
|
||||
},
|
||||
async removeFolder({ folderID }) {
|
||||
if (folderID) {
|
||||
await deleteUserCollection(folderID)
|
||||
}
|
||||
},
|
||||
async moveFolder({ destinationPath, path }) {
|
||||
const { newSourcePath, newDestinationPath } = getPathsAfterMoving(
|
||||
path,
|
||||
destinationPath ?? undefined
|
||||
)
|
||||
|
||||
if (newSourcePath) {
|
||||
const sourceCollectionID = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
newSourcePath.split("/").map((index) => parseInt(index))
|
||||
)?.id
|
||||
|
||||
const destinationCollectionID = destinationPath
|
||||
? newDestinationPath &&
|
||||
navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
newDestinationPath.split("/").map((index) => parseInt(index))
|
||||
)?.id
|
||||
: undefined
|
||||
|
||||
if (sourceCollectionID) {
|
||||
await moveUserCollection(sourceCollectionID, destinationCollectionID)
|
||||
}
|
||||
}
|
||||
},
|
||||
editRequest({ path, requestIndex, requestNew }) {
|
||||
const request = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)?.requests[requestIndex]
|
||||
|
||||
const requestBackendID = request?.id
|
||||
|
||||
if (requestBackendID) {
|
||||
editUserRequest(
|
||||
requestBackendID,
|
||||
(requestNew as HoppRESTRequest).name,
|
||||
JSON.stringify(requestNew)
|
||||
)
|
||||
}
|
||||
},
|
||||
async saveRequestAs({ path, request }) {
|
||||
const folder = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const parentCollectionBackendID = folder?.id
|
||||
|
||||
if (parentCollectionBackendID) {
|
||||
const newRequest = folder.requests[folder.requests.length - 1]
|
||||
|
||||
const res = await createRESTUserRequest(
|
||||
(request as HoppRESTRequest).name,
|
||||
JSON.stringify(request),
|
||||
parentCollectionBackendID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const { id } = res.right.createRESTUserRequest
|
||||
|
||||
newRequest.id = id
|
||||
removeDuplicateRESTCollectionOrFolder(
|
||||
id,
|
||||
`${path}/${folder.requests.length - 1}`,
|
||||
"request"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
async removeRequest({ requestID }) {
|
||||
if (requestID) {
|
||||
await deleteUserRequest(requestID)
|
||||
}
|
||||
},
|
||||
moveRequest({ destinationPath, path, requestIndex }) {
|
||||
moveOrReorderRequests(requestIndex, path, destinationPath)
|
||||
},
|
||||
updateRequestOrder({
|
||||
destinationCollectionPath,
|
||||
destinationRequestIndex,
|
||||
requestIndex,
|
||||
}) {
|
||||
/**
|
||||
* currently the FE implementation only supports reordering requests between the same collection,
|
||||
* so destinationCollectionPath and sourceCollectionPath will be same
|
||||
*/
|
||||
moveOrReorderRequests(
|
||||
requestIndex,
|
||||
destinationCollectionPath,
|
||||
destinationCollectionPath,
|
||||
destinationRequestIndex ?? undefined
|
||||
)
|
||||
},
|
||||
async updateCollectionOrder({
|
||||
collectionIndex: collectionPath,
|
||||
destinationCollectionIndex: destinationCollectionPath,
|
||||
}) {
|
||||
const collections = restCollectionStore.value.state
|
||||
|
||||
const sourcePathIndexes = getParentPathIndexesFromPath(collectionPath)
|
||||
const sourceCollectionIndex = getCollectionIndexFromPath(collectionPath)
|
||||
|
||||
const destinationCollectionIndex = !!destinationCollectionPath
|
||||
? getCollectionIndexFromPath(destinationCollectionPath)
|
||||
: undefined
|
||||
|
||||
let updatedCollectionIndexs:
|
||||
| [newSourceIndex: number, newDestinationIndex: number | undefined]
|
||||
| undefined
|
||||
|
||||
if (
|
||||
(sourceCollectionIndex || sourceCollectionIndex == 0) &&
|
||||
(destinationCollectionIndex || destinationCollectionIndex == 0)
|
||||
) {
|
||||
updatedCollectionIndexs = getIndexesAfterReorder(
|
||||
sourceCollectionIndex,
|
||||
destinationCollectionIndex
|
||||
)
|
||||
} else if (sourceCollectionIndex || sourceCollectionIndex == 0) {
|
||||
if (sourcePathIndexes.length == 0) {
|
||||
// we're reordering root collections
|
||||
updatedCollectionIndexs = [collections.length - 1, undefined]
|
||||
} else {
|
||||
const sourceCollection = navigateToFolderWithIndexPath(collections, [
|
||||
...sourcePathIndexes,
|
||||
])
|
||||
|
||||
if (sourceCollection && sourceCollection.folders.length > 0) {
|
||||
updatedCollectionIndexs = [
|
||||
sourceCollection.folders.length - 1,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceCollectionID =
|
||||
updatedCollectionIndexs &&
|
||||
navigateToFolderWithIndexPath(collections, [
|
||||
...sourcePathIndexes,
|
||||
updatedCollectionIndexs[0],
|
||||
])?.id
|
||||
|
||||
const destinationCollectionID =
|
||||
updatedCollectionIndexs &&
|
||||
(updatedCollectionIndexs[1] || updatedCollectionIndexs[1] == 0)
|
||||
? navigateToFolderWithIndexPath(collections, [
|
||||
...sourcePathIndexes,
|
||||
updatedCollectionIndexs[1],
|
||||
])?.id
|
||||
: undefined
|
||||
|
||||
if (sourceCollectionID) {
|
||||
await updateUserCollectionOrder(
|
||||
sourceCollectionID,
|
||||
destinationCollectionID
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const collectionsSyncer = getSyncInitFunction(
|
||||
restCollectionStore,
|
||||
storeSyncDefinition,
|
||||
() => settingsStore.value.syncCollections,
|
||||
getSettingSubject("syncCollections")
|
||||
)
|
||||
|
||||
export async function moveOrReorderRequests(
|
||||
requestIndex: number,
|
||||
path: string,
|
||||
destinationPath: string,
|
||||
nextRequestIndex?: number,
|
||||
requestType: "REST" | "GQL" = "REST"
|
||||
) {
|
||||
const { collectionStore } = getStoreByCollectionType(requestType)
|
||||
|
||||
const sourceCollectionBackendID = navigateToFolderWithIndexPath(
|
||||
collectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)?.id
|
||||
|
||||
const destinationCollection = navigateToFolderWithIndexPath(
|
||||
collectionStore.value.state,
|
||||
destinationPath.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const destinationCollectionBackendID = destinationCollection?.id
|
||||
|
||||
let requestBackendID: string | undefined
|
||||
|
||||
let nextRequestBackendID: string | undefined
|
||||
|
||||
// we only need this for reordering requests, not for moving requests
|
||||
if (nextRequestIndex) {
|
||||
// reordering
|
||||
const [newRequestIndex, newDestinationIndex] = getIndexesAfterReorder(
|
||||
requestIndex,
|
||||
nextRequestIndex
|
||||
)
|
||||
|
||||
requestBackendID =
|
||||
destinationCollection?.requests[newRequestIndex]?.id ?? undefined
|
||||
|
||||
nextRequestBackendID =
|
||||
destinationCollection?.requests[newDestinationIndex]?.id ?? undefined
|
||||
} else {
|
||||
// moving
|
||||
const requests = destinationCollection?.requests
|
||||
requestBackendID =
|
||||
requests && requests.length > 0
|
||||
? requests[requests.length - 1]?.id
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (
|
||||
sourceCollectionBackendID &&
|
||||
destinationCollectionBackendID &&
|
||||
requestBackendID
|
||||
) {
|
||||
await moveUserRequest(
|
||||
sourceCollectionBackendID,
|
||||
destinationCollectionBackendID,
|
||||
requestBackendID,
|
||||
nextRequestBackendID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getParentPathIndexesFromPath(path: string) {
|
||||
const indexes = path.split("/")
|
||||
indexes.pop()
|
||||
return indexes.map((index) => parseInt(index))
|
||||
}
|
||||
|
||||
export function getCollectionIndexFromPath(collectionPath: string) {
|
||||
const sourceCollectionIndexString = collectionPath.split("/").pop()
|
||||
const sourceCollectionIndex = sourceCollectionIndexString
|
||||
? parseInt(sourceCollectionIndexString)
|
||||
: undefined
|
||||
|
||||
return sourceCollectionIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* the sync function is called after the reordering has happened on the store
|
||||
* because of this we need to find the new source and destination indexes after the reordering
|
||||
*/
|
||||
function getIndexesAfterReorder(
|
||||
oldSourceIndex: number,
|
||||
oldDestinationIndex: number
|
||||
): [newSourceIndex: number, newDestinationIndex: number] {
|
||||
// Source Becomes Destination -1
|
||||
// Destination Remains Same
|
||||
if (oldSourceIndex < oldDestinationIndex) {
|
||||
return [oldDestinationIndex - 1, oldDestinationIndex]
|
||||
}
|
||||
|
||||
// Source Becomes The Destination
|
||||
// Destintion Becomes Source + 1
|
||||
if (oldSourceIndex > oldDestinationIndex) {
|
||||
return [oldDestinationIndex, oldDestinationIndex + 1]
|
||||
}
|
||||
|
||||
throw new Error("Source and Destination are the same")
|
||||
}
|
||||
|
||||
/**
|
||||
* the sync function is called after moving a folder has happened on the store,
|
||||
* because of this the source index given to the sync function is not the live one
|
||||
* we need to find the new source index after the moving
|
||||
*/
|
||||
function getPathsAfterMoving(sourcePath: string, destinationPath?: string) {
|
||||
if (!destinationPath) {
|
||||
return {
|
||||
newSourcePath: `${restCollectionStore.value.state.length - 1}`,
|
||||
newDestinationPath: destinationPath,
|
||||
}
|
||||
}
|
||||
|
||||
const sourceParentPath = getParentPathFromPath(sourcePath)
|
||||
const destinationParentPath = getParentPathFromPath(destinationPath)
|
||||
|
||||
const isSameParentPath = sourceParentPath === destinationParentPath
|
||||
|
||||
let newDestinationPath: string
|
||||
|
||||
if (isSameParentPath) {
|
||||
const sourceIndex = getCollectionIndexFromPath(sourcePath)
|
||||
const destinationIndex = getCollectionIndexFromPath(destinationPath)
|
||||
|
||||
if (
|
||||
(sourceIndex || sourceIndex == 0) &&
|
||||
(destinationIndex || destinationIndex == 0) &&
|
||||
sourceIndex < destinationIndex
|
||||
) {
|
||||
newDestinationPath = destinationParentPath
|
||||
? `${destinationParentPath}/${destinationIndex - 1}`
|
||||
: `${destinationIndex - 1}`
|
||||
} else {
|
||||
newDestinationPath = destinationPath
|
||||
}
|
||||
} else {
|
||||
newDestinationPath = destinationPath
|
||||
}
|
||||
|
||||
const destinationFolder = navigateToFolderWithIndexPath(
|
||||
restCollectionStore.value.state,
|
||||
newDestinationPath.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const newSourcePath = destinationFolder
|
||||
? `${newDestinationPath}/${destinationFolder?.folders.length - 1}`
|
||||
: undefined
|
||||
|
||||
return {
|
||||
newSourcePath,
|
||||
newDestinationPath,
|
||||
}
|
||||
}
|
||||
|
||||
function getParentPathFromPath(path: string | undefined) {
|
||||
const indexes = path ? path.split("/") : []
|
||||
indexes.pop()
|
||||
|
||||
return indexes.join("/")
|
||||
}
|
||||
|
||||
export function getStoreByCollectionType(type: "GQL" | "REST") {
|
||||
const isGQL = type == "GQL"
|
||||
|
||||
const collectionStore = isGQL ? graphqlCollectionStore : restCollectionStore
|
||||
|
||||
return { collectionStore }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
graphqlCollectionStore,
|
||||
navigateToFolderWithIndexPath,
|
||||
removeDuplicateGraphqlCollectionOrFolder,
|
||||
} from "@hoppscotch/common/newstore/collections"
|
||||
import {
|
||||
getSettingSubject,
|
||||
settingsStore,
|
||||
} from "@hoppscotch/common/newstore/settings"
|
||||
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
|
||||
import { getSyncInitFunction } from "../../lib/sync"
|
||||
|
||||
import { StoreSyncDefinitionOf } from "../../lib/sync"
|
||||
import { createMapper } from "../../lib/sync/mapper"
|
||||
import {
|
||||
createGQLChildUserCollection,
|
||||
createGQLRootUserCollection,
|
||||
createGQLUserRequest,
|
||||
deleteUserCollection,
|
||||
deleteUserRequest,
|
||||
editGQLUserRequest,
|
||||
renameUserCollection,
|
||||
} from "./collections.api"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
import { moveOrReorderRequests } from "./collections.sync"
|
||||
|
||||
// gqlCollectionsMapper uses the collectionPath as the local identifier
|
||||
export const gqlCollectionsMapper = createMapper<string, string>()
|
||||
|
||||
// gqlRequestsMapper uses the collectionPath/requestIndex as the local identifier
|
||||
export const gqlRequestsMapper = createMapper<string, string>()
|
||||
|
||||
// temp implementation untill the backend implements an endpoint that accepts an entire collection
|
||||
// TODO: use importCollectionsJSON to do this
|
||||
const recursivelySyncCollections = async (
|
||||
collection: HoppCollection<HoppRESTRequest>,
|
||||
collectionPath: string,
|
||||
parentUserCollectionID?: string
|
||||
) => {
|
||||
let parentCollectionID = parentUserCollectionID
|
||||
|
||||
// if parentUserCollectionID does not exist, create the collection as a root collection
|
||||
if (!parentUserCollectionID) {
|
||||
const res = await createGQLRootUserCollection(collection.name)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
parentCollectionID = res.right.createGQLRootUserCollection.id
|
||||
|
||||
collection.id = parentCollectionID
|
||||
removeDuplicateGraphqlCollectionOrFolder(
|
||||
parentCollectionID,
|
||||
collectionPath
|
||||
)
|
||||
} else {
|
||||
parentCollectionID = undefined
|
||||
}
|
||||
} else {
|
||||
// if parentUserCollectionID exists, create the collection as a child collection
|
||||
const res = await createGQLChildUserCollection(
|
||||
collection.name,
|
||||
parentUserCollectionID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const childCollectionId = res.right.createGQLChildUserCollection.id
|
||||
|
||||
collection.id = childCollectionId
|
||||
|
||||
removeDuplicateGraphqlCollectionOrFolder(
|
||||
childCollectionId,
|
||||
`${collectionPath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// create the requests
|
||||
if (parentCollectionID) {
|
||||
collection.requests.forEach(async (request) => {
|
||||
const res =
|
||||
parentCollectionID &&
|
||||
(await createGQLUserRequest(
|
||||
request.name,
|
||||
JSON.stringify(request),
|
||||
parentCollectionID
|
||||
))
|
||||
|
||||
if (res && E.isRight(res)) {
|
||||
const requestId = res.right.createGQLUserRequest.id
|
||||
|
||||
request.id = requestId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// create the folders aka child collections
|
||||
if (parentCollectionID)
|
||||
collection.folders.forEach(async (folder, index) => {
|
||||
recursivelySyncCollections(
|
||||
folder,
|
||||
`${collectionPath}/${index}`,
|
||||
parentCollectionID
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: generalize this
|
||||
// TODO: ask backend to send enough info on the subscription to not need this
|
||||
export const collectionReorderOrMovingOperations: {
|
||||
sourceCollectionID: string
|
||||
destinationCollectionID?: string
|
||||
reorderOperation: {
|
||||
fromPath: string
|
||||
toPath?: string
|
||||
}
|
||||
}[] = []
|
||||
|
||||
type OperationStatus = "pending" | "completed"
|
||||
|
||||
type OperationCollectionRemoved = {
|
||||
type: "COLLECTION_REMOVED"
|
||||
collectionBackendID: string
|
||||
status: OperationStatus
|
||||
}
|
||||
|
||||
export const gqlCollectionsOperations: Array<OperationCollectionRemoved> = []
|
||||
|
||||
export const storeSyncDefinition: StoreSyncDefinitionOf<
|
||||
typeof graphqlCollectionStore
|
||||
> = {
|
||||
appendCollections({ entries }) {
|
||||
let indexStart = graphqlCollectionStore.value.state.length - entries.length
|
||||
|
||||
entries.forEach((collection) => {
|
||||
recursivelySyncCollections(collection, `${indexStart}`)
|
||||
indexStart++
|
||||
})
|
||||
},
|
||||
async addCollection({ collection }) {
|
||||
const lastCreatedCollectionIndex =
|
||||
graphqlCollectionStore.value.state.length - 1
|
||||
|
||||
await recursivelySyncCollections(
|
||||
collection,
|
||||
`${lastCreatedCollectionIndex}`
|
||||
)
|
||||
},
|
||||
async removeCollection({ collectionID }) {
|
||||
if (collectionID) {
|
||||
await deleteUserCollection(collectionID)
|
||||
}
|
||||
},
|
||||
editCollection({ collection, collectionIndex }) {
|
||||
const collectionID = navigateToFolderWithIndexPath(
|
||||
graphqlCollectionStore.value.state,
|
||||
[collectionIndex]
|
||||
)?.id
|
||||
|
||||
if (collectionID && collection.name) {
|
||||
renameUserCollection(collectionID, collection.name)
|
||||
}
|
||||
},
|
||||
async addFolder({ name, path }) {
|
||||
const parentCollection = navigateToFolderWithIndexPath(
|
||||
graphqlCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const parentCollectionBackendID = parentCollection?.id
|
||||
|
||||
if (parentCollectionBackendID) {
|
||||
const foldersLength = parentCollection.folders.length
|
||||
|
||||
const res = await createGQLChildUserCollection(
|
||||
name,
|
||||
parentCollectionBackendID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const { id } = res.right.createGQLChildUserCollection
|
||||
|
||||
if (foldersLength) {
|
||||
parentCollection.folders[foldersLength - 1].id = id
|
||||
removeDuplicateGraphqlCollectionOrFolder(
|
||||
id,
|
||||
`${path}/${foldersLength - 1}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editFolder({ folder, path }) {
|
||||
const folderBackendId = navigateToFolderWithIndexPath(
|
||||
graphqlCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)?.id
|
||||
|
||||
if (folderBackendId && folder.name) {
|
||||
renameUserCollection(folderBackendId, folder.name)
|
||||
}
|
||||
},
|
||||
async removeFolder({ folderID }) {
|
||||
if (folderID) {
|
||||
await deleteUserCollection(folderID)
|
||||
}
|
||||
},
|
||||
editRequest({ path, requestIndex, requestNew }) {
|
||||
const request = navigateToFolderWithIndexPath(
|
||||
graphqlCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)?.requests[requestIndex]
|
||||
|
||||
const requestBackendID = request?.id
|
||||
|
||||
if (requestBackendID) {
|
||||
editGQLUserRequest(
|
||||
requestBackendID,
|
||||
(requestNew as HoppRESTRequest).name,
|
||||
JSON.stringify(requestNew)
|
||||
)
|
||||
}
|
||||
},
|
||||
async saveRequestAs({ path, request }) {
|
||||
const folder = navigateToFolderWithIndexPath(
|
||||
graphqlCollectionStore.value.state,
|
||||
path.split("/").map((index) => parseInt(index))
|
||||
)
|
||||
|
||||
const parentCollectionBackendID = folder?.id
|
||||
|
||||
if (parentCollectionBackendID) {
|
||||
const newRequest = folder.requests[folder.requests.length - 1]
|
||||
|
||||
const res = await createGQLUserRequest(
|
||||
(request as HoppRESTRequest).name,
|
||||
JSON.stringify(request),
|
||||
parentCollectionBackendID
|
||||
)
|
||||
|
||||
if (E.isRight(res)) {
|
||||
const { id } = res.right.createGQLUserRequest
|
||||
|
||||
newRequest.id = id
|
||||
removeDuplicateGraphqlCollectionOrFolder(
|
||||
id,
|
||||
`${path}/${folder.requests.length - 1}`,
|
||||
"request"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
async removeRequest({ requestID }) {
|
||||
if (requestID) {
|
||||
await deleteUserRequest(requestID)
|
||||
}
|
||||
},
|
||||
moveRequest({ destinationPath, path, requestIndex }) {
|
||||
moveOrReorderRequests(requestIndex, path, destinationPath, undefined, "GQL")
|
||||
},
|
||||
}
|
||||
|
||||
export const gqlCollectionsSyncer = getSyncInitFunction(
|
||||
graphqlCollectionStore,
|
||||
storeSyncDefinition,
|
||||
() => settingsStore.value.syncCollections,
|
||||
getSettingSubject("syncCollections")
|
||||
)
|
||||