Merge branch 'orphan-pr/2243' into 2087-openapi

This commit is contained in:
Andrew Bastin
2022-06-29 22:14:14 +05:30
committed by GitHub
165 changed files with 8076 additions and 3222 deletions

View File

@@ -12,11 +12,11 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Install pnpm
run: curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm@6
- name: Install Dependencies
run: pnpm install
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.2
with:
version: 7
run_install: true
- name: Setup Environment
run: mv packages/hoppscotch-app/.env.example packages/hoppscotch-app/.env
@@ -24,11 +24,11 @@ jobs:
- name: Build Site
run: pnpm run generate
# Deploy the site with netlify-cli
- name: Deploy to Netlify
# Deploy the production site with netlify-cli
- name: Deploy to Netlify (production)
uses: netlify/actions/cli@master
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
with:
args: deploy --dir=packages/hoppscotch-app/dist --prod

View File

@@ -0,0 +1,45 @@
name: Deploy to Staging Netlify
on:
push:
# TODO: Migrate to staging branch only
branches: [main]
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.2
with:
version: 7
run_install: true
- name: Build Site
env:
GA_ID: ${{ secrets.STAGING_GA_ID }}
GTM_ID: ${{ secrets.STAGING_GTM_ID }}
API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
run: pnpm run generate
# Deploy the staging site with netlify-cli
- name: Deploy to Netlify (staging)
uses: netlify/actions/cli@master
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
with:
args: deploy --dir=packages/hoppscotch-app/dist --prod

View File

@@ -17,12 +17,15 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- name: Install pnpm
run: curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm@6
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.2
with:
version: 7
run_install: true
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- name: Run tests
run: pnpm i && pnpm -r test
run: pnpm test

View File

@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/codemirror-lang-graphql",
"version": "0.1.0",
"version": "0.2.0",
"description": "GraphQL language support for CodeMirror",
"author": "Hoppscotch (support@hoppscotch.io)",
"license": "MIT",

View File

@@ -27,16 +27,22 @@ export const GQLLanguage = LRLanguage.define({
},
}),
styleTags({
Name: t.definition(t.variableName),
"OperationDefinition/Name": t.definition(t.function(t.variableName)),
OperationType: t.keyword,
BooleanValue: t.bool,
StringValue: t.string,
IntValue: t.number,
FloatValue: t.number,
NullValue: t.null,
ObjectValue: t.brace,
Comment: t.lineComment,
Name: t.propertyName,
StringValue: t.string,
IntValue: t.integer,
FloatValue: t.float,
NullValue: t.null,
BooleanValue: t.bool,
Comma: t.separator,
"OperationDefinition/Name": t.definition(t.function(t.variableName)),
"OperationType TypeKeyword SchemaKeyword FragmentKeyword OnKeyword DirectiveKeyword RepeatableKeyword SchemaKeyword ExtendKeyword ScalarKeyword InterfaceKeyword UnionKeyword EnumKeyword InputKeyword ImplementsKeyword": t.keyword,
"ExecutableDirectiveLocation TypeSystemDirectiveLocation": t.atom,
"DirectiveName!": t.annotation,
"\"{\" \"}\"": t.brace,
"\"(\" \")\"": t.paren,
"\"[\" \"]\"": t.squareBracket,
"Type! NamedType": t.typeName,
}),
],
}),

View File

@@ -33,16 +33,24 @@ TypeSystemExtension {
TypeExtension
}
SchemaKeyword {
@specialize<Name, "schema">
}
SchemaDefinition {
Description? @specialize<Name, "schema"> Directives? RootTypeDef
Description? SchemaKeyword Directives? RootTypeDef
}
RootTypeDef {
"{" RootOperationTypeDefinition+ "}"
}
ExtendKeyword {
@specialize<Name, "extend">
}
SchemaExtension {
@specialize<Name, "extend"> @specialize<Name, "schema"> Directives? RootTypeDef
ExtendKeyword SchemaKeyword Directives? RootTypeDef
}
TypeExtension {
@@ -54,33 +62,53 @@ TypeExtension {
InputObjectTypeExtension
}
ScalarKeyword {
@specialize<Name, "scalar">
}
ScalarTypeExtension {
@specialize<Name, "extend"> @specialize<Name, "scalar"> Name Directives
ExtendKeyword ScalarKeyword Name Directives
}
ObjectTypeExtension /* precedence: right 0 */ {
@specialize<Name, "extend"> @specialize<Name, "type"> Name ImplementsInterfaces? Directives? !typeDef FieldsDefinition |
@specialize<Name, "extend"> @specialize<Name, "type"> Name ImplementsInterfaces? Directives?
ExtendKeyword TypeKeyword Name ImplementsInterfaces? Directives? !typeDef FieldsDefinition |
ExtendKeyword TypeKeyword Name ImplementsInterfaces? Directives?
}
InterfaceKeyword {
@specialize<Name, "interface">
}
InterfaceTypeExtension /* precedence: right 0 */ {
@specialize<Name, "extend"> @specialize<Name, "interface"> Name ImplementsInterfaces? Directives? FieldsDefinition |
@specialize<Name, "extend"> @specialize<Name, "interface"> Name ImplementsInterfaces? Directives?
ExtendKeyword InterfaceKeyword Name ImplementsInterfaces? Directives? FieldsDefinition |
ExtendKeyword InterfaceKeyword Name ImplementsInterfaces? Directives?
}
UnionKeyword {
@specialize<Name, "union">
}
UnionTypeExtension /* precedence: right 0 */ {
@specialize<Name, "extend"> @specialize<Name, "union"> Name Directives? UnionMemberTypes |
@specialize<Name, "extend"> @specialize<Name, "union"> Name Directives?
ExtendKeyword UnionKeyword Name Directives? UnionMemberTypes |
ExtendKeyword UnionKeyword Name Directives?
}
EnumKeyword {
@specialize<Name, "enum">
}
EnumTypeExtension /* precedence: right 0 */ {
@specialize<Name, "extend"> @specialize<Name, "enum"> Name Directives? !typeDef EnumValuesDefinition |
@specialize<Name, "extend"> @specialize<Name, "enum"> Name Directives?
ExtendKeyword EnumKeyword Name Directives? !typeDef EnumValuesDefinition |
ExtendKeyword EnumKeyword Name Directives?
}
InputKeyword {
@specialize<Name, "input">
}
InputObjectTypeExtension /* precedence: right 0 */ {
@specialize<Name, "extend"> @specialize<Name, "input"> Name Directives? InputFieldsDefinition+ |
@specialize<Name, "extend"> @specialize<Name, "input"> Name Directives?
ExtendKeyword InputKeyword Name Directives? InputFieldsDefinition+ |
ExtendKeyword InputKeyword Name Directives?
}
InputFieldsDefinition {
@@ -95,9 +123,13 @@ EnumValueDefinition {
Description? EnumValue Directives?
}
ImplementsKeyword {
@specialize<Name, "implements">
}
ImplementsInterfaces {
ImplementsInterfaces "&" NamedType |
@specialize<Name, "implements"> "&"? NamedType
ImplementsKeyword "&"? NamedType
}
FieldsDefinition {
@@ -144,27 +176,31 @@ TypeDefinition {
}
ScalarTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "scalar"> Name Directives?
Description? ScalarKeyword Name Directives?
}
TypeKeyword {
@specialize<Name, "type">
}
ObjectTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "type"> Name ImplementsInterfaces? Directives? FieldsDefinition?
Description? TypeKeyword Name ImplementsInterfaces? Directives? FieldsDefinition?
}
InterfaceTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "interface"> Name ImplementsInterfaces? Directives? FieldsDefinition?
Description? InterfaceKeyword Name ImplementsInterfaces? Directives? FieldsDefinition?
}
UnionTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "union"> Name Directives? UnionMemberTypes?
Description? UnionKeyword Name Directives? UnionMemberTypes?
}
EnumTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "enum"> Name Directives? !typeDef EnumValuesDefinition?
Description? EnumKeyword Name Directives? !typeDef EnumValuesDefinition?
}
InputObjectTypeDefinition /* precedence: right 0 */ {
Description? @specialize<Name, "input"> Name Directives? !typeDef InputFieldsDefinition?
Description? InputKeyword Name Directives? !typeDef InputFieldsDefinition?
}
VariableDefinitions {
@@ -237,8 +273,12 @@ FragmentSpread {
"..." FragmentName Directives?
}
FragmentKeyword {
@specialize<Name, "fragment">
}
FragmentDefinition {
@specialize<Name, "fragment"> FragmentName TypeCondition Directives? SelectionSet
FragmentKeyword FragmentName TypeCondition Directives? SelectionSet
}
FragmentName {
@@ -249,20 +289,36 @@ InlineFragment {
"..." TypeCondition? Directives? SelectionSet
}
OnKeyword {
@specialize<Name, "on">
}
TypeCondition {
@specialize<Name, "on"> NamedType
OnKeyword NamedType
}
Directives {
Directive+
}
DirectiveName {
"@" Name
}
Directive {
"@" Name Arguments?
DirectiveName Arguments?
}
DirectiveKeyword {
@specialize<Name, "directive">
}
RepeatableKeyword {
@specialize<Name, "repeatable">
}
DirectiveDefinition /* precedence: right 1 */ {
Description? @specialize<Name, "directive"> "@" Name ArgumentsDefinition? @specialize<Name, "repeatable"> ? @specialize<Name, "on"> DirectiveLocations
Description? DirectiveKeyword "@" Name ArgumentsDefinition? RepeatableKeyword ? OnKeyword DirectiveLocations
}
DirectiveLocations {
@@ -299,8 +355,8 @@ Description {
}
OperationType {
@specialize<Name, "query">
| @specialize<Name, "mutation">
@specialize<Name, "query">
| @specialize<Name, "mutation">
| @specialize<Name, "subscription">
}
@@ -317,7 +373,7 @@ ExecutableDirectiveLocation {
@specialize<Name, "QUERY">
| @specialize<Name, "MUTATION">
| @specialize<Name, "SUBSCRIPTION">
| @specialize<Name, "FIELD">
| @specialize<Name, "FIELD">
| @specialize<Name, "FRAGMENT_DEFINITION">
| @specialize<Name, "FRAGMENT_SPREAD">
| @specialize<Name, "INLINE_FRAGMENT">
@@ -338,10 +394,9 @@ TypeSystemDirectiveLocation {
| @specialize<Name, "INPUT_FIELD_DEFINITION">
}
@skip { Whitespace | Comment }
@tokens {
Whitespace {
whitespace {
std.whitespace+
}
StringValue {
@@ -353,7 +408,7 @@ TypeSystemDirectiveLocation {
}
FloatValue {
IntValue ("." std.digit+ | ("e" | "E") IntValue+)
IntValue ("." std.digit+ | ("e" | "E") IntValue+)
}
@precedence { IntValue, FloatValue }
@@ -361,12 +416,19 @@ TypeSystemDirectiveLocation {
Name {
$[_A-Za-z] $[_0-9A-Za-z]*
}
Comment {
"#" ![\n]*
}
Comma {
","
}
Comment {
"#" ![\n]*
}
"{" "}"
}
@detectDelim
@skip { whitespace | Comment }
@detectDelim

View File

@@ -16,3 +16,7 @@ MEASUREMENT_ID=G-BBJ3R80PJT
# Base URL
BASE_URL=https://hoppscotch.io
# Backend URLs
BACKEND_GQL_URL=https://api.hoppscotch.io/graphql
BACKEND_WS_URL=wss://api.hoppscotch.io/graphql

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="17" y1="7" x2="7" y2="17"></line>
<polyline points="17 17 7 17 7 7"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<polyline points="19 12 12 19 5 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="19" x2="12" y2="5"></line>
<polyline points="5 12 12 5 19 12"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="7 13 12 18 17 13"></polyline>
<polyline points="7 6 12 11 17 6"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 11 12 6 7 11"></polyline>
<polyline points="17 18 12 13 7 18"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>

After

Width:  |  Height:  |  Size: 329 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View File

@@ -15,6 +15,7 @@
::-webkit-scrollbar-track {
@apply bg-transparent;
@apply border-solid border-l border-t-0 border-b-0 border-r-0 border-dividerLight;
}
::-webkit-scrollbar-thumb {
@@ -27,17 +28,17 @@
::-webkit-scrollbar {
@apply w-4;
@apply h-4;
@apply h-0;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
// .hide-scrollbar {
// -ms-overflow-style: none;
// scrollbar-width: none;
// }
.hide-scrollbar::-webkit-scrollbar {
@apply hidden;
}
// .hide-scrollbar::-webkit-scrollbar {
// @apply hidden;
// }
input::placeholder,
textarea::placeholder,

View File

@@ -255,6 +255,7 @@
--upper-mobile-raw-tertiary-sticky-fold: 8.188rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5rem;
--lower-tertiary-sticky-fold: 7.05rem;
--sidebar-primary-sticky-fold: 2rem;
}
@@ -270,6 +271,7 @@
--upper-mobile-raw-tertiary-sticky-fold: 8.938rem;
--lower-primary-sticky-fold: 3.25rem;
--lower-secondary-sticky-fold: 5.5rem;
--lower-tertiary-sticky-fold: 7.8rem;
--sidebar-primary-sticky-fold: 2.25rem;
}
@@ -285,6 +287,7 @@
--upper-mobile-raw-tertiary-sticky-fold: 9.688rem;
--lower-primary-sticky-fold: 3.5rem;
--lower-secondary-sticky-fold: 6rem;
--lower-tertiary-sticky-fold: 8.55rem;
--sidebar-primary-sticky-fold: 2.5rem;
}

View File

@@ -22,7 +22,7 @@
</template>
<script setup lang="ts">
import { ref } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import {
useI18n,
@@ -45,7 +45,7 @@ const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const copyIcon = ref("copy")
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
// Copy user auth token to clipboard
const copyUserAuthToken = () => {
@@ -53,7 +53,6 @@ const copyUserAuthToken = () => {
copyToClipboard(userAuthToken.value)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
} else {
toast.error(`${t("error.something_went_wrong")}`)
}

View File

@@ -1,7 +1,7 @@
<template>
<div>
<header
class="flex items-center justify-between flex-1 px-2 py-2 space-x-2"
class="flex items-center justify-between flex-1 px-2 py-2 space-x-2 overflow-x-auto"
>
<div class="inline-flex items-center space-x-2">
<ButtonSecondary

View File

@@ -8,11 +8,7 @@
{{ t("settings.interceptor_description") }}
</p>
</div>
<SmartRadioGroup
:radios="interceptors"
:selected="interceptorSelection"
@change="toggleSettingKey"
/>
<SmartRadioGroup v-model="interceptorSelection" :radios="interceptors" />
<div
v-if="interceptorSelection == 'EXTENSIONS_ENABLED' && !extensionVersion"
class="flex space-x-2"
@@ -38,58 +34,29 @@
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from "@nuxtjs/composition-api"
import { KeysMatching } from "~/types/ts-utils"
import {
applySetting,
SettingsType,
toggleSetting,
useSetting,
} from "~/newstore/settings"
import { hasExtensionInstalled } from "~/helpers/strategies/ExtensionStrategy"
import { useI18n, usePolled } from "~/helpers/utils/composables"
import { computed } from "@nuxtjs/composition-api"
import { applySetting, toggleSetting, useSetting } from "~/newstore/settings"
import { useI18n, useReadonlyStream } from "~/helpers/utils/composables"
import { extensionStatus$ } from "~/newstore/HoppExtension"
const t = useI18n()
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
const toggleSettingKey = <
K extends KeysMatching<SettingsType | "BROWSER_ENABLED", boolean>
>(
key: K
) => {
interceptorSelection.value = key
if (key === "EXTENSIONS_ENABLED") {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
}
if (key === "PROXY_ENABLED") {
applySetting("PROXY_ENABLED", true)
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
}
if (key === "BROWSER_ENABLED") {
applySetting("PROXY_ENABLED", false)
applySetting("EXTENSIONS_ENABLED", false)
}
}
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
const extensionVersion = usePolled(5000, (stopPolling) => {
const result = hasExtensionInstalled()
? window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
const extensionVersion = computed(() => {
return currentExtensionStatus.value === "available"
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
: null
// We don't need to poll anymore after we get value
if (result) stopPolling()
return result
})
const interceptors = computed(() => [
{ value: "BROWSER_ENABLED", label: t("state.none") },
{ value: "PROXY_ENABLED", label: t("settings.proxy") },
{ value: "BROWSER_ENABLED" as const, label: t("state.none") },
{ value: "PROXY_ENABLED" as const, label: t("settings.proxy") },
{
value: "EXTENSIONS_ENABLED",
value: "EXTENSIONS_ENABLED" as const,
label:
`${t("settings.extensions")}: ` +
(extensionVersion.value !== null
@@ -98,15 +65,27 @@ const interceptors = computed(() => [
},
])
const interceptorSelection = ref("")
type InterceptorMode = typeof interceptors["value"][number]["value"]
watchEffect(() => {
if (PROXY_ENABLED.value) {
interceptorSelection.value = "PROXY_ENABLED"
} else if (EXTENSIONS_ENABLED.value) {
interceptorSelection.value = "EXTENSIONS_ENABLED"
} else {
interceptorSelection.value = "BROWSER_ENABLED"
}
const interceptorSelection = computed<InterceptorMode>({
get() {
if (PROXY_ENABLED.value) return "PROXY_ENABLED"
if (EXTENSIONS_ENABLED.value) return "EXTENSIONS_ENABLED"
return "BROWSER_ENABLED"
},
set(val) {
if (val === "EXTENSIONS_ENABLED") {
applySetting("EXTENSIONS_ENABLED", true)
if (PROXY_ENABLED.value) toggleSetting("PROXY_ENABLED")
}
if (val === "PROXY_ENABLED") {
applySetting("PROXY_ENABLED", true)
if (EXTENSIONS_ENABLED.value) toggleSetting("EXTENSIONS_ENABLED")
}
if (val === "BROWSER_ENABLED") {
applySetting("PROXY_ENABLED", false)
applySetting("EXTENSIONS_ENABLED", false)
}
},
})
</script>

View File

@@ -28,7 +28,7 @@
</Splitpanes>
</Pane>
<Pane
v-if="SIDEBAR"
v-if="SIDEBAR && hasSidebar"
size="25"
min-size="20"
class="hide-scrollbar !overflow-auto flex flex-col"
@@ -42,6 +42,7 @@
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { computed, useSlots } from "@nuxtjs/composition-api"
import { useSetting } from "~/newstore/settings"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@@ -52,4 +53,8 @@ const mdAndLarger = breakpoints.greater("md")
const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const SIDEBAR = useSetting("SIDEBAR")
const slots = useSlots()
const hasSidebar = computed(() => !!slots.sidebar)
</script>

View File

@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { ref } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n, useToast } from "~/helpers/utils/composables"
@@ -60,7 +60,8 @@ const subject = "Checkout Hoppscotch - an open source API development ecosystem"
const summary = `Hi there!%0D%0A%0D%0AI thought you'll like this new platform that I joined called Hoppscotch - https://hoppscotch.io.%0D%0AIt is a simple and intuitive interface for creating and managing your APIs. You can build, test, document, and share your APIs.%0D%0A%0D%0AThe best part about Hoppscotch is that it is open source and free to get started.%0D%0A%0D%0A`
const twitter = "hoppscotch_io"
const copyIcon = ref("copy")
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
const platforms = [
{
name: "Email",
@@ -93,7 +94,6 @@ const copyAppLink = () => {
copyToClipboard(url)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
}
const hideModal = () => {

View File

@@ -7,6 +7,7 @@
:to="localePath(navigation.target)"
class="nav-link"
tabindex="0"
:exact="navigation.exact"
>
<div v-if="navigation.svg">
<SmartIcon :name="navigation.svg" class="svg-icons" />
@@ -40,26 +41,31 @@ const primaryNavigation = [
target: "index",
svg: "link-2",
title: t("navigation.rest"),
exact: true,
},
{
target: "graphql",
svg: "graphql",
title: t("navigation.graphql"),
exact: false,
},
{
target: "realtime",
svg: "globe",
title: t("navigation.realtime"),
exact: false,
},
{
target: "documentation",
svg: "book-open",
title: t("navigation.doc"),
exact: false,
},
{
target: "settings",
svg: "settings",
title: t("navigation.settings"),
exact: false,
},
]
</script>
@@ -105,6 +111,20 @@ const primaryNavigation = [
@apply text-tiny;
}
&.active-link {
@apply text-secondaryDark;
@apply bg-primaryLight;
@apply hover:text-secondaryDark;
.material-icons,
.svg-icons {
@apply opacity-100;
}
&::after {
@apply bg-accent;
}
}
&.exact-active-link {
@apply text-secondaryDark;
@apply bg-primaryLight;

View File

@@ -1,6 +1,10 @@
<template>
<div v-if="show">
<SmartTabs :id="'collections_tab'" v-model="selectedCollectionTab">
<SmartTabs
:id="'collections_tab'"
v-model="selectedCollectionTab"
render-inactive-tabs
>
<SmartTab
:id="'my-collections'"
:label="`${$t('collection.my_collections')}`"

View File

@@ -244,7 +244,7 @@ const createCollectionGist = async () => {
return
}
getJSONCollection()
await getJSONCollection()
try {
const res = await axios.$post(
@@ -316,8 +316,8 @@ const importToTeams = async (content: HoppCollection<HoppRESTRequest>) => {
importingMyCollections.value = false
}
const exportJSON = () => {
getJSONCollection()
const exportJSON = async () => {
await getJSONCollection()
const dataToWrite = collectionJson.value
const file = new Blob([dataToWrite], { type: "application/json" })

View File

@@ -233,6 +233,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
req: cloneDeep(requestUpdated),
})
requestSaved()
@@ -249,6 +250,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
})
requestSaved()
@@ -265,6 +267,7 @@ const saveRequestAs = async () => {
originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
})
requestSaved()
@@ -293,6 +296,7 @@ const saveRequestAs = async () => {
setRESTSaveContext({
originLocation: "team-collection",
requestID: picked.value.requestID,
req: cloneDeep(requestUpdated),
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
@@ -319,6 +323,7 @@ const saveRequestAs = async () => {
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.folderID,
req: cloneDeep(requestUpdated),
})
requestSaved()
@@ -348,6 +353,7 @@ const saveRequestAs = async () => {
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.collectionID,
req: cloneDeep(requestUpdated),
})
requestSaved()

View File

@@ -232,7 +232,11 @@ import {
editRESTRequest,
saveRESTRequestAs,
} from "~/newstore/collections"
import { setRESTRequest, getRESTRequest } from "~/newstore/RESTSession"
import {
setRESTRequest,
getRESTRequest,
getRESTSaveContext,
} from "~/newstore/RESTSession"
import {
useReadonlyStream,
useStreamSubscriber,
@@ -495,12 +499,27 @@ export default defineComponent({
},
// Intented to by called by CollectionsEditRequest modal submit event
updateEditingRequest(requestUpdateData) {
const saveCtx = getRESTSaveContext()
const requestUpdated = {
...this.editingRequest,
name: requestUpdateData.name || this.editingRequest.name,
}
if (this.collectionsType.type === "my-collections") {
// Update REST Session with the updated state
if (
saveCtx &&
saveCtx.originLocation === "user-collection" &&
saveCtx.requestIndex === this.editingRequestIndex &&
saveCtx.folderPath === this.editingFolderPath
) {
setRESTRequest({
...getRESTRequest(),
name: requestUpdateData.name,
})
}
editRESTRequest(
this.editingFolderPath,
this.editingRequestIndex,
@@ -515,6 +534,18 @@ export default defineComponent({
const requestName = requestUpdateData.name || this.editingRequest.name
// Update REST Session with the updated state
if (
saveCtx &&
saveCtx.originLocation === "team-collection" &&
saveCtx.requestID === this.editingRequestIndex
) {
setRESTRequest({
...getRESTRequest(),
name: requestUpdateData.name,
})
}
runMutation(UpdateRequestDocument, {
data: {
request: JSON.stringify(requestUpdated),

View File

@@ -322,20 +322,24 @@ const setRestReq = (request: any) => {
)
}
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
const selectRequest = () => {
if (!active.value) {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
confirmChange.value = true
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
@@ -345,16 +349,6 @@ const selectRequest = () => {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
} else {
confirmChange.value = true
}
@@ -374,16 +368,6 @@ const saveRequestChange = () => {
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
if (!isActive.value) {
setRESTSaveContext({
originLocation: "user-collection",

View File

@@ -261,7 +261,7 @@ const active = useReadonlyStream(restSaveContext$, null)
const isSelected = computed(
() =>
props.picked &&
props.picked.pickedType === "team-collection" &&
props.picked.pickedType === "teams-collection" &&
props.picked.requestID === props.requestIndex
)
@@ -308,16 +308,19 @@ const setRestReq = (request: HoppRESTRequest) => {
}
const selectRequest = () => {
if (!active.value) {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "teams-collection",
requestID: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
confirmChange.value = true
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "team-collection",
requestID: props.requestIndex,
},
})
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
@@ -327,13 +330,6 @@ const selectRequest = () => {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "team-collection",
requestID: props.requestIndex,
},
})
} else {
confirmChange.value = true
}
@@ -353,13 +349,6 @@ const saveRequestChange = () => {
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (props.saveRequest)
emit("select", {
picked: {
pickedType: "team-collection",
requestID: props.requestIndex,
},
})
if (!isActive.value) {
setRESTSaveContext({
originLocation: "team-collection",
@@ -367,7 +356,6 @@ const discardRequestChange = () => {
req: props.request,
})
}
confirmChange.value = false
}

View File

@@ -120,10 +120,11 @@ import clone from "lodash/clone"
import { computed, ref, watch } from "@nuxtjs/composition-api"
import * as E from "fp-ts/Either"
import { Environment, parseTemplateStringE } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import {
createEnvironment,
environments$,
getEnviroment,
getEnvironment,
getGlobalVariables,
globalEnv$,
setCurrentEnvironment,
@@ -160,7 +161,8 @@ const emit = defineEmits<{
const name = ref<string | null>(null)
const vars = ref([{ key: "", value: "" }])
const clearIcon = ref("trash-2")
const clearIcon = refAutoReset<"trash-2" | "check">("trash-2", 1000)
const globalVars = useReadonlyStream(globalEnv$, [])
@@ -176,7 +178,7 @@ const workingEnv = computed(() => {
variables: props.envVars(),
}
} else if (props.editingEnvironmentIndex !== null) {
return getEnviroment(props.editingEnvironmentIndex)
return getEnvironment(props.editingEnvironmentIndex)
} else {
return null
}
@@ -225,7 +227,6 @@ const clearContent = () => {
vars.value = []
clearIcon.value = "check"
toast.success(`${t("state.cleared")}`)
setTimeout(() => (clearIcon.value = "trash-2"), 1000)
}
const addEnvironmentVariable = () => {

View File

@@ -3,6 +3,7 @@
<SmartTabs
v-model="selectedOptionTab"
styles="sticky bg-primary top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
<SmartTab
:id="'query'"
@@ -312,6 +313,7 @@ import {
import draggable from "vuedraggable"
import isEqual from "lodash/isEqual"
import cloneDeep from "lodash/cloneDeep"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import {
useNuxt,
@@ -612,10 +614,13 @@ useCodemirror(queryEditor, gqlQueryString, {
environmentHighlights: false,
})
const copyQueryIcon = ref("copy")
const copyVariablesIcon = ref("copy")
const prettifyQueryIcon = ref("wand")
const prettifyVariablesIcon = ref("wand")
const copyQueryIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyVariablesIcon = refAutoReset<"copy" | "check">("copy", 1000)
const prettifyQueryIcon = refAutoReset<"wand" | "check" | "info">("wand", 1000)
const prettifyVariablesIcon = refAutoReset<"wand" | "check" | "info">(
"wand",
1000
)
const showSaveRequestModal = ref(false)
@@ -623,7 +628,6 @@ const copyQuery = () => {
copyToClipboard(gqlQueryString.value)
copyQueryIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyQueryIcon.value = "copy"), 1000)
}
const response = useStream(gqlResponse$, "", setGQLResponse)
@@ -699,7 +703,6 @@ const prettifyQuery = () => {
toast.error(`${t("error.gql_prettify_invalid_query")}`)
prettifyQueryIcon.value = "info"
}
setTimeout(() => (prettifyQueryIcon.value = "wand"), 1000)
}
const saveRequest = () => {
@@ -710,7 +713,6 @@ const copyVariables = () => {
copyToClipboard(variableString.value)
copyVariablesIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyVariablesIcon.value = "copy"), 1000)
}
const prettifyVariableString = () => {
@@ -723,7 +725,6 @@ const prettifyVariableString = () => {
prettifyVariablesIcon.value = "info"
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
setTimeout(() => (prettifyVariablesIcon.value = "wand"), 1000)
}
const clearGQLQuery = () => {

View File

@@ -78,6 +78,7 @@
<script setup lang="ts">
import { reactive, ref } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import {
@@ -111,14 +112,16 @@ useCodemirror(
})
)
const downloadResponseIcon = ref("download")
const copyResponseIcon = ref("copy")
const downloadResponseIcon = refAutoReset<"download" | "check">(
"download",
1000
)
const copyResponseIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyResponse = () => {
copyToClipboard(responseString.value!)
copyResponseIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyResponseIcon.value = "copy"), 1000)
}
const downloadResponse = () => {
@@ -135,7 +138,6 @@ const downloadResponse = () => {
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
downloadResponseIcon.value = "download"
}, 1000)
}
</script>

View File

@@ -3,6 +3,7 @@
v-model="selectedNavigationTab"
styles="sticky bg-primary z-10 top-0"
vertical
render-inactive-tabs
>
<SmartTab :id="'history'" icon="clock" :label="`${t('tab.history')}`">
<History
@@ -64,6 +65,7 @@
<SmartTabs
v-model="selectedGqlTab"
styles="border-t border-b border-dividerLight bg-primary sticky z-10 top-sidebarPrimaryStickyFold"
render-inactive-tabs
>
<SmartTab
v-if="queryFields.length > 0"
@@ -193,6 +195,7 @@ import { computed, nextTick, reactive, ref } from "@nuxtjs/composition-api"
import { GraphQLField, GraphQLType } from "graphql"
import { map } from "rxjs/operators"
import { GQLHeader } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { GQLConnection } from "~/helpers/GQLConnection"
import { copyToClipboard } from "~/helpers/utils/clipboard"
@@ -306,8 +309,8 @@ const graphqlTypes = useReadonlyStream(
[]
)
const downloadSchemaIcon = ref("download")
const copySchemaIcon = ref("copy")
const downloadSchemaIcon = refAutoReset<"download" | "check">("download", 1000)
const copySchemaIcon = refAutoReset<"copy" | "check">("copy", 1000)
const graphqlFieldsFilterText = ref("")
@@ -423,7 +426,6 @@ const downloadSchema = () => {
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
downloadSchemaIcon.value = "download"
}, 1000)
}
@@ -432,7 +434,6 @@ const copySchema = () => {
copyToClipboard(schemaString.value)
copySchemaIcon.value = "check"
setTimeout(() => (copySchemaIcon.value = "copy"), 1000)
}
const handleUseHistory = (entry: GQLHistoryEntry) => {

View File

@@ -22,7 +22,10 @@
/>
</span>
</template>
<div class="flex flex-col" role="menu">
<div
class="flex flex-col space-y-1 divide-y divide-dividerLight"
role="menu"
>
<SmartItem
:label="$t('state.none').toLowerCase()"
:info-icon="contentType === null ? 'done' : ''"
@@ -34,19 +37,36 @@
}
"
/>
<SmartItem
v-for="(contentTypeItem, index) in validContentTypes"
:key="`contentTypeItem-${index}`"
:label="contentTypeItem"
:info-icon="contentTypeItem === contentType ? 'done' : ''"
:active-info-icon="contentTypeItem === contentType"
@click.native="
() => {
contentType = contentTypeItem
$refs.contentTypeOptions.tippy().hide()
}
"
/>
<div
v-for="(
contentTypeItems, contentTypeItemsIndex
) in segmentedContentTypes"
:key="`contentTypeItems-${contentTypeItemsIndex}`"
class="flex flex-col py-2 text-left"
>
<div class="flex rounded py-2 px-4">
<span class="text-tiny text-secondaryLight font-bold">
{{ $t(contentTypeItems.title) }}
</span>
</div>
<div class="flex flex-col">
<SmartItem
v-for="(
contentTypeItem, contentTypeIndex
) in contentTypeItems.contentTypes"
:key="`contentTypeItem-${contentTypeIndex}`"
:label="contentTypeItem"
:info-icon="contentTypeItem === contentType ? 'done' : ''"
:active-info-icon="contentTypeItem === contentType"
@click.native="
() => {
contentType = contentTypeItem
$refs.contentTypeOptions.tippy().hide()
}
"
/>
</div>
</div>
</div>
</tippy>
<ButtonSecondary
@@ -106,7 +126,7 @@ import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { RequestOptionTabs } from "./RequestOptions.vue"
import { useStream } from "~/helpers/utils/composables"
import { knownContentTypes } from "~/helpers/utils/contenttypes"
import { segmentedContentTypes } from "~/helpers/utils/contenttypes"
import {
restContentType$,
restHeaders$,
@@ -119,7 +139,6 @@ const emit = defineEmits<{
(e: "change-tab", value: string): void
}>()
const validContentTypes = Object.keys(knownContentTypes)
const contentType = useStream(restContentType$, null, setRESTContentType)
// The functional headers list (the headers actually in the system)

View File

@@ -38,8 +38,8 @@
drag-class="cursor-grabbing"
>
<div
v-for="(param, index) in workingParams"
:key="`param-${index}`"
v-for="({ id, entry }, index) in workingParams"
:key="`param=${id}-${index}`"
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
@@ -54,21 +54,21 @@
/>
</span>
<SmartEnvInput
v-model="param.key"
v-model="entry.key"
:placeholder="`${$t('count.parameter', { count: index + 1 })}`"
@change="
updateBodyParam(index, {
key: $event,
value: param.value,
active: param.active,
isFile: param.isFile,
value: entry.value,
active: entry.active,
isFile: entry.isFile,
})
"
/>
<div v-if="param.isFile" class="file-chips-container hide-scrollbar">
<div v-if="entry.isFile" class="file-chips-container hide-scrollbar">
<div class="space-x-2 file-chips-wrapper">
<SmartFileChip
v-for="(file, fileIndex) in param.value"
v-for="(file, fileIndex) in entry.value"
:key="`param-${index}-file-${fileIndex}`"
>{{ file.name }}</SmartFileChip
>
@@ -76,14 +76,14 @@
</div>
<span v-else class="flex flex-1">
<SmartEnvInput
v-model="param.value"
v-model="entry.value"
:placeholder="`${$t('count.value', { count: index + 1 })}`"
@change="
updateBodyParam(index, {
key: param.key,
key: entry.key,
value: $event,
active: param.active,
isFile: param.isFile,
active: entry.active,
isFile: entry.isFile,
})
"
/>
@@ -97,7 +97,7 @@
type="file"
multiple
class="p-1 cursor-pointer transition file:transition file:cursor-pointer text-secondaryLight hover:text-secondaryDark file:mr-2 file:py-1 file:px-4 file:rounded file:border-0 file:text-tiny text-tiny file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
@change="setRequestAttachment(index, param, $event)"
@change="setRequestAttachment(index, entry, $event)"
/>
</label>
</span>
@@ -105,15 +105,15 @@
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
param.hasOwnProperty('active')
? param.active
entry.hasOwnProperty('active')
? entry.active
? $t('action.turn_off')
: $t('action.turn_on')
: $t('action.turn_off')
"
:svg="
param.hasOwnProperty('active')
? param.active
entry.hasOwnProperty('active')
? entry.active
? 'check-circle'
: 'circle'
: 'check-circle'
@@ -121,10 +121,10 @@
color="green"
@click.native="
updateBodyParam(index, {
key: param.key,
value: param.value,
active: param.hasOwnProperty('active') ? !param.active : false,
isFile: param.isFile,
key: entry.key,
value: entry.value,
active: entry.hasOwnProperty('active') ? !entry.active : false,
isFile: entry.isFile,
})
"
/>
@@ -164,6 +164,9 @@
<script setup lang="ts">
import { ref, Ref, watch } from "@nuxtjs/composition-api"
import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { FormDataKeyValue } from "@hoppscotch/data"
import isEqual from "lodash/isEqual"
import { clone } from "lodash"
@@ -171,10 +174,14 @@ import draggable from "vuedraggable"
import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
import { useRESTRequestBody } from "~/newstore/RESTSession"
type WorkingFormDataKeyValue = { id: number; entry: FormDataKeyValue }
const t = useI18n()
const toast = useToast()
const idTicker = ref(0)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref<
@@ -182,23 +189,32 @@ const bodyParams = pluckRef<any, any>(useRESTRequestBody(), "body") as Ref<
>
// The UI representation of the parameters list (has the empty end param)
const workingParams = ref<FormDataKeyValue[]>([
const workingParams = ref<WorkingFormDataKeyValue[]>([
{
key: "",
value: "",
active: true,
isFile: false,
id: idTicker.value++,
entry: {
key: "",
value: "",
active: true,
isFile: false,
},
},
])
// Rule: Working Params always have last element is always an empty param
watch(workingParams, (paramsList) => {
if (paramsList.length > 0 && paramsList[paramsList.length - 1].key !== "") {
if (
paramsList.length > 0 &&
paramsList[paramsList.length - 1].entry.key !== ""
) {
workingParams.value.push({
key: "",
value: "",
active: true,
isFile: false,
id: idTicker.value++,
entry: {
key: "",
value: "",
active: true,
isFile: false,
},
})
}
})
@@ -208,19 +224,37 @@ watch(
bodyParams,
(newParamsList) => {
// Sync should overwrite working params
const filteredWorkingParams = workingParams.value.filter(
(e) => e.key !== ""
const filteredWorkingParams = pipe(
workingParams.value,
A.filterMap(
flow(
O.fromPredicate((e) => e.entry.key !== ""),
O.map((e) => e.entry)
)
)
)
if (!isEqual(newParamsList, filteredWorkingParams)) {
workingParams.value = newParamsList
workingParams.value = pipe(
newParamsList,
A.map((x) => ({ id: idTicker.value++, entry: x }))
)
}
},
{ immediate: true }
)
watch(workingParams, (newWorkingParams) => {
const fixedParams = newWorkingParams.filter((e) => e.key !== "")
const fixedParams = pipe(
newWorkingParams,
A.filterMap(
flow(
O.fromPredicate((e) => e.entry.key !== ""),
O.map((e) => e.entry)
)
)
)
if (!isEqual(bodyParams.value, fixedParams)) {
bodyParams.value = fixedParams
}
@@ -228,16 +262,19 @@ watch(workingParams, (newWorkingParams) => {
const addBodyParam = () => {
workingParams.value.push({
key: "",
value: "",
active: true,
isFile: false,
id: idTicker.value++,
entry: {
key: "",
value: "",
active: true,
isFile: false,
},
})
}
const updateBodyParam = (index: number, param: FormDataKeyValue) => {
const updateBodyParam = (index: number, entry: FormDataKeyValue) => {
workingParams.value = workingParams.value.map((h, i) =>
i === index ? param : h
i === index ? { id: h.id, entry } : h
)
}
@@ -280,10 +317,13 @@ const clearContent = () => {
// set params list to the initial state
workingParams.value = [
{
key: "",
value: "",
active: true,
isFile: false,
id: idTicker.value++,
entry: {
key: "",
value: "",
active: true,
isFile: false,
},
},
]
}

View File

@@ -87,6 +87,7 @@
import { computed, ref, watch } from "@nuxtjs/composition-api"
import * as O from "fp-ts/Option"
import { Environment, makeRESTRequest } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import {
@@ -118,9 +119,10 @@ const options = ref<any | null>(null)
const request = ref(getRESTRequest())
const codegenType = ref<CodegenName>("shell-curl")
const copyIcon = ref("copy")
const errorState = ref(false)
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
const requestCode = computed(() => {
const aggregateEnvs = getAggregateEnvs()
const env: Environment = {
@@ -184,7 +186,6 @@ const copyRequestCode = () => {
copyToClipboard(requestCode.value)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
}
const searchQuery = ref("")

View File

@@ -137,6 +137,47 @@
/>
</span>
</div>
<div
v-for="(header, index) in computedHeaders"
:key="`header-${index}`"
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<ButtonSecondary
svg="lock"
class="opacity-25 cursor-auto text-secondaryLight bg-divider"
tabindex="-1"
/>
</span>
<SmartEnvInput
v-model="header.header.key"
:placeholder="`${t('count.value', { count: index + 1 })}`"
readonly
/>
<SmartEnvInput
:value="mask(header)"
:placeholder="`${t('count.value', { count: index + 1 })}`"
readonly
/>
<span>
<ButtonSecondary
v-if="header.source === 'auth'"
:svg="masking ? 'eye' : 'eye-off'"
@click.native="toggleMask()"
/>
<ButtonSecondary
v-else
svg="arrow-up-right"
class="cursor-auto text-primary hover:text-primary"
/>
</span>
<span>
<ButtonSecondary
svg="arrow-up-right"
@click.native="changeTab(header.source)"
/>
</span>
</div>
</draggable>
<div
v-if="workingHeaders.length === 0"
@@ -162,7 +203,7 @@
</template>
<script setup lang="ts">
import { Ref, ref, watch } from "@nuxtjs/composition-api"
import { computed, Ref, ref, watch } from "@nuxtjs/composition-api"
import isEqual from "lodash/isEqual"
import {
HoppRESTHeader,
@@ -177,13 +218,29 @@ import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import cloneDeep from "lodash/cloneDeep"
import draggable from "vuedraggable"
import { RequestOptionTabs } from "./RequestOptions.vue"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { restHeaders$, setRESTHeaders } from "~/newstore/RESTSession"
import {
getRESTRequest,
restHeaders$,
restRequest$,
setRESTHeaders,
} from "~/newstore/RESTSession"
import { commonHeaders } from "~/helpers/headers"
import { useI18n, useStream, useToast } from "~/helpers/utils/composables"
import {
useI18n,
useReadonlyStream,
useStream,
useToast,
} from "~/helpers/utils/composables"
import linter from "~/helpers/editor/linting/rawKeyValue"
import { throwError } from "~/helpers/functional/error"
import { objRemoveKey } from "~/helpers/functional/object"
import {
ComputedHeader,
getComputedHeaders,
} from "~/helpers/utils/EffectiveURL"
import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
const t = useI18n()
const toast = useToast()
@@ -196,6 +253,10 @@ const bulkEditor = ref<any | null>(null)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const emit = defineEmits<{
(e: "change-tab", value: RequestOptionTabs): void
}>()
useCodemirror(bulkEditor, bulkHeaders, {
extendedEditorConfig: {
mode: "text/x-yaml",
@@ -379,4 +440,28 @@ const clearContent = () => {
bulkHeaders.value = ""
}
const restRequest = useReadonlyStream(restRequest$, getRESTRequest())
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computed(() =>
getComputedHeaders(restRequest.value, aggregateEnvs.value)
)
const masking = ref(true)
const toggleMask = () => {
masking.value = !masking.value
}
const mask = (header: ComputedHeader) => {
if (header.source === "auth" && masking.value)
return header.header.value.replace(/\S/gi, "*")
return header.header.value
}
const changeTab = (tab: ComputedHeader["source"]) => {
if (tab === "auth") emit("change-tab", "authorization")
else emit("change-tab", "bodyParams")
}
</script>

View File

@@ -39,6 +39,7 @@
<script setup lang="ts">
import { ref, watch } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { setRESTRequest } from "~/newstore/RESTSession"
import { useI18n, useToast } from "~/helpers/utils/composables"
@@ -95,7 +96,7 @@ const handleImport = () => {
hideModal()
}
const pasteIcon = ref("clipboard")
const pasteIcon = refAutoReset<"clipboard" | "check">("clipboard", 1000)
const handlePaste = async () => {
try {
@@ -103,7 +104,6 @@ const handlePaste = async () => {
if (text) {
curl.value = text
pasteIcon.value = "check"
setTimeout(() => (pasteIcon.value = "clipboard"), 1000)
}
} catch (e) {
console.error("Failed to copy: ", e)

View File

@@ -61,6 +61,7 @@ import { computed, reactive, Ref, ref } from "@nuxtjs/composition-api"
import * as TO from "fp-ts/TaskOption"
import { pipe } from "fp-ts/function"
import { HoppRESTReqBody, ValidContentTypes } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { getEditorLangForMimeType } from "~/helpers/editorutils"
import { pluckRef, useI18n, useToast } from "~/helpers/utils/composables"
@@ -91,7 +92,8 @@ const rawParamsBody = pluckRef(
>,
"body"
)
const prettifyIcon = ref("wand")
const prettifyIcon = refAutoReset<"wand" | "check" | "info">("wand", 1000)
const rawInputEditorLang = computed(() =>
getEditorLangForMimeType(props.contentType)
@@ -148,6 +150,5 @@ const prettifyRequestBody = () => {
prettifyIcon.value = "info"
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
setTimeout(() => (prettifyIcon.value = "wand"), 1000)
}
</script>

View File

@@ -171,6 +171,12 @@
}
"
/>
<SmartItem
svg="link-2"
:label="`${t('request.view_my_links')}`"
to="/profile"
/>
<hr />
<SmartItem
ref="saveRequestAction"
:label="`${t('request.save_as')}`"
@@ -208,6 +214,8 @@
import { computed, ref, watch } from "@nuxtjs/composition-api"
import { isLeft, isRight } from "fp-ts/lib/Either"
import * as E from "fp-ts/Either"
import cloneDeep from "lodash/cloneDeep"
import { refAutoReset } from "@vueuse/core"
import {
updateRESTResponse,
restEndpoint$,
@@ -386,7 +394,11 @@ const clearContent = () => {
resetRESTRequest()
}
const copyLinkIcon = hasNavigatorShare ? ref("share-2") : ref("copy")
const copyLinkIcon = refAutoReset<"share-2" | "copy" | "check">(
hasNavigatorShare ? "share-2" : "copy",
1000
)
const shareLink = ref<string | null>("")
const fetchingShareLink = ref(false)
@@ -441,7 +453,6 @@ const copyShareLink = (shareLink: string) => {
copyLinkIcon.value = "check"
copyToClipboard(`https://hopp.sh/r${shareLink}`)
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyLinkIcon.value = "copy"), 2000)
}
}
@@ -477,14 +488,21 @@ const saveRequest = () => {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
const req = getRESTRequest()
try {
editRESTRequest(
saveCtx.folderPath,
saveCtx.requestIndex,
getRESTRequest()
)
setRESTSaveContext({
originLocation: "user-collection",
folderPath: saveCtx.folderPath,
requestIndex: saveCtx.requestIndex,
req: cloneDeep(req),
})
toast.success(`${t("request.saved")}`)
} catch (e) {
setRESTSaveContext(null)
@@ -505,6 +523,11 @@ const saveRequest = () => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: saveCtx.requestID,
req: cloneDeep(req),
})
toast.success(`${t("request.saved")}`)
}
})

View File

@@ -2,6 +2,7 @@
<SmartTabs
v-model="selectedRealtimeTab"
styles="sticky bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
<SmartTab
:id="'params'"
@@ -18,7 +19,7 @@
:label="`${$t('tab.headers')}`"
:info="`${newActiveHeadersCount$}`"
>
<HttpHeaders />
<HttpHeaders @change-tab="changeTab" />
</SmartTab>
<SmartTab :id="'authorization'" :label="`${$t('tab.authorization')}`">
<HttpAuthorization />

View File

@@ -117,9 +117,21 @@
<span class="text-secondary"> {{ t("response.time") }}: </span>
{{ `${response.meta.responseDuration} ms` }}
</span>
<span v-if="response.meta && response.meta.responseSize">
<span
v-if="response.meta && response.meta.responseSize"
v-tippy="
readableResponseSize
? { theme: 'tooltip' }
: { onShow: () => false }
"
:title="`${response.meta.responseSize} B`"
>
<span class="text-secondary"> {{ t("response.size") }}: </span>
{{ `${response.meta.responseSize} B` }}
{{
readableResponseSize
? readableResponseSize
: `${response.meta.responseSize} B`
}}
</span>
</div>
</div>
@@ -141,6 +153,29 @@ const props = defineProps<{
response: HoppRESTResponse
}>()
/**
* Gives the response size in a human readable format
* (changes unit from B to MB/KB depending on the size)
* If no changes (error res state) or value can be made (size < 1KB ?),
* it returns undefined
*/
const readableResponseSize = computed(() => {
if (
props.response.type === "loading" ||
props.response.type === "network_fail" ||
props.response.type === "script_fail" ||
props.response.type === "fail"
)
return undefined
const size = props.response.meta.responseSize
if (size >= 100000) return (size / 1000000).toFixed(2) + " MB"
if (size >= 1000) return (size / 1000).toFixed(2) + " KB"
return undefined
})
const statusCategory = computed(() => {
if (
props.response.type === "loading" ||

View File

@@ -3,6 +3,7 @@
v-model="selectedNavigationTab"
styles="sticky bg-primary z-10 top-0"
vertical
render-inactive-tabs
>
<SmartTab :id="'history'" icon="clock" :label="`${$t('tab.history')}`">
<History ref="historyComponent" :page="'rest'" />

View File

@@ -12,10 +12,13 @@
<span class="text-secondaryDark">
{{ env.key }}
</span>
<span class="text-secondaryDark">
<span class="text-secondaryDark pl-2 break-all">
{{ ` \xA0 — \xA0 ${env.value}` }}
</span>
<span v-if="status === 'updations'" class="text-secondaryLight">
<span
v-if="status === 'updations'"
class="text-secondaryLight px-2 break-all"
>
{{ ` \xA0 ← \xA0 ${env.previousValue}` }}
</span>
</div>

View File

@@ -17,32 +17,17 @@
/>
</div>
</div>
<div
<LensesHeadersRendererEntry
v-for="(header, index) in headers"
:key="`header-${index}`"
class="flex border-b divide-x divide-dividerLight border-dividerLight group"
>
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.key }}
</span>
</span>
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.value }}
</span>
</span>
</div>
:key="index"
:header="header"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "@nuxtjs/composition-api"
import { HoppRESTHeader } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n, useToast } from "~/helpers/utils/composables"
@@ -54,12 +39,11 @@ const props = defineProps<{
headers: Array<HoppRESTHeader>
}>()
const copyIcon = ref("copy")
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyHeaders = () => {
copyToClipboard(JSON.stringify(props.headers))
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div
class="flex border-b divide-x divide-dividerLight border-dividerLight group"
>
<span
class="flex flex-1 min-w-0 px-4 py-2 transition group-hover:text-secondaryDark"
>
<span class="truncate rounded-sm select-all">
{{ header.key }}
</span>
</span>
<span
class="flex flex-1 min-w-0 pl-4 py-2 transition group-hover:text-secondaryDark justify-between"
>
<span class="truncate rounded-sm select-all">
{{ header.value }}
</span>
<ButtonSecondary
ref="copyHeader"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="copyIcon"
class="hidden group-hover:inline-flex !py-0"
@click.native="copyHeader(header.value)"
/>
</span>
</div>
</template>
<script setup lang="ts">
import { refAutoReset } from "@vueuse/core"
import { HoppRESTHeader } from "~/../hoppscotch-data/dist"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n, useToast } from "~/helpers/utils/composables"
const t = useI18n()
const toast = useToast()
defineProps<{
header: HoppRESTHeader
}>()
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyHeader = (headerValue: string) => {
copyToClipboard(headerValue)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
}
</script>

View File

@@ -3,6 +3,7 @@
v-if="response"
v-model="selectedLensTab"
styles="sticky z-10 bg-primary top-lowerPrimaryStickyFold"
render-inactive-tabs
>
<SmartTab
v-for="(lens, index) in validLenses"

View File

@@ -1,12 +1,15 @@
<template>
<div class="flex flex-col flex-1">
<div
v-if="response.type === 'success' || response.type === 'fail'"
class="flex flex-col flex-1"
>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-lowerSecondaryStickyFold"
>
<label class="font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div class="flex items-center">
<ButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
@@ -15,6 +18,14 @@
svg="wrap-text"
@click.native.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.filter_response')"
svg="filter"
:class="{ '!text-accent': toggleFilter }"
@click.native.prevent="toggleFilterState"
/>
<ButtonSecondary
v-if="response.body"
ref="downloadResponse"
@@ -33,7 +44,47 @@
/>
</div>
</div>
<div ref="jsonResponse" class="flex flex-col flex-1"></div>
<div
v-if="toggleFilter"
class="bg-primary flex sticky top-lowerTertiaryStickyFold z-10 border-b border-dividerLight"
>
<div
class="bg-primaryLight border-divider text-secondaryDark inline-flex flex-1 items-center"
>
<span class="inline-flex flex-1 items-center px-4">
<SmartIcon name="search" class="h-4 w-4 text-secondaryLight" />
<input
v-model="filterQueryText"
v-focus
class="input !border-0 !px-2"
:placeholder="`${t('response.filter_response_body')}`"
type="text"
/>
</span>
<div
v-if="filterResponseError"
class="px-2 py-1 text-tiny flex items-center justify-center text-accentContrast rounded"
:class="{
'bg-red-500':
filterResponseError.type === 'JSON_PARSE_FAILED' ||
filterResponseError.type === 'JSON_PATH_QUERY_ERROR',
'bg-amber-500': filterResponseError.type === 'RESPONSE_EMPTY',
}"
>
<SmartIcon name="info" class="svg-icons mr-1.5" />
<span>{{ filterResponseError.error }}</span>
</div>
<ButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('app.wiki')"
svg="help-circle"
to="https://github.com/JSONPath-Plus/JSONPath"
blank
/>
</div>
</div>
<div ref="jsonResponse" class="flex flex-col flex-1 h-auto h-full"></div>
<div
v-if="outlinePath"
class="sticky bottom-0 z-10 flex px-2 overflow-auto border-t bg-primaryLight border-dividerLight flex-nowrap hide-scrollbar"
@@ -142,8 +193,10 @@
<script setup lang="ts">
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { computed, ref, reactive } from "@nuxtjs/composition-api"
import { JSONPath } from "jsonpath-plus"
import { useCodemirror } from "~/helpers/editor/codemirror"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
@@ -165,16 +218,51 @@ const props = defineProps<{
const { responseBodyText } = useResponseBody(props.response)
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const toggleFilter = ref(false)
const filterQueryText = ref("")
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
responseBodyText
type BodyParseError =
| { type: "JSON_PARSE_FAILED" }
| { type: "JSON_PATH_QUERY_FAILED"; error: Error }
const responseJsonObject = computed(() =>
pipe(
responseBodyText.value,
E.tryCatchK(
LJSON.parse,
(): BodyParseError => ({ type: "JSON_PARSE_FAILED" })
)
)
)
const jsonResponseBodyText = computed(() => {
if (filterQueryText.value.length > 0) {
return pipe(
responseJsonObject.value,
E.chain((parsedJSON) =>
E.tryCatch(
() =>
JSONPath({
path: filterQueryText.value,
json: parsedJSON,
}) as undefined,
(err): BodyParseError => ({
type: "JSON_PATH_QUERY_FAILED",
error: err as Error,
})
)
),
E.map(JSON.stringify)
)
} else {
return E.right(responseBodyText.value)
}
})
const jsonBodyText = computed(() =>
pipe(
responseBodyText.value,
jsonResponseBodyText.value,
E.getOrElse(() => responseBodyText.value),
O.tryCatchK(LJSON.parse),
O.map((val) => LJSON.stringify(val, undefined, 2)),
O.getOrElse(() => responseBodyText.value)
@@ -189,6 +277,38 @@ const ast = computed(() =>
)
)
const filterResponseError = computed(() =>
pipe(
jsonResponseBodyText.value,
E.match(
(e) => {
switch (e.type) {
case "JSON_PATH_QUERY_FAILED":
return { type: "JSON_PATH_QUERY_ERROR", error: e.error.message }
case "JSON_PARSE_FAILED":
return {
type: "JSON_PARSE_FAILED",
error: t("error.json_parsing_failed").toString(),
}
}
},
(result) =>
result === "[]"
? {
type: "RESPONSE_EMPTY",
error: t("error.no_results_found").toString(),
}
: undefined
)
)
)
const { copyIcon, copyResponse } = useCopyResponse(jsonBodyText)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
jsonBodyText
)
const outlineOptions = ref<any | null>(null)
const jsonResponse = ref<any | null>(null)
const linewrapEnabled = ref(true)
@@ -227,6 +347,11 @@ const outlinePath = computed(() =>
O.getOrElseW(() => null)
)
)
const toggleFilterState = () => {
filterQueryText.value = ""
toggleFilter.value = !toggleFilter.value
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,136 @@
<template>
<div
class="table-row-groups lg:flex block my-6 lg:my-0 w-full border lg:border-0 divide-y lg:divide-y-0 lg:divide-x divide-dividerLight border-dividerLight"
>
<div
class="table-column font-mono text-tiny"
:data-label="t('shortcodes.short_code')"
>
{{ shortcode.id }}
</div>
<div
class="table-column"
:class="requestLabelColor"
:data-label="t('shortcodes.method')"
>
{{ parseShortcodeRequest.method }}
</div>
<div class="table-column" :data-label="t('shortcodes.url')">
{{ parseShortcodeRequest.endpoint }}
</div>
<div
ref="timeStampRef"
class="table-column"
:data-label="t('shortcodes.created_on')"
>
<span v-tippy="{ theme: 'tooltip' }" :title="timeStamp">
{{ dateStamp }}
</span>
</div>
<div
class="flex flex-1 items-center justify-center px-3"
:data-label="t('shortcodes.actions')"
>
<SmartAnchor
v-tippy="{ theme: 'tooltip' }"
:title="t('action.open_workspace')"
:to="`https://hopp.sh/r/${shortcode.id}`"
blank
svg="external-link"
class="px-3 text-accent hover:text-accent"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
color="green"
:svg="copyIconRefs"
class="px-3"
@click.native="copyShortcode(shortcode.id)"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
svg="trash"
color="red"
class="px-3"
@click.native="deleteShortcode(shortcode.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
import { translateToNewRequest } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { Shortcode } from "~/helpers/shortcodes/Shortcode"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
shortcode: Shortcode
}>()
const emit = defineEmits<{
(e: "delete-shortcode", codeID: string): void
}>()
const deleteShortcode = (codeID: string) => {
emit("delete-shortcode", codeID)
}
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
const timeStampRef = ref()
const copyIconRefs = refAutoReset<"copy" | "check">("copy", 1000)
const parseShortcodeRequest = computed(() =>
pipe(props.shortcode.request, JSON.parse, translateToNewRequest)
)
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(parseShortcodeRequest.value.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
const dateStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleDateString()
)
const timeStamp = computed(() =>
new Date(props.shortcode.createdOn).toLocaleTimeString()
)
const copyShortcode = (codeID: string) => {
copyToClipboard(`https://hopp.sh/r/${codeID}`)
toast.success(`${t("state.copied_to_clipboard")}`)
copyIconRefs.value = "check"
}
</script>
<style lang="scss">
.table-column {
@apply flex flex-1 items-center px-3 py-3 truncate;
}
.table-row-groups {
.table-column {
@apply before:text-secondary before:font-bold before:content-[attr(data-label)] lg:before:hidden;
}
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="flex flex-col flex-1">
<div v-if="showEventField" class="flex items-center justify-between p-4">
<input
id="event_name"
v-model="eventName"
class="input"
name="event_name"
:placeholder="`${t('socketio.event_name')}`"
type="text"
autocomplete="off"
/>
</div>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold text-secondaryLight">
{{ $t("websocket.message") }}
</label>
<tippy
ref="contentTypeOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<ButtonSecondary
:label="contentType || $t('state.none').toLowerCase()"
class="pr-8 ml-2 rounded-none"
/>
</span>
</template>
<div class="flex flex-col" role="menu">
<SmartItem
v-for="(contentTypeItem, index) in validContentTypes"
:key="`contentTypeItem-${index}`"
:label="contentTypeItem"
:info-icon="contentTypeItem === contentType ? 'done' : ''"
:active-info-icon="contentTypeItem === contentType"
@click.native="
() => {
contentType = contentTypeItem
$refs.contentTypeOptions.tippy().hide()
}
"
/>
</div>
</tippy>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t('action.send')}`"
:label="`${t('action.send')}`"
:disabled="!communicationBody || !isConnected"
svg="send"
class="rounded-none !text-accent !hover:text-accentDark"
@click.native="sendMessage()"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/body"
blank
:title="t('app.wiki')"
svg="help-circle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
svg="wrap-text"
@click.native.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear')"
svg="trash-2"
@click.native="clearContent"
/>
<ButtonSecondary
v-if="contentType && contentType == 'JSON'"
ref="prettifyRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:svg="prettifyIcon"
@click.native="prettifyRequestBody"
/>
<label for="payload">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('import.title')"
svg="file-plus"
@click.native="$refs.payload.click()"
/>
</label>
<input
ref="payload"
class="input"
name="payload"
type="file"
@change="uploadPayload"
/>
</div>
</div>
<div ref="wsCommunicationBody" class="flex flex-col flex-1"></div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "@nuxtjs/composition-api"
import { pipe } from "fp-ts/function"
import * as TO from "fp-ts/TaskOption"
import * as O from "fp-ts/Option"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "~/helpers/editor/codemirror"
import jsonLinter from "~/helpers/editor/linting/json"
import { readFileAsText } from "~/helpers/functional/files"
import { useI18n, useToast } from "~/helpers/utils/composables"
import { isJSONContentType } from "~/helpers/utils/contenttypes"
defineProps({
showEventField: {
type: Boolean,
default: false,
},
isConnected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(
e: "send-message",
body: {
eventName: string
message: string
}
): void
}>()
const t = useI18n()
const toast = useToast()
const linewrapEnabled = ref(true)
const wsCommunicationBody = ref<HTMLElement>()
const prettifyIcon = refAutoReset<"wand" | "check" | "info">("wand", 1000)
const knownContentTypes = {
JSON: "application/ld+json",
Raw: "text/plain",
} as const
const validContentTypes = Object.keys(knownContentTypes)
const contentType = ref<keyof typeof knownContentTypes>("JSON")
const eventName = ref("")
const communicationBody = ref("")
const rawInputEditorLang = computed(() => knownContentTypes[contentType.value])
const langLinter = computed(() =>
isJSONContentType(contentType.value) ? jsonLinter : null
)
useCodemirror(
wsCommunicationBody,
communicationBody,
reactive({
extendedEditorConfig: {
lineWrapping: linewrapEnabled,
mode: rawInputEditorLang,
placeholder: t("websocket.message").toString(),
},
linter: langLinter,
completer: null,
environmentHighlights: true,
})
)
const clearContent = () => {
communicationBody.value = ""
}
const sendMessage = () => {
if (!communicationBody.value) return
emit("send-message", {
eventName: eventName.value,
message: communicationBody.value,
})
communicationBody.value = ""
}
const uploadPayload = async (e: InputEvent) => {
const result = await pipe(
(e.target as HTMLInputElement).files?.[0],
TO.fromNullable,
TO.chain(readFileAsText)
)()
if (O.isSome(result)) {
communicationBody.value = result.value
toast.success(`${t("state.file_imported")}`)
} else {
toast.error(`${t("action.choose_file")}`)
}
}
const prettifyRequestBody = () => {
try {
const jsonObj = JSON.parse(communicationBody.value)
communicationBody.value = JSON.stringify(jsonObj, null, 2)
prettifyIcon.value = "check"
} catch (e) {
console.error(e)
prettifyIcon.value = "info"
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
}
</script>

View File

@@ -1,77 +1,129 @@
<template>
<div class="flex flex-col flex-1">
<div ref="container" class="flex flex-col flex-1 overflow-y-auto">
<div
class="sticky top-0 z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
class="sticky top-0 z-10 flex items-center justify-between flex-none pl-4 border-b bg-primary border-dividerLight"
>
<label for="log" class="py-2 font-semibold text-secondaryLight">
{{ title }}
</label>
<div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
svg="trash"
@click.native="emit('delete')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_top')"
svg="arrow-up"
@click.native="scrollTo('top')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_bottom')"
svg="arrow-down"
@click.native="scrollTo('bottom')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.autoscroll')"
svg="chevrons-down"
:class="toggleAutoscrollColor"
@click.native="toggleAutoscroll()"
/>
</div>
</div>
<div ref="logsRef" name="log" class="realtime-log">
<span v-if="log" class="space-y-2">
<span
<div
v-if="log.length !== 0"
ref="logs"
class="overflow-y-auto border-b border-dividerLight"
>
<div
class="flex flex-col h-auto h-full border-r divide-y divide-dividerLight border-dividerLight"
>
<RealtimeLogEntry
v-for="(entry, index) in log"
:key="`entry-${index}`"
:style="{ color: entry.color }"
class="font-mono"
>{{ entry.ts }}{{ source(entry.source) }}{{ entry.payload }}</span
>
</span>
<span v-else>{{ t("response.waiting_for_connection") }}</span>
:entry="entry"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from "@nuxtjs/composition-api"
import { getSourcePrefix as source } from "~/helpers/utils/string"
import { ref, PropType, computed, watch } from "@nuxtjs/composition-api"
import { useThrottleFn, useScroll } from "@vueuse/core"
import { useI18n } from "~/helpers/utils/composables"
const t = useI18n()
export type LogEntryData = {
prefix?: string
ts: number | undefined
source: "info" | "client" | "server" | "disconnected"
payload: string
event: "connecting" | "connected" | "disconnected" | "error"
}
const props = defineProps({
log: { type: Array, default: () => [] },
log: { type: Array as PropType<LogEntryData[]>, default: () => [] },
title: {
type: String,
default: "",
},
})
const logsRef = ref<any | null>(null)
const BOTTOM_SCROLL_DIST_INACCURACY = 5
const emit = defineEmits<{
(e: "delete"): void
}>()
const t = useI18n()
const container = ref<HTMLElement | null>(null)
const logs = ref<HTMLElement | null>(null)
const autoScrollEnabled = ref(true)
const logListScroll = useScroll(logs)
// Disable autoscroll when scrolling to top
watch(logListScroll.isScrolling, (isScrolling) => {
if (isScrolling && logListScroll.directions.top)
autoScrollEnabled.value = false
})
const scrollTo = (position: "top" | "bottom") => {
if (position === "top") {
logs.value?.scroll({
behavior: "smooth",
top: 0,
})
} else if (position === "bottom") {
logs.value?.scroll({
behavior: "smooth",
top: logs.value?.scrollHeight,
})
}
}
watch(
() => props.log,
() => {
if (!logsRef.value) return
const distToBottom =
logsRef.value.scrollHeight -
logsRef.value.scrollTop -
logsRef.value.clientHeight
if (distToBottom < BOTTOM_SCROLL_DIST_INACCURACY) {
nextTick(() => (logsRef.value.scrollTop = logsRef.value.scrollHeight))
}
}
useThrottleFn(() => {
if (autoScrollEnabled.value) scrollTo("bottom")
}, 200),
{ flush: "post" }
)
const toggleAutoscroll = () => {
autoScrollEnabled.value = !autoScrollEnabled.value
}
const toggleAutoscrollColor = computed(() =>
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
)
</script>
<style scoped lang="scss">
.realtime-log {
@apply p-4;
@apply bg-transparent;
@apply text-secondary;
@apply overflow-auto;
height: 256px;
&,
span {
@apply select-text;
}
span {
@apply block;
@apply break-words break-all;
}
}
</style>
<style></style>

View File

@@ -0,0 +1,392 @@
<template>
<div v-if="entry" class="divide-y divide-dividerLight">
<div :style="{ color: entryColor }" class="realtime-log">
<div class="flex group">
<div class="flex flex-1 divide-x divide-dividerLight">
<div class="inline-flex items-center p-2">
<SmartIcon
class="svg-icons"
:name="iconName"
:style="{ color: iconColor }"
@click.native="copyQuery(entry.payload)"
/>
</div>
<div
v-if="entry.ts !== undefined"
class="items-center hidden px-1 w-18 sm:inline-flex"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="relativeTime"
class="mx-auto truncate ts-font text-secondaryLight hover:text-secondary hover:text-center"
>
{{ new Date(entry.ts).toLocaleTimeString() }}
</span>
</div>
<div
class="items-center flex-1 min-w-0 p-2 inline-grid"
@click="toggleExpandPayload()"
>
<div class="truncate">
<span v-if="entry.prefix !== undefined" class="!inline">{{
entry.prefix
}}</span>
{{ entry.payload }}
</div>
</div>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="`${copyQueryIcon}`"
class="hidden group-hover:inline-flex"
@click.native="copyQuery(entry.payload)"
/>
<ButtonSecondary
svg="chevron-down"
class="transform"
:class="{ 'rotate-180': !minimized }"
@click.native="toggleExpandPayload()"
/>
</div>
</div>
<div v-if="!minimized" class="overflow-hidden bg-primaryLight">
<SmartTabs
v-model="selectedTab"
styles="bg-primaryLight"
render-inactive-tabs
>
<SmartTab v-if="isJSON(entry.payload)" id="json" label="JSON" />
<SmartTab id="raw" label="Raw" />
</SmartTabs>
<div
class="z-10 flex items-center justify-between pl-4 border-b border-dividerLight top-lowerSecondaryStickyFold"
>
<label class="font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
svg="wrap-text"
@click.native.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
ref="downloadResponse"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:svg="downloadIcon"
@click.native="downloadResponse"
/>
<ButtonSecondary
ref="copyResponse"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:svg="copyIcon"
@click.native="copyResponse"
/>
</div>
</div>
<div ref="editor"></div>
<div
v-if="outlinePath && selectedTab === 'json'"
class="sticky bottom-0 z-10 flex px-2 overflow-auto border-t bg-primaryLight border-dividerLight flex-nowrap hide-scrollbar"
>
<div
v-for="(item, index) in outlinePath"
:key="`item-${index}`"
class="flex items-center"
>
<tippy
ref="outlineOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<div v-if="item.kind === 'RootObject'" class="outline-item">
{}
</div>
<div v-if="item.kind === 'RootArray'" class="outline-item">
[]
</div>
<div v-if="item.kind === 'ArrayMember'" class="outline-item">
{{ item.index }}
</div>
<div v-if="item.kind === 'ObjectMember'" class="outline-item">
{{ item.name }}
</div>
</template>
<div
v-if="item.kind === 'ArrayMember' || item.kind === 'ObjectMember'"
>
<div
v-if="item.kind === 'ArrayMember'"
class="flex flex-col"
role="menu"
>
<SmartItem
v-for="(arrayMember, astIndex) in item.astParent.values"
:key="`ast-${astIndex}`"
:label="`${astIndex}`"
@click.native="
() => {
jumpCursor(arrayMember)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
<div
v-if="item.kind === 'ObjectMember'"
class="flex flex-col"
role="menu"
>
<SmartItem
v-for="(objectMember, astIndex) in item.astParent.members"
:key="`ast-${astIndex}`"
:label="objectMember.key.value"
@click.native="
() => {
jumpCursor(objectMember)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
</div>
<div
v-if="item.kind === 'RootObject'"
class="flex flex-col"
role="menu"
>
<SmartItem
label="{}"
@click.native="
() => {
jumpCursor(item.astValue)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
<div
v-if="item.kind === 'RootArray'"
class="flex flex-col"
role="menu"
>
<SmartItem
label="[]"
@click.native="
() => {
jumpCursor(item.astValue)
outlineOptions[index].tippy().hide()
}
"
/>
</div>
</tippy>
<i
v-if="index + 1 !== outlinePath.length"
class="opacity-50 text-secondaryLight material-icons"
>
chevron_right
</i>
</div>
</div>
</div>
</div>
<div v-else>{{ t("response.waiting_for_connection") }}</div>
</template>
<script setup lang="ts">
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { ref, computed, reactive, watch } from "@nuxtjs/composition-api"
import { refAutoReset, useTimeAgo } from "@vueuse/core"
import { LogEntryData } from "./Log.vue"
import { useI18n } from "~/helpers/utils/composables"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { isJSON } from "~/helpers/functional/json"
import useCopyResponse from "~/helpers/lenses/composables/useCopyResponse"
import useDownloadResponse from "~/helpers/lenses/composables/useDownloadResponse"
import { useCodemirror } from "~/helpers/editor/codemirror"
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
import { getJSONOutlineAtPos } from "~/helpers/newOutline"
import {
convertIndexToLineCh,
convertLineChToIndex,
} from "~/helpers/editor/utils"
const t = useI18n()
const props = defineProps<{ entry: LogEntryData }>()
const outlineOptions = ref<any | null>(null)
const editor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const logPayload = computed(() => props.entry.payload)
const selectedTab = ref<"json" | "raw">(
isJSON(props.entry.payload) ? "json" : "raw"
)
// CodeMirror Implementation
const jsonBodyText = computed(() =>
pipe(
logPayload.value,
O.tryCatchK(LJSON.parse),
O.map((val) => LJSON.stringify(val, undefined, 2)),
O.getOrElse(() => logPayload.value)
)
)
const ast = computed(() =>
pipe(
jsonBodyText.value,
O.tryCatchK(jsonParse),
O.getOrElseW(() => null)
)
)
const editorText = computed(() => {
if (selectedTab.value === "json") return jsonBodyText.value
else return logPayload.value
})
const editorMode = computed(() => {
if (selectedTab.value === "json") return "application/ld+json"
else return "text/plain"
})
const { cursor } = useCodemirror(
editor,
editorText,
reactive({
extendedEditorConfig: {
mode: editorMode,
readOnly: true,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
pos.line--
cursor.value = pos
}
const outlinePath = computed(() =>
pipe(
ast.value,
O.fromNullable,
O.map((ast) =>
getJSONOutlineAtPos(
ast,
convertLineChToIndex(jsonBodyText.value, cursor.value)
)
),
O.getOrElseW(() => null)
)
)
// Code for UI Changes
const minimized = ref(true)
watch(minimized, () => {
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
})
const toggleExpandPayload = () => {
minimized.value = !minimized.value
}
const { copyIcon, copyResponse } = useCopyResponse(logPayload)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
logPayload
)
const copyQueryIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyQuery = (entry: string) => {
copyToClipboard(entry)
copyQueryIcon.value = "check"
}
// Relative Time
// TS could be undefined here. We're just assigning a default value to 0 because we're not showing it in the UI
const relativeTime = useTimeAgo(computed(() => props.entry.ts ?? 0))
const ENTRY_COLORS = {
connected: "#10b981",
connecting: "#10b981",
error: "#ff5555",
disconnected: "#ff5555",
} as const
// Assigns color based on entry event
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
const ICONS = {
info: {
iconName: "info-realtime",
iconColor: "#10b981",
},
client: {
iconName: "arrow-up-right",
iconColor: "#eaaa45",
},
server: {
iconName: "arrow-down-left",
iconColor: "#38d4ea",
},
disconnected: {
iconName: "info-disconnect",
iconColor: "#ff5555",
},
} as const
const iconColor = computed(() => ICONS[props.entry.source].iconColor)
const iconName = computed(() => ICONS[props.entry.source].iconName)
</script>
<style scoped lang="scss">
.realtime-log {
@apply text-secondary;
@apply overflow-hidden;
&,
span {
@apply select-text;
}
span {
@apply block;
@apply break-words break-all;
}
}
.outline-item {
@apply cursor-pointer;
@apply flex-grow-0 flex-shrink-0;
@apply text-secondaryLight;
@apply inline-flex;
@apply items-center;
@apply px-2;
@apply py-1;
@apply transition;
@apply hover: text-secondary;
}
.ts-font {
font-size: 0.6rem;
}
</style>

View File

@@ -1,382 +0,0 @@
<template>
<AppPaneLayout>
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary hide-scrollbar"
>
<div class="inline-flex flex-1 space-x-2">
<input
id="mqtt-url"
v-model="url"
type="url"
autocomplete="off"
spellcheck="false"
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('mqtt.url')"
:disabled="connectionState"
@keyup.enter="validUrl ? toggleConnection() : null"
/>
<ButtonPrimary
id="connect"
:disabled="!validUrl"
class="w-32"
:label="
connectionState ? $t('action.disconnect') : $t('action.connect')
"
:loading="connectingState"
@click.native="toggleConnection"
/>
</div>
<div class="flex space-x-4">
<input
id="mqtt-username"
v-model="username"
type="text"
spellcheck="false"
class="input"
:placeholder="$t('authorization.username')"
/>
<input
id="mqtt-password"
v-model="password"
type="password"
spellcheck="false"
class="input"
:placeholder="$t('authorization.password')"
/>
</div>
</div>
</template>
<template #secondary>
<RealtimeLog :title="$t('mqtt.log')" :log="log" />
</template>
<template #sidebar>
<div class="flex items-center justify-between p-4">
<label for="pub_topic" class="font-semibold text-secondaryLight">
{{ $t("mqtt.topic") }}
</label>
</div>
<div class="flex px-4">
<input
id="pub_topic"
v-model="pub_topic"
class="input"
:placeholder="$t('mqtt.topic_name')"
type="text"
autocomplete="off"
spellcheck="false"
/>
</div>
<div class="flex items-center justify-between p-4">
<label for="mqtt-message" class="font-semibold text-secondaryLight">
{{ $t("mqtt.communication") }}
</label>
</div>
<div class="flex px-4 space-x-2">
<input
id="mqtt-message"
v-model="msg"
class="input"
type="text"
autocomplete="off"
:placeholder="$t('mqtt.message')"
spellcheck="false"
/>
<ButtonPrimary
id="publish"
name="get"
:disabled="!canpublish"
:label="$t('mqtt.publish')"
@click.native="publish"
/>
</div>
<div
class="flex items-center justify-between p-4 mt-4 border-t border-dividerLight"
>
<label for="sub_topic" class="font-semibold text-secondaryLight">
{{ $t("mqtt.topic") }}
</label>
</div>
<div class="flex px-4 space-x-2">
<input
id="sub_topic"
v-model="sub_topic"
type="text"
autocomplete="off"
:placeholder="$t('mqtt.topic_name')"
spellcheck="false"
class="input"
/>
<ButtonPrimary
id="subscribe"
name="get"
:disabled="!cansubscribe"
:label="
subscriptionState ? $t('mqtt.unsubscribe') : $t('mqtt.subscribe')
"
reverse
@click.native="toggleSubscription"
/>
</div>
</template>
</AppPaneLayout>
</template>
<script>
import { defineComponent } from "@nuxtjs/composition-api"
import Paho from "paho-mqtt"
import debounce from "lodash/debounce"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import {
MQTTEndpoint$,
setMQTTEndpoint,
MQTTConnectingState$,
MQTTConnectionState$,
setMQTTConnectingState,
setMQTTConnectionState,
MQTTSubscriptionState$,
setMQTTSubscriptionState,
MQTTSocket$,
setMQTTSocket,
MQTTLog$,
setMQTTLog,
addMQTTLogLine,
} from "~/newstore/MQTTSession"
import { useStream } from "~/helpers/utils/composables"
export default defineComponent({
setup() {
return {
url: useStream(MQTTEndpoint$, "", setMQTTEndpoint),
connectionState: useStream(
MQTTConnectionState$,
false,
setMQTTConnectionState
),
connectingState: useStream(
MQTTConnectingState$,
false,
setMQTTConnectingState
),
subscriptionState: useStream(
MQTTSubscriptionState$,
false,
setMQTTSubscriptionState
),
log: useStream(MQTTLog$, null, setMQTTLog),
client: useStream(MQTTSocket$, null, setMQTTSocket),
}
},
data() {
return {
isUrlValid: true,
pub_topic: "",
sub_topic: "",
msg: "",
manualDisconnect: false,
username: "",
password: "",
}
},
computed: {
validUrl() {
return this.isUrlValid
},
canpublish() {
return this.pub_topic !== "" && this.msg !== "" && this.connectionState
},
cansubscribe() {
return this.sub_topic !== "" && this.connectionState
},
},
watch: {
url() {
this.debouncer()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "ws", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
connect() {
this.connectingState = true
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
const parseUrl = new URL(this.url)
this.client = new Paho.Client(
`${parseUrl.hostname}${
parseUrl.pathname !== "/" ? parseUrl.pathname : ""
}`,
parseUrl.port !== "" ? Number(parseUrl.port) : 8081,
"hoppscotch"
)
const connectOptions = {
onSuccess: this.onConnectionSuccess,
onFailure: this.onConnectionFailure,
useSSL: parseUrl.protocol !== "ws:",
}
if (this.username !== "") {
connectOptions.userName = this.username
}
if (this.password !== "") {
connectOptions.password = this.password
}
this.client.connect(connectOptions)
this.client.onConnectionLost = this.onConnectionLost
this.client.onMessageArrived = this.onMessageArrived
logHoppRequestRunToAnalytics({
platform: "mqtt",
})
},
onConnectionFailure() {
this.connectingState = false
this.connectionState = false
addMQTTLogLine({
payload: this.$t("error.something_went_wrong"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
onConnectionSuccess() {
this.connectingState = false
this.connectionState = true
addMQTTLogLine({
payload: this.$t("state.connected_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
})
this.$toast.success(this.$t("state.connected"))
},
onMessageArrived({ payloadString, destinationName }) {
addMQTTLogLine({
payload: `Message: ${payloadString} arrived on topic: ${destinationName}`,
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
})
},
toggleConnection() {
if (this.connectionState) {
this.disconnect()
} else {
this.connect()
}
},
disconnect() {
this.manualDisconnect = true
this.client.disconnect()
addMQTTLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
onConnectionLost() {
this.connectingState = false
this.connectionState = false
if (this.manualDisconnect) {
this.$toast.error(this.$t("state.disconnected"))
} else {
this.$toast.error(this.$t("error.something_went_wrong"))
}
this.manualDisconnect = false
this.subscriptionState = false
},
publish() {
try {
this.client.publish(this.pub_topic, this.msg, 0, false)
addMQTTLogLine({
payload: `Published message: ${this.msg} to topic: ${this.pub_topic}`,
ts: new Date().toLocaleTimeString(),
source: "info",
color: "var(--accent-color)",
})
} catch (e) {
addMQTTLogLine({
payload:
this.$t("error.something_went_wrong") +
`while publishing msg: ${this.msg} to topic: ${this.pub_topic}`,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
}
},
toggleSubscription() {
if (this.subscriptionState) {
this.unsubscribe()
} else {
this.subscribe()
}
},
subscribe() {
try {
this.client.subscribe(this.sub_topic, {
onSuccess: this.usubSuccess,
onFailure: this.usubFailure,
})
} catch (e) {
addMQTTLogLine({
payload:
this.$t("error.something_went_wrong") +
`while subscribing to topic: ${this.sub_topic}`,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
}
},
usubSuccess() {
this.subscriptionState = !this.subscriptionState
addMQTTLogLine({
payload:
`Successfully ` +
(this.subscriptionState ? "subscribed" : "unsubscribed") +
` to topic: ${this.sub_topic}`,
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
})
},
usubFailure() {
addMQTTLogLine({
payload:
`Failed to ` +
(this.subscriptionState ? "unsubscribe" : "subscribe") +
` to topic: ${this.sub_topic}`,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
unsubscribe() {
this.client.unsubscribe(this.sub_topic, {
onSuccess: this.usubSuccess,
onFailure: this.usubFailure,
})
},
},
})
</script>

View File

@@ -1,521 +0,0 @@
<template>
<AppPaneLayout>
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary hide-scrollbar"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<label for="client-version">
<tippy
ref="versionOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<input
id="client-version"
v-tippy="{ theme: 'tooltip' }"
title="socket.io-client version"
class="flex px-4 py-2 font-semibold border rounded-l cursor-pointer bg-primaryLight border-divider text-secondaryDark w-26"
:value="`Client ${clientVersion}`"
readonly
:disabled="connectionState"
/>
</span>
</template>
<div class="flex flex-col" role="menu">
<SmartItem
v-for="(_, version) in socketIoClients"
:key="`client-${version}`"
:label="`Client ${version}`"
@click.native="onSelectVersion(version)"
/>
</div>
</tippy>
</label>
<input
id="socketio-url"
v-model="url"
type="url"
autocomplete="off"
spellcheck="false"
:class="{ error: !urlValid }"
class="flex flex-1 w-full px-4 py-2 border bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('socketio.url')"
:disabled="connectionState"
@keyup.enter="urlValid ? toggleConnection() : null"
/>
<input
id="socketio-path"
v-model="path"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="connectionState"
@keyup.enter="urlValid ? toggleConnection() : null"
/>
</div>
<ButtonPrimary
id="connect"
:disabled="!urlValid"
name="connect"
class="w-32"
:label="
!connectionState ? $t('action.connect') : $t('action.disconnect')
"
:loading="connectingState"
@click.native="toggleConnection"
/>
</div>
</div>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold text-secondaryLight">
{{ $t("authorization.type") }}
</label>
<tippy
ref="authTypeOptions"
interactive
trigger="click"
theme="popover"
arrow
>
<template #trigger>
<span class="select-wrapper">
<ButtonSecondary
class="pr-8 ml-2 rounded-none"
:label="authType"
/>
</span>
</template>
<div class="flex flex-col" role="menu">
<SmartItem
label="None"
:icon="
authType === 'None'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authType === 'None'"
@click.native="
() => {
authType = 'None'
authTypeOptions.tippy().hide()
}
"
/>
<SmartItem
label="Bearer Token"
:icon="
authType === 'Bearer'
? 'radio_button_checked'
: 'radio_button_unchecked'
"
:active="authType === 'Bearer'"
@click.native="
() => {
authType = 'Bearer'
authTypeOptions.tippy().hide()
}
"
/>
</div>
</tippy>
</span>
<div class="flex">
<SmartCheckbox
:on="authActive"
class="px-2"
@change="authActive = !authActive"
>
{{ $t("state.enabled") }}
</SmartCheckbox>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/authorization"
blank
:title="$t('app.wiki')"
svg="help-circle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear')"
svg="trash-2"
@click.native="clearContent"
/>
</div>
</div>
<div
v-if="authType === 'None'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${$colorMode.value}/login.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="$t('empty.authorization')"
/>
<span class="pb-4 text-center">
This SocketIO connection does not use any authentication.
</span>
<ButtonSecondary
outline
:label="$t('app.documentation')"
to="https://docs.hoppscotch.io/features/authorization"
blank
svg="external-link"
reverse
class="mb-4"
/>
</div>
<div
v-if="authType === 'Bearer'"
class="flex flex-1 border-b border-dividerLight"
>
<div class="w-2/3 border-r border-dividerLight">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
</div>
</div>
<div
class="sticky h-full p-4 overflow-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
>
<div class="p-2">
<div class="pb-2 text-secondaryLight">
{{ $t("helpers.authorization") }}
</div>
<SmartAnchor
class="link"
:label="`${$t('authorization.learn')} \xA0 →`"
to="https://docs.hoppscotch.io/features/authorization"
blank
/>
</div>
</div>
</div>
</template>
<template #secondary>
<RealtimeLog :title="$t('socketio.log')" :log="log" />
</template>
<template #sidebar>
<div class="flex items-center justify-between p-4">
<label for="events" class="font-semibold text-secondaryLight">
{{ $t("socketio.events") }}
</label>
</div>
<div class="flex px-4">
<input
id="event_name"
v-model="communication.eventName"
class="input"
name="event_name"
:placeholder="$t('socketio.event_name')"
type="text"
autocomplete="off"
:disabled="!connectionState"
/>
</div>
<div class="flex items-center justify-between p-4">
<label class="font-semibold text-secondaryLight">
{{ $t("socketio.communication") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('add.new')"
svg="plus"
@click.native="addCommunicationInput"
/>
</div>
</div>
<div class="flex flex-col px-4 pb-4 space-y-2">
<div
v-for="(input, index) of communication.inputs"
:key="`input-${index}`"
>
<div class="flex space-x-2">
<input
v-model="communication.inputs[index]"
class="input"
name="message"
:placeholder="$t('count.message', { count: index + 1 })"
type="text"
autocomplete="off"
:disabled="!connectionState"
@keyup.enter="connectionState ? sendMessage() : null"
/>
<ButtonSecondary
v-if="index + 1 !== communication.inputs.length"
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.remove')"
svg="trash"
color="red"
outline
@click.native="removeCommunicationInput({ index })"
/>
<ButtonPrimary
v-if="index + 1 === communication.inputs.length"
id="send"
name="send"
:disabled="!connectionState"
:label="$t('action.send')"
@click.native="sendMessage"
/>
</div>
</div>
</div>
</template>
</AppPaneLayout>
</template>
<script>
import { defineComponent, ref } from "@nuxtjs/composition-api"
// All Socket.IO client version imports
import ClientV2 from "socket.io-client-v2"
import { io as ClientV3 } from "socket.io-client-v3"
import { io as ClientV4 } from "socket.io-client-v4"
import wildcard from "socketio-wildcard"
import debounce from "lodash/debounce"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import {
SIOEndpoint$,
setSIOEndpoint,
SIOVersion$,
setSIOVersion,
SIOPath$,
setSIOPath,
SIOConnectionState$,
SIOConnectingState$,
setSIOConnectionState,
setSIOConnectingState,
SIOSocket$,
setSIOSocket,
SIOLog$,
setSIOLog,
addSIOLogLine,
} from "~/newstore/SocketIOSession"
import { useStream } from "~/helpers/utils/composables"
const socketIoClients = {
v4: ClientV4,
v3: ClientV3,
v2: ClientV2,
}
export default defineComponent({
setup() {
return {
socketIoClients,
url: useStream(SIOEndpoint$, "", setSIOEndpoint),
clientVersion: useStream(SIOVersion$, "", setSIOVersion),
path: useStream(SIOPath$, "", setSIOPath),
connectingState: useStream(
SIOConnectingState$,
false,
setSIOConnectingState
),
connectionState: useStream(
SIOConnectionState$,
false,
setSIOConnectionState
),
io: useStream(SIOSocket$, null, setSIOSocket),
log: useStream(SIOLog$, [], setSIOLog),
authTypeOptions: ref(null),
}
},
data() {
return {
isUrlValid: true,
communication: {
eventName: "",
inputs: [""],
},
authType: "None",
bearerToken: "",
authActive: true,
}
},
computed: {
urlValid() {
return this.isUrlValid
},
},
watch: {
url() {
this.debouncer()
},
connectionState(connected) {
if (connected) this.$refs.versionOptions.tippy().disable()
else this.$refs.versionOptions.tippy().enable()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "socketio", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
removeCommunicationInput({ index }) {
this.$delete(this.communication.inputs, index)
},
addCommunicationInput() {
this.communication.inputs.push("")
},
toggleConnection() {
// If it is connecting:
if (!this.connectionState) return this.connect()
// Otherwise, it's disconnecting.
else return this.disconnect()
},
connect() {
this.connectingState = true
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
},
]
try {
if (!this.path) {
this.path = "/socket.io"
}
const Client = socketIoClients[this.clientVersion]
if (this.authActive && this.authType === "Bearer") {
this.io = new Client(this.url, {
path: this.path,
auth: {
token: this.bearerToken,
},
})
} else {
this.io = new Client(this.url, { path: this.path })
}
// Add ability to listen to all events
wildcard(Client.Manager)(this.io)
this.io.on("connect", () => {
this.connectingState = false
this.connectionState = true
this.log = [
{
payload: this.$t("state.connected_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
this.$toast.success(this.$t("state.connected"))
})
this.io.on("*", ({ data }) => {
const [eventName, message] = data
addSIOLogLine({
payload: `[${eventName}] ${message ? JSON.stringify(message) : ""}`,
source: "server",
ts: new Date().toLocaleTimeString(),
})
})
this.io.on("connect_error", (error) => {
this.handleError(error)
})
this.io.on("reconnect_error", (error) => {
this.handleError(error)
})
this.io.on("error", () => {
this.handleError()
})
this.io.on("disconnect", () => {
this.connectingState = false
this.connectionState = false
addSIOLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
this.$toast.error(this.$t("state.disconnected"))
})
} catch (e) {
this.handleError(e)
this.$toast.error(this.$t("error.something_went_wrong"))
}
logHoppRequestRunToAnalytics({
platform: "socketio",
})
},
disconnect() {
this.io.close()
},
handleError(error) {
this.disconnect()
this.connectingState = false
this.connectionState = false
addSIOLogLine({
payload: this.$t("error.something_went_wrong"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
if (error !== null)
addSIOLogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
sendMessage() {
const eventName = this.communication.eventName
const messages = (this.communication.inputs || [])
.map((input) => {
try {
return JSON.parse(input)
} catch (e) {
return input
}
})
.filter((message) => !!message)
if (this.io) {
this.io.emit(eventName, ...messages, (data) => {
// receive response from server
addSIOLogLine({
payload: `[${eventName}] ${JSON.stringify(data)}`,
source: "server",
ts: new Date().toLocaleTimeString(),
})
})
addSIOLogLine({
payload: `[${eventName}] ${JSON.stringify(messages)}`,
source: "client",
ts: new Date().toLocaleTimeString(),
})
this.communication.inputs = [""]
}
},
onSelectVersion(version) {
this.clientVersion = version
this.$refs.versionOptions.tippy().hide()
},
},
})
</script>

View File

@@ -1,222 +0,0 @@
<template>
<AppPaneLayout>
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary hide-scrollbar"
>
<div class="inline-flex flex-1 space-x-2">
<div class="flex flex-1">
<input
id="server"
v-model="server"
type="url"
autocomplete="off"
:class="{ error: !serverValid }"
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
:placeholder="$t('sse.url')"
:disabled="connectionSSEState"
@keyup.enter="serverValid ? toggleSSEConnection() : null"
/>
<label
for="event-type"
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
>
{{ $t("sse.event_type") }}
</label>
<input
id="event-type"
v-model="eventType"
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
spellcheck="false"
:disabled="connectionSSEState"
@keyup.enter="serverValid ? toggleSSEConnection() : null"
/>
</div>
<ButtonPrimary
id="start"
:disabled="!serverValid"
name="start"
class="w-32"
:label="
!connectionSSEState ? $t('action.start') : $t('action.stop')
"
:loading="connectingState"
@click.native="toggleSSEConnection"
/>
</div>
</div>
</template>
<template #secondary>
<RealtimeLog :title="$t('sse.log')" :log="log" />
</template>
</AppPaneLayout>
</template>
<script>
import { defineComponent } from "@nuxtjs/composition-api"
import "splitpanes/dist/splitpanes.css"
import debounce from "lodash/debounce"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import {
SSEEndpoint$,
setSSEEndpoint,
SSEEventType$,
setSSEEventType,
SSESocket$,
setSSESocket,
SSEConnectingState$,
SSEConnectionState$,
setSSEConnectionState,
setSSEConnectingState,
SSELog$,
setSSELog,
addSSELogLine,
} from "~/newstore/SSESession"
import { useStream } from "~/helpers/utils/composables"
export default defineComponent({
setup() {
return {
connectionSSEState: useStream(
SSEConnectionState$,
false,
setSSEConnectionState
),
connectingState: useStream(
SSEConnectingState$,
false,
setSSEConnectingState
),
server: useStream(SSEEndpoint$, "", setSSEEndpoint),
eventType: useStream(SSEEventType$, "", setSSEEventType),
sse: useStream(SSESocket$, null, setSSESocket),
log: useStream(SSELog$, [], setSSELog),
}
},
data() {
return {
isUrlValid: true,
}
},
computed: {
serverValid() {
return this.isUrlValid
},
},
watch: {
server() {
this.debouncer()
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
debouncer: debounce(function () {
this.worker.postMessage({ type: "sse", url: this.server })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
toggleSSEConnection() {
// If it is connecting:
if (!this.connectionSSEState) return this.start()
// Otherwise, it's disconnecting.
else return this.stop()
},
start() {
this.connectingState = true
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.server }),
source: "info",
color: "var(--accent-color)",
},
]
if (typeof EventSource !== "undefined") {
try {
this.sse = new EventSource(this.server)
this.sse.onopen = () => {
this.connectingState = false
this.connectionSSEState = true
this.log = [
{
payload: this.$t("state.connected_to", { name: this.server }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
this.$toast.success(this.$t("state.connected"))
}
this.sse.onerror = () => {
this.handleSSEError()
}
this.sse.onclose = () => {
this.connectionSSEState = false
addSSELogLine({
payload: this.$t("state.disconnected_from", {
name: this.server,
}),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
this.$toast.error(this.$t("state.disconnected"))
}
this.sse.addEventListener(this.eventType, ({ data }) => {
addSSELogLine({
payload: data,
source: "server",
ts: new Date().toLocaleTimeString(),
})
})
} catch (e) {
this.handleSSEError(e)
this.$toast.error(this.$t("error.something_went_wrong"))
}
} else {
this.log = [
{
payload: this.$t("error.browser_support_sse"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
},
]
}
logHoppRequestRunToAnalytics({
platform: "sse",
})
},
handleSSEError(error) {
this.stop()
this.connectionSSEState = false
addSSELogLine({
payload: this.$t("error.something_went_wrong"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
if (error !== null)
addSSELogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
stop() {
this.sse.close()
this.sse.onclose()
},
},
})
</script>

View File

@@ -1,433 +0,0 @@
<template>
<AppPaneLayout>
<template #primary>
<div
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary hide-scrollbar"
>
<div class="inline-flex flex-1 space-x-2">
<input
id="websocket-url"
v-model="url"
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
type="url"
autocomplete="off"
spellcheck="false"
:class="{ error: !urlValid }"
:placeholder="$t('websocket.url')"
:disabled="connectionState"
@keyup.enter="urlValid ? toggleConnection() : null"
/>
<ButtonPrimary
id="connect"
:disabled="!urlValid"
class="w-32"
name="connect"
:label="
!connectionState ? $t('action.connect') : $t('action.disconnect')
"
:loading="connectingState"
@click.native="toggleConnection"
/>
</div>
</div>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
>
<label class="font-semibold text-secondaryLight">
{{ $t("websocket.protocols") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.clear_all')"
svg="trash-2"
@click.native="clearContent"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('add.new')"
svg="plus"
@click.native="addProtocol"
/>
</div>
</div>
<draggable
v-model="protocols"
animation="250"
handle=".draggable-handle"
draggable=".draggable-content"
ghost-class="cursor-move"
chosen-class="bg-primaryLight"
drag-class="cursor-grabbing"
>
<div
v-for="(protocol, index) of protocols"
:key="`protocol-${index}`"
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<ButtonSecondary
svg="grip-vertical"
class="cursor-auto text-primary hover:text-primary"
:class="{
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
index !== protocols?.length - 1,
}"
tabindex="-1"
/>
</span>
<input
v-model="protocol.value"
class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="$t('count.protocol', { count: index + 1 })"
name="message"
type="text"
autocomplete="off"
@change="
updateProtocol(index, {
value: $event.target.value,
active: protocol.active,
})
"
/>
<span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
protocol.hasOwnProperty('active')
? protocol.active
? $t('action.turn_off')
: $t('action.turn_on')
: $t('action.turn_off')
"
:svg="
protocol.hasOwnProperty('active')
? protocol.active
? 'check-circle'
: 'circle'
: 'check-circle'
"
color="green"
@click.native="
updateProtocol(index, {
value: protocol.value,
active: !protocol.active,
})
"
/>
</span>
<span>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="$t('action.remove')"
svg="trash"
color="red"
@click.native="deleteProtocol({ index })"
/>
</span>
</div>
</draggable>
<div
v-if="protocols.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${$colorMode.value}/add_category.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
:alt="$t('empty.protocols')"
/>
<span class="mb-4 text-center">
{{ $t("empty.protocols") }}
</span>
</div>
</template>
<template #secondary>
<RealtimeLog :title="$t('websocket.log')" :log="log" />
</template>
<template #sidebar>
<div class="flex items-center justify-between p-4">
<label
for="websocket-message"
class="font-semibold text-secondaryLight"
>
{{ $t("websocket.communication") }}
</label>
</div>
<div class="flex px-4 space-x-2">
<input
id="websocket-message"
v-model="communication.input"
name="message"
type="text"
autocomplete="off"
:disabled="!connectionState"
:placeholder="$t('websocket.message')"
class="input"
@keyup.enter="connectionState ? sendMessage() : null"
@keyup.up="connectionState ? walkHistory('up') : null"
@keyup.down="connectionState ? walkHistory('down') : null"
/>
<ButtonPrimary
id="send"
name="send"
:disabled="!connectionState"
:label="$t('action.send')"
@click.native="sendMessage"
/>
</div>
</template>
</AppPaneLayout>
</template>
<script>
import { defineComponent } from "@nuxtjs/composition-api"
import debounce from "lodash/debounce"
import draggable from "vuedraggable"
import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
import {
setWSEndpoint,
WSEndpoint$,
WSProtocols$,
setWSProtocols,
addWSProtocol,
deleteWSProtocol,
updateWSProtocol,
deleteAllWSProtocols,
WSSocket$,
setWSSocket,
setWSConnectionState,
setWSConnectingState,
WSConnectionState$,
WSConnectingState$,
addWSLogLine,
WSLog$,
setWSLog,
} from "~/newstore/WebSocketSession"
import { useStream } from "~/helpers/utils/composables"
export default defineComponent({
components: {
draggable,
},
setup() {
return {
url: useStream(WSEndpoint$, "", setWSEndpoint),
protocols: useStream(WSProtocols$, [], setWSProtocols),
connectionState: useStream(
WSConnectionState$,
false,
setWSConnectionState
),
connectingState: useStream(
WSConnectingState$,
false,
setWSConnectingState
),
socket: useStream(WSSocket$, null, setWSSocket),
log: useStream(WSLog$, [], setWSLog),
}
},
data() {
return {
isUrlValid: true,
communication: {
input: "",
},
currentIndex: -1, // index of the message log array to put in input box
activeProtocols: [],
}
},
computed: {
urlValid() {
return this.isUrlValid
},
},
watch: {
url() {
this.debouncer()
},
protocols: {
handler(newVal) {
this.activeProtocols = newVal
.filter((item) =>
Object.prototype.hasOwnProperty.call(item, "active")
? item.active === true
: true
)
.map(({ value }) => value)
},
deep: true,
},
},
created() {
if (process.browser) {
this.worker = this.$worker.createRejexWorker()
this.worker.addEventListener("message", this.workerResponseHandler)
}
},
destroyed() {
this.worker.terminate()
},
methods: {
clearContent() {
deleteAllWSProtocols()
},
debouncer: debounce(function () {
this.worker.postMessage({ type: "ws", url: this.url })
}, 1000),
workerResponseHandler({ data }) {
if (data.url === this.url) this.isUrlValid = data.result
},
toggleConnection() {
// If it is connecting:
if (!this.connectionState) return this.connect()
// Otherwise, it's disconnecting.
else return this.disconnect()
},
connect() {
this.log = [
{
payload: this.$t("state.connecting_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
},
]
try {
this.connectingState = true
this.socket = new WebSocket(this.url, this.activeProtocols)
this.socket.onopen = () => {
this.connectingState = false
this.connectionState = true
this.log = [
{
payload: this.$t("state.connected_to", { name: this.url }),
source: "info",
color: "var(--accent-color)",
ts: new Date().toLocaleTimeString(),
},
]
this.$toast.success(this.$t("state.connected"))
}
this.socket.onerror = () => {
this.handleError()
}
this.socket.onclose = () => {
this.connectionState = false
addWSLogLine({
payload: this.$t("state.disconnected_from", { name: this.url }),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
this.$toast.error(this.$t("state.disconnected"))
}
this.socket.onmessage = ({ data }) => {
addWSLogLine({
payload: data,
source: "server",
ts: new Date().toLocaleTimeString(),
})
}
} catch (e) {
this.handleError(e)
this.$toast.error(this.$t("error.something_went_wrong"))
}
logHoppRequestRunToAnalytics({
platform: "wss",
})
},
disconnect() {
if (this.socket) {
this.socket.close()
this.connectionState = false
this.connectingState = false
}
},
handleError(error) {
this.disconnect()
this.connectionState = false
addWSLogLine({
payload: this.$t("error.something_went_wrong"),
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
if (error !== null)
addWSLogLine({
payload: error,
source: "info",
color: "#ff5555",
ts: new Date().toLocaleTimeString(),
})
},
sendMessage() {
const message = this.communication.input
this.socket.send(message)
addWSLogLine({
payload: message,
source: "client",
ts: new Date().toLocaleTimeString(),
})
this.communication.input = ""
},
walkHistory(direction) {
const clientMessages = this.log.filter(
({ source }) => source === "client"
)
const length = clientMessages.length
switch (direction) {
case "up":
if (length > 0 && this.currentIndex !== 0) {
// does nothing if message log is empty or the currentIndex is 0 when up arrow is pressed
if (this.currentIndex === -1) {
this.currentIndex = length - 1
this.communication.input =
clientMessages[this.currentIndex].payload
} else if (this.currentIndex === 0) {
this.communication.input = clientMessages[0].payload
} else if (this.currentIndex > 0) {
this.currentIndex = this.currentIndex - 1
this.communication.input =
clientMessages[this.currentIndex].payload
}
}
break
case "down":
if (length > 0 && this.currentIndex > -1) {
if (this.currentIndex === length - 1) {
this.currentIndex = -1
this.communication.input = ""
} else if (this.currentIndex < length - 1) {
this.currentIndex = this.currentIndex + 1
this.communication.input =
clientMessages[this.currentIndex].payload
}
}
break
}
},
addProtocol() {
addWSProtocol({ value: "", active: true })
},
deleteProtocol({ index }) {
const oldProtocols = this.protocols.slice()
deleteWSProtocol(index)
this.$toast.success(this.$t("state.deleted"), {
action: {
text: this.$t("action.undo"),
duration: 4000,
onClick: (_, toastObject) => {
this.protocols = oldProtocols
toastObject.remove()
},
},
})
},
updateProtocol(index, updated) {
updateWSProtocol(index, updated)
},
},
})
</script>

View File

@@ -47,6 +47,7 @@ const props = withDefaults(
styles: string
envs: { key: string; value: string; source: string }[] | null
focus: boolean
readonly: boolean
}>(),
{
value: "",
@@ -54,6 +55,7 @@ const props = withDefaults(
styles: "",
envs: null,
focus: false,
readonly: false,
}
)
@@ -123,7 +125,23 @@ const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
const initView = (el: any) => {
const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.updateListener.of((update) => {
if (props.readonly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !props.readonly),
inputTheme,
props.readonly
? EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color) !important",
color: "var(--secondary-dark-color) !important",
backgroundColor: "var(--divider-color) !important",
opacity: 0.25,
},
})
: EditorView.theme({}),
tooltips({
position: "absolute",
}),
@@ -141,6 +159,8 @@ const initView = (el: any) => {
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (props.readonly) return
if (update.docChanged) {
const prevValue = clone(cachedValue.value)

View File

@@ -1,33 +1,31 @@
<template>
<SmartItem
:label="label"
:icon="
value === selected ? 'radio_button_checked' : 'radio_button_unchecked'
"
:active="value === selected"
:icon="selected ? 'radio_button_checked' : 'radio_button_unchecked'"
:active="selected"
role="radio"
:aria-checked="value === selected"
@click.native="$emit('change', value)"
:aria-checked="selected"
@click.native="emit('change', value)"
/>
</template>
<script>
import { defineComponent } from "@nuxtjs/composition-api"
<script setup lang="ts">
const emit = defineEmits<{
(e: "change", value: string): void
}>()
export default defineComponent({
props: {
value: {
type: String,
default: "",
},
label: {
type: String,
default: "",
},
selected: {
type: String,
default: "",
},
defineProps({
value: {
type: String,
default: "",
},
label: {
type: String,
default: "",
},
selected: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -5,18 +5,22 @@
:key="`radio-${index}`"
:value="radio.value"
:label="radio.label"
:selected="selected"
@change="$emit('change', radio.value)"
:selected="value === radio.value"
@change="emit('input', radio.value)"
/>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: "input", value: string): void
}>()
defineProps<{
radios: Array<{
value: string
value: string // The key of the radio option
label: string
}>
selected: string
value: string // Should be a radio key given in the radios array
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div v-show="active" class="flex flex-col flex-1">
<div v-if="shouldRender" v-show="active" class="flex flex-col flex-1">
<slot></slot>
</div>
</template>
@@ -33,11 +33,24 @@ const tabMeta = computed<TabMeta>(() => ({
label: props.label,
}))
const { activeTabID, addTabEntry, updateTabEntry, removeTabEntry } =
inject<TabProvider>("tabs-system")!
const {
activeTabID,
renderInactive,
addTabEntry,
updateTabEntry,
removeTabEntry,
} = inject<TabProvider>("tabs-system")!
const active = computed(() => activeTabID.value === props.id)
const shouldRender = computed(() => {
// If render inactive is true, then it should be rendered nonetheless
if (renderInactive.value) return true
// Else, return whatever is the active state
return active.value
})
onMounted(() => {
addTabEntry(props.id, tabMeta.value)
})

View File

@@ -80,6 +80,8 @@ export type TabMeta = {
}
export type TabProvider = {
// Whether inactive tabs should remain rendered
renderInactive: ComputedRef<boolean>
activeTabID: ComputedRef<string>
addTabEntry: (tabID: string, meta: TabMeta) => void
updateTabEntry: (tabID: string, newMeta: TabMeta) => void
@@ -91,6 +93,10 @@ const props = defineProps({
type: String,
default: "",
},
renderInactiveTabs: {
type: Boolean,
default: false,
},
vertical: {
type: Boolean,
default: false,
@@ -144,6 +150,7 @@ const removeTabEntry = (tabID: string) => {
}
provide<TabProvider>("tabs-system", {
renderInactive: computed(() => props.renderInactiveTabs),
activeTabID: computed(() => props.value),
addTabEntry,
updateTabEntry,

View File

@@ -25,7 +25,7 @@ import { getRESTRequest, setRESTTestResults } from "~/newstore/RESTSession"
import {
environmentsStore,
getCurrentEnvironment,
getEnviroment,
getEnvironment,
getGlobalVariables,
setGlobalEnvVariables,
updateEnvironment,
@@ -97,7 +97,7 @@ export const runRESTRequest$ = (): TaskEither<
setGlobalEnvVariables(runResult.right.envs.global)
if (environmentsStore.value.currentEnvironmentIndex !== -1) {
const env = getEnviroment(
const env = getEnvironment(
environmentsStore.value.currentEnvironmentIndex
)
updateEnvironment(

View File

@@ -45,28 +45,23 @@ import {
} from "~/helpers/fb/auth"
const BACKEND_GQL_URL =
process.env.context === "production"
? "https://api.hoppscotch.io/graphql"
: "https://api.hoppscotch.io/graphql"
process.env.BACKEND_GQL_URL ?? "https://api.hoppscotch.io/graphql"
const BACKEND_WS_URL =
process.env.BACKEND_WS_URL ?? "wss://api.hoppscotch.io/graphql"
// const storage = makeDefaultStorage({
// idbName: "hoppcache-v1",
// maxAge: 7,
// })
const subscriptionClient = new SubscriptionClient(
process.env.context === "production"
? "wss://api.hoppscotch.io/graphql"
: "wss://api.hoppscotch.io/graphql",
{
reconnect: true,
connectionParams: () => {
return {
authorization: `Bearer ${authIdToken$.value}`,
}
},
}
)
const subscriptionClient = new SubscriptionClient(BACKEND_WS_URL, {
reconnect: true,
connectionParams: () => {
return {
authorization: `Bearer ${authIdToken$.value}`,
}
},
})
authIdToken$.subscribe(() => {
subscriptionClient.client?.close()

View File

@@ -0,0 +1,3 @@
mutation DeleteShortcode($code: ID!) {
revokeShortcode(code: $code)
}

View File

@@ -0,0 +1,7 @@
query GetUserShortcodes($cursor: ID) {
myShortcodes(cursor: $cursor) {
id
request
createdOn
}
}

View File

@@ -0,0 +1,7 @@
subscription ShortcodeCreated {
myShortcodesCreated {
id
request
createdOn
}
}

View File

@@ -0,0 +1,5 @@
subscription ShortcodeDeleted {
myShortcodesRevoked {
id
}
}

View File

@@ -17,7 +17,7 @@ import {
GetCollectionTitleDocument,
} from "./graphql"
const BACKEND_PAGE_SIZE = 10
export const BACKEND_PAGE_SIZE = 10
const getCollectionChildrenIDs = async (collID: string) => {
const collsList: string[] = []

View File

@@ -4,8 +4,13 @@ import {
CreateShortcodeDocument,
CreateShortcodeMutation,
CreateShortcodeMutationVariables,
DeleteShortcodeDocument,
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
} from "../graphql"
type DeleteShortcodeErrors = "shortcode/not_found"
export const createShortcode = (request: HoppRESTRequest) =>
runMutation<CreateShortcodeMutation, CreateShortcodeMutationVariables, "">(
CreateShortcodeDocument,
@@ -13,3 +18,12 @@ export const createShortcode = (request: HoppRESTRequest) =>
request: JSON.stringify(request),
}
)
export const deleteShortcode = (code: string) =>
runMutation<
DeleteShortcodeMutation,
DeleteShortcodeMutationVariables,
DeleteShortcodeErrors
>(DeleteShortcodeDocument, {
code,
})

View File

@@ -768,11 +768,52 @@ const samples = [
testScript: "",
}),
},
{
command: `curl \`
google.com -H "content-type: application/json"`,
response: makeRESTRequest({
method: "GET",
name: "Untitled request",
endpoint: "https://google.com/",
auth: {
authType: "none",
authActive: true,
},
body: {
contentType: null,
body: null,
},
params: [],
headers: [],
preRequestScript: "",
testScript: "",
}),
},
{
command: `curl 192.168.0.24:8080/ping`,
response: makeRESTRequest({
method: "GET",
name: "Untitled request",
endpoint: "http://192.168.0.24:8080/ping",
auth: {
authType: "none",
authActive: true,
},
body: {
contentType: null,
body: null,
},
params: [],
headers: [],
preRequestScript: "",
testScript: "",
}),
},
]
describe("parseCurlToHoppRESTReq", () => {
describe("Parse curl command to Hopp REST Request", () => {
for (const [i, { command, response }] of samples.entries()) {
test(`matches expectation for sample #${i + 1}`, () => {
test(`for sample #${i + 1}:\n\n${command}`, () => {
expect(parseCurlToHoppRESTReq(command)).toEqual(response)
})
}

View File

@@ -12,7 +12,7 @@ import { getHeaders, recordToHoppHeaders } from "./sub_helpers/headers"
// import { getCookies } from "./sub_helpers/cookies"
import { getQueries } from "./sub_helpers/queries"
import { getMethod } from "./sub_helpers/method"
import { concatParams, parseURL } from "./sub_helpers/url"
import { concatParams, getURLObject } from "./sub_helpers/url"
import { preProcessCurlCommand } from "./sub_helpers/preproc"
import { getBody, getFArgumentMultipartData } from "./sub_helpers/body"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
@@ -42,7 +42,7 @@ export const parseCurlCommand = (curlCommand: string) => {
const method = getMethod(parsedArguments)
// const cookies = getCookies(parsedArguments)
const urlObject = parseURL(parsedArguments)
const urlObject = getURLObject(parsedArguments)
const auth = getAuthObject(parsedArguments, headers, urlObject)
let rawData: string | string[] = pipe(

View File

@@ -161,8 +161,7 @@ const getXMLBody = (rawData: string) =>
const getFormattedJSON = flow(
safeParseJSON,
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
O.getOrElse(() => "{}"),
O.of
O.getOrElse(() => "{ }")
)
const getXWWWFormUrlEncodedBody = flow(
@@ -189,7 +188,7 @@ export function parseBody(
case "application/ld+json":
case "application/vnd.api+json":
case "application/json":
return getFormattedJSON(rawData)
return O.some(getFormattedJSON(rawData))
case "application/x-www-form-urlencoded":
return getXWWWFormUrlEncodedBody(rawData)

View File

@@ -19,10 +19,11 @@ const replaceables: { [key: string]: string } = {
const paperCuts = flow(
// remove '\' and newlines
S.replace(/ ?\\ ?$/gm, " "),
S.replace(/\n/g, ""),
S.replace(/\n/g, " "),
// remove all $ symbols from start of argument values
S.replace(/\$'/g, "'"),
S.replace(/\$"/g, '"')
S.replace(/\$"/g, '"'),
S.trim
)
// replace --zargs option with -Z

View File

@@ -1,48 +1,80 @@
import parser from "yargs-parser"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { getDefaultRESTRequest } from "~/newstore/RESTSession"
import { stringArrayJoin } from "~/helpers/functional/array"
const defaultRESTReq = getDefaultRESTRequest()
const getProtocolForBaseURL = (baseURL: string) =>
const getProtocolFromURL = (url: string) =>
pipe(
// get the base URL
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(baseURL),
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(url),
O.fromNullable,
O.filter((burl) => burl.length > 1),
O.map((burl) => burl[2]),
// set protocol to http for local URLs
O.map((burl) =>
burl === "localhost" || burl === "127.0.0.1"
? "http://" + baseURL
: "https://" + baseURL
burl === "localhost" ||
burl === "2130706433" ||
/127(\.0){0,2}\.1/.test(burl) ||
/0177(\.0){0,2}\.1/.test(burl) ||
/0x7f(\.0){0,2}\.1/.test(burl) ||
/192\.168(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){2}/.test(burl) ||
/10(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/.test(burl)
? "http://" + url
: "https://" + url
)
)
/**
* Checks if the URL is valid using the URL constructor
* @param urlString URL string (with protocol)
* @returns boolean whether the URL is valid using the inbuilt URL class
*/
const isURLValid = (urlString: string) =>
pipe(
O.tryCatch(() => new URL(urlString)),
O.isSome
)
/**
* Checks and returns URL object for the valid URL
* @param urlText Raw URL string provided by argument parser
* @returns Option of URL object
*/
const parseURL = (urlText: string | number) =>
pipe(
urlText,
O.fromNullable,
// preprocess url string
O.map((u) => u.toString().replaceAll(/[^a-zA-Z0-9_\-./?&=:@%+#,;\s]/g, "")),
O.filter((u) => u.length > 0),
O.chain((u) =>
pipe(
u,
// check if protocol is available
O.fromPredicate(
(url: string) => /^[^:\s]+(?=:\/\/)/.exec(url) !== null
),
O.alt(() => getProtocolFromURL(u))
)
),
O.filter(isURLValid),
O.map((u) => new URL(u))
)
/**
* Processes URL string and returns the URL object
* @param parsedArguments Parsed Arguments object
* @returns URL object
*/
export function parseURL(parsedArguments: parser.Arguments) {
export function getURLObject(parsedArguments: parser.Arguments) {
return pipe(
// contains raw url string
parsedArguments._[1],
O.fromNullable,
// preprocess url string
O.map((u) => u.toString().replace(/["']/g, "").trim()),
O.chain((u) =>
pipe(
// check if protocol is available
/^[^:\s]+(?=:\/\/)/.exec(u),
O.fromNullable,
O.map((_) => u),
O.alt(() => getProtocolForBaseURL(u))
)
),
O.map((u) => new URL(u)),
// contains raw url strings
parsedArguments._.slice(1),
A.findFirstMap(parseURL),
// no url found
O.getOrElse(() => new URL(defaultRESTReq.endpoint))
)

View File

@@ -28,8 +28,6 @@ import { javascriptLanguage } from "@codemirror/lang-javascript"
import { xmlLanguage } from "@codemirror/lang-xml"
import { jsonLanguage } from "@codemirror/lang-json"
import { GQLLanguage } from "@hoppscotch/codemirror-lang-graphql"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { StreamLanguage } from "@codemirror/stream-parser"
import { html } from "@codemirror/legacy-modes/mode/xml"
import { shell } from "@codemirror/legacy-modes/mode/shell"
@@ -40,7 +38,6 @@ import { Completer } from "./completion"
import { LinterDefinition } from "./linting/linter"
import { basicSetup, baseTheme, baseHighlightStyle } from "./themes/baseTheme"
import { HoppEnvironmentPlugin } from "./extensions/HoppEnvironment"
import { IndentedLineWrapPlugin } from "./extensions/IndentedLineWrap"
// TODO: Migrate from legacy mode
type ExtendedEditorConfig = {
@@ -96,8 +93,10 @@ const hoppCompleterExt = (completer: Completer): Extension => {
})
}
const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
const hoppLinterExt = (hoppLinter: LinterDefinition | undefined): Extension => {
return linter(async (view) => {
if (!hoppLinter) return []
// Requires full document scan, hence expensive on big files, force disable on big files ?
const linterResult = await hoppLinter(
view.state.doc.toJSON().join(view.state.lineBreak)
@@ -119,16 +118,16 @@ const hoppLinterExt = (hoppLinter: LinterDefinition): Extension => {
}
const hoppLang = (
language: Language,
language: Language | undefined,
linter?: LinterDefinition | undefined,
completer?: Completer | undefined
) => {
): Extension | LanguageSupport => {
const exts: Extension[] = []
if (linter) exts.push(hoppLinterExt(linter))
exts.push(hoppLinterExt(linter))
if (completer) exts.push(hoppCompleterExt(completer))
return new LanguageSupport(language, exts)
return language ? new LanguageSupport(language, exts) : exts
}
const getLanguage = (langMime: string): Language | null => {
@@ -156,12 +155,7 @@ const getEditorLanguage = (
langMime: string,
linter: LinterDefinition | undefined,
completer: Completer | undefined
): Extension =>
pipe(
O.fromNullable(getLanguage(langMime)),
O.map((lang) => hoppLang(lang, linter, completer)),
O.getOrElseW(() => [])
)
): Extension => hoppLang(getLanguage(langMime) ?? undefined, linter, completer)
export function useCodemirror(
el: Ref<any | null>,
@@ -243,7 +237,7 @@ export function useCodemirror(
),
lineWrapping.of(
options.extendedEditorConfig.lineWrapping
? [IndentedLineWrapPlugin]
? [EditorView.lineWrapping]
: []
),
keymap.of([
@@ -330,7 +324,7 @@ export function useCodemirror(
(newMode) => {
view.value?.dispatch({
effects: lineWrapping.reconfigure(
newMode ? [EditorView.lineWrapping, IndentedLineWrapPlugin] : []
newMode ? [EditorView.lineWrapping] : []
),
})
}

View File

@@ -1,27 +0,0 @@
import { EditorView } from "@codemirror/view"
const WrappedLineIndenter = EditorView.updateListener.of((update) => {
const view = update.view
const charWidth = view.defaultCharacterWidth
const lineHeight = view.defaultLineHeight
const basePadding = 10
view.viewportLines((line) => {
const domAtPos = view.domAtPos(line.from)
const lineCount = (line.bottom - line.top) / lineHeight
if (lineCount <= 1) return
const belowPadding = basePadding * charWidth
const node = domAtPos.node as HTMLElement
node.style.textIndent = `-${belowPadding - charWidth + 1}px`
node.style.paddingLeft = `${belowPadding}px`
})
})
export const IndentedLineWrapPlugin = [
EditorView.lineWrapping,
WrappedLineIndenter,
]

View File

@@ -17,6 +17,6 @@ export const trace = <T>(x: T) => {
export const namedTrace =
(name: string) =>
<T>(x: T) => {
console.log(`${name}: `, x)
console.log(`${name}:`, x)
return x
}

View File

@@ -1,4 +1,5 @@
import * as O from "fp-ts/Option"
import { flow } from "fp-ts/function"
/**
* Checks and Parses JSON string
@@ -15,3 +16,10 @@ export const safeParseJSON = (str: string): O.Option<object> =>
*/
export const prettyPrintJSON = (obj: unknown): O.Option<string> =>
O.tryCatch(() => JSON.stringify(obj, null, "\t"))
/**
* Checks if given string is a JSON string
* @param str Raw string to be checked
* @returns If string is a JSON string
*/
export const isJSON = flow(safeParseJSON, O.isSome)

View File

@@ -1,4 +1,5 @@
import { Ref, ref } from "@nuxtjs/composition-api"
import { Ref } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n, useToast } from "~/helpers/utils/composables"
@@ -8,13 +9,13 @@ export default function useCopyResponse(responseBodyText: Ref<any>): {
} {
const toast = useToast()
const t = useI18n()
const copyIcon = ref("copy")
const copyIcon = refAutoReset<"copy" | "check">("copy", 1000)
const copyResponse = () => {
copyToClipboard(responseBodyText.value)
copyIcon.value = "check"
toast.success(`${t("state.copied_to_clipboard")}`)
setTimeout(() => (copyIcon.value = "copy"), 1000)
}
return {

View File

@@ -1,7 +1,8 @@
import * as S from "fp-ts/string"
import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
import { pipe } from "fp-ts/function"
import { Ref, ref } from "@nuxtjs/composition-api"
import { Ref } from "@nuxtjs/composition-api"
import { refAutoReset } from "@vueuse/core"
import { useI18n, useToast } from "~/helpers/utils/composables"
export type downloadResponseReturnType = (() => void) | Ref<any>
@@ -13,7 +14,8 @@ export default function useDownloadResponse(
downloadIcon: Ref<string>
downloadResponse: () => void
} {
const downloadIcon = ref("download")
const downloadIcon = refAutoReset<"download" | "check">("download", 1000)
const toast = useToast()
const t = useI18n()
@@ -42,7 +44,6 @@ export default function useDownloadResponse(
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
downloadIcon.value = "download"
}, 1000)
}
return {

View File

@@ -0,0 +1,223 @@
import Paho, { ConnectionOptions } from "paho-mqtt"
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type MQTTMessage = { topic: string; message: string }
export type MQTTError =
| { type: "CONNECTION_NOT_ESTABLISHED"; value: unknown }
| { type: "CONNECTION_LOST" }
| { type: "CONNECTION_FAILED" }
| { type: "SUBSCRIPTION_FAILED"; topic: string }
| { type: "PUBLISH_ERROR"; topic: string; message: string }
export type MQTTEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: MQTTMessage }
| { type: "SUBSCRIBED"; topic: string }
| { type: "SUBSCRIPTION_FAILED"; topic: string }
| { type: "MESSAGE_RECEIVED"; message: MQTTMessage }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: MQTTError }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class MQTTConnection {
subscriptionState$ = new BehaviorSubject<boolean>(false)
connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
event$: Subject<MQTTEvent> = new Subject()
private mqttClient: Paho.Client | undefined
private manualDisconnect = false
private addEvent(event: MQTTEvent) {
this.event$.next(event)
}
connect(url: string, username: string, password: string) {
try {
this.connectionState$.next("CONNECTING")
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
const parseUrl = new URL(url)
const { hostname, pathname, port } = parseUrl
this.mqttClient = new Paho.Client(
`${hostname + (pathname !== "/" ? pathname : "")}`,
port !== "" ? Number(port) : 8081,
"hoppscotch"
)
const connectOptions: ConnectionOptions = {
onSuccess: this.onConnectionSuccess.bind(this),
onFailure: this.onConnectionFailure.bind(this),
useSSL: parseUrl.protocol !== "ws:",
}
if (username !== "") {
connectOptions.userName = username
}
if (password !== "") {
connectOptions.password = password
}
this.mqttClient.connect(connectOptions)
this.mqttClient.onConnectionLost = this.onConnectionLost.bind(this)
this.mqttClient.onMessageArrived = this.onMessageArrived.bind(this)
} catch (e) {
this.handleError(e)
}
logHoppRequestRunToAnalytics({
platform: "mqtt",
})
}
onConnectionFailure() {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_FAILED",
},
})
}
onConnectionSuccess() {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
}
onConnectionLost() {
this.connectionState$.next("DISCONNECTED")
if (this.manualDisconnect) {
this.addEvent({
time: Date.now(),
type: "DISCONNECTED",
manual: this.manualDisconnect,
})
} else {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_LOST",
},
})
}
this.manualDisconnect = false
this.subscriptionState$.next(false)
}
onMessageArrived({
payloadString: message,
destinationName: topic,
}: {
payloadString: string
destinationName: string
}) {
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: {
topic,
message,
},
})
}
private handleError(error: unknown) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "CONNECTION_NOT_ESTABLISHED",
value: error,
},
})
}
publish(topic: string, message: string) {
if (this.connectionState$.value === "DISCONNECTED") return
try {
// it was publish
this.mqttClient?.send(topic, message, 0, false)
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message: {
topic,
message,
},
})
} catch (e) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "PUBLISH_ERROR",
topic,
message,
},
})
}
}
subscribe(topic: string) {
try {
this.mqttClient?.subscribe(topic, {
onSuccess: this.usubSuccess.bind(this, topic),
onFailure: this.usubFailure.bind(this, topic),
})
} catch (e) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "SUBSCRIPTION_FAILED",
topic,
},
})
}
}
usubSuccess(topic: string) {
this.subscriptionState$.next(!this.subscriptionState$.value)
this.addEvent({
time: Date.now(),
type: "SUBSCRIBED",
topic,
})
}
usubFailure(topic: string) {
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type: "SUBSCRIPTION_FAILED",
topic,
},
})
}
unsubscribe(topic: string) {
this.mqttClient?.unsubscribe(topic, {
onSuccess: this.usubSuccess.bind(this, topic),
onFailure: this.usubFailure.bind(this, topic),
})
}
disconnect() {
this.manualDisconnect = true
this.mqttClient?.disconnect()
this.connectionState$.next("DISCONNECTED")
}
}

View File

@@ -0,0 +1,84 @@
import wildcard from "socketio-wildcard"
import ClientV2 from "socket.io-client-v2"
import { io as ClientV4, Socket as SocketV4 } from "socket.io-client-v4"
import { io as ClientV3, Socket as SocketV3 } from "socket.io-client-v3"
type Options = {
path: string
auth: {
token: string | undefined
}
}
type PossibleEvent =
| "connect"
| "connect_error"
| "reconnect_error"
| "error"
| "disconnect"
| "*"
export interface SIOClient {
connect(url: string, opts?: Options): void
on(event: PossibleEvent, cb: (data: any) => void): void
emit(event: string, data: any, cb: (data: any) => void): void
close(): void
}
export class SIOClientV4 implements SIOClient {
private client: SocketV4 | undefined
connect(url: string, opts?: Options) {
this.client = ClientV4(url, opts)
}
on(event: PossibleEvent, cb: (data: any) => void) {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}
export class SIOClientV3 implements SIOClient {
private client: SocketV3 | undefined
connect(url: string, opts?: Options) {
this.client = ClientV3(url, opts)
}
on(event: PossibleEvent, cb: (data: any) => void): void {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}
export class SIOClientV2 implements SIOClient {
private client: any | undefined
connect(url: string, opts?: Options) {
this.client = new ClientV2(url, opts)
wildcard(ClientV2.Manager)(this.client)
}
on(event: PossibleEvent, cb: (data: any) => void): void {
this.client?.on(event, cb)
}
emit(event: string, data: any, cb: (data: any) => void): void {
this.client?.emit(event, data, cb)
}
close(): void {
this.client?.close()
}
}

View File

@@ -0,0 +1,163 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
import { SIOClientV2, SIOClientV3, SIOClientV4, SIOClient } from "./SIOClients"
import { SIOClientVersion } from "~/newstore/SocketIOSession"
export const SOCKET_CLIENTS = {
v2: SIOClientV2,
v3: SIOClientV3,
v4: SIOClientV4,
} as const
type SIOAuth = { type: "None" } | { type: "Bearer"; token: string }
export type ConnectionOption = {
url: string
path: string
clientVersion: SIOClientVersion
auth: SIOAuth | undefined
}
export type SIOMessage = {
eventName: string
value: unknown
}
type SIOErrorType = "CONNECTION" | "RECONNECT_ERROR" | "UNKNOWN"
export type SIOError = {
type: SIOErrorType
value: unknown
}
export type SIOEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: SIOMessage }
| { type: "MESSAGE_RECEIVED"; message: SIOMessage }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: SIOError }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class SIOConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<SIOEvent> = new Subject()
socket: SIOClient | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
}
private addEvent(event: SIOEvent) {
this.event$.next(event)
}
connect({ url, path, clientVersion, auth }: ConnectionOption) {
this.connectionState$.next("CONNECTING")
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
try {
this.socket = new SOCKET_CLIENTS[clientVersion]()
if (auth?.type === "Bearer") {
this.socket.connect(url, {
path,
auth: {
token: auth.token,
},
})
} else {
this.socket.connect(url)
}
this.socket.on("connect", () => {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
})
this.socket.on("*", ({ data }: { data: string[] }) => {
const [eventName, message] = data
this.addEvent({
message: { eventName, value: message },
type: "MESSAGE_RECEIVED",
time: Date.now(),
})
})
this.socket.on("connect_error", (error: unknown) => {
this.handleError(error, "CONNECTION")
})
this.socket.on("reconnect_error", (error: unknown) => {
this.handleError(error, "RECONNECT_ERROR")
})
this.socket.on("error", (error: unknown) => {
this.handleError(error, "UNKNOWN")
})
this.socket.on("disconnect", () => {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
type: "DISCONNECTED",
time: Date.now(),
manual: true,
})
})
} catch (error) {
this.handleError(error, "CONNECTION")
}
logHoppRequestRunToAnalytics({
platform: "socketio",
})
}
private handleError(error: unknown, type: SIOErrorType) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error: {
type,
value: error,
},
})
}
sendMessage(event: { message: string; eventName: string }) {
if (this.connectionState$.value === "DISCONNECTED") return
const { message, eventName } = event
this.socket?.emit(eventName, message, (data) => {
// receive response from server
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: {
eventName,
value: data,
},
})
})
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message: {
eventName,
value: message,
},
})
}
disconnect() {
this.socket?.close()
this.connectionState$.next("DISCONNECTED")
}
}

View File

@@ -0,0 +1,86 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type SSEEvent = { time: number } & (
| { type: "STARTING" }
| { type: "STARTED" }
| { type: "MESSAGE_RECEIVED"; message: string }
| { type: "STOPPED"; manual: boolean }
| { type: "ERROR"; error: Event | null }
)
export type ConnectionState = "STARTING" | "STARTED" | "STOPPED"
export class SSEConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<SSEEvent> = new Subject()
sse: EventSource | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("STOPPED")
}
private addEvent(event: SSEEvent) {
this.event$.next(event)
}
start(url: string, eventType: string) {
this.connectionState$.next("STARTING")
this.addEvent({
time: Date.now(),
type: "STARTING",
})
if (typeof EventSource !== "undefined") {
try {
this.sse = new EventSource(url)
this.sse.onopen = () => {
this.connectionState$.next("STARTED")
this.addEvent({
type: "STARTED",
time: Date.now(),
})
}
this.sse.onerror = this.handleError
this.sse.addEventListener(eventType, ({ data }) => {
this.addEvent({
type: "MESSAGE_RECEIVED",
message: data,
time: Date.now(),
})
})
} catch (error) {
// A generic event type returned if anything goes wrong or browser doesn't support SSE
// https://developer.mozilla.org/en-US/docs/Web/API/EventSource/error_event#event_type
this.handleError(error as Event)
}
} else {
this.addEvent({
type: "ERROR",
time: Date.now(),
error: null,
})
}
logHoppRequestRunToAnalytics({
platform: "sse",
})
}
private handleError(error: Event) {
this.stop()
this.addEvent({
time: Date.now(),
type: "ERROR",
error,
})
}
stop() {
this.sse?.close()
this.connectionState$.next("STOPPED")
this.addEvent({
type: "STOPPED",
time: Date.now(),
manual: true,
})
}
}

View File

@@ -0,0 +1,102 @@
import { BehaviorSubject, Subject } from "rxjs"
import { logHoppRequestRunToAnalytics } from "../fb/analytics"
export type WSErrorMessage = SyntaxError | Event
export type WSEvent = { time: number } & (
| { type: "CONNECTING" }
| { type: "CONNECTED" }
| { type: "MESSAGE_SENT"; message: string }
| { type: "MESSAGE_RECEIVED"; message: string }
| { type: "DISCONNECTED"; manual: boolean }
| { type: "ERROR"; error: WSErrorMessage }
)
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export class WSConnection {
connectionState$: BehaviorSubject<ConnectionState>
event$: Subject<WSEvent> = new Subject()
socket: WebSocket | undefined
constructor() {
this.connectionState$ = new BehaviorSubject<ConnectionState>("DISCONNECTED")
}
private addEvent(event: WSEvent) {
this.event$.next(event)
}
connect(url: string, protocols: string[]) {
try {
this.connectionState$.next("CONNECTING")
this.socket = new WebSocket(url, protocols)
this.addEvent({
time: Date.now(),
type: "CONNECTING",
})
this.socket.onopen = () => {
this.connectionState$.next("CONNECTED")
this.addEvent({
type: "CONNECTED",
time: Date.now(),
})
}
this.socket.onerror = (error) => {
this.handleError(error)
}
this.socket.onclose = () => {
this.connectionState$.next("DISCONNECTED")
this.addEvent({
type: "DISCONNECTED",
time: Date.now(),
manual: true,
})
}
this.socket.onmessage = ({ data }) => {
this.addEvent({
time: Date.now(),
type: "MESSAGE_RECEIVED",
message: data,
})
}
} catch (error) {
// We will have SyntaxError if anything goes wrong with WebSocket constructor
// See https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#exceptions
this.handleError(error as SyntaxError)
}
logHoppRequestRunToAnalytics({
platform: "wss",
})
}
private handleError(error: WSErrorMessage) {
this.disconnect()
this.addEvent({
time: Date.now(),
type: "ERROR",
error,
})
}
sendMessage(event: { message: string; eventName: string }) {
if (this.connectionState$.value === "DISCONNECTED") return
const { message } = event
this.socket?.send(message)
this.addEvent({
time: Date.now(),
type: "MESSAGE_SENT",
message,
})
}
disconnect() {
this.socket?.close()
}
}

View File

@@ -0,0 +1,8 @@
/**
* Defines how a Shortcode is represented in the ShortcodeListAdapter
*/
export interface Shortcode {
id: string
request: string
createdOn: Date
}

View File

@@ -0,0 +1,149 @@
import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs"
import { GQLError, runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
import {
GetUserShortcodesQuery,
GetUserShortcodesDocument,
ShortcodeCreatedDocument,
ShortcodeDeletedDocument,
} from "../backend/graphql"
import { BACKEND_PAGE_SIZE } from "../backend/helpers"
import { Shortcode } from "./Shortcode"
export default class ShortcodeListAdapter {
error$: BehaviorSubject<GQLError<string> | null>
loading$: BehaviorSubject<boolean>
shortcodes$: BehaviorSubject<GetUserShortcodesQuery["myShortcodes"]>
hasMoreShortcodes$: BehaviorSubject<boolean>
private timeoutHandle: ReturnType<typeof setTimeout> | null
private isDispose: boolean
private myShortcodesCreated: Subscription | null
private myShortcodesRevoked: Subscription | null
constructor(deferInit: boolean = false) {
this.error$ = new BehaviorSubject<GQLError<string> | null>(null)
this.loading$ = new BehaviorSubject<boolean>(false)
this.shortcodes$ = new BehaviorSubject<
GetUserShortcodesQuery["myShortcodes"]
>([])
this.hasMoreShortcodes$ = new BehaviorSubject<boolean>(true)
this.timeoutHandle = null
this.isDispose = false
this.myShortcodesCreated = null
this.myShortcodesRevoked = null
if (!deferInit) this.initialize()
}
unsubscribeSubscriptions() {
this.myShortcodesCreated?.unsubscribe()
this.myShortcodesRevoked?.unsubscribe()
}
initialize() {
if (this.timeoutHandle) throw new Error(`Adapter already initialized`)
if (this.isDispose) throw new Error(`Adapter has been disposed`)
this.fetchList()
this.registerSubscriptions()
}
public dispose() {
if (!this.timeoutHandle) throw new Error(`Adapter has not been initialized`)
if (!this.isDispose) throw new Error(`Adapter has been disposed`)
this.isDispose = true
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
this.unsubscribeSubscriptions()
}
fetchList() {
this.loadMore(true)
}
async loadMore(forcedAttempt = false) {
if (!this.hasMoreShortcodes$.value && !forcedAttempt) return
this.loading$.next(true)
const lastCodeID =
this.shortcodes$.value.length > 0
? this.shortcodes$.value[this.shortcodes$.value.length - 1].id
: undefined
const result = await runGQLQuery({
query: GetUserShortcodesDocument,
variables: {
cursor: lastCodeID,
},
})
if (E.isLeft(result)) {
this.error$.next(result.left)
console.error(result.left)
this.loading$.next(false)
throw new Error(`Failed fetching short codes list: ${result.left}`)
}
const fetchedResult = result.right.myShortcodes
this.pushNewShortcodes(fetchedResult)
if (fetchedResult.length !== BACKEND_PAGE_SIZE) {
this.hasMoreShortcodes$.next(false)
}
this.loading$.next(false)
}
private pushNewShortcodes(results: Shortcode[]) {
const userShortcodes = this.shortcodes$.value
userShortcodes.push(...results)
this.shortcodes$.next(userShortcodes)
}
private createShortcode(shortcode: Shortcode) {
const userShortcodes = this.shortcodes$.value
userShortcodes.unshift(shortcode)
this.shortcodes$.next(userShortcodes)
}
private deleteShortcode(codeId: string) {
const newShortcodes = this.shortcodes$.value.filter(
({ id }) => id !== codeId
)
this.shortcodes$.next(newShortcodes)
}
private registerSubscriptions() {
this.myShortcodesCreated = runGQLSubscription({
query: ShortcodeCreatedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Create Error ${result.left}`)
}
this.createShortcode(result.right.myShortcodesCreated)
})
this.myShortcodesRevoked = runGQLSubscription({
query: ShortcodeDeletedDocument,
}).subscribe((result) => {
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(`Shortcode Delete Error ${result.left}`)
}
this.deleteShortcode(result.right.myShortcodesRevoked.id)
})
}
}

View File

@@ -1,4 +1,5 @@
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { AxiosRequestConfig } from "axios"
import cloneDeep from "lodash/cloneDeep"
@@ -15,12 +16,42 @@ export const hasFirefoxExtensionInstalled = () =>
hasExtensionInstalled() && browserIsFirefox()
export const cancelRunningExtensionRequest = () => {
if (
hasExtensionInstalled() &&
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest
) {
window.__POSTWOMAN_EXTENSION_HOOK__.cancelRunningRequest()
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRunningRequest()
}
export const defineSubscribableObject = <T extends object>(obj: T) => {
const proxyObject = {
...obj,
_subscribers: {} as {
// eslint-disable-next-line no-unused-vars
[key in keyof T]?: ((...args: any[]) => any)[]
},
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
if (Array.isArray(this._subscribers[prop])) {
this._subscribers[prop]?.push(func)
} else {
this._subscribers[prop] = [func]
}
},
}
type SubscribableProxyObject = typeof proxyObject
return new Proxy(proxyObject, {
set(obj, prop, newVal) {
obj[prop as keyof SubscribableProxyObject] = newVal
const currentSubscribers = obj._subscribers[prop as keyof T]
if (Array.isArray(currentSubscribers)) {
for (const subscriber of currentSubscribers) {
subscriber(newVal)
}
}
return true
},
})
}
const preProcessRequest = (req: AxiosRequestConfig): AxiosRequestConfig => {
@@ -56,13 +87,20 @@ const extensionStrategy: NetworkStrategy = (req) =>
// Run the request
TE.bind("response", ({ processedReq }) =>
TE.tryCatch(
() =>
window.__POSTWOMAN_EXTENSION_HOOK__.sendRequest({
...processedReq,
wantsBinary: true,
}) as Promise<NetworkResponse>,
(err) => err as any
pipe(
window.__POSTWOMAN_EXTENSION_HOOK__,
O.fromNullable,
TE.fromOption(() => "NO_PW_EXT_HOOK" as const),
TE.chain((extensionHook) =>
TE.tryCatch(
() =>
extensionHook.sendRequest({
...processedReq,
wantsBinary: true,
}),
(err) => err as any
)
)
)
),

View File

@@ -122,13 +122,6 @@ describe("cancelRunningExtensionRequest", () => {
cancelRunningExtensionRequest()
expect(cancelFunc).not.toHaveBeenCalled()
})
test("does not cancel request if extension installed but function not present", () => {
global.__POSTWOMAN_EXTENSION_HOOK__ = {}
cancelRunningExtensionRequest()
expect(cancelFunc).not.toHaveBeenCalled()
})
})
describe("extensionStrategy", () => {

View File

@@ -1,8 +1,9 @@
export type HoppRealtimeLogLine = {
prefix?: string
payload: string
source: string
color?: string
ts: string
ts: number | undefined
}
export type HoppRealtimeLog = HoppRealtimeLogLine[]

View File

@@ -11,6 +11,8 @@ import {
parseBodyEnvVariables,
parseRawKeyValueEntries,
Environment,
HoppRESTHeader,
HoppRESTParam,
} from "@hoppscotch/data"
import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData"
@@ -29,6 +31,146 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
effectiveFinalBody: FormData | string | null
}
/**
* Get headers that can be generated by authorization config of the request
* @param req Request to check
* @param envVars Currently active environment variables
* @returns The list of headers
*/
const getComputedAuthHeaders = (
req: HoppRESTRequest,
envVars: Environment["variables"]
) => {
// If Authorization header is also being user-defined, that takes priority
if (req.headers.find((h) => h.key.toLowerCase() === "authorization"))
return []
if (!req.auth.authActive) return []
const headers: HoppRESTHeader[] = []
// TODO: Support a better b64 implementation than btoa ?
if (req.auth.authType === "basic") {
const username = parseTemplateString(req.auth.username, envVars)
const password = parseTemplateString(req.auth.password, envVars)
headers.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (
req.auth.authType === "bearer" ||
req.auth.authType === "oauth-2"
) {
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(req.auth.token, envVars)}`,
})
} else if (req.auth.authType === "api-key") {
const { key, value, addTo } = req.auth
if (addTo === "Headers") {
headers.push({
active: true,
key: parseTemplateString(key, envVars),
value: parseTemplateString(value, envVars),
})
}
}
return headers
}
/**
* Get headers that can be generated by body config of the request
* @param req Request to check
* @returns The list of headers
*/
export const getComputedBodyHeaders = (
req: HoppRESTRequest
): HoppRESTHeader[] => {
// If a content-type is already defined, that will override this
if (
req.headers.find(
(req) => req.active && req.key.toLowerCase() === "content-type"
)
)
return []
// Body should have a non-null content-type
if (req.body.contentType === null) return []
return [
{
active: true,
key: "content-type",
value: req.body.contentType,
},
]
}
export type ComputedHeader = {
source: "auth" | "body"
header: HoppRESTHeader
}
/**
* Returns a list of headers that will be added during execution of the request
* For e.g, Authorization headers maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The headers that are generated along with the source of that header
*/
export const getComputedHeaders = (
req: HoppRESTRequest,
envVars: Environment["variables"]
): ComputedHeader[] => [
...getComputedAuthHeaders(req, envVars).map((header) => ({
source: "auth" as const,
header,
})),
...getComputedBodyHeaders(req).map((header) => ({
source: "body" as const,
header,
})),
]
export type ComputedParam = {
source: "auth"
param: HoppRESTParam
}
/**
* Returns a list of params that will be added during execution of the request
* For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The params that are generated along with the source of that header
*/
export const getComputedParams = (
req: HoppRESTRequest,
envVars: Environment["variables"]
): ComputedParam[] => {
// When this gets complex, its best to split this function off (like with getComputedHeaders)
// API-key auth can be added to query params
if (!req.auth.authActive) return []
if (req.auth.authType !== "api-key") return []
if (req.auth.addTo !== "Query params") return []
return [
{
source: "auth",
param: {
active: true,
key: parseTemplateString(req.auth.key, envVars),
value: parseTemplateString(req.auth.value, envVars),
},
},
]
}
// Resolves environment variables in the body
export const resolvesEnvsInBody = (
body: HoppRESTReqBody,
@@ -135,83 +277,29 @@ export function getEffectiveRESTRequest(
): EffectiveHoppRESTRequest {
const envVariables = [...environment.variables, ...getGlobalVariables()]
const effectiveFinalHeaders = request.headers
.filter(
(x) =>
x.key !== "" && // Remove empty keys
x.active // Only active
)
.map((x) => ({
// Parse out environment template strings
const effectiveFinalHeaders = pipe(
getComputedHeaders(request, envVariables).map((h) => h.header),
A.concat(request.headers),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables),
}))
)
const effectiveFinalParams = request.params
.filter(
(x) =>
x.key !== "" && // Remove empty keys
x.active // Only active
)
.map((x) => ({
const effectiveFinalParams = pipe(
getComputedParams(request, envVariables).map((p) => p.param),
A.concat(request.params),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables),
}))
// Authentication
if (request.auth.authActive) {
// TODO: Support a better b64 implementation than btoa ?
if (request.auth.authType === "basic") {
const username = parseTemplateString(request.auth.username, envVariables)
const password = parseTemplateString(request.auth.password, envVariables)
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (
request.auth.authType === "bearer" ||
request.auth.authType === "oauth-2"
) {
effectiveFinalHeaders.push({
active: true,
key: "Authorization",
value: `Bearer ${parseTemplateString(
request.auth.token,
envVariables
)}`,
})
} else if (request.auth.authType === "api-key") {
const { key, value, addTo } = request.auth
if (addTo === "Headers") {
effectiveFinalHeaders.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
})
} else if (addTo === "Query params") {
effectiveFinalParams.push({
active: true,
key: parseTemplateString(key, envVariables),
value: parseTemplateString(value, envVariables),
})
}
}
}
)
const effectiveFinalBody = getFinalBodyFromRequest(request, envVariables)
const contentTypeInHeader = effectiveFinalHeaders.find(
(x) => x.key.toLowerCase() === "content-type"
)
if (request.body.contentType && !contentTypeInHeader?.value)
effectiveFinalHeaders.push({
active: true,
key: "content-type",
value: request.body.contentType,
})
return {
...request,

View File

@@ -14,6 +14,37 @@ export const knownContentTypes: Record<ValidContentTypes, Content> = {
"text/plain": "plain",
}
type ContentTypeTitle =
| "request.content_type_titles.text"
| "request.content_type_titles.structured"
| "request.content_type_titles.others"
type SegmentedContentType = {
title: ContentTypeTitle
contentTypes: ValidContentTypes[]
}
export const segmentedContentTypes: SegmentedContentType[] = [
{
title: "request.content_type_titles.text",
contentTypes: [
"application/json",
"application/ld+json",
"application/hal+json",
"application/vnd.api+json",
"application/xml",
],
},
{
title: "request.content_type_titles.structured",
contentTypes: ["application/x-www-form-urlencoded", "multipart/form-data"],
},
{
title: "request.content_type_titles.others",
contentTypes: ["text/html", "text/plain"],
},
]
export function isJSONContentType(contentType: string) {
return /\bjson\b/i.test(contentType)
}

View File

@@ -1,12 +0,0 @@
const sourceEmojis = {
// Source used for info messages.
info: "\t [INFO]:\t",
// Source used for client to server messages.
client: "\t⬅ [SENT]:\t",
// Source used for server to client messages.
server: "\t➡ [RECEIVED]:\t",
}
export function getSourcePrefix(source: keyof typeof sourceEmojis) {
return sourceEmojis[source]
}

View File

@@ -64,6 +64,8 @@ import {
useRouter,
watch,
ref,
onMounted,
onBeforeUnmount,
} from "@nuxtjs/composition-api"
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
@@ -77,6 +79,12 @@ import { hookKeybindingsListener } from "~/helpers/keybindings"
import { defineActionHandler } from "~/helpers/actions"
import { useSentry } from "~/helpers/sentry"
import { useColorMode } from "~/helpers/utils/composables"
import {
changeExtensionStatus,
ExtensionStatus,
} from "~/newstore/HoppExtension"
import { defineSubscribableObject } from "~/helpers/strategies/ExtensionStrategy"
function appLayout() {
const rightSidebar = useSetting("SIDEBAR")
@@ -202,6 +210,62 @@ function defineJumpActions() {
})
}
function setupExtensionHooks() {
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
onMounted(() => {
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
changeExtensionStatus(window.__HOPP_EXTENSION_STATUS_PROXY__.status)
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
"status",
(status: ExtensionStatus) => changeExtensionStatus(status)
)
} else {
const statusProxy = defineSubscribableObject({
status: "waiting" as ExtensionStatus,
})
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
statusProxy.subscribe("status", (status: ExtensionStatus) =>
changeExtensionStatus(status)
)
/**
* Keeping identifying extension backward compatible
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
* then we use a poll to find the version, this will get the version for 0.24 and any other version
* of the extension, but will have a slight lag.
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
*/
extensionPollIntervalId.value = setInterval(() => {
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
if (extensionPollIntervalId.value)
clearInterval(extensionPollIntervalId.value)
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
if (
version.major === 0 &&
version.minor <= 23 &&
window.__HOPP_EXTENSION_STATUS_PROXY__
) {
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
}
}
}, 2000)
}
})
// Cleanup timer
onBeforeUnmount(() => {
if (extensionPollIntervalId.value) {
clearInterval(extensionPollIntervalId.value)
}
})
}
export default defineComponent({
components: { Splitpanes, Pane },
setup() {
@@ -229,6 +293,8 @@ export default defineComponent({
showSupport.value = !showSupport.value
})
setupExtensionHooks()
return {
mdAndLarger,
spacerClass,

View File

@@ -1,7 +1,7 @@
{
"action": {
"cancel": "取消",
"choose_file": "选择一个文件",
"choose_file": "选择文件",
"clear": "清除",
"clear_all": "全部清除",
"connect": "连接",
@@ -9,18 +9,18 @@
"delete": "删除",
"disconnect": "断开连接",
"dismiss": "忽略",
"dont_save": "Don't save",
"dont_save": "不保存",
"download_file": "下载文件",
"duplicate": "复制",
"edit": "编辑",
"go_back": "返回",
"label": "标签",
"learn_more": "了解更多",
"less": "Less",
"less": "更少",
"more": "更多",
"new": "新增",
"no": "否",
"paste": "Paste",
"paste": "粘贴",
"prettify": "美化",
"remove": "移除",
"restore": "恢复",
@@ -45,9 +45,9 @@
"chat_with_us": "与我们交谈",
"contact_us": "联系我们",
"copy": "复制",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
"copy_user_id": "复制认证 Token",
"developer_option": "开发者选项",
"developer_option_description": "开发者工具,有助于开发和维护 Hoppscotch",
"discord": "Discord",
"documentation": "帮助文档",
"github": "GitHub",
@@ -60,7 +60,7 @@
"keyboard_shortcuts": "键盘快捷键",
"name": "Hoppscotch",
"new_version_found": "已发现新版本。刷新页面以更新。",
"options": "Options",
"options": "选项",
"proxy_privacy_policy": "代理隐私政策",
"reload": "重新加载",
"search": "搜索",
@@ -68,7 +68,7 @@
"shortcuts": "快捷方式",
"spotlight": "聚光灯",
"status": "状态",
"status_description": "Check the status of the website",
"status_description": "检查网站状态",
"terms_and_privacy": "隐私条款",
"twitter": "Twitter",
"type_a_command_search": "输入命令或搜索内容……",
@@ -82,7 +82,7 @@
"continue_with_email": "使用电子邮箱登录",
"continue_with_github": "使用 GitHub 登录",
"continue_with_google": "使用 Google 登录",
"continue_with_microsoft": "Continue with Microsoft",
"continue_with_microsoft": "使用 Microsoft 登录",
"email": "电子邮箱地址",
"logged_out": "登出",
"login": "登录",
@@ -106,32 +106,32 @@
"username": "用户名"
},
"collection": {
"created": "合已创建",
"edit": "编辑合",
"invalid_name": "请提供有效的合名称",
"my_collections": "我的合",
"name": "我的新合",
"name_length_insufficient": "Collection name should be at least 3 characters long",
"new": "新建合",
"renamed": "合已更名",
"request_in_use": "Request in use",
"created": "合已创建",
"edit": "编辑合",
"invalid_name": "请提供有效的合名称",
"my_collections": "我的合",
"name": "我的新合",
"name_length_insufficient": "集合名字至少需要 3 个字符",
"new": "新建合",
"renamed": "合已更名",
"request_in_use": "请求正在使用中",
"save_as": "另存为",
"select": "选择一个合",
"select": "选择一个合",
"select_location": "选择位置",
"select_team": "选择一个团队",
"team_collections": "团队合"
"team_collections": "团队合"
},
"confirm": {
"exit_team": "你确定要离开此团队吗?",
"logout": "你确定要登出吗?",
"remove_collection": "你确定要永久删除该合吗?",
"remove_collection": "你确定要永久删除该合吗?",
"remove_environment": "你确定要永久删除该环境吗?",
"remove_folder": "你确定要永久删除该文件夹吗?",
"remove_history": "你确定要永久删除全部历史记录吗?",
"remove_request": "你确定要永久删除该请求吗?",
"remove_team": "你确定要删除该团队吗?",
"remove_telemetry": "你确定要退出遥测服务吗?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"request_change": "你确定你要放弃当前的请求,未保存的修改将被丢失。",
"sync": "您确定要同步该工作区吗?"
},
"count": {
@@ -144,13 +144,13 @@
},
"documentation": {
"generate": "生成文档",
"generate_message": "导入 Hoppscotch 合以随时随地生成 API 文档。"
"generate_message": "导入 Hoppscotch 合以随时随地生成 API 文档。"
},
"empty": {
"authorization": "该请求没有使用任何授权",
"body": "该请求没有任何请求体",
"collection": "合为空",
"collections": "合为空",
"collection": "合为空",
"collections": "合为空",
"documentation": "连接至 GraphQL 端点以查看文档",
"endpoint": "端点不能为空",
"environments": "环境为空",
@@ -169,20 +169,20 @@
"tests": "没有针对该请求的测试"
},
"environment": {
"add_to_global": "Add to Global",
"added": "Environment addition",
"create_new": "创建新环境",
"created": "Environment created",
"deleted": "Environment deletion",
"add_to_global": "添加到全局环境",
"added": "环境已添加",
"create_new": "创建新环境",
"created": "环境已创建",
"deleted": "环境已删除",
"edit": "编辑环境",
"invalid_name": "请提供有效的环境名称",
"nested_overflow": "nested environment variables are limited to 10 levels",
"nested_overflow": "环境嵌套深度超过限制10层",
"new": "新建环境",
"no_environment": "无环境",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"no_environment_description": "没有选择环境。选择如何处理以下变量。",
"select": "选择环境",
"title": "环境",
"updated": "Environment updation",
"updated": "环境已更新",
"variable_list": "变量列表"
},
"error": {
@@ -190,9 +190,9 @@
"check_console_details": "检查控制台日志以获悉详情",
"curl_invalid_format": "cURL 格式不正确",
"empty_req_name": "空请求名称",
"f12_details": "(F12 详情)",
"f12_details": "F12 详情",
"gql_prettify_invalid_query": "无法美化无效的查询,处理查询语法错误并重试",
"incomplete_config_urls": "Incomplete configuration URLs",
"incomplete_config_urls": "配置文件中的 URL 无效",
"incorrect_email": "电子邮箱错误",
"invalid_link": "无效链接",
"invalid_link_description": "你点击的链接无效或已过期。",
@@ -202,7 +202,7 @@
"no_duration": "无持续时间",
"script_fail": "无法执行预请求脚本",
"something_went_wrong": "发生了一些错误",
"test_script_fail": "Could not execute post-request script"
"test_script_fail": "无法执行请求脚本"
},
"export": {
"as_json": "导出为 JSON",
@@ -215,7 +215,7 @@
"created": "已创建文件夹",
"edit": "编辑文件夹",
"invalid_name": "请提供文件夹的名称",
"name_length_insufficient": "Folder name should be at least 3 characters long",
"name_length_insufficient": "文件夹名称应至少为 3 个字符",
"new": "新文件夹",
"renamed": "文件夹已更名"
},
@@ -238,46 +238,46 @@
"post_request_tests": "测试脚本使用 JavaScript 编写,并在收到响应后执行。",
"pre_request_script": "预请求脚本使用 JavaScript 编写,并在请求发送前执行。",
"script_fail": "预请求脚本中似乎存在故障。 检查下面的错误并相应地修复脚本。",
"test_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again",
"test_script_fail": "测试脚本似乎有一个错误。请修复错误并再次运行测试",
"tests": "编写测试脚本以自动调试。"
},
"hide": {
"collection": "Collapse Collection Panel",
"collection": "隐藏集合",
"more": "隐藏更多",
"preview": "隐藏预览",
"sidebar": "隐藏侧边栏"
},
"import": {
"collections": "导入合",
"collections": "导入合",
"curl": "导入 cURL",
"failed": "导入失败",
"from_gist": "从 Gist 导入",
"from_gist_description": "Import from Gist URL",
"from_insomnia": "Import from Insomnia",
"from_insomnia_description": "Import from Insomnia collection",
"from_json": "Import from Hoppscotch",
"from_json_description": "Import from Hoppscotch collection file",
"from_my_collections": "从我的合导入",
"from_my_collections_description": "Import from My Collections file",
"from_openapi": "Import from OpenAPI",
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman",
"from_postman_description": "Import from Postman collection",
"from_url": "Import from URL",
"from_gist_description": " Gist URL 导入",
"from_insomnia": " Insomnia 导入",
"from_insomnia_description": "从 Insomnia 集合中导入",
"from_json": " Hoppscotch 导入",
"from_json_description": " Hoppscotch 集合中导入",
"from_my_collections": "从我的合导入",
"from_my_collections_description": "从我的集合文件导入",
"from_openapi": " OpenAPI 导入",
"from_openapi_description": "从 OpenAPI 文件导入(YML/JSON",
"from_postman": " Postman 导入",
"from_postman_description": "从 Postman 集合中导入",
"from_url": " URL 导入",
"gist_url": "输入 Gist URL",
"json_description": "Import collections from a Hoppscotch Collections JSON file",
"json_description": "从 Hoppscotch 的集合文件导入JSON",
"title": "导入"
},
"layout": {
"collapse_collection": "Collapse or Expand Collections",
"collapse_sidebar": "Collapse or Expand the sidebar",
"collapse_collection": "折叠/展开集合",
"collapse_sidebar": "折叠/展开边栏",
"column": "垂直布局",
"name": "Layout",
"name": "布局",
"row": "水平布局",
"zen_mode": "禅意模式"
"zen_mode": "ZEN 模式"
},
"modal": {
"collections": "合",
"collections": "合",
"confirm": "确认",
"edit_request": "编辑请求",
"import_export": "导入/导出"
@@ -315,12 +315,12 @@
"email_verification_mail": "确认邮件已发送至你的邮箱,请点击链接以验证你的电子邮箱。",
"no_permission": "你无权执行此操作。",
"owner": "所有者",
"owner_description": "所有者可以添加、编辑和删除请求、合及团队成员。",
"owner_description": "所有者可以添加、编辑和删除请求、合及团队成员。",
"roles": "角色",
"roles_description": "角色用以控制共享合的访问权限。",
"roles_description": "角色用以控制共享合的访问权限。",
"updated": "档案已更新",
"viewer": "阅览者",
"viewer_description": "阅览者只可查看与使用请求。"
"viewer": "查看者",
"viewer_description": "查看者只可查看与使用请求。"
},
"remove": {
"star": "移除星标"
@@ -340,10 +340,10 @@
"invalid_name": "请提供请求名称",
"method": "方法",
"name": "请求名称",
"new": "New Request",
"override": "Override",
"override_help": "Set <xmp>Content-Type</xmp> in Headers",
"overriden": "Overridden",
"new": "新请求",
"override": "覆盖",
"override_help": "设置 <xmp>Content-Type</xmp> ",
"overriden": "覆盖",
"parameter_list": "查询参数",
"parameters": "参数",
"path": "路径",
@@ -356,7 +356,7 @@
"save_as": "另存为",
"saved": "请求已保存",
"share": "分享",
"share_description": "Share Hoppscotch with your friends",
"share_description": "分享 Hoppscotch 给你的朋友",
"title": "请求",
"type": "请求类型",
"url": "URL",
@@ -396,7 +396,7 @@
"extension_version": "扩展版本",
"extensions": "扩展",
"extensions_use_toggle": "使用浏览器扩展发送请求(如果存在)",
"follow": "Follow Us",
"follow": "关注我们",
"font_size": "字体大小",
"font_size_large": "大",
"font_size_medium": "中",
@@ -417,7 +417,7 @@
"reset_default": "重置为默认",
"sidebar_on_left": "侧边栏移至左侧",
"sync": "同步",
"sync_collections": "合",
"sync_collections": "合",
"sync_description": "这些设置会同步到云。",
"sync_environments": "环境",
"sync_history": "历史",
@@ -464,21 +464,21 @@
"previous_method": "选择上一个方法",
"put_method": "选择 PUT 方法",
"reset_request": "重置请求",
"save_to_collections": "保存到合",
"save_to_collections": "保存到合",
"send_request": "发送请求",
"title": "请求"
},
"theme": {
"black": "Switch theme to black mode",
"dark": "Switch theme to dark mode",
"light": "Switch theme to light mode",
"system": "Switch theme to system mode",
"title": "Theme"
"black": "切换为黑色主题",
"dark": "切换为深色主题",
"light": "切换为浅色主题",
"system": "切换为系统主题",
"title": "主题"
}
},
"show": {
"code": "显示代码",
"collection": "Expand Collection Panel",
"collection": "展开集合",
"more": "显示更多",
"sidebar": "显示侧边栏"
},
@@ -525,7 +525,7 @@
"community": "提问与互助",
"documentation": "阅读更多有关 Hoppscotch 的内容",
"forum": "答疑解惑",
"github": "Follow us on Github",
"github": " Github 关注我们",
"shortcuts": "更快浏览应用",
"team": "与团队保持联系",
"title": "支持",
@@ -534,7 +534,7 @@
"tab": {
"authorization": "授权",
"body": "请求体",
"collections": "合",
"collections": "合",
"documentation": "帮助文档",
"headers": "请求头",
"history": "历史记录",
@@ -552,18 +552,18 @@
"websocket": "WebSocket"
},
"team": {
"already_member": "你已经是此团队的成员。请联系你的团队所有人。",
"already_member": "你已经是此团队的成员。请联系你的团队。",
"create_new": "创建新团队",
"deleted": "团队已删除",
"edit": "编辑团队",
"email": "电子邮箱",
"email_do_not_match": "邮箱无法与你的帐户信息匹配。请联系你的团队所有人。",
"email_do_not_match": "邮箱无法与你的帐户信息匹配。请联系你的团队。",
"exit": "退出团队",
"exit_disabled": "团队所有者无法退出团队",
"invalid_email_format": "电子邮箱格式无效",
"invalid_id": "无效的团队 ID请联系你的团队所有人。",
"invalid_id": "无效的团队 ID请联系你的团队。",
"invalid_invite_link": "无效的邀请链接",
"invalid_invite_link_description": "你点击的链接无效。请联系你的团队所有人。",
"invalid_invite_link_description": "你点击的链接无效。请联系你的团队。",
"invalid_member_permission": "请为团队成员提供有效的权限",
"invite": "邀请",
"invite_more": "邀请更多成员",
@@ -578,8 +578,8 @@
"login_to_continue": "登录以继续",
"login_to_continue_description": "你需要登录以加入团队",
"logout_and_try_again": "登出并以其他帐户登录",
"member_has_invite": "此邮箱 ID 已有邀请。请联系你的团队所有人。",
"member_not_found": "未找到成员。请联系你的团队所有人。",
"member_has_invite": "此邮箱 ID 已有邀请。请联系你的团队。",
"member_not_found": "未找到成员。请联系你的团队。",
"member_removed": "用户已移除",
"member_role_updated": "用户角色已更新",
"members": "成员",
@@ -588,10 +588,10 @@
"new": "新团队",
"new_created": "已创建新团队",
"new_name": "我的新团队",
"no_access": "你没有编辑合的权限",
"no_invite_found": "未找到邀请。请联系你的团队所有人。",
"not_found": "Team not found. Contact your team owner.",
"not_valid_viewer": "你不是有效的阅览者。请联系你的团队所有人。",
"no_access": "你没有编辑合的权限",
"no_invite_found": "未找到邀请。请联系你的团队。",
"not_found": "没有找到团队,请联系您的团队所有者。",
"not_valid_viewer": "你不是有效的查看者。请联系你的团队。",
"pending_invites": "待办邀请",
"permissions": "权限",
"saved": "团队已保存",

View File

@@ -1,5 +1,6 @@
{
"action": {
"autoscroll": "Autoscroll",
"cancel": "Cancel",
"choose_file": "Choose a file",
"clear": "Clear",
@@ -13,6 +14,7 @@
"download_file": "Download file",
"duplicate": "Duplicate",
"edit": "Edit",
"filter_response": "Filter response",
"go_back": "Go back",
"label": "Label",
"learn_more": "Learn more",
@@ -20,11 +22,14 @@
"more": "More",
"new": "New",
"no": "No",
"open_workspace": "Open workspace",
"paste": "Paste",
"prettify": "Prettify",
"remove": "Remove",
"restore": "Restore",
"save": "Save",
"scroll_to_bottom": "Scroll to bottom",
"scroll_to_top": "Scroll to top",
"search": "Search",
"send": "Send",
"start": "Start",
@@ -164,6 +169,7 @@
"profile": "Login in to view your profile",
"protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema",
"shortcodes": "Shortcodes are empty",
"team_name": "Team name empty",
"teams": "You don't belong to any teams",
"tests": "There are no tests for this request"
@@ -197,9 +203,11 @@
"invalid_link": "Invalid link",
"invalid_link_description": "The link you clicked is invalid or expired.",
"json_prettify_invalid_body": "Couldn't prettify an invalid body, solve json syntax errors and try again",
"json_parsing_failed": "Invalid JSON",
"network_error": "There seems to be a network error. Please try again.",
"network_fail": "Could not send request",
"no_duration": "No duration",
"no_results_found": "No matches found",
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script"
@@ -335,6 +343,11 @@
"body": "Request Body",
"choose_language": "Choose language",
"content_type": "Content Type",
"content_type_titles": {
"others": "Others",
"structured": "Structured",
"text": "Text"
},
"copy_link": "Copy link",
"duration": "Duration",
"enter_curl": "Enter cURL",
@@ -364,10 +377,12 @@
"title": "Request",
"type": "Request type",
"url": "URL",
"variables": "Variables"
"variables": "Variables",
"view_my_links": "View my links"
},
"response": {
"body": "Response Body",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Headers",
"html": "HTML",
"image": "Image",
@@ -419,6 +434,8 @@
"proxy_use_toggle": "Use the proxy middleware to send requests",
"read_the": "Read the",
"reset_default": "Reset to default",
"short_codes": "Short codes",
"short_codes_description": "Short codes which were created by you.",
"sidebar_on_left": "Sidebar on left",
"sync": "Synchronise",
"sync_collections": "Collections",
@@ -480,6 +497,15 @@
"title": "Theme"
}
},
"shortcodes":{
"actions":"Actions",
"created_on": "Created on",
"deleted" : "Shortcode deleted",
"method": "Method",
"not_found":"Shortcode not found",
"short_code":"Short code",
"url": "URL"
},
"show": {
"code": "Show code",
"collection": "Expand Collection Panel",
@@ -491,7 +517,8 @@
"event_name": "Event Name",
"events": "Events",
"log": "Log",
"url": "URL"
"url": "URL",
"connection_not_authorized": "This SocketIO connection does not use any authentication."
},
"sse": {
"event_type": "Event type",
@@ -521,7 +548,19 @@
"loading": "Loading...",
"none": "None",
"nothing_found": "Nothing found for",
"waiting_send_request": "Waiting to send request"
"waiting_send_request": "Waiting to send request",
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}",
"subscribed_failed": "Failed to subscribe to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",
"published_message": "Published message: {message} to topic: {topic}",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
"message_received": "Message: {message} arrived on topic: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}",
"connection_lost": "Connection lost",
"connection_failed": "Connection failed",
"connection_error": "Failed to connect",
"reconnection_error": "Failed to reconnect"
},
"support": {
"changelog": "Read more about latest releases",

View File

@@ -1,5 +1,6 @@
{
"action": {
"autoscroll": "Automatikus görgetés",
"cancel": "Mégse",
"choose_file": "Válasszon egy fájlt",
"clear": "Törlés",
@@ -9,7 +10,7 @@
"delete": "Törlés",
"disconnect": "Leválasztás",
"dismiss": "Eltüntetés",
"dont_save": "Don't save",
"dont_save": "Ne mentse",
"download_file": "Fájl letöltése",
"duplicate": "Kettőzés",
"edit": "Szerkesztés",
@@ -20,11 +21,13 @@
"more": "Több",
"new": "Új",
"no": "Nem",
"paste": "Paste",
"paste": "Beillesztés",
"prettify": "Csinosítás",
"remove": "Eltávolítás",
"restore": "Visszaállítás",
"save": "Mentés",
"scroll_to_bottom": "Görgetés az aljára",
"scroll_to_top": "Görgetés a tetejére",
"search": "Keresés",
"send": "Küldés",
"start": "Indítás",
@@ -45,9 +48,9 @@
"chat_with_us": "Csevegjen velünk",
"contact_us": "Lépjen kapcsolatba velünk",
"copy": "Másolás",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
"copy_user_id": "Felhasználó-hitelesítési token másolása",
"developer_option": "Fejlesztői beállítások",
"developer_option_description": "Fejlesztői eszközök, amelyek segítenek a Hoppscotch fejlesztésében és karbantartásában.",
"discord": "Discord",
"documentation": "Dokumentáció",
"github": "GitHub",
@@ -60,7 +63,7 @@
"keyboard_shortcuts": "Gyorsbillentyűk",
"name": "Hoppscotch",
"new_version_found": "Új verzió található. Töltse újra az oldalt a frissítéshez.",
"options": "Options",
"options": "Beállítások",
"proxy_privacy_policy": "Proxy adatvédelmi irányelvei",
"reload": "Újratöltés",
"search": "Keresés",
@@ -68,7 +71,7 @@
"shortcuts": "Gyorsbillentyűk",
"spotlight": "Reflektorfény",
"status": "Állapot",
"status_description": "Check the status of the website",
"status_description": "A weboldal állapotának ellenőrzése",
"terms_and_privacy": "Feltételek és adatvédelem",
"twitter": "Twitter",
"type_a_command_search": "Írjon be parancsot vagy keresést…",
@@ -82,7 +85,7 @@
"continue_with_email": "Folytatás e-mail-címmel",
"continue_with_github": "Folytatás GitHub használatával",
"continue_with_google": "Folytatás Google használatával",
"continue_with_microsoft": "Continue with Microsoft",
"continue_with_microsoft": "Folytatás Microsoft használatával",
"email": "E-mail",
"logged_out": "Kijelentkezett",
"login": "Bejelentkezés",
@@ -111,7 +114,7 @@
"invalid_name": "Adjon nevet a gyűjteménynek",
"my_collections": "Saját gyűjtemények",
"name": "Saját új gyűjtemény",
"name_length_insufficient": "Collection name should be at least 3 characters long",
"name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie",
"new": "Új gyűjtemény",
"renamed": "Gyűjtemény átnevezve",
"request_in_use": "A kérés használatban",
@@ -131,7 +134,7 @@
"remove_request": "Biztosan véglegesen törölni szeretné ezt a kérést?",
"remove_team": "Biztosan törölni szeretné ezt a csapatot?",
"remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.",
"sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát."
},
"count": {
@@ -169,20 +172,20 @@
"tests": "Nincsenek tesztek ehhez a kéréshez"
},
"environment": {
"add_to_global": "Add to Global",
"added": "Environment addition",
"add_to_global": "Hozzáadás a globálishoz",
"added": "Környezet hozzáadása",
"create_new": "Új környezet létrehozása",
"created": "Environment created",
"deleted": "Environment deletion",
"created": "Környezet létrehozva",
"deleted": "Környezet törlése",
"edit": "Környezet szerkesztése",
"invalid_name": "Adjon nevet a környezetnek",
"nested_overflow": "nested environment variables are limited to 10 levels",
"nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva",
"new": "Új környezet",
"no_environment": "Nincs környezet",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.",
"select": "Környezet kiválasztása",
"title": "Környezetek",
"updated": "Environment updation",
"updated": "Környezet frissítve",
"variable_list": "Változólista"
},
"error": {
@@ -192,7 +195,7 @@
"empty_req_name": "Üres kérésnév",
"f12_details": "(F12 a részletekért)",
"gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra",
"incomplete_config_urls": "Incomplete configuration URLs",
"incomplete_config_urls": "Befejezetlen beállítási URL-ek",
"incorrect_email": "Hibás e-mail",
"invalid_link": "Érvénytelen hivatkozás",
"invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.",
@@ -202,20 +205,20 @@
"no_duration": "Nincs időtartam",
"script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt",
"something_went_wrong": "Valami elromlott",
"test_script_fail": "Could not execute post-request script"
"test_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt"
},
"export": {
"as_json": "Exportálás JSON formátumban",
"create_secret_gist": "Titkos Gist létrehozása",
"gist_created": "Gist létrehozva",
"require_github": "Jelentkezzen be GitHub használatával a titkos Gist létrehozásához",
"title": "Export"
"title": "Exportálás"
},
"folder": {
"created": "Mappa létrehozva",
"edit": "Mappa szerkesztése",
"invalid_name": "Adjon nevet a mappának",
"name_length_insufficient": "Folder name should be at least 3 characters long",
"name_length_insufficient": "A mappa nevének legalább 3 karakter hosszúságúnak kell lennie",
"new": "Új mappa",
"renamed": "Mappa átnevezve"
},
@@ -238,11 +241,11 @@
"post_request_tests": "A tesztparancsfájlokat JavaScriptben írták, és a válasz megérkezése után lesznek futtatva.",
"pre_request_script": "A kérés előtti parancsfájlokat JavaScriptben írták, és a kérés elküldése előtt lesznek futtatva.",
"script_fail": "Úgy tűnik, hogy működési hiba van a kérés előtti parancsfájlban. Nézze meg az alábbi hibát, és annak megfelelően javítsa a parancsfájlt.",
"test_script_fail": "There seems to be an error with test script. Please fix the errors and run tests again",
"test_script_fail": "Úgy tűnik, hogy hiba van a tesztparancsfájlokkal. Javítsa ki a hibákat, és futtassa újra a teszteket.",
"tests": "Írjon tesztparancsfájlt a hibakeresés automatizálására."
},
"hide": {
"collection": "Collapse Collection Panel",
"collection": "Gyűjteménypanel összecsukása",
"more": "Több elrejtése",
"preview": "Előnézet elrejtése",
"sidebar": "Oldalsáv összecsukása"
@@ -266,13 +269,17 @@
"from_url": "Importálás URL-ből",
"gist_url": "Gist URL megadása",
"json_description": "Gyűjtemények importálása Hoppscotch-gyűjtemények JSON-fájlból",
"title": "Importálás"
"title": "Importálás",
"import_from_url_success": "Gyűjtemények importálva",
"import_from_url_invalid_file_format": "Hiba a gyűjtemények importálása során",
"import_from_url_invalid_type": "Nem támogatott típus. Az elfogadott értékek: „hoppscotch”, „openapi”, „postman” vagy „insomnia”.",
"import_from_url_invalid_fetch": "Nem sikerült lekérni az adatokat az URL-ről"
},
"layout": {
"collapse_collection": "Collapse or Expand Collections",
"collapse_sidebar": "Collapse or Expand the sidebar",
"collapse_collection": "Gyűjtemények összecsukása vagy kinyitása",
"collapse_sidebar": "Az oldalsáv összecsukása vagy kinyitása",
"column": "Függőleges elrendezés",
"name": "Layout",
"name": "Elrendezés",
"row": "Vízszintes elrendezés",
"zen_mode": "Zen mód"
},
@@ -340,10 +347,10 @@
"invalid_name": "Adjon nevet a kérésnek",
"method": "Módszer",
"name": "Kérés neve",
"new": "New Request",
"override": "Override",
"override_help": "Set <xmp>Content-Type</xmp> in Headers",
"overriden": "Overridden",
"new": "Új kérés",
"override": "Felülbírálás",
"override_help": "A <xmp>Content-Type</xmp> beállítása a fejlécekben",
"overriden": "Felülbírálva",
"parameter_list": "Lekérdezési paraméterek",
"parameters": "Paraméterek",
"path": "Útvonal",
@@ -356,7 +363,7 @@
"save_as": "Mentés másként",
"saved": "Kérés elmentve",
"share": "Megosztás",
"share_description": "Share Hoppscotch with your friends",
"share_description": "A Hoppscotch megosztása az ismerőseivel",
"title": "Kérés",
"type": "Kérés típusa",
"url": "URL",
@@ -396,7 +403,7 @@
"extension_version": "Kiterjesztés verziója",
"extensions": "Böngészőkiterjesztés",
"extensions_use_toggle": "A böngészőkiterjesztés használata a kérések küldéséhez (ha jelen van)",
"follow": "Follow Us",
"follow": "Kövessen minket",
"font_size": "Betűméret",
"font_size_large": "Nagy",
"font_size_medium": "Közepes",
@@ -461,7 +468,7 @@
"method": "Módszer",
"next_method": "Következő módszer kiválasztása",
"post_method": "POST módszer kiválasztása",
"previous_method": "Elősző módszer kiválasztása",
"previous_method": "Előző módszer kiválasztása",
"put_method": "PUT módszer kiválasztása",
"reset_request": "Kérés visszaállítása",
"save_to_collections": "Mentés a gyűjteményekbe",
@@ -478,7 +485,7 @@
},
"show": {
"code": "Kód megjelenítése",
"collection": "Expand Collection Panel",
"collection": "Gyűjteménypanel kinyitása",
"more": "Több megjelenítése",
"sidebar": "Oldalsáv kinyitása"
},
@@ -525,11 +532,11 @@
"community": "Tegyen fel kérdéseket és segítsen másoknak",
"documentation": "Tudjon meg többet a Hoppscotchról",
"forum": "Tegyen fel kérdéseket és kapjon válaszokat",
"github": "Follow us on Github",
"github": "Kövessen minket GitHubon",
"shortcuts": "Az alkalmazás gyorsabb böngészése",
"team": "Vegye fel a kapcsolatot a csapattal",
"title": "Támogatás",
"twitter": "Kövess minket Twitteren"
"twitter": "Kövessen minket Twitteren"
},
"tab": {
"authorization": "Felhatalmazás",
@@ -590,7 +597,7 @@
"new_name": "Saját új csapat",
"no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez",
"no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"not_found": "Team not found. Contact your team owner.",
"not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"pending_invites": "Függőben lévő meghívások",
"permissions": "Jogosultságok",

View File

@@ -2,16 +2,16 @@
"action": {
"cancel": "Cancelar",
"choose_file": "Escolha um arquivo",
"clear": "Claro",
"clear": "Limpar",
"clear_all": "Limpar tudo",
"connect": "Conectar",
"copy": "Copiar",
"delete": "Excluir",
"disconnect": "desconectar",
"disconnect": "Desconectar",
"dismiss": "Dispensar",
"dont_save": "Don't save",
"dont_save": "Não Salvar",
"download_file": "⇬ Fazer download do arquivo",
"duplicate": "Duplicate",
"duplicate": "Duplicar",
"edit": "Editar",
"go_back": "Voltar",
"label": "Etiqueta",
@@ -35,7 +35,7 @@
"turn_off": "Desligar",
"turn_on": "Ligar",
"undo": "Desfazer",
"yes": "sim"
"yes": "Sim"
},
"add": {
"new": "Adicionar novo",
@@ -45,9 +45,9 @@
"chat_with_us": "Converse conosco",
"contact_us": "Contate-Nos",
"copy": "Copiar",
"copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
"copy_user_id": "Copiar token de autenticação do usuário",
"developer_option": "Opções de desenvolvedor",
"developer_option_description": "Opções de desenvolvedor que ajudam no desenvolvimento e manutenção do Hoppscotch.",
"discord": "Discord",
"documentation": "Documentação",
"github": "GitHub",
@@ -60,18 +60,18 @@
"keyboard_shortcuts": "Atalhos do teclado",
"name": "Hoppscotch",
"new_version_found": "Nova versão encontrada. Atualize para atualizar.",
"options": "Options",
"options": "Opções",
"proxy_privacy_policy": "Política de privacidade do proxy",
"reload": "recarregar",
"reload": "Recarregar",
"search": "Procurar",
"share": "Compartilhado",
"shortcuts": "Atalhos",
"spotlight": "Holofote",
"status": "Status",
"status_description": "Check the status of the website",
"status": "Estado",
"status_description": "Cheque o estado do website.",
"terms_and_privacy": "Termos e privacidade",
"twitter": "Twitter",
"type_a_command_search": "Digite um comando ou pesquise ...",
"type_a_command_search": "Digite um comando ou pesquise...",
"we_use_cookies": "Usamos cookies",
"whats_new": "O que há de novo?",
"wiki": "Wiki"
@@ -114,7 +114,7 @@
"name_length_insufficient": "O nome da coleção deve ter pelo menos 3 caracteres",
"new": "Nova coleção",
"renamed": "Coleção renomeada",
"request_in_use": "Request in use",
"request_in_use": "Requisição em uso",
"save_as": "Salvar como",
"select": "Selecione uma coleção",
"select_location": "Selecione a localização",
@@ -131,7 +131,7 @@
"remove_request": "Tem certeza de que deseja excluir permanentemente esta solicitação?",
"remove_team": "Tem certeza que deseja excluir esta equipe?",
"remove_telemetry": "Tem certeza de que deseja cancelar a telemetria?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"request_change": "Tem certeza que deseja descartar a requisição atual? Alterações não salvas serão perdidas.",
"sync": "Tem certeza de que deseja sincronizar este espaço de trabalho?"
},
"count": {
@@ -151,8 +151,8 @@
"body": "Este pedido não tem corpo",
"collection": "Coleção está vazia",
"collections": "Coleções estão vazias",
"documentation": "Connect to a GraphQL endpoint to view documentation",
"endpoint": "Endpoint cannot be empty",
"documentation": "Se conecte à um endpoint GraphQL para ver a documentação",
"endpoint": "O endpoint não pode ser vazio",
"environments": "Ambientes estão vazios",
"folder": "Pasta está vazia",
"headers": "Esta solicitação não possui cabeçalhos",
@@ -172,11 +172,11 @@
"add_to_global": "Adicionar ao Global",
"added": "Adição de ambiente",
"create_new": "Crie um novo ambiente",
"created": "Environment created",
"created": "Ambiente criado",
"deleted": "Deleção de ambiente",
"edit": "Editar Ambiente",
"invalid_name": "Forneça um nome válido para o ambiente",
"nested_overflow": "variáveis de ambiente aninhadas são limitadas a 10 níveis",
"nested_overflow": "Variáveis de ambiente aninhadas são limitadas a 10 níveis",
"new": "Novo ambiente",
"no_environment": "Sem ambiente",
"no_environment_description": "Nenhum ambiente foi selecionado. Escolha o que fazer com as seguintes variáveis.",
@@ -195,9 +195,9 @@
"incomplete_config_urls": "URLs de configuração incompletas",
"incorrect_email": "Email incorreto",
"invalid_link": "Link inválido",
"invalid_link_description": "The link you clicked is invalid or expired.",
"invalid_link_description": "O link que você clicou é inválido ou já expirou.",
"json_prettify_invalid_body": "Não foi possível embelezar um corpo inválido, resolver erros de sintaxe json e tentar novamente",
"network_error": "There seems to be a network error. Please try again.",
"network_error": "Parece que houve um problema de rede. Por favor, tente novamente.",
"network_fail": "Não foi possível enviar requisição",
"no_duration": "Sem duração",
"script_fail": "Não foi possível executar o script pré-requisição",
@@ -252,25 +252,25 @@
"curl": "Importar cURL",
"failed": "A importação falhou",
"from_gist": "Importar do Gist",
"from_gist_description": "Import from Gist URL",
"from_insomnia": "Import from Insomnia",
"from_insomnia_description": "Import from Insomnia collection",
"from_json": "Import from Hoppscotch",
"from_json_description": "Import from Hoppscotch collection file",
"from_gist_description": "Importar de URL Gist",
"from_insomnia": "Importar de Insomnia",
"from_insomnia_description": "Importa de coleção Insomnia",
"from_json": "Importar de Hoppscotch",
"from_json_description": "Importa de arquivo de coleção Hoppscotch",
"from_my_collections": "Importar de minhas coleções",
"from_my_collections_description": "Import from My Collections file",
"from_openapi": "Import from OpenAPI",
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman",
"from_postman_description": "Import from Postman collection",
"from_url": "Import from URL",
"gist_url": "Insira o URL da essência",
"json_description": "Import collections from a Hoppscotch Collections JSON file",
"from_my_collections_description": "Importa de arquivo Minhas Coleções",
"from_openapi": "Importar de OpenAPI",
"from_openapi_description": "Importa de arquivo de especificação OpenAPI (YML/JSON)",
"from_postman": "Importar de Postman",
"from_postman_description": "Importa de coleção Postman",
"from_url": "Importar de URL",
"gist_url": "Insira o URL do Gist",
"json_description": "Importa coleções de um arquivo JSON de Coleções Hoppscotch",
"title": "Importar"
},
"layout": {
"collapse_collection": "Collapse or Expand Collections",
"collapse_sidebar": "Collapse or Expand the sidebar",
"collapse_collection": "Encolher ou expandir coleções",
"collapse_sidebar": "Encolher ou Expandir a barra lateral",
"column": "Layout vertical",
"name": "Layout",
"row": "Layout horizontal",
@@ -311,16 +311,16 @@
"profile": {
"app_settings": "App Settings",
"editor": "Editor",
"editor_description": "Editors can add, edit, and delete requests.",
"email_verification_mail": "A verification email has been sent to your email address. Please click on the link to verify your email address.",
"no_permission": "You do not have permission to perform this action.",
"owner": "Owner",
"owner_description": "Owners can add, edit, and delete requests, collections and team members.",
"roles": "Roles",
"roles_description": "Roles are used to control access to the shared collections.",
"updated": "Profile updated",
"viewer": "Viewer",
"viewer_description": "Viewers can only view and use requests."
"editor_description": "Editores podem adicionar, editar e deletar requisições.",
"email_verification_mail": "Um e-mail de verificação foi enviado ao seu endereço de e-mail. Por favor, clique no link para verificar seu endereço e-mail.",
"no_permission": "Você não tem permissão para realizar esta ação.",
"owner": "Dono",
"owner_description": "Donos podem adicionar, editar e deletar requisições, coleções e membros de equipe.",
"roles": "Funções",
"roles_description": "Funções são utilizadas para gerenciar acesso às coleções compartilhadas.",
"updated": "Perfil atualizado",
"viewer": "Espectador",
"viewer_description": "Espectadores só podem ver e usar requisições."
},
"remove": {
"star": "Remover estrela"
@@ -340,10 +340,10 @@
"invalid_name": "Forneça um nome para a requisição",
"method": "Método",
"name": "Nome da requisição",
"new": "New Request",
"override": "Override",
"override_help": "Set <xmp>Content-Type</xmp> in Headers",
"overriden": "Overridden",
"new": "Nova requisição",
"override": "Substituir",
"override_help": "Substituir <xmp>Content-Type</xmp> em Headers",
"overriden": "Substituído",
"parameter_list": "Parâmetros da requisição",
"parameters": "Parâmetros",
"path": "Caminho",
@@ -356,7 +356,7 @@
"save_as": "Salvar como",
"saved": "Requisição salva",
"share": "Compartilhadar",
"share_description": "Share Hoppscotch with your friends",
"share_description": "Compartilhe o Hoppscotch com seus amigos",
"title": "Solicitar",
"type": "Tipo de requisição",
"url": "URL",
@@ -396,7 +396,7 @@
"extension_version": "Versão da extensão",
"extensions": "Extensões",
"extensions_use_toggle": "Use a extensão do navegador para enviar solicitações (se houver)",
"follow": "Follow Us",
"follow": "Nos siga",
"font_size": "Tamanho da fonte",
"font_size_large": "Grande",
"font_size_medium": "Médio",
@@ -407,7 +407,7 @@
"light_mode": "Luz",
"official_proxy_hosting": "Official Proxy é hospedado por Hoppscotch.",
"profile": "Perfil",
"profile_description": "Update your profile details",
"profile_description": "Atualize os detalhes de seu perfil",
"profile_email": "Endereço de email",
"profile_name": "Nome do perfil",
"proxy": "Proxy",

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