feat: initial desktop app commit

This commit is contained in:
Andrew Bastin
2023-10-11 12:04:43 +05:30
parent e2b15cedd4
commit 4587cee189
119 changed files with 18023 additions and 94 deletions

View File

@@ -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']

View 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?

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: http://localhost:3000/sitemap.xml

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

View 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).

View 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>

View 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,
},
]

View 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"
}
}

View 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

View 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

View File

@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/

File diff suppressed because it is too large Load Diff

View 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"]

View 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>

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1 @@
pub mod window;

View 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();
}

View 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");
}

View File

@@ -0,0 +1 @@
pub mod window;

View File

@@ -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));
}

View File

@@ -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
}
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
mutation ClearGlobalEnvironments($id: ID!) {
clearGlobalEnvironments(id: $id) {
id
}
}

View File

@@ -0,0 +1,11 @@
mutation CreateGQLChildUserCollection(
$title: String!
$parentUserCollectionID: ID!
) {
createGQLChildUserCollection(
title: $title
parentUserCollectionID: $parentUserCollectionID
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateGQLRootUserCollection($title: String!) {
createGQLRootUserCollection(title: $title) {
id
}
}

View File

@@ -0,0 +1,13 @@
mutation CreateGQLUserRequest(
$title: String!
$request: String!
$collectionID: ID!
) {
createGQLUserRequest(
title: $title
request: $request
collectionID: $collectionID
) {
id
}
}

View File

@@ -0,0 +1,11 @@
mutation CreateRESTChildUserCollection(
$title: String!
$parentUserCollectionID: ID!
) {
createRESTChildUserCollection(
title: $title
parentUserCollectionID: $parentUserCollectionID
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateRESTRootUserCollection($title: String!) {
createRESTRootUserCollection(title: $title) {
id
}
}

View File

@@ -0,0 +1,13 @@
mutation CreateRESTUserRequest(
$collectionID: ID!
$title: String!
$request: String!
) {
createRESTUserRequest(
collectionID: $collectionID
title: $title
request: $request
) {
id
}
}

View File

@@ -0,0 +1,9 @@
mutation CreateUserEnvironment($name: String!, $variables: String!) {
createUserEnvironment(name: $name, variables: $variables) {
id
userUid
name
variables
isGlobal
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateUserGlobalEnvironment($variables: String!) {
createUserGlobalEnvironment(variables: $variables) {
id
}
}

View File

@@ -0,0 +1,13 @@
mutation CreateUserHistory(
$reqData: String!
$resMetadata: String!
$reqType: ReqType!
) {
createUserHistory(
reqData: $reqData
resMetadata: $resMetadata
reqType: $reqType
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateUserSettings($properties: String!) {
createUserSettings(properties: $properties) {
id
}
}

View File

@@ -0,0 +1,6 @@
mutation DeleteAllUserHistory($reqType: ReqType!) {
deleteAllUserHistory(reqType: $reqType) {
count
reqType
}
}

View File

@@ -0,0 +1,3 @@
mutation DeleteUserCollection($userCollectionID: ID!) {
deleteUserCollection(userCollectionID: $userCollectionID)
}

View File

@@ -0,0 +1,3 @@
mutation DeleteUserEnvironment($id: ID!) {
deleteUserEnvironment(id: $id)
}

View File

@@ -0,0 +1,3 @@
mutation DeleteUserRequest($requestID: ID!) {
deleteUserRequest(id: $requestID)
}

View File

@@ -0,0 +1,8 @@
mutation MoveUserCollection($destCollectionID: ID, $userCollectionID: ID!) {
moveUserCollection(
destCollectionID: $destCollectionID
userCollectionID: $userCollectionID
) {
id
}
}

View File

@@ -0,0 +1,15 @@
mutation MoveUserRequest(
$sourceCollectionID: ID!
$requestID: ID!
$destinationCollectionID: ID!
$nextRequestID: ID
) {
moveUserRequest(
sourceCollectionID: $sourceCollectionID
requestID: $requestID
destinationCollectionID: $destinationCollectionID
nextRequestID: $nextRequestID
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation RemoveRequestFromHistory($id: ID!) {
removeRequestFromHistory(id: $id) {
id
}
}

View File

@@ -0,0 +1,8 @@
mutation RenameUserCollection($userCollectionID: ID!, $newTitle: String!) {
renameUserCollection(
userCollectionID: $userCollectionID
newTitle: $newTitle
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation ToggleHistoryStarStatus($id: ID!) {
toggleHistoryStarStatus(id: $id) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation UpdateGQLUserRequest($id: ID!, $request: String!, $title: String) {
updateGQLUserRequest(id: $id, request: $request, title: $title) {
id
}
}

View File

@@ -0,0 +1,7 @@
mutation UpdateRESTUserRequest($id: ID!, $title: String!, $request: String!) {
updateRESTUserRequest(id: $id, title: $title, request: $request) {
id
collectionID
request
}
}

View File

@@ -0,0 +1,6 @@
mutation UpdateUserCollectionOrder($collectionID: ID!, $nextCollectionID: ID) {
updateUserCollectionOrder(
collectionID: $collectionID
nextCollectionID: $nextCollectionID
)
}

View File

@@ -0,0 +1,9 @@
mutation UpdateUserEnvironment($id: ID!, $name: String!, $variables: String!) {
updateUserEnvironment(id: $id, name: $name, variables: $variables) {
id
userUid
name
variables
isGlobal
}
}

View File

@@ -0,0 +1,11 @@
mutation UpdateUserSession(
$currentSession: String!
$sessionType: SessionType!
) {
updateUserSessions(
currentSession: $currentSession
sessionType: $sessionType
) {
currentRESTSession
}
}

View File

@@ -0,0 +1,5 @@
mutation UpdateUserSettings($properties: String!) {
updateUserSettings(properties: $properties) {
id
}
}

View File

@@ -0,0 +1,9 @@
mutation CreateUserEnvironment($name: String!, $variables: String!) {
createUserEnvironment(name: $name, variables: $variables) {
id
userUid
name
variables
isGlobal
}
}

View File

@@ -0,0 +1,12 @@
query ExportUserCollectionsToJSON(
$collectionID: ID
$collectionType: ReqType!
) {
exportUserCollectionsToJSON(
collectionID: $collectionID
collectionType: $collectionType
) {
collectionType
exportedCollection
}
}

View File

@@ -0,0 +1,5 @@
query GetCurrentRESTSession {
me {
currentRESTSession
}
}

View File

@@ -0,0 +1,11 @@
query GetGlobalEnvironments {
me {
globalEnvironments {
id
isGlobal
name
userUid
variables
}
}
}

View File

@@ -0,0 +1,23 @@
query GetRESTUserHistory {
me {
RESTHistory {
id
userUid
reqType
request
responseMetadata
isStarred
executedOn
}
GQLHistory {
id
userUid
reqType
request
responseMetadata
isStarred
executedOn
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,11 @@
query GetUserEnvironments {
me {
environments {
id
isGlobal
name
userUid
variables
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,8 @@
query GetUserSettings {
me {
settings {
id
properties
}
}
}

View File

@@ -0,0 +1,10 @@
subscription UserCollectionCreated {
userCollectionCreated {
parent {
id
}
id
title
type
}
}

View File

@@ -0,0 +1,9 @@
subscription UserCollectionMoved {
userCollectionMoved {
id
parent {
id
}
type
}
}

View File

@@ -0,0 +1,17 @@
subscription UserCollectionOrderUpdated {
userCollectionOrderUpdated {
userCollection {
id
parent {
id
}
}
nextUserCollection {
id
parent {
id
}
}
}
}

View File

@@ -0,0 +1,6 @@
subscription UserCollectionRemoved {
userCollectionRemoved {
id
type
}
}

View File

@@ -0,0 +1,10 @@
subscription userCollectionUpdated {
userCollectionUpdated {
id
title
type
parent {
id
}
}
}

View File

@@ -0,0 +1,9 @@
subscription UserEnvironmentCreated {
userEnvironmentCreated {
id
isGlobal
name
userUid
variables
}
}

View File

@@ -0,0 +1,5 @@
subscription UserEnvironmentDeleted {
userEnvironmentDeleted {
id
}
}

View File

@@ -0,0 +1,9 @@
subscription UserEnvironmentUpdated {
userEnvironmentUpdated {
id
userUid
name
variables
isGlobal
}
}

View File

@@ -0,0 +1,10 @@
subscription UserHistoryCreated {
userHistoryCreated {
id
reqType
request
responseMetadata
isStarred
executedOn
}
}

View File

@@ -0,0 +1,6 @@
subscription userHistoryDeleted {
userHistoryDeleted {
id
reqType
}
}

View File

@@ -0,0 +1,6 @@
subscription UserHistoryDeletedMany {
userHistoryDeletedMany {
count
reqType
}
}

View File

@@ -0,0 +1,10 @@
subscription UserHistoryUpdated {
userHistoryUpdated {
id
reqType
request
responseMetadata
isStarred
executedOn
}
}

View File

@@ -0,0 +1,9 @@
subscription UserRequestCreated {
userRequestCreated {
id
collectionID
title
request
type
}
}

View File

@@ -0,0 +1,9 @@
subscription UserRequestDeleted {
userRequestDeleted {
id
collectionID
title
request
type
}
}

View File

@@ -0,0 +1,13 @@
subscription UserRequestMoved {
userRequestMoved {
request {
id
collectionID
type
}
nextRequest {
id
collectionID
}
}
}

View File

@@ -0,0 +1,9 @@
subscription UserRequestUpdated {
userRequestUpdated {
id
collectionID
title
request
type
}
}

View File

@@ -0,0 +1,6 @@
subscription UserSettingsUpdated {
userSettingsUpdated {
id
properties
}
}

View 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,
}
}

View 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)
}
},
}
}

View 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()
}
}
})

View 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",
})
},
}

View File

@@ -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: {} })

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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")
)

Some files were not shown because too many files have changed in this diff Show More