feat: graphql document generation from docs (#4626)

Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Anwarul Islam 2025-01-30 11:45:37 +06:00 committed by GitHub
parent 0a83894e6a
commit 499505397f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 2016 additions and 373 deletions

View file

@ -343,6 +343,7 @@
"collection": "Collection is empty",
"collections": "Collections are empty",
"documentation": "Connect to a GraphQL endpoint to view documentation",
"empty_schema": "No schema found",
"endpoint": "Endpoint cannot be empty",
"environments": "Environments are empty",
"folder": "Folder is empty",
@ -487,11 +488,16 @@
"connection_error_http": "Failed to fetch GraphQL Schema due to network error.",
"connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is",
"connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is",
"deprecated": "Deprecated",
"mutation": "Mutation",
"mutations": "Mutations",
"schema": "Schema",
"show_depricated_values": "Show deprecated values",
"subscription": "Subscription",
"subscriptions": "Subscriptions",
"switch_connection": "Switch connection",
"url_placeholder": "Enter a GraphQL endpoint URL"
"url_placeholder": "Enter a GraphQL endpoint URL",
"query": "Query"
},
"graphql_collections": {
"title": "GraphQL Collections"

View file

@ -44,6 +44,7 @@
"@noble/curves": "1.6.0",
"@scure/base": "1.1.9",
"@shopify/lang-jsonc": "1.0.0",
"@types/markdown-it": "14.1.2",
"@unhead/vue": "1.11.10",
"@urql/core": "5.0.6",
"@urql/devtools": "2.0.3",
@ -70,6 +71,7 @@
"jsonpath-plus": "10.0.0",
"lodash-es": "4.17.21",
"lossless-json": "4.0.2",
"markdown-it": "14.1.0",
"minisearch": "7.1.0",
"nprogress": "0.2.0",
"paho-mqtt": "1.1.0",

View file

@ -29,6 +29,7 @@ declare module 'vue' {
AppInspection: typeof import('./components/app/Inspection.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default']
AppMarkdown: typeof import('./components/app/Markdown.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppShare: typeof import('./components/app/Share.vue')['default']
@ -95,19 +96,40 @@ declare module 'vue' {
EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default']
FirebaseLogin: typeof import('./components/firebase/Login.vue')['default']
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
GraphqlArgument: typeof import('./components/graphql/Argument.vue')['default']
GraphqlArguments: typeof import('./components/graphql/Arguments.vue')['default']
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
GraphqlDefaultValue: typeof import('./components/graphql/DefaultValue.vue')['default']
GraphqlDeprecationReason: typeof import('./components/graphql/DeprecationReason.vue')['default']
GraphqlDirective: typeof import('./components/graphql/Directive.vue')['default']
GraphqlDirectives: typeof import('./components/graphql/Directives.vue')['default']
GraphqlDoc: typeof import('./components/graphql/Doc.vue')['default']
GraphqlDocExplorer: typeof import('./components/graphql/DocExplorer.vue')['default']
GraphqlEnumValues: typeof import('./components/graphql/EnumValues.vue')['default']
GraphqlExplorerSection: typeof import('./components/graphql/ExplorerSection.vue')['default']
GraphqlField: typeof import('./components/graphql/Field.vue')['default']
GraphqlFieldDocumentation: typeof import('./components/graphql/FieldDocumentation.vue')['default']
GraphqlFieldLink: typeof import('./components/graphql/FieldLink.vue')['default']
GraphqlFieldOld: typeof import('./components/graphql/FieldOld.vue')['default']
GraphqlFields: typeof import('./components/graphql/Fields.vue')['default']
GraphqlHeaders: typeof import('./components/graphql/Headers.vue')['default']
GraphqlImplementsInterfaces: typeof import('./components/graphql/ImplementsInterfaces.vue')['default']
GraphqlQuery: typeof import('./components/graphql/Query.vue')['default']
GraphqlRequest: typeof import('./components/graphql/Request.vue')['default']
GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default']
GraphqlRequestTab: typeof import('./components/graphql/RequestTab.vue')['default']
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSchemaBrowser: typeof import('./components/graphql/SchemaBrowser.vue')['default']
GraphqlSchemaDoc: typeof import('./components/graphql/SchemaDoc.vue')['default']
GraphqlSchemaDocumentation: typeof import('./components/graphql/SchemaDocumentation.vue')['default']
GraphqlSchemaSearch: typeof import('./components/graphql/SchemaSearch.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlSubscriptionLog: typeof import('./components/graphql/SubscriptionLog.vue')['default']
GraphqlTabHead: typeof import('./components/graphql/TabHead.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeDocumentation: typeof import('./components/graphql/TypeDocumentation.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
GraphqlTypeLinkNew: typeof import('./components/graphql/TypeLink.vue')['default']
GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default']
History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
@ -208,7 +230,9 @@ declare module 'vue' {
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
@ -218,8 +242,10 @@ declare module 'vue' {
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePlay: (typeof import("~icons/lucide/play"))["default"]
IconLucidePlaySquare: (typeof import("~icons/lucide/play-square"))["default"]
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']

View file

@ -0,0 +1,35 @@
<template>
<div :class="classes" v-html="renderedMarkdown"></div>
</template>
<script setup lang="ts">
import { computed, defineProps, useSlots } from "vue"
import MarkdownIt from "markdown-it"
const markdown = new MarkdownIt({
breaks: true,
linkify: true,
})
interface Props {
onlyShowFirstChild?: boolean
type: "description" | "deprecation"
}
const props = defineProps<Props>()
const slots: { default?: () => any[] } = useSlots()
const classes = computed(() => {
let classList = `hopp-markdown-${props.type}`
if (props.onlyShowFirstChild) {
classList += " hopp-markdown-preview"
}
return classList
})
const renderedMarkdown = computed(() =>
markdown.render(slots.default ? slots.default()[0].children : "")
)
</script>

View file

@ -0,0 +1,88 @@
<template>
<template v-if="inline">
<span>
<span class="hopp-doc-explorer-argument-name"> {{ arg.name }} </span>:
<GraphqlTypeLink :type="arg.type" />
<GraphqlDefaultValue v-if="showDefaultValue !== false" :field="arg" />
</span>
</template>
<div v-else class="hopp-doc-explorer-argument">
<p class="inline-flex items-center mb-0 gap-2 align-bottom">
<span
v-if="showAddButton"
class="hover:text-accent cursor-pointer"
:class="{ 'text-accent': isArgumentInOperation(arg) }"
@click="insertQuery"
>
<icon-lucide-plus-circle v-if="!isArgumentInOperation(arg)" />
<icon-lucide-circle-check v-else />
</span>
<span class="hopp-doc-explorer-argument-name"> {{ arg.name }} </span>:
<GraphqlTypeLink :type="arg.type" />
<GraphqlDefaultValue v-if="showDefaultValue !== false" :field="arg" />
</p>
<!-- <AppMarkdown v-if="arg.description" type="description">
{{ arg.description }}
</AppMarkdown> -->
<div
v-if="arg.deprecationReason"
class="hopp-doc-explorer-argument-deprecation"
>
<div class="hopp-doc-explorer-argument-deprecation-label">Deprecated</div>
<AppMarkdown type="deprecationReason">
{{ arg.deprecationReason }}
</AppMarkdown>
</div>
</div>
</template>
<script setup lang="ts">
import type { GraphQLArgument } from "graphql"
import { useQuery } from "~/helpers/graphql/query"
const { handleAddArgument, isArgumentInOperation } = useQuery()
interface ArgumentProps {
/**
* The argument that should be rendered.
*/
arg: GraphQLArgument
/**
* Toggle if the default value for the argument is shown (if there is one)
* @default false
*/
showDefaultValue?: boolean
/**
* Toggle whether to render the whole argument including description and
* deprecation reason (`false`) or to just render the argument name, type,
* and default value in a single line (`true`).
* @default false
*/
inline?: boolean
/**
* Whether to show the add button or not
*/
showAddButton?: boolean
}
const props = withDefaults(defineProps<ArgumentProps>(), {
showDefaultValue: false,
inline: false,
showAddButton: false,
})
const insertQuery = () => {
handleAddArgument(props.arg)
}
</script>
<style scoped lang="scss">
.hopp-doc-explorer-argument {
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<template v-if="'args' in field">
<GraphqlExplorerSection v-if="field.args.length > 0" title="Arguments">
<GraphqlArgument
v-for="arg in field.args"
:key="arg.name"
:arg="arg"
:show-add-button="true"
/>
</GraphqlExplorerSection>
</template>
</template>
<script setup lang="ts">
import { GraphQLField } from "graphql"
defineProps<{
field: GraphQLField<any, any>
}>()
</script>

View file

@ -0,0 +1,34 @@
<template>
<template v-if="defaultValueAst">
{' = '}
<span class="hopp-doc-explorer-default-value">
{{ printDefault(defaultValueAst) }}
</span>
</template>
</template>
<script setup lang="ts">
import { astFromValue, print } from "graphql"
import type { ValueNode } from "graphql"
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
import { computed } from "vue"
const props = defineProps<{
field: ExplorerFieldDef
}>()
const printDefault = (ast?: ValueNode | null): string => {
if (!ast) return ""
return print(ast)
}
const defaultValueAst = computed(() => {
if (
!("defaultValue" in props.field) ||
props.field.defaultValue === undefined
) {
return null
}
return astFromValue(props.field.defaultValue, props.field.type)
})
</script>

View file

@ -0,0 +1,21 @@
<template>
<ExplorerSection v-if="directives.length > 0" title="Directives">
<div v-for="directive in directives" :key="directive.name.value">
<span class="hopp-doc-explorer-directive">
@{{ directive?.name?.value }}
</span>
</div>
</ExplorerSection>
</template>
<script setup lang="ts">
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
import ExplorerSection from "./ExplorerSection.vue"
import { computed } from "vue"
const props = defineProps<{
field: ExplorerFieldDef
}>()
const directives = computed(() => props.field.astNode?.directives || [])
</script>

View file

@ -0,0 +1,95 @@
<template>
<section
v-if="schema"
class="hopp-doc-explorer pb-10"
aria-label="Documentation Explorer"
>
<div class="sticky top-0 z-10 border-b border-dividerLight bg-primary">
<GraphqlSchemaSearch />
<div
class="flex items-center overflow-x-auto whitespace-nowrap px-3 py-2 text-tiny text-secondaryLight"
>
<template v-for="(item, index) in navStack" :key="index">
<span
class="cursor-pointer hover:text-secondary"
@click="navigateToIndex(index)"
>
{{ item.name }}
</span>
<span>
<icon-lucide-chevron-right
v-if="index < navStack.length - 1"
class="mx-1"
/>
</span>
</template>
</div>
</div>
<div class="hopp-doc-explorer-content mt-4">
<template v-if="navStack.length === 1">
<GraphqlSchemaDocumentation :schema="schema" />
</template>
<template v-else-if="isType(currentNavItem.def)">
<div
class="hopp-doc-explorer-title text-xl font-bold break-words px-3 mb-4"
>
{{ currentNavItem.name }}
</div>
<GraphqlTypeDocumentation :type="currentNavItem.def" />
</template>
<template v-else-if="currentNavItem.def">
<GraphqlFieldDocumentation :field="currentNavItem.def" />
</template>
</div>
</section>
<HoppSmartPlaceholder
v-else
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="t('empty.empty_schema')"
:text="t('empty.empty_schema')"
>
</HoppSmartPlaceholder>
</template>
<script setup lang="ts">
import { isType } from "graphql"
import { schema } from "~/helpers/graphql/connection"
import { useExplorer } from "../../helpers/graphql/explorer"
import { useColorMode } from "~/composables/theming"
import { useI18n } from "~/composables/i18n"
const colorMode = useColorMode()
const t = useI18n()
// Use explorer composable
const { navStack, currentNavItem, navigateToIndex } = useExplorer()
</script>
<style lang="scss">
.hopp-doc-explorer-field-name {
color: hsl(208, 100%, 72%);
}
.hopp-doc-explorer-root-type {
color: hsl(208, 100%, 72%);
}
.hopp-doc-explorer-type-name {
cursor: pointer;
color: hsl(30, 100%, 80%);
}
.hopp-doc-explorer-argument-name {
color: hsl(243, 100%, 77%);
}
.hopp-doc-explorer-argument-multiple {
margin-left: 0.5rem;
}
.hopp-doc-explorer-argument-deprecation {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: hsl(0, 100%, 90%);
border-radius: 0.25rem;
}
</style>

View file

@ -0,0 +1,79 @@
<template>
<template v-if="isEnumType(type)">
<GraphqlExplorerSection
v-if="enumValues.values.length > 0"
title="Enum Values"
>
<div
v-for="value in enumValues.values"
:key="value.name"
class="hopp-doc-explorer-item"
>
<div class="hopp-doc-explorer-enum-value">{{ value.name }}</div>
<GraphqlMarkdown v-if="value.description" type="description">
{{ value.description }}
</GraphqlMarkdown>
<GraphqlMarkdown v-if="value.deprecationReason" type="deprecation">
{{ value.deprecationReason }}
</GraphqlMarkdown>
</div>
</GraphqlExplorerSection>
<template v-if="enumValues.deprecatedValues.length > 0">
<ExplorerSection
v-if="showDeprecated || enumValues.values.length === 0"
title="Deprecated Enum Values"
>
<EnumValue
v-for="value in enumValues.deprecatedValues"
:key="value.name"
:value="value"
/>
</ExplorerSection>
<button v-else type="button" @click="handleShowDeprecated">
{{ t("graphql.show_depricated_values") }}
</button>
</template>
</template>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import type { GraphQLNamedType, GraphQLEnumValue } from "graphql"
import { isEnumType } from "graphql"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
interface EnumValuesProps {
type: GraphQLNamedType
}
const props = defineProps<EnumValuesProps>()
const showDeprecated = ref(false)
const handleShowDeprecated = () => {
showDeprecated.value = true
}
const enumValues = computed(() => {
if (!isEnumType(props.type)) {
return { values: [], deprecatedValues: [] }
}
const values: GraphQLEnumValue[] = []
const deprecatedValues: GraphQLEnumValue[] = []
for (const value of props.type.getValues()) {
if (value.deprecationReason) {
deprecatedValues.push(value)
} else {
values.push(value)
}
}
return { values, deprecatedValues }
})
</script>

View file

@ -0,0 +1,61 @@
<template>
<div class="mb-6">
<div class="hopp-doc-explorer-section-title flex gap-2 mb-2 font-bold">
<!-- <component :is="iconComponent" /> -->
{{ title }}
</div>
<div class="hopp-doc-explorer-section-content">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import IconArgument from "~icons/lucide/diamond-minus"
import IconDeprecatedArgument from "~icons/lucide/diamond-minus"
import IconDeprecatedEnumValue from "~icons/lucide/diamond-minus"
import IconDeprecatedField from "~icons/lucide/diamond-minus"
import IconDierctive from "~icons/lucide/arrow-right"
import IconEnumValue from "~icons/lucide/arrow-right"
import IconField from "~icons/lucide/rectangle-ellipsis"
import IconImplements from "~icons/lucide/arrow-right"
import IconType from "~icons/lucide/file-type"
import IconRootTypes from "~icons/lucide/folder-kanban"
type ExplorerSectionTitle =
| "Root Types"
| "Fields"
| "Deprecated Fields"
| "Type"
| "Arguments"
| "Deprecated Arguments"
| "Implements"
| "Implementations"
| "Possible Types"
| "Enum Values"
| "Deprecated Enum Values"
| "Directives"
| "All Schema Types"
const props = defineProps<{
title: ExplorerSectionTitle
}>()
const TYPE_TO_ICON: Record<ExplorerSectionTitle, any> = {
Arguments: IconArgument,
"Deprecated Arguments": IconDeprecatedArgument,
"Deprecated Enum Values": IconDeprecatedEnumValue,
"Deprecated Fields": IconDeprecatedField,
Directives: IconDierctive,
"Enum Values": IconEnumValue,
Fields: IconField,
Implements: IconImplements,
Implementations: IconType,
"Possible Types": IconType,
"Root Types": IconRootTypes,
Type: IconType,
"All Schema Types": IconType,
}
const iconComponent = TYPE_TO_ICON[props.title]
</script>

View file

@ -1,102 +1,90 @@
<template>
<div>
<div class="flex justify-between gap-2">
<div class="hopp-doc-explorer-item" @click="handleClick">
<div class="flex">
<div
class="field-title flex-1"
:class="{ 'field-highlighted': isHighlighted }"
:class="{
'!line-through': field.deprecationReason,
}"
>
{{ fieldName }}
<span v-if="fieldArgs.length > 0">
(
<span v-for="(field, index) in fieldArgs" :key="`field-${index}`">
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
@jump-to-type="jumpToType"
/>
<span v-if="index !== fieldArgs.length - 1">, </span>
</span>
) </span
<GraphqlFieldLink
:field="field"
:show-add-field="showAddField"
:is-added="isFieldInOperation(field)"
@add-field="insertQuery"
/>
<template v-if="args.length > 0">
(<span>
<template v-for="arg in args" :key="arg.name">
<div
v-if="args.length > 1"
class="hopp-doc-explorer-argument-multiple"
>
<GraphqlArgument :arg="arg" inline />
</div>
<GraphqlArgument v-else :arg="arg" inline />
</template> </span
>)</template
>:
<GraphqlTypeLink :gql-type="gqlField.type" @jump-to-type="jumpToType" />
</div>
<div v-if="gqlField.deprecationReason">
<span
v-tippy="{ theme: 'tomato' }"
class="flex cursor-pointer items-center gap-2 text-xs !text-red-500 hover:!text-red-600"
:title="gqlField.deprecationReason"
>
<IconAlertTriangle /> {{ t("state.deprecated") }}
</span>
<GraphqlTypeLink :type="field.type" />
<GraphqlDefaultValue :field="field" />
</div>
<span
v-if="field.deprecationReason"
v-tippy="{ theme: 'tooltip' }"
class="hopp-doc-explorer-deprecated inline ml-auto text-red-500"
:title="field.deprecationReason"
>
<icon-lucide-triangle-alert />
</span>
</div>
<div
v-if="gqlField.description"
class="field-desc py-2 text-secondaryLight"
<AppMarkdown
v-if="field.description"
type="description"
class="hidden"
:only-show-first-child="true"
>
{{ gqlField.description }}
</div>
<div v-if="fieldArgs.length > 0">
<h5 class="my-2">Arguments:</h5>
<div class="border-l-2 border-divider pl-4">
<div v-for="(field, index) in fieldArgs" :key="`field-${index}`">
<span>
{{ field.name }}:
<GraphqlTypeLink
:gql-type="field.type"
@jump-to-type="jumpToType"
/>
</span>
<div
v-if="field.description"
class="field-desc py-2 text-secondaryLight"
>
{{ field.description }}
</div>
</div>
</div>
</div>
{{ field.description }}
</AppMarkdown>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { GraphQLType } from "graphql"
import { computed } from "vue"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
const t = useI18n()
import { ExplorerFieldDef, useExplorer } from "~/helpers/graphql/explorer"
import { useQuery } from "~/helpers/graphql/query"
const props = withDefaults(
defineProps<{
gqlField: any
isHighlighted: boolean
field: ExplorerFieldDef
showAddField: boolean
}>(),
{
gqlField: {},
isHighlighted: false,
showAddField: true,
}
)
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
const { push } = useExplorer()
const { handleAddField, isFieldInOperation } = useQuery()
const fieldName = computed(() => props.gqlField.name)
const fieldArgs = computed(() => props.gqlField.args || [])
const jumpToType = (type: GraphQLType) => {
emit("jump-to-type", type)
const handleClick = () => {
push({ name: props.field.name, def: props.field })
}
const insertQuery = () => {
handleAddField(props.field)
}
const args = computed(() =>
"args" in props.field
? props.field.args.filter((arg) => !arg.deprecationReason)
: []
)
</script>
<style lang="scss" scoped>
.field-highlighted {
@apply border-b-2 border-accent;
}
.field-title {
@apply select-text;
<style scoped lang="scss">
.hopp-doc-explorer-item {
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="px-3">
<div
class="hopp-doc-explorer-title text-xl font-bold break-words flex-wrap mb-4 flex items-center gap-2 leading-[inherit]"
>
{{ field.name }}:
<GraphqlTypeLink :type="field.type" />
</div>
<div
v-if="deprecationReason"
class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-3 mb-4 -mt-2"
role="alert"
>
<p class="font-bold uppercase">
{{ t("graphql.deprecated") }}
</p>
<AppMarkdown type="deprecation">
{{ deprecationReason }}
</AppMarkdown>
</div>
<AppMarkdown v-if="hasDescription" type="description" class="mb-6">
{{ description }}
</AppMarkdown>
<!-- <GraphqlExplorerSection title="Type">
<GraphqlTypeLink :type="field.type" />
</GraphqlExplorerSection> -->
<GraphqlArguments :field="field" />
<GraphqlFields :type="resolvedType" />
<GraphqlDirectives :field="field" />
</div>
</template>
<script setup lang="ts">
import { GraphQLOutputType, getNamedType } from "graphql"
import { computed, defineProps } from "vue"
import { useI18n } from "~/composables/i18n"
// Update the Field interface
interface Field {
name: string
description: string | null
deprecationReason: string
type: GraphQLOutputType
}
const props = defineProps<{
field: Field
}>()
const t = useI18n()
// Computed properties
const hasDescription = computed(() => props.field.description !== null)
const description = computed(() => props.field.description)
const deprecationReason = computed(() => props.field.deprecationReason)
// Add new computed property to resolve the named type
const resolvedType = computed(() => getNamedType(props.field.type))
</script>

View file

@ -0,0 +1,48 @@
<template>
<p class="inline-flex items-center mb-0 gap-2 align-bottom">
<span
v-if="showAddField"
class="hover:text-accent cursor-pointer"
:class="{ 'text-accent': isAdded }"
@click.stop="emit('add-field', field)"
>
<icon-lucide-plus-circle v-if="!isAdded" />
<icon-lucide-circle-check v-else />
</span>
<span
class="hopp-doc-explorer-field-name [text-decoration:inherit]"
@click="clickable ? handleClick : undefined"
>
{{ field.name }}
</span>
</p>
</template>
<script setup lang="ts">
import { type ExplorerFieldDef, useExplorer } from "~/helpers/graphql/explorer"
const props = withDefaults(
defineProps<{
field: ExplorerFieldDef
clickable?: boolean
showAddField: boolean
isAdded?: boolean
}>(),
{
clickable: true,
showAddField: true,
isAdded: false,
}
)
const emit = defineEmits<{
(event: "add-field", field: ExplorerFieldDef): void
}>()
const { push } = useExplorer()
const handleClick = (event: MouseEvent) => {
event.preventDefault()
push({ name: props.field.name, def: props.field })
}
</script>

View file

@ -0,0 +1,48 @@
<template>
<div>
<GraphqlExplorerSection v-if="fields.length > 0" title="Fields">
<GraphqlField
v-for="field in fields"
:key="field.name"
:field="field"
:show-add-field="showAddField"
/>
</GraphqlExplorerSection>
</div>
</template>
<script setup lang="ts">
import {
GraphQLNamedType,
isInputObjectType,
isInterfaceType,
isObjectType,
} from "graphql"
import { computed } from "vue"
import { ExplorerFieldDef } from "~/helpers/graphql/explorer"
const props = withDefaults(
defineProps<{
type: GraphQLNamedType
showAddField: boolean
}>(),
{
showAddField: true,
}
)
const fieldMap = computed(() => {
if (
!isObjectType(props.type) &&
!isInterfaceType(props.type) &&
!isInputObjectType(props.type)
) {
return {}
}
return props.type.getFields()
})
const fields = computed(() => {
return Object.values(fieldMap.value) as ExplorerFieldDef[]
})
</script>

View file

@ -0,0 +1,26 @@
<template>
<GraphqlExplorerSection v-if="interfaces.length > 0" title="Implements">
<div
v-for="implementedInterface in interfaces"
:key="implementedInterface.name"
>
<GraphqlTypeLink :type="implementedInterface" />
</div>
</GraphqlExplorerSection>
</template>
<script setup lang="ts">
import { defineProps, computed } from "vue"
import { isObjectType, GraphQLNamedType } from "graphql"
const props = defineProps<{
type: GraphQLNamedType
}>()
const interfaces = computed(() => {
if (!isObjectType(props.type)) {
return []
}
return props.type.getInterfaces()
})
</script>

View file

@ -93,14 +93,14 @@ import IconCheck from "~icons/lucide/check"
import IconInfo from "~icons/lucide/info"
import IconWand from "~icons/lucide/wand"
import IconWrapText from "~icons/lucide/wrap-text"
import { onMounted, reactive, ref, markRaw } from "vue"
import { onMounted, reactive, ref, markRaw, watch, nextTick } from "vue"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { refAutoReset, useVModel } from "@vueuse/core"
import { useToast } from "~/composables/toast"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import * as gql from "graphql"
import { OperationDefinitionNode, parse, print } from "graphql"
import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
import queryCompleter from "~/helpers/editor/completion/gqlQuery"
import { selectedGQLOpHighlight } from "~/helpers/editor/gql/operation"
@ -114,6 +114,7 @@ import {
} from "~/helpers/graphql/connection"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { useQuery } from "~/helpers/graphql/query"
// Template refs
const queryEditor = ref<any | null>(null)
@ -128,7 +129,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: "save-request"): void
(e: "update:modelValue", val: string): void
(e: "run-query", definition: gql.OperationDefinitionNode | null): void
(e: "cursor-position", val: number): void
(e: "run-query", definition: OperationDefinitionNode | null): void
}>()
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
@ -141,45 +143,60 @@ const prettifyQueryIcon = refAutoReset<
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlQuery")
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
const selectedOperation = ref<OperationDefinitionNode | null>(null)
const gqlQueryString = useVModel(props, "modelValue", emit)
// Add useQuery
const { updatedQuery, cursorPosition, operationDefinitions } = useQuery()
const debouncedOnUpdateQueryState = debounce((update: ViewUpdate) => {
const selectedPos = update.state.selection.main.head
emit("cursor-position", selectedPos)
selectedOperation.value = null
const queryString = update.state.doc.toJSON().join(update.state.lineBreak)
try {
const operations = gql.parse(queryString)
if (operations.definitions.length === 1) {
selectedOperation.value = operations
.definitions[0] as gql.OperationDefinitionNode
const ast = parse(queryString)
operationDefinitions.value = ast.definitions.filter(
(def) => def.kind === "OperationDefinition"
) as OperationDefinitionNode[]
if (ast.definitions.length === 1) {
selectedOperation.value = ast.definitions[0] as OperationDefinitionNode
return
}
selectedOperation.value =
(operations.definitions.find((def) => {
(ast.definitions.find((def) => {
if (def.kind !== "OperationDefinition") return false
const { start, end } = def.loc!
return selectedPos >= start && selectedPos <= end
}) as gql.OperationDefinitionNode) ?? null
}) as OperationDefinitionNode) ?? null
} catch (error) {
// console.error(error)
if (queryString.trim() === "") {
operationDefinitions.value = []
}
}
}, 300)
}, 150)
onMounted(() => {
try {
const operations = gql.parse(gqlQueryString.value)
if (operations.definitions.length) {
selectedOperation.value = operations
.definitions[0] as gql.OperationDefinitionNode
const ast = parse(gqlQueryString.value)
operationDefinitions.value = ast.definitions.filter(
(def) => def.kind === "OperationDefinition"
) as OperationDefinitionNode[]
if (ast.definitions.length) {
selectedOperation.value = ast.definitions[0] as OperationDefinitionNode
return
}
} catch (error) {}
})
useCodemirror(
const cmQueryEditor = useCodemirror(
queryEditor,
gqlQueryString,
reactive({
@ -196,13 +213,27 @@ useCodemirror(
})
)
// Add watcher for query updates
watch(updatedQuery, async (newQuery) => {
if (newQuery) {
gqlQueryString.value = newQuery
await nextTick()
// Update cursor position
if (cursorPosition.value) {
cmQueryEditor.cursor.value = cursorPosition.value
}
}
})
// operations on graphql query string
// const operations = useReadonlyStream(props.request.operations$, [])
const prettifyQuery = () => {
try {
gqlQueryString.value = gql.print(
gql.parse(gqlQueryString.value, {
gqlQueryString.value = print(
parse(gqlQueryString.value, {
allowLegacyFragmentVariables: true,
})
)
@ -223,7 +254,7 @@ const clearGQLQuery = () => {
gqlQueryString.value = ""
}
const runQuery = (definition: gql.OperationDefinitionNode | null = null) => {
const runQuery = (definition: OperationDefinitionNode | null = null) => {
emit("run-query", definition)
}
const unsubscribe = () => {

View file

@ -13,6 +13,7 @@
v-model="request.query"
@run-query="runQuery"
@save-request="saveRequest"
@cursor-position="updateCursorPos"
/>
</HoppSmartTab>
<HoppSmartTab
@ -244,6 +245,10 @@ watch(
{ deep: true }
)
const updateCursorPos = (pos: number) => {
tabs.currentActiveTab.value.document.cursorPosition = pos
}
const hideRequestModal = () => {
showSaveRequestModal.value = false
}

View file

@ -0,0 +1,105 @@
<template>
<div class="px-3">
<AppMarkdown type="description" class="mb-4">
{{ schemaDescription }}
</AppMarkdown>
<GraphqlExplorerSection title="Root Types">
<div
v-if="queryType"
class="hopp-doc-explorer-root-wrapper"
@click="handleTypeClick(queryType)"
>
<span class="hopp-doc-explorer-root-type">
{{ t("graphql.query") }}
</span>
{{ ": " }}
<GraphqlTypeLink :type="queryType" />
</div>
<div
v-if="mutationType"
class="hopp-doc-explorer-root-wrapper"
@click="handleTypeClick(mutationType)"
>
<span class="hopp-doc-explorer-root-type">
{{ t("graphql.mutation") }}
</span>
{{ ": " }}
<GraphqlTypeLink :type="mutationType" />
</div>
<div
v-if="subscriptionType"
class="hopp-doc-explorer-root-wrapper"
@click="handleTypeClick(subscriptionType)"
>
<span class="hopp-doc-explorer-root-type">
{{ t("graphql.subscription") }}
</span>
{{ ": " }}
<GraphqlTypeLink :type="subscriptionType" />
</div>
</GraphqlExplorerSection>
<GraphqlExplorerSection title="All Schema Types">
<div v-if="filteredTypes">
<div v-for="type in filteredTypes" :key="type.name" class="px-2">
<GraphqlTypeLink :type="type" :clickable="true" />
</div>
</div>
</GraphqlExplorerSection>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import type { GraphQLNamedType, GraphQLSchema } from "graphql"
import { useExplorer } from "~/helpers/graphql/explorer"
import { useI18n } from "~/composables/i18n"
const props = defineProps<{
schema: GraphQLSchema
}>()
const t = useI18n()
const queryType = props.schema.getQueryType()
const mutationType = props.schema.getMutationType?.()
const subscriptionType = props.schema.getSubscriptionType?.()
const typeMap = props.schema.getTypeMap()
const schemaDescription = computed(
() =>
props.schema.description ||
"A GraphQL schema provides a root type for each kind of operation."
)
const { push } = useExplorer()
const handleTypeClick = (namedType: GraphQLNamedType) => {
push({ name: namedType.name, def: namedType })
}
const ignoreTypesInAllSchema = computed(() => [
queryType?.name,
mutationType?.name,
subscriptionType?.name,
])
const filteredTypes = computed(() => {
if (!typeMap) return []
return Object.values(typeMap).filter(
(type) =>
!ignoreTypesInAllSchema.value.includes(type.name) &&
!type.name.startsWith("__")
)
})
</script>
<style scoped lang="scss">
.hopp-doc-explorer-root-wrapper {
@apply cursor-pointer py-1 px-2 hover:bg-primaryLight;
}
.hopp-doc-explorer-root-type {
@apply lowercase;
}
</style>

View file

@ -0,0 +1,355 @@
<template>
<div class="border-b border-dividerLight autocomplete-wrapper">
<div ref="searchWrapper" class="no-scrollbar inset-0 flex flex-1">
<input
v-model="searchText"
type="search"
autocomplete="off"
class="flex w-full bg-transparent px-3 py-2 h-8"
:placeholder="t('action.search')"
@keydown="handleSearchKeystroke"
@focusin="showSearchResults = true"
/>
</div>
<ul
v-if="showSearchResults && filteredResults.length > 0"
ref="resultsMenu"
class="suggestions"
>
<li
v-for="(result, index) in filteredResults"
:key="`result-${index}`"
:class="{ active: currentResultIndex === index }"
@click="selectSearchResult(result)"
>
<span class="truncate py-0.5">
{{ result.name }}
<span class="text-secondaryLight">- {{ result.type }}</span>
</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type {
GraphQLArgument,
GraphQLField,
GraphQLInputField,
GraphQLNamedType,
} from "graphql"
import { isInputObjectType, isInterfaceType, isObjectType } from "graphql"
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"
import { useI18n } from "vue-i18n"
import { schema } from "~/helpers/graphql/connection"
import { ExplorerNavStackItem, useExplorer } from "~/helpers/graphql/explorer"
// Types
type SearchResult = {
name: string
type: string
def:
| GraphQLNamedType
| GraphQLField<unknown, unknown>
| GraphQLInputField
| GraphQLArgument
}
// Composables
const { t } = useI18n()
const { navStack, push, pop } = useExplorer()
// Refs
const searchText = ref("")
const showSearchResults = ref(false)
const currentResultIndex = ref(-1)
const searchWrapper = ref<HTMLElement | null>(null)
const resultsMenu = ref<HTMLElement | null>(null)
// Search Results Generation
const generateSearchResults = () => {
const results: SearchResult[] = []
if (!schema.value) return results
const typeMap = schema.value.getTypeMap()
const typeNames = Object.keys(typeMap).filter(
(name) => !name.startsWith("__")
) // Filter out __ types
for (const typeName of typeNames) {
if (results.length >= 100) break
const type = typeMap[typeName]
// Add type match
if (typeName.toLowerCase().includes(searchText.value.toLowerCase())) {
results.push({
name: typeName,
type: "Type",
def: type,
})
}
// Skip if not an object, interface, or input object type
if (
!isObjectType(type) &&
!isInterfaceType(type) &&
!isInputObjectType(type)
) {
continue
}
// Search fields
const fields = type.getFields()
for (const fieldName in fields) {
// Skip fields starting with __
if (fieldName.startsWith("__")) continue
const field = fields[fieldName]
// Field name match
if (fieldName.toLowerCase().includes(searchText.value.toLowerCase())) {
results.push({
name: fieldName,
type: `Field in ${typeName}`,
def: field,
})
}
// Search arguments if applicable
if ("args" in field) {
field.args.forEach((arg) => {
if (arg.name.toLowerCase().includes(searchText.value.toLowerCase())) {
results.push({
name: arg.name,
type: `Argument in ${fieldName}`,
def: arg,
})
}
})
}
}
}
return results
}
// Computed
const filteredResults = computed(() =>
searchText.value.trim() ? generateSearchResults() : []
)
// Methods
const buildNavigationPath = (result: SearchResult): ExplorerNavStackItem[] => {
const path: ExplorerNavStackItem[] = []
if (result.type === "Type") {
// For type results, traverse through schema to build proper path
const type = result.def as GraphQLNamedType
const rootTypes = {
query: schema.value?.getQueryType(),
mutation: schema.value?.getMutationType(),
subscription: schema.value?.getSubscriptionType(),
}
// Check if the type is directly accessible from root types
const rootEntry = Object.entries(rootTypes).find(
([, rootType]) => rootType?.name === type.name
)
if (rootEntry) {
path.push({
name: rootEntry[0].charAt(0).toUpperCase() + rootEntry[0].slice(1),
def: type,
})
} else {
// If not a root type, try to find the path through fields
const parentType = Object.values(rootTypes).find((rootType) => {
if (!rootType || !isObjectType(rootType)) return false
const fields = rootType.getFields()
return Object.values(fields).some((field) =>
field.type.toString().includes(type.name)
)
})
if (parentType) {
path.push({
name: parentType.name,
def: parentType,
})
// Find the field that leads to our type
const field = Object.values(parentType.getFields()).find((field) =>
field.type.toString().includes(type.name)
)
if (field) {
path.push({
name: field.name,
def: field,
})
}
}
path.push({
name: type.name,
def: type,
})
}
} else if (result.type.startsWith("Field in")) {
// For fields, ensure we have the complete path from root
const parentTypeName = result.type.replace("Field in ", "")
const parentType = schema.value?.getType(parentTypeName)
if (parentType) {
// First find path to parent type
const parentPath = buildNavigationPath({
name: parentType.name,
type: "Type",
def: parentType,
})
path.push(...parentPath)
path.push({
name: result.name,
def: result.def,
})
}
}
return path
}
const selectSearchResult = (result: SearchResult) => {
const navigationPath = buildNavigationPath(result)
// Reset to root
while (navStack.value.length > 1) {
pop()
}
// Push each item in the path sequentially
navigationPath.forEach((item, i) => {
if (
i === 0 ||
(!isObjectType(item.def) &&
!isInterfaceType(item.def) &&
!isInputObjectType(item.def))
)
push(item)
})
showSearchResults.value = false
searchText.value = ""
currentResultIndex.value = -1
}
const scrollActiveResultIntoView = async () => {
await nextTick()
if (resultsMenu.value && currentResultIndex.value > -1) {
const activeElement = resultsMenu.value.children[
currentResultIndex.value
] as HTMLElement
activeElement?.scrollIntoView({ block: "nearest" })
}
}
const handleSearchKeystroke = (ev: KeyboardEvent) => {
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
if (["Escape", "Tab"].includes(ev.key)) {
showSearchResults.value = false
}
if (ev.key === "Enter" && currentResultIndex.value > -1) {
selectSearchResult(filteredResults.value[currentResultIndex.value])
}
if (ev.key === "ArrowDown") {
currentResultIndex.value =
currentResultIndex.value < filteredResults.value.length - 1
? currentResultIndex.value + 1
: filteredResults.value.length - 1
scrollActiveResultIntoView()
}
if (ev.key === "ArrowUp") {
currentResultIndex.value =
currentResultIndex.value > 0 ? currentResultIndex.value - 1 : 0
scrollActiveResultIntoView()
}
}
// Click outside handling
const handleClickOutside = (event: MouseEvent) => {
if (
searchWrapper.value &&
!searchWrapper.value.contains(event.target as Node)
) {
showSearchResults.value = false
}
}
// Add watch for searchText
watch(searchText, () => {
currentResultIndex.value = -1
if (searchText.value.trim()) {
showSearchResults.value = true
}
})
// Lifecycle
onMounted(() => {
document.addEventListener("click", handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside)
})
</script>
<style lang="scss" scoped>
.autocomplete-wrapper {
@apply relative;
@apply whitespace-nowrap;
.suggestions {
@apply absolute;
@apply bg-popover;
@apply z-50;
@apply shadow-lg;
@apply max-h-46;
@apply border-x border-b border-divider;
@apply overflow-y-auto;
@apply -left-[1px];
@apply -right-[1px];
top: calc(100% + 1px);
border-radius: 0 0 8px 8px;
li {
@apply flex;
@apply items-center;
@apply justify-between;
@apply w-full;
@apply px-4 py-2;
@apply text-secondary;
@apply cursor-pointer;
&:last-child {
border-radius: 0 0 0 8px;
}
&:hover,
&.active {
@apply bg-primaryDark;
@apply text-secondaryDark;
@apply cursor-pointer;
}
}
}
}
</style>

View file

@ -10,103 +10,7 @@
:icon="IconBookOpen"
:label="`${t('tab.documentation')}`"
>
<HoppSmartPlaceholder
v-if="
queryFields.length === 0 &&
mutationFields.length === 0 &&
subscriptionFields.length === 0 &&
graphqlTypes.length === 0
"
:src="`/images/states/${colorMode.value}/add_comment.svg`"
:alt="`${t('empty.documentation')}`"
:text="t('empty.documentation')"
/>
<div v-else>
<div
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
>
<input
v-model="graphqlFieldsFilterText"
type="search"
autocomplete="off"
class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="`${t('action.search')}`"
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/protocols/graphql"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</div>
</div>
<HoppSmartTabs
v-model="selectedGqlTab"
styles="border-t border-b border-dividerLight bg-primary sticky overflow-x-auto flex-shrink-0 z-10 top-sidebarPrimaryStickyFold"
render-inactive-tabs
>
<HoppSmartTab
v-if="queryFields.length > 0"
:id="'queries'"
:label="`${t('tab.queries')}`"
class="divide-y divide-dividerLight"
>
<GraphqlField
v-for="(field, index) in filteredQueryFields"
:key="`field-${index}`"
:gql-field="field"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
v-if="mutationFields.length > 0"
:id="'mutations'"
:label="`${t('graphql.mutations')}`"
class="divide-y divide-dividerLight"
>
<GraphqlField
v-for="(field, index) in filteredMutationFields"
:key="`field-${index}`"
:gql-field="field"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
v-if="subscriptionFields.length > 0"
:id="'subscriptions'"
:label="`${t('graphql.subscriptions')}`"
class="divide-y divide-dividerLight"
>
<GraphqlField
v-for="(field, index) in filteredSubscriptionFields"
:key="`field-${index}`"
:gql-field="field"
class="p-4"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
<HoppSmartTab
v-if="graphqlTypes.length > 0"
:id="'types'"
:label="`${t('tab.types')}`"
class="divide-y divide-dividerLight"
>
<GraphqlType
v-for="(type, index) in filteredGraphqlTypes"
:key="`type-${index}`"
:gql-type="type"
:gql-types="graphqlTypes"
:is-highlighted="isGqlTypeHighlighted(type)"
:highlighted-fields="getGqlTypeHighlightedFields(type)"
@jump-to-type="handleJumpToType"
/>
</HoppSmartTab>
</HoppSmartTabs>
</div>
<GraphqlDocExplorer />
</HoppSmartTab>
<HoppSmartTab :id="'schema'" :icon="IconBox" :label="`${t('tab.schema')}`">
<div
@ -175,91 +79,34 @@
</template>
<script setup lang="ts">
import IconFolder from "~icons/lucide/folder"
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { refAutoReset } from "@vueuse/core"
import { reactive, ref } from "vue"
import { useNestedSetting } from "~/composables/settings"
import { schemaString } from "~/helpers/graphql/connection"
import { toggleNestedSetting } from "~/newstore/settings"
import { platform } from "~/platform"
import IconBookOpen from "~icons/lucide/book-open"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconWrapText from "~icons/lucide/wrap-text"
import IconDownload from "~icons/lucide/download"
import IconBox from "~icons/lucide/box"
import IconCheck from "~icons/lucide/check"
import IconClock from "~icons/lucide/clock"
import IconCopy from "~icons/lucide/copy"
import IconBox from "~icons/lucide/box"
import { computed, nextTick, reactive, ref } from "vue"
import { GraphQLField, GraphQLType, getNamedType } from "graphql"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "@composables/codemirror"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import {
graphqlTypes,
mutationFields,
queryFields,
schemaString,
subscriptionFields,
} from "~/helpers/graphql/connection"
import { platform } from "~/platform"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import IconDownload from "~icons/lucide/download"
import IconFolder from "~icons/lucide/folder"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconWrapText from "~icons/lucide/wrap-text"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
const selectedNavigationTab = ref<NavigationTabs>("docs")
const selectedGqlTab = ref<GqlTabs>("queries")
const t = useI18n()
const colorMode = useColorMode()
function isTextFoundInGraphqlFieldObject(
text: string,
field: GraphQLField<any, any>
) {
const normalizedText = text.toLowerCase()
const isFilterTextFoundInDescription = field.description
? field.description.toLowerCase().includes(normalizedText)
: false
const isFilterTextFoundInName = field.name
.toLowerCase()
.includes(normalizedText)
return isFilterTextFoundInDescription || isFilterTextFoundInName
}
function getFilteredGraphqlFields(
filterText: string,
fields: GraphQLField<any, any>[]
) {
if (!filterText) return fields
return fields.filter((field) =>
isTextFoundInGraphqlFieldObject(filterText, field)
)
}
function getFilteredGraphqlTypes(filterText: string, types: GraphQLType[]) {
if (!filterText) return types
return types.filter((type) => {
const isFilterTextMatching = isTextFoundInGraphqlFieldObject(
filterText,
type as any
)
if (isFilterTextMatching) {
return true
}
const isFilterTextMatchingAtLeastOneField = Object.values(
(type as any)._fields || {}
).some((field) => isTextFoundInGraphqlFieldObject(filterText, field as any))
return isFilterTextMatchingAtLeastOneField
})
}
const toast = useToast()
const downloadSchemaIcon = refAutoReset<typeof IconDownload | typeof IconCheck>(
@ -271,83 +118,6 @@ const copySchemaIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
1000
)
const graphqlFieldsFilterText = ref("")
const filteredQueryFields = computed(() => {
return getFilteredGraphqlFields(
graphqlFieldsFilterText.value,
queryFields.value as any
)
})
const filteredMutationFields = computed(() => {
return getFilteredGraphqlFields(
graphqlFieldsFilterText.value,
mutationFields.value as any
)
})
const filteredSubscriptionFields = computed(() => {
return getFilteredGraphqlFields(
graphqlFieldsFilterText.value,
subscriptionFields.value as any
)
})
const filteredGraphqlTypes = computed(() => {
return getFilteredGraphqlTypes(
graphqlFieldsFilterText.value,
graphqlTypes.value as any
)
})
const isGqlTypeHighlighted = (gqlType: GraphQLType) => {
if (!graphqlFieldsFilterText.value) return false
return isTextFoundInGraphqlFieldObject(
graphqlFieldsFilterText.value,
gqlType as any
)
}
const getGqlTypeHighlightedFields = (gqlType: GraphQLType) => {
if (!graphqlFieldsFilterText.value) return []
const fields = Object.values((gqlType as any)._fields || {})
if (!fields || fields.length === 0) return []
return fields.filter((field) =>
isTextFoundInGraphqlFieldObject(graphqlFieldsFilterText.value, field as any)
)
}
const handleJumpToType = async (type: GraphQLType) => {
selectedGqlTab.value = "types"
await nextTick()
const rootTypeName = getNamedType(type).name
const target = document.getElementById(`type_${rootTypeName}`)
if (target) {
target.scrollIntoView({ block: "center", behavior: "smooth" })
target.classList.add(
"transition-all",
"ring-inset",
"ring-accentLight",
"ring-4"
)
setTimeout(
() =>
target.classList.remove(
"ring-inset",
"ring-accentLight",
"ring-4",
"transition-all"
),
2000
)
}
}
const schemaEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlSchema")

View file

@ -0,0 +1,28 @@
<template>
<div v-if="isNamedType(type)" class="px-3">
<AppMarkdown v-if="type.description" type="description">
{{ type.description }}
</AppMarkdown>
<GraphqlImplementsInterfaces :type="type" />
<GraphqlFields
:type="type"
:insert-query="false"
:show-add-field="isShowAddField"
/>
<GraphqlEnumValues :type="type" />
<GraphqlPossibleTypes :type="type" />
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue"
import { GraphQLNamedType, isNamedType } from "graphql"
const props = defineProps<{
type: GraphQLNamedType
}>()
const isShowAddField = ["Query", "Mutation", "Subscription"].includes(
props.type.name
)
</script>

View file

@ -1,31 +1,42 @@
<template>
<span
:class="isScalar ? 'text-secondaryLight' : 'cursor-pointer text-accent'"
@click="jumpToType"
>
{{ typeString }}
</span>
<template v-if="type">
<component :is="renderedComponent" />
</template>
</template>
<script setup lang="ts">
import { GraphQLScalarType, GraphQLType, getNamedType } from "graphql"
import { computed } from "vue"
import { GraphQLNamedType, GraphQLType } from "graphql"
import { computed, h } from "vue"
import { renderType, useExplorer } from "~/helpers/graphql/explorer"
const props = defineProps<{
gqlType: GraphQLType
type: GraphQLType
clickable?: boolean
}>()
const emit = defineEmits<{
(e: "jump-to-type", type: GraphQLType): void
}>()
const { push } = useExplorer()
const typeString = computed(() => `${props.gqlType}`)
const isScalar = computed(() => {
return getNamedType(props.gqlType) instanceof GraphQLScalarType
})
function jumpToType() {
if (isScalar.value) return
emit("jump-to-type", props.gqlType)
const handleTypeClick = (event: MouseEvent, namedType: GraphQLNamedType) => {
event.preventDefault()
push({ name: namedType.name, def: namedType })
}
/**
* Generates the rendered HTML for the GraphQL type.
*/
const renderedComponent = computed(() => {
if (!props.type) return ""
return renderType(props.type, (namedType: GraphQLNamedType) => {
return h(
"span",
{
class: "hopp-doc-explorer-type-name",
onClick: (event: MouseEvent) =>
props.clickable ? handleTypeClick(event, namedType) : undefined,
},
namedType.name
)
})
})
</script>

View file

@ -68,7 +68,7 @@
</div>
</div>
<div class="h-full relative">
<div ref="variableEditor" class="flex flex-1 flex-col"></div>
<div ref="variableEditor" class="flex flex-1 flex-col h-full"></div>
</div>
</template>

View file

@ -532,7 +532,8 @@ export function useCodemirror(
cachedCursor.value.ch !== newPos.ch
) {
const line = view.value.state.doc.line(newPos.line + 1)
const selUpdate = EditorSelection.cursor(line.from + newPos.ch - 1)
const ch = newPos.ch === -1 ? line.length : newPos.ch
const selUpdate = EditorSelection.cursor(line.from + ch)
view.value?.focus()

View file

@ -207,6 +207,7 @@ export const disconnect = () => {
clearTimeout(timeoutSubscription)
connection.state = "DISCONNECTED"
connection.schema = null
}
export const reset = () => {

View file

@ -53,6 +53,11 @@ export type HoppGQLDocument = {
*/
isDirty: boolean
/**
* The cursor position in the document
*/
cursorPosition: number
/**
* Info about where this request should be saved.
* This contains where the request is originated from basically.

View file

@ -0,0 +1,224 @@
import { ref, computed, h, VNode } from "vue"
import type {
GraphQLSchema,
GraphQLNamedType,
GraphQLField,
GraphQLInputField,
GraphQLArgument,
GraphQLType,
} from "graphql"
import {
isNamedType,
isObjectType,
isInputObjectType,
isScalarType,
isEnumType,
isInterfaceType,
isUnionType,
isNonNullType,
isListType,
} from "graphql"
/**
* Represents a field definition in the GraphQL explorer
* Can be a field, input field, or argument
*/
export type ExplorerFieldDef =
| GraphQLField<unknown, unknown, unknown>
| GraphQLInputField
| GraphQLArgument
/**
* Represents a single item in the explorer navigation stack
*/
export type ExplorerNavStackItem = {
name: string
def?: GraphQLNamedType | ExplorerFieldDef
}
/**
* Represents the complete navigation stack for the explorer
* Must contain at least one item
*/
export type ExplorerNavStack = [ExplorerNavStackItem, ...ExplorerNavStackItem[]]
const initialNavStackItem: ExplorerNavStackItem = { name: "Root" }
const navStack = ref<ExplorerNavStack>([initialNavStackItem])
const schema = ref<GraphQLSchema | null>()
const validationErrors = ref<any[]>([])
/**
* Hook to manage the GraphQL schema explorer state and navigation
* @param initialSchema - Optional initial GraphQL schema
* @returns Object containing explorer state and methods
*/
export function useExplorer(initialSchema?: GraphQLSchema) {
schema.value = initialSchema ?? null
const currentNavItem = computed(() => {
const lastItem = navStack.value[navStack.value.length - 1]
return lastItem
})
/**
* Adds a new item to the navigation stack
* @param item - The navigation stack item to add
*/
function push(item: ExplorerNavStackItem) {
const lastItem = navStack.value[navStack.value.length - 1]
// Avoid pushing duplicate items
if (lastItem.def === item.def) return
navStack.value.push(item)
}
/**
* Removes the last item from the navigation stack
* Won't remove the root item
*/
function pop() {
if (navStack.value.length > 1) {
navStack.value.pop()
}
}
/**
* Resets the navigation stack to initial state
*/
function reset() {
navStack.value =
navStack.value.length === 1 ? navStack.value : [initialNavStackItem]
}
/**
* Updates the schema and validation errors
* Rebuilds the navigation stack if needed
* @param newSchema - The new GraphQL schema
* @param newValidationErrors - Array of validation errors
*/
function updateSchema(
newSchema: GraphQLSchema,
newValidationErrors: any[] = []
) {
schema.value = newSchema
validationErrors.value = newValidationErrors
// If schema is invalid, reset navigation
if (!newSchema || newValidationErrors.length > 0) {
reset()
return
}
// Rebuild navigation stack with new schema
rebuildNavStack()
}
/**
* Navigates to a specific index in the navigation stack
* @param index - Target index to navigate to
*/
const navigateToIndex = (index: number) => {
while (navStack.value.length > index + 1) {
pop()
}
}
/**
* Rebuilds the navigation stack based on the current schema
* Used when schema is updated to maintain valid navigation
*/
function rebuildNavStack() {
if (!schema.value) return
const newNavStack: ExplorerNavStack = [initialNavStackItem]
let lastEntity: GraphQLNamedType | GraphQLField<any, any, any> | null = null
for (const item of navStack.value.slice(1)) {
if (item.def) {
if (isNamedType(item.def)) {
const newType = schema.value.getType(item.def.name)
if (newType) {
newNavStack.push({
name: item.name,
def: newType,
})
lastEntity = newType
} else {
break
}
} else if (lastEntity === null) {
break
} else if (isObjectType(lastEntity) || isInputObjectType(lastEntity)) {
const field = lastEntity.getFields()[item.name]
if (field) {
newNavStack.push({
name: item.name,
def: field,
})
} else {
break
}
} else if (
isScalarType(lastEntity) ||
isEnumType(lastEntity) ||
isInterfaceType(lastEntity) ||
isUnionType(lastEntity)
) {
break
} else {
const field: GraphQLField<any, any> = lastEntity
const arg = field.args.find((a) => a.name === item.name)
if (arg) {
newNavStack.push({
name: item.name,
def: field,
})
} else {
break
}
}
} else {
lastEntity = null
newNavStack.push(item)
}
}
navStack.value = newNavStack
}
return {
navStack,
currentNavItem,
schema,
validationErrors,
push,
pop,
navigateToIndex,
reset,
updateSchema,
}
}
/**
* Recursively renders a GraphQL type as a Vue virtual node
* Handles non-null types, list types, and named types
*
* @param type - The GraphQL type to render
* @param renderNamedType - Function to render named types
* @returns VNode representing the rendered type
*/
export function renderType(
type: GraphQLType,
renderNamedType: (namedType: GraphQLNamedType) => any
): VNode {
if (isNonNullType(type)) {
return h("span", {}, [renderType(type.ofType, renderNamedType), "!"])
}
if (isListType(type)) {
return h("span", {}, ["[", renderType(type.ofType, renderNamedType), "]"])
}
return renderNamedType(type as GraphQLNamedType)
}

View file

@ -0,0 +1,418 @@
import { useService } from "dioc/vue"
import {
ArgumentNode,
DocumentNode,
FieldNode,
getNamedType,
GraphQLArgument,
GraphQLType,
Kind,
OperationDefinitionNode,
OperationTypeNode,
print,
} from "graphql"
import { ref } from "vue"
import { GQLTabService } from "~/services/tab/graphql"
import { ExplorerFieldDef, ExplorerNavStackItem, useExplorer } from "./explorer"
/**
* Makes all properties in type T mutable
*/
type Mutable<T> = {
-readonly [K in keyof T]: T[K]
}
const updatedQuery = ref("")
const cursorPosition = ref({ line: 0, ch: 1 })
const operations = ref<OperationDefinitionNode[]>([])
/**
* Hook to manage GraphQL query operations and mutations
* Provides functionality for building and modifying GraphQL operations
*/
export function useQuery() {
const tabs = useService(GQLTabService)
const { navStack } = useExplorer()
/**
* Returns default value for a GraphQL type
* @param type - GraphQL type to get default value for
*/
const getDefaultArgumentValue = (type: GraphQLType): string => {
const namedType = getNamedType(type)
const defaultValues: Record<string, string> = {
String: "",
Int: "0",
Float: "0.0",
Boolean: "false",
}
return defaultValues[namedType.name] || "null"
}
/**
* Maps operation name to GraphQL operation type
* @param name - Operation name to convert
*/
const getOperationTypeNode = (name: string): OperationTypeNode => {
const operationTypes: Record<string, OperationTypeNode> = {
Query: OperationTypeNode.QUERY,
Mutation: OperationTypeNode.MUTATION,
Subscription: OperationTypeNode.SUBSCRIPTION,
}
return operationTypes[name] || OperationTypeNode.QUERY
}
/**
* Finds operation definition node at given cursor position
* @param cursorPosition - Position to find operation at
*/
const getOperation = (cursorPosition: number) => {
return operations.value.find(
({ loc }) =>
loc && cursorPosition >= loc.start && cursorPosition <= loc.end
)
}
/**
* Creates an ArgumentNode with default value
* @param argName - Name of the argument
* @param type - GraphQL type of the argument
*/
const createArgumentNode = (
argName: string,
type: GraphQLType
): ArgumentNode => ({
kind: Kind.ARGUMENT,
name: { kind: Kind.NAME, value: argName },
value: { kind: Kind.STRING, value: getDefaultArgumentValue(type) },
})
/**
* Creates a FieldNode with optional arguments and nested fields
* @param name - Field name
* @param args - Field arguments
* @param hasNestedFields - Whether field has nested selections
*/
const createFieldNode = (
name: string,
args: GraphQLArgument[] | undefined,
hasNestedFields = false
): Mutable<FieldNode> => ({
kind: Kind.FIELD,
name: { kind: Kind.NAME, value: name },
arguments: args?.map((arg) => createArgumentNode(arg.name, arg.type)) || [],
directives: [],
selectionSet: hasNestedFields
? { kind: Kind.SELECTION_SET, selections: [] }
: undefined,
})
/**
* Result of processing an operation, including document and field location
*/
type OperationResult = {
append?: boolean
document: DocumentNode | null
fieldLocation?: {
start: number
end: number
}
}
/**
* Processes GraphQL operation based on navigation stack
* Handles merging with existing operations and field/argument modifications
*
* @param navItems - Navigation stack items
* @param existingOperation - Existing operation to modify (if any)
* @param isArgument - Whether processing an argument
*/
const processOperation = (
navItems: ExplorerNavStackItem[],
existingOperation?: OperationDefinitionNode,
isArgument = false
): OperationResult => {
const queryPath = navItems.slice(2, isArgument ? -1 : undefined)
const argumentItem = isArgument ? navItems[navItems.length - 1] : null
const lastItem = queryPath[queryPath.length - 1]
const requestedOperationType = getOperationTypeNode(navItems[1].name)
// Handle new operations
if (
!existingOperation ||
existingOperation.operation !== requestedOperationType
) {
// Build from bottom up starting with the last field
let currentSelection = createFieldNode(
lastItem.name,
lastItem.def?.args,
lastItem.def?.fields?.length > 0
)
for (let i = queryPath.length - 2; i >= 0; i--) {
const item = queryPath[i]
const parentField = createFieldNode(item.name, item.def?.args, true)
parentField.selectionSet!.selections = [currentSelection]
currentSelection = parentField
}
return {
document: {
kind: Kind.DOCUMENT,
definitions: [
{
kind: Kind.OPERATION_DEFINITION,
operation: requestedOperationType,
name: { kind: Kind.NAME, value: queryPath[0].name },
variableDefinitions: [],
directives: [],
selectionSet: {
kind: Kind.SELECTION_SET,
selections: [currentSelection],
},
},
],
},
}
}
// For existing operations
let currentSelectionSet = existingOperation.selectionSet
let fieldExists = false
let fieldLocation: { start: number; end: number } | undefined
let append = false
if (
requestedOperationType === OperationTypeNode.SUBSCRIPTION &&
requestedOperationType === existingOperation?.operation
) {
// Check if paths are different at the top level
const existingTopField = existingOperation.selectionSet
.selections[0] as FieldNode
if (existingTopField.name.value !== queryPath[0].name) {
append = true
currentSelectionSet.selections = []
}
}
for (let i = 0; i < queryPath.length; i++) {
const item = queryPath[i]
const isLastItem = i === queryPath.length - 1
const existingFieldIndex = currentSelectionSet.selections.findIndex(
(selection): selection is FieldNode =>
selection.kind === Kind.FIELD && selection.name.value === item.name
)
if (existingFieldIndex !== -1) {
const existingField = currentSelectionSet.selections[
existingFieldIndex
] as Mutable<FieldNode>
if (isLastItem) {
if (isArgument && argumentItem) {
// Handle argument modifications
const argIndex =
existingField.arguments?.findIndex(
(arg) => arg.name.value === argumentItem.name
) ?? -1
if (argIndex !== -1) {
existingField.arguments?.splice(argIndex, 1)
} else {
const newArg = createArgumentNode(
argumentItem.name,
(argumentItem.def as any)?.type
)
existingField.arguments = existingField.arguments || []
existingField.arguments.push(newArg)
}
} else {
// Remove the field if it's not an argument operation
currentSelectionSet.selections.splice(existingFieldIndex, 1)
fieldExists = true
}
if (existingField.loc) {
fieldLocation = {
start: existingField.loc.start,
end: existingField.loc.end,
}
}
break
}
// Ensure parent has a selection set
if (!existingField.selectionSet) {
existingField.selectionSet = {
kind: Kind.SELECTION_SET,
selections: [],
}
}
// Move to the next level
currentSelectionSet = existingField.selectionSet ?? {
kind: Kind.SELECTION_SET,
selections: [],
}
} else {
const newField = createFieldNode(
item.name,
(item.def as any)?.args, // these type assertion is avoidable
!isLastItem || (isLastItem && (item.def as any)?.fields?.length > 0)
)
// Store the approximate location where field will be added
if (currentSelectionSet.loc) {
fieldLocation = {
start: currentSelectionSet.loc.end - 1,
end: currentSelectionSet.loc.end - 1,
}
}
if (!isLastItem) {
// Ensure non-leaf nodes have a selection set
newField.selectionSet = {
kind: Kind.SELECTION_SET,
selections: [],
}
}
currentSelectionSet.selections.push(newField)
if (!isLastItem) {
// Move to the next level
currentSelectionSet = newField.selectionSet!
}
}
}
return {
document:
existingOperation.selectionSet.selections.length === 0
? null
: {
kind: Kind.DOCUMENT,
definitions: [existingOperation],
},
fieldLocation,
append,
}
}
/**
* Handles operation modifications for fields and arguments
* Updates query and cursor position based on changes
*
* @param item - Field or argument to process
* @param isArgument - Whether item is an argument
*/
const handleOperation = (item: ExplorerFieldDef, isArgument = false) => {
const currentTab = tabs.currentActiveTab.value
if (!currentTab) return
const currentQuery = currentTab.document.request.query || ""
const selectedOperation = getOperation(currentTab.document.cursorPosition)
const navItems = [...navStack.value, { name: item.name, def: item }]
const result = processOperation(
navItems as ExplorerNavStackItem[],
selectedOperation,
isArgument
)
const newQuery = result.document
? print(result.document.definitions[0])
: "\n"
// If operation type is different or no existing operation,
// append as a new operation
if (
!selectedOperation ||
selectedOperation.operation !== getOperationTypeNode(navItems[1].name) ||
result.append
) {
updatedQuery.value = currentQuery.trim()
? `${currentQuery}\n\n${newQuery}`
: newQuery
cursorPosition.value = {
line: currentQuery.split("\n").length + (currentQuery.trim() ? 2 : 1),
ch: -1,
}
return
}
// Replace existing operation if operation types match
updatedQuery.value = currentQuery.replace(
currentQuery.substring(
selectedOperation.loc!.start,
selectedOperation.loc!.end
),
newQuery
)
// Update cursor position to field location
if (result.fieldLocation) {
const precedingText = currentQuery.substring(
0,
result.fieldLocation.start
)
const lines = precedingText.split("\n")
cursorPosition.value = {
line: lines.length - 1,
ch: -1,
}
}
}
const isItemInOperation = (
item: ExplorerFieldDef,
isArgument = false
): boolean => {
const operation = getOperation(
tabs.currentActiveTab.value?.document.cursorPosition
)
if (!operation) return false
// Get the current navigation path (excluding root and operation type)
const navPath = navStack.value.slice(2)
// Start from the operation's selection set
let currentSelections = operation.selectionSet.selections
// Follow the navigation path
for (let i = 0; i < navPath.length; i++) {
const pathItem = navPath[i]
const foundField = currentSelections.find(
(selection) =>
selection.kind === Kind.FIELD &&
selection.name.value === pathItem.name
) as FieldNode | undefined
if (!foundField?.selectionSet) return false
currentSelections = foundField.selectionSet.selections
}
// Check based on type
return currentSelections.some((selection) => {
if (selection.kind !== Kind.FIELD) return false
return isArgument
? selection.arguments?.some(
(argNode) => argNode.name.value === item.name
)
: selection.name.value === item.name
})
}
return {
handleAddField: (field: ExplorerFieldDef) => handleOperation(field),
handleAddArgument: (arg: ExplorerFieldDef) => handleOperation(arg, true),
updatedQuery,
cursorPosition,
operationDefinitions: operations,
isFieldInOperation: (field: ExplorerFieldDef) => isItemInOperation(field),
isArgumentInOperation: (arg: ExplorerFieldDef) =>
isItemInOperation(arg, true),
}
}

View file

@ -8,7 +8,7 @@
v-if="currentTabID"
:id="'gql_windows'"
:model-value="currentTabID"
@update:model-value="(tabID) => tabs.setActiveTab(tabID)"
@update:model-value="changeTab"
@remove-tab="removeTab"
@add-tab="addNewTab"
@sort="sortTabs"
@ -92,12 +92,14 @@ import { defineActionHandler } from "~/helpers/actions"
import { connection, disconnect } from "~/helpers/graphql/connection"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { useExplorer } from "~/helpers/graphql/explorer"
import { InspectionService } from "~/services/inspection"
import { HoppTab } from "~/services/tab"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const tabs = useService(GQLTabService)
const { reset } = useExplorer()
const currentTabID = computed(() => tabs.currentTabID.value)
@ -115,6 +117,7 @@ const addNewTab = () => {
const tab = tabs.createNewTab({
request: getDefaultGQLRequest(),
isDirty: false,
cursorPosition: 0,
})
tabs.setActiveTab(tab.id)
@ -122,6 +125,10 @@ const addNewTab = () => {
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
tabs.updateTabOrdering(e.oldIndex, e.newIndex)
}
const changeTab = (tabID: string) => {
reset()
tabs.setActiveTab(tabID)
}
const removeTab = (tabID: string) => {
const tabState = tabs.getTabRef(tabID).value
@ -211,6 +218,7 @@ const duplicateTab = (tabID: string) => {
const newTab = tabs.createNewTab({
request: tab.value.document.request,
isDirty: true,
cursorPosition: 0,
})
tabs.setActiveTab(newTab.id)
}
@ -221,6 +229,7 @@ defineActionHandler("gql.request.open", ({ request, saveContext }) => {
saveContext,
request: request,
isDirty: false,
cursorPosition: 0,
})
})

View file

@ -329,6 +329,7 @@ export const GQL_TAB_STATE_SCHEMA = z
responseTabPreference: z.optional(z.string()),
optionTabPreference: z.optional(z.enum(validGqlOperations)),
inheritedProperties: z.optional(HoppInheritedPropertySchema),
cursorPosition: z.optional(z.number()),
})
.strict(),
})

View file

@ -360,6 +360,7 @@ export class CollectionsSpotlightSearcherService
folderPath: folderPath.join("/"),
requestIndex: reqIndex,
},
cursorPosition: 0,
request: req,
isDirty: false,
inheritedProperties: {

View file

@ -19,6 +19,7 @@ export class GQLTabService extends TabService<HoppGQLDocument> {
request: getDefaultGQLRequest(),
isDirty: false,
optionTabPreference: "query",
cursorPosition: 0,
},
})

View file

@ -523,6 +523,9 @@ importers:
'@shopify/lang-jsonc':
specifier: 1.0.0
version: 1.0.0
'@types/markdown-it':
specifier: 14.1.2
version: 14.1.2
'@unhead/vue':
specifier: 1.11.10
version: 1.11.10(vue@3.5.12(typescript@5.3.3))
@ -601,6 +604,9 @@ importers:
lossless-json:
specifier: 4.0.2
version: 4.0.2
markdown-it:
specifier: 14.1.0
version: 14.1.0
minisearch:
specifier: 7.1.0
version: 7.1.0
@ -5113,6 +5119,9 @@ packages:
'@types/jsonwebtoken@9.0.6':
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash-es@4.17.10':
resolution: {integrity: sha512-YJP+w/2khSBwbUSFdGsSqmDvmnN3cCKoPOL7Zjle6s30ZtemkkqhjVfFqGwPN7ASil5VyjE2GtyU/yqYY6mC0A==}
@ -5131,6 +5140,12 @@ packages:
'@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@ -9121,12 +9136,19 @@ packages:
map-stream@0.0.7:
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@ -16918,6 +16940,8 @@ snapshots:
dependencies:
'@types/node': 22.7.6
'@types/linkify-it@5.0.0': {}
'@types/lodash-es@4.17.10':
dependencies:
'@types/lodash': 4.17.10
@ -16934,6 +16958,13 @@ snapshots:
'@types/luxon@3.4.2': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {}
@ -22167,7 +22198,6 @@ snapshots:
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
optional: true
lint-staged@15.2.10:
dependencies:
@ -22402,12 +22432,23 @@ snapshots:
map-stream@0.0.7: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
mdn-data@2.0.28:
optional: true
mdn-data@2.0.30:
optional: true
mdurl@2.0.0: {}
media-typer@0.3.0: {}
memfs@3.5.3:
@ -24084,8 +24125,7 @@ snapshots:
end-of-stream: 1.4.4
once: 1.4.0
punycode.js@2.3.1:
optional: true
punycode.js@2.3.1: {}
punycode@1.4.1: {}
@ -25598,8 +25638,7 @@ snapshots:
ua-parser-js@1.0.39: {}
uc.micro@2.1.0:
optional: true
uc.micro@2.1.0: {}
ufo@1.5.4: {}