Fix lint errors and show docs explorer on Cmd click

This commit is contained in:
Gregory Schier
2025-07-14 14:52:16 -07:00
parent 6f1fd7a254
commit 0c60d190af
48 changed files with 454 additions and 199 deletions

View File

@@ -3,11 +3,11 @@ import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react';
import React from 'react';
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
import { useCurrentGraphQLSchema } from '../hooks/useIntrospectGraphQL';
import type { SlotProps } from './core/SplitLayout';
import { SplitLayout } from './core/SplitLayout';
import { GraphQLDocsExplorer } from './GraphQLDocsExplorer';
import { GraphQLDocsExplorer } from './graphql/GraphQLDocsExplorer';
import { showGraphQLDocExplorerAtom } from './graphql/graphqlAtoms';
import { HttpRequestPane } from './HttpRequestPane';
import { HttpResponsePane } from './HttpResponsePane';
@@ -17,7 +17,6 @@ interface Props {
}
export function HttpRequestLayout({ activeRequest, style }: Props) {
const { bodyType } = activeRequest;
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
@@ -39,11 +38,11 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
/>
);
if (bodyType === 'graphql' && showGraphQLDocExplorer && graphQLSchema != null) {
if (activeRequest.bodyType === 'graphql' && showGraphQLDocExplorer && graphQLSchema != null) {
return (
<SplitLayout
name="graphql_layout"
defaultRatio={1/3}
defaultRatio={1 / 3}
firstSlot={requestResponseSplit}
secondSlot={({ style, orientation }) => (
<GraphQLDocsExplorer

View File

@@ -47,13 +47,13 @@ import type { TabItem } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { RequestMethodDropdown } from './RequestMethodDropdown';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
import { GraphQLEditor } from './graphql/GraphQLEditor';
interface Props {
style: CSSProperties;

View File

@@ -6,7 +6,9 @@ import {
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import {
codeFolding,
@@ -35,15 +37,15 @@ import {
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { graphql } from 'cm6-graphql';
import { renderMarkdown } from '../../../lib/markdown';
import { pluralizeCount } from '../../../lib/pluralize';
import { showInGraphQLDocsExplorer } from '../../graphql/useGraphQLDocsExplorer';
import type { EditorProps } from './Editor';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import { pairs } from './pairs/extension';
import { url } from './url/extension';
export const syntaxHighlightStyle = HighlightStyle.define([
@@ -123,7 +125,22 @@ export function getLanguageExtension({
// GraphQL is a special exception
if (language === 'graphql') {
return [graphql(), extraExtensions];
return [
graphql(undefined, {
async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {
if (!gqlCompletionItem.documentation) return null;
const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);
const span = document.createElement('span');
span.innerHTML = innerHTML;
return span;
},
onShowInDocs(field, type, parentType) {
console.log("SHOW IN DOCS", field);
showInGraphQLDocsExplorer(field, type, parentType).catch(console.error);
},
}),
extraExtensions,
];
}
const base_ = syntaxExtensions[language ?? 'text'] ?? text();
@@ -229,7 +246,6 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
}
},
}),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
rectangularSelection(),
crosshairCursor(),

View File

@@ -64,7 +64,7 @@ export function SplitLayout({
}
const size = useContainerSize(containerRef);
const verticalBasedOnSize = size.width < STACK_VERTICAL_WIDTH;
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {
@@ -142,31 +142,25 @@ export function SplitLayout({
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);
const containerQueryReady = size.width > 0 || size.height > 0;
return (
<div
ref={containerRef}
style={styles}
className={classNames(className, 'grid w-full h-full overflow-hidden')}
>
{containerQueryReady && (
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
{secondSlot && (
<>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}
{secondSlot && (
<>
<ResizeHandle
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? '-translate-y-1.5' : '-translate-x-1.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
</>
)}
<ResizeHandle
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? '-translate-y-1.5' : '-translate-x-1.5')}
onResizeStart={handleResizeStart}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"
/>
{secondSlot({ style: areaR, orientation: vertical ? 'vertical' : 'horizontal' })}
</>
)}
</div>

View File

@@ -23,17 +23,18 @@ import {
} from 'graphql';
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
import { useClickOutside } from '../hooks/useClickOutside';
import { useContainerSize } from '../hooks/useContainerQuery';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { jotaiStore } from '../lib/jotai';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { PlainInput } from './core/PlainInput';
import { Markdown } from './Markdown';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useContainerSize } from '../../hooks/useContainerQuery';
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { jotaiStore } from '../../lib/jotai';
import { CountBadge } from '../core/CountBadge';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { PlainInput } from '../core/PlainInput';
import { Markdown } from '../Markdown';
import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
import { useGraphQLDocsExplorerEvent } from './useGraphQLDocsExplorer';
interface Props {
style?: CSSProperties;
@@ -58,6 +59,17 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
const mutType = schema.getMutationType();
const subType = schema.getSubscriptionType();
useGraphQLDocsExplorerEvent('gql_docs_explorer.show_in_docs', ({ field }) => {
walkTypeGraph(schema, null, (t) => {
if (t.name === field) {
setActiveItem(toExplorerItem(t, null));
return false;
} else {
return true;
}
});
});
const qryItem: ExplorerItem = qryType ? { kind: 'type', type: qryType, from: null } : null;
const mutItem: ExplorerItem = mutType ? { kind: 'type', type: mutType, from: null } : null;
const subItem: ExplorerItem = subType ? { kind: 'type', type: subType, from: null } : null;
@@ -642,22 +654,20 @@ function GqlSchemaSearch({
const results = useMemo(() => {
const results: SearchResult[] = [];
walkTypeGraph(
currentItem?.type ?? null,
(type, from, depth) => {
if (type === currentItem?.type) {
return null; // Remove the current type from results
}
walkTypeGraph(schema, currentItem?.type ?? null, (type, from, depth) => {
if (type === currentItem?.type) {
return true; // Skip the current type and continue
}
const match = fuzzyMatch(type.name, debouncedValue);
if (match == null) {
// Do nothing
} else {
results.push({ name: type.name, type, score: match.score, from, depth });
}
},
schema,
);
const match = fuzzyMatch(type.name, debouncedValue);
if (match == null) {
// Do nothing
} else {
results.push({ name: type.name, type, score: match.score, from, depth });
}
return true; // Continue searching
});
results.sort((a, b) => {
if (value == '') {
if (a.name.startsWith('_') && !b.name.startsWith('_')) {
@@ -831,13 +841,13 @@ function DocMarkdown({ children, className }: { children: string | null; classNa
}
function walkTypeGraph(
schema: GraphQLSchema,
start: GraphQLType | GraphQLField<any, any> | GraphQLInputField | null,
cb: (
type: GraphQLNamedType | GraphQLField<any, any> | GraphQLInputField,
from: GraphQLNamedType | null,
path: string[],
) => void,
schema: GraphQLSchema,
) => boolean,
) {
const visited = new Set<string>();
const queue: Array<{
@@ -867,7 +877,8 @@ function walkTypeGraph(
if (visited.has(name)) continue;
visited.add(name);
cb(current, from, path);
const cont = cb(current, from, path);
if (!cont) break;
if (isObjectType(current) || isInterfaceType(current)) {
for (const field of Object.values(current.getFields())) {

View File

@@ -6,19 +6,19 @@ import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai';
import { useEffect, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
import { useIntrospectGraphQL } from '../hooks/useIntrospectGraphQL';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { showDialog } from '../lib/dialog';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import type { EditorProps } from './core/Editor/Editor';
import { Editor } from './core/Editor/Editor';
import { FormattedError } from './core/FormattedError';
import { Icon } from './core/Icon';
import { Separator } from './core/Separator';
import { useIntrospectGraphQL } from '../../hooks/useIntrospectGraphQL';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import { showDialog } from '../../lib/dialog';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import type { DropdownItem } from '../core/Dropdown';
import { Dropdown } from '../core/Dropdown';
import type { EditorProps } from '../core/Editor/Editor';
import { Editor } from '../core/Editor/Editor';
import { FormattedError } from '../core/FormattedError';
import { Icon } from '../core/Icon';
import { Separator } from '../core/Separator';
import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
baseRequest: HttpRequest;

View File

@@ -1,3 +1,3 @@
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
export const showGraphQLDocExplorerAtom = atomWithKVStorage<boolean>('show_graphql_docs', false);

View File

@@ -0,0 +1,63 @@
import EventEmitter from 'eventemitter3';
import type { DependencyList } from 'react';
import { useEffect } from 'react';
import { jotaiStore } from '../../lib/jotai';
import { sleep } from '../../lib/sleep';
import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
type EventDataMap = {
'gql_docs_explorer.show_in_docs': { field?: string; type?: string; parentType?: string };
'gql_docs_explorer.focus_tab': undefined;
};
export function useGraphQLDocsExplorerEvent<
Event extends keyof EventDataMap,
Data extends EventDataMap[Event],
>(event: Event, fn: (data: Data) => void, deps?: DependencyList) {
useEffect(() => {
emitter.on(event, fn);
return () => {
emitter.off(event, fn);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
export async function showInGraphQLDocsExplorer(
field: string | undefined,
type: string | undefined,
parentType: string | undefined,
) {
const isVisible = jotaiStore.get(showGraphQLDocExplorerAtom);
if (!isVisible) {
// Show and give some time for the explorer to start listening for events
jotaiStore.set(showGraphQLDocExplorerAtom, true);
await sleep(100);
}
emitter.emit('gql_docs_explorer.show_in_docs', { field, type, parentType });
}
const emitter = new (class GraphQLDocsExplorerEventEmitter {
#emitter: EventEmitter = new EventEmitter();
emit<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
data: Data,
) {
this.#emitter.emit(event, data);
}
on<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.on(event, fn);
}
off<Event extends keyof EventDataMap, Data extends EventDataMap[Event]>(
event: Event,
fn: (data: Data) => void,
) {
this.#emitter.off(event, fn);
}
})();

27
src-web/lib/markdown.ts Normal file
View File

@@ -0,0 +1,27 @@
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
const renderer = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, {
// handlers: {
// link: (state, node, parent) => {
// return node;
// },
// },
})
.use(rehypeStringify);
export async function renderMarkdown(md: string): Promise<string> {
try {
const r = await renderer.process(md);
return r.toString();
} catch (err) {
console.log('FAILED TO RENDER MARKDOWN', err);
return 'error';
}
}

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite dev --force",
"build": "vite build",
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
"lint": "eslint . --ext .ts,.tsx"
},
"dependencies": {
"@codemirror/commands": "^6.8.1",
@@ -61,6 +61,8 @@
"react-markdown": "^10.1.0",
"react-pdf": "^10.0.1",
"react-use": "^17.6.0",
"rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"slugify": "^1.6.6",
"uuid": "^11.1.0",