-
+
+
- {{ fieldName }}
-
- (
-
- {{ field.name }}:
-
- ,
-
- )
+
+ (
+
+
+
+
+
+ ):
-
-
-
-
- {{ t("state.deprecated") }}
-
+
+
+
+
+
+
-
- {{ gqlField.description }}
-
-
-
Arguments:
-
-
-
- {{ field.name }}:
-
-
-
- {{ field.description }}
-
-
-
-
+ {{ field.description }}
+
-
diff --git a/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue
new file mode 100644
index 00000000..6bf18cad
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/FieldDocumentation.vue
@@ -0,0 +1,61 @@
+
+
+
+ {{ field.name }}:
+
+
+
+
+
+ {{ t("graphql.deprecated") }}
+
+
+ {{ deprecationReason }}
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/FieldLink.vue b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue
new file mode 100644
index 00000000..54934355
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/FieldLink.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+ {{ field.name }}
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/Fields.vue b/packages/hoppscotch-common/src/components/graphql/Fields.vue
new file mode 100644
index 00000000..c6c3f80d
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/Fields.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue
new file mode 100644
index 00000000..20c29663
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/ImplementsInterfaces.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/Query.vue b/packages/hoppscotch-common/src/components/graphql/Query.vue
index ea42bfb7..14d88b32 100644
--- a/packages/hoppscotch-common/src/components/graphql/Query.vue
+++ b/packages/hoppscotch-common/src/components/graphql/Query.vue
@@ -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
(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(
@@ -141,45 +143,60 @@ const prettifyQueryIcon = refAutoReset<
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlQuery")
-const selectedOperation = ref(null)
+const selectedOperation = ref(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 = () => {
diff --git a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue
index a224174f..cf5c5190 100644
--- a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue
+++ b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue
@@ -13,6 +13,7 @@
v-model="request.query"
@run-query="runQuery"
@save-request="saveRequest"
+ @cursor-position="updateCursorPos"
/>
{
+ tabs.currentActiveTab.value.document.cursorPosition = pos
+}
+
const hideRequestModal = () => {
showSaveRequestModal.value = false
}
diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue
new file mode 100644
index 00000000..26a74e0c
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/SchemaDocumentation.vue
@@ -0,0 +1,105 @@
+
+
+
+ {{ schemaDescription }}
+
+
+
+
+
+ {{ t("graphql.query") }}
+
+ {{ ": " }}
+
+
+
+
+ {{ t("graphql.mutation") }}
+
+ {{ ": " }}
+
+
+
+
+ {{ t("graphql.subscription") }}
+
+ {{ ": " }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue
new file mode 100644
index 00000000..c7207a18
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/graphql/SchemaSearch.vue
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+ -
+
+ {{ result.name }}
+ - {{ result.type }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/Sidebar.vue b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue
index 0bc34cd6..e31e0632 100644
--- a/packages/hoppscotch-common/src/components/graphql/Sidebar.vue
+++ b/packages/hoppscotch-common/src/components/graphql/Sidebar.vue
@@ -10,103 +10,7 @@
:icon="IconBookOpen"
:label="`${t('tab.documentation')}`"
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/packages/hoppscotch-common/src/components/graphql/TypeLink.vue b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue
index 8404dd04..f352b579 100644
--- a/packages/hoppscotch-common/src/components/graphql/TypeLink.vue
+++ b/packages/hoppscotch-common/src/components/graphql/TypeLink.vue
@@ -1,31 +1,42 @@
-
- {{ typeString }}
-
+
+
+
diff --git a/packages/hoppscotch-common/src/components/graphql/Variable.vue b/packages/hoppscotch-common/src/components/graphql/Variable.vue
index 0547edcd..71b5da07 100644
--- a/packages/hoppscotch-common/src/components/graphql/Variable.vue
+++ b/packages/hoppscotch-common/src/components/graphql/Variable.vue
@@ -68,7 +68,7 @@
diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts
index 6241a553..7aa5b1f4 100644
--- a/packages/hoppscotch-common/src/composables/codemirror.ts
+++ b/packages/hoppscotch-common/src/composables/codemirror.ts
@@ -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()
diff --git a/packages/hoppscotch-common/src/helpers/graphql/connection.ts b/packages/hoppscotch-common/src/helpers/graphql/connection.ts
index abf79730..7a963e53 100644
--- a/packages/hoppscotch-common/src/helpers/graphql/connection.ts
+++ b/packages/hoppscotch-common/src/helpers/graphql/connection.ts
@@ -207,6 +207,7 @@ export const disconnect = () => {
clearTimeout(timeoutSubscription)
connection.state = "DISCONNECTED"
+ connection.schema = null
}
export const reset = () => {
diff --git a/packages/hoppscotch-common/src/helpers/graphql/document.ts b/packages/hoppscotch-common/src/helpers/graphql/document.ts
index 15edda05..f501e069 100644
--- a/packages/hoppscotch-common/src/helpers/graphql/document.ts
+++ b/packages/hoppscotch-common/src/helpers/graphql/document.ts
@@ -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.
diff --git a/packages/hoppscotch-common/src/helpers/graphql/explorer.ts b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts
new file mode 100644
index 00000000..e453ddd5
--- /dev/null
+++ b/packages/hoppscotch-common/src/helpers/graphql/explorer.ts
@@ -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
+ | 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([initialNavStackItem])
+const schema = ref()
+const validationErrors = ref([])
+
+/**
+ * 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 | 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 = 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)
+}
diff --git a/packages/hoppscotch-common/src/helpers/graphql/query.ts b/packages/hoppscotch-common/src/helpers/graphql/query.ts
new file mode 100644
index 00000000..302ba9d4
--- /dev/null
+++ b/packages/hoppscotch-common/src/helpers/graphql/query.ts
@@ -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 = {
+ -readonly [K in keyof T]: T[K]
+}
+
+const updatedQuery = ref("")
+const cursorPosition = ref({ line: 0, ch: 1 })
+const operations = ref([])
+
+/**
+ * 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: "",
+ 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 = {
+ 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 => ({
+ 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
+
+ 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),
+ }
+}
diff --git a/packages/hoppscotch-common/src/pages/graphql.vue b/packages/hoppscotch-common/src/pages/graphql.vue
index 8bdffb80..05f033c6 100644
--- a/packages/hoppscotch-common/src/pages/graphql.vue
+++ b/packages/hoppscotch-common/src/pages/graphql.vue
@@ -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,
})
})
diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
index b6faa3e8..6a9e1382 100644
--- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
+++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts
@@ -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(),
})
diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts
index cb195b0b..048d2e31 100644
--- a/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts
+++ b/packages/hoppscotch-common/src/services/spotlight/searchers/collections.searcher.ts
@@ -360,6 +360,7 @@ export class CollectionsSpotlightSearcherService
folderPath: folderPath.join("/"),
requestIndex: reqIndex,
},
+ cursorPosition: 0,
request: req,
isDirty: false,
inheritedProperties: {
diff --git a/packages/hoppscotch-common/src/services/tab/graphql.ts b/packages/hoppscotch-common/src/services/tab/graphql.ts
index afaa257a..c88d619f 100644
--- a/packages/hoppscotch-common/src/services/tab/graphql.ts
+++ b/packages/hoppscotch-common/src/services/tab/graphql.ts
@@ -19,6 +19,7 @@ export class GQLTabService extends TabService {
request: getDefaultGQLRequest(),
isDirty: false,
optionTabPreference: "query",
+ cursorPosition: 0,
},
})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d162804f..297850b0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}