diff --git a/README.md b/README.md index 3bae674b8..c4e941ff0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,58 @@ pnpm run build:ds pnpm run build ``` +## Features + +### Remote URL Import with Relative References + +Studio supports importing AsyncAPI files from remote URLs with automatic resolution of relative `$ref` references: + +- Import files from any URL (e.g., GitHub raw URLs, public APIs) +- The parser automatically resolves relative references using the remote URL as base path +- Example: A file at `https://example.com/specs/api.yaml` referencing `../schemas/user.json` resolves to `https://example.com/schemas/user.json` + +### Local Folder Access for Reference Resolution + +Studio can resolve local file references (e.g., `$ref: './schema.avsc'`) by requesting folder access: + +**Workflow:** +1. Click **Import** → **Open Folder** +2. Select the root folder containing your AsyncAPI files and schemas +3. Select the main AsyncAPI file within that folder +4. The parser automatically resolves all relative file references + +**Supported reference formats:** +- `./schema.avsc` - Same directory as the AsyncAPI file +- `../common/types.yaml` - Parent directory +- `apis/avro/schema.avsc` - Subdirectory path + +**Supported schema formats:** +- Avro `.avsc` files +- JSON Schema `.json` files +- YAML schema `.yaml` files + +**Browser compatibility:** +- ✅ Chrome, Edge, Brave (File System Access API supported) +- ❌ Firefox, Safari (not supported) + +**Security note:** Folder access is granted per session only and is not persisted. You must grant access each time you open the application. + +### Schema Editing + +- Edit both the main AsyncAPI document and referenced schema files +- Changes to referenced schemas are automatically reflected when the parser re-validates +- Real-time validation across all files + +### File Saving + +- Files opened from a folder (using **Open Folder**) can be saved to their original location with the **Save** button +- For other files, the **Save** button behaves as **Save As**, allowing you to export the current editor content to a selected local file + +### Additional Viewers + +- **Markdown Preview**: View documentation files with full Markdown rendering, including Mermaid diagrams +- **Avro Schema Viewer**: Visualize Avro schemas with automatically generated Mermaid diagrams + ## Architecture decision records ### Create a new architecture decision record diff --git a/apps/studio/package.json b/apps/studio/package.json index 487703b50..88c854976 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -86,6 +86,7 @@ "js-yaml": "^4.1.1", "monaco-editor": "0.34.1", "monaco-yaml": "4.0.2", + "mermaid": "^11.13.0", "next": "14.2.35", "postcss": "8.4.31", "react": "18.2.0", @@ -120,6 +121,7 @@ "@types/node": "^18.11.9", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", + "@types/wicg-file-system-access": "^2023.10.7", "assert": "^2.0.0", "autoprefixer": "^10.4.13", "browserify-zlib": "^0.2.0", diff --git a/apps/studio/src/components/Content.tsx b/apps/studio/src/components/Content.tsx index 8a61b3d79..8bfba0930 100644 --- a/apps/studio/src/components/Content.tsx +++ b/apps/studio/src/components/Content.tsx @@ -19,7 +19,7 @@ export const Content: FunctionComponent = () => { // eslint-disabl const navigationEnabled = show.primarySidebar; const editorEnabled = show.primaryPanel; const viewEnabled = show.secondaryPanel; - const viewType = secondaryPanelType; + const viewType = secondaryPanelType === 'avro' ? 'template' : secondaryPanelType; const splitPosLeft = 'splitPos:left'; const splitPosRight = 'splitPos:right'; diff --git a/apps/studio/src/components/Editor/ConvertDropdown.tsx b/apps/studio/src/components/Editor/ConvertDropdown.tsx index f9961060e..8e414a255 100644 --- a/apps/studio/src/components/Editor/ConvertDropdown.tsx +++ b/apps/studio/src/components/Editor/ConvertDropdown.tsx @@ -11,7 +11,7 @@ import { useDocumentsState, useFilesState } from '../../state'; export const ConvertDropdown: React.FC = () => { const { editorSvc } = useServices(); const isInvalidDocument = !useDocumentsState(state => - state.documents['asyncapi'].valid + state.documents['asyncapi']?.valid ); const language = useFilesState(state => state.files['asyncapi'].language); @@ -19,9 +19,9 @@ export const ConvertDropdown: React.FC = () => { - + } buttonHoverClassName="text-gray-500 hover:text-white" diff --git a/apps/studio/src/components/Editor/EditorDropdown.tsx b/apps/studio/src/components/Editor/EditorDropdown.tsx index cc3506984..4e885d4e3 100644 --- a/apps/studio/src/components/Editor/EditorDropdown.tsx +++ b/apps/studio/src/components/Editor/EditorDropdown.tsx @@ -9,6 +9,7 @@ import { GeneratorModal, ConvertModal, ImportUUIDModal, + OpenFolderModal, } from '../Modals'; import { Dropdown } from '../common'; @@ -20,9 +21,10 @@ interface EditorDropdownProps {} export const EditorDropdown: React.FunctionComponent = () => { const { editorSvc } = useServices(); const isInvalidDocument = !useDocumentsState(state => { - return state.documents['asyncapi'].valid + return state.documents['asyncapi']?.valid }); - const language = useFilesState(state => state.files['asyncapi'].language); + const file = useFilesState(state => state.files['asyncapi']); + const language = file.language; const importUrlButton = ( ); + const fileInputRef = React.useRef(null); + const importFileButton = ( ); + const openFolderButton = ( + + ); + const importBase64Button = ( - ); - - const convertLangAndSaveButton = ( - ); @@ -255,6 +234,9 @@ export const EditorDropdown: React.FunctionComponent = () =
  • {importUrlButton}
  • +
  • + {openFolderButton} +
  • {importFileButton}
  • @@ -279,9 +261,6 @@ export const EditorDropdown: React.FunctionComponent = () =
  • {saveFileButton}
  • -
  • - {convertLangAndSaveButton} -
  • @@ -295,3 +274,4 @@ export const EditorDropdown: React.FunctionComponent = () = ); }; + diff --git a/apps/studio/src/components/Editor/EditorSidebar.tsx b/apps/studio/src/components/Editor/EditorSidebar.tsx index 79a049ecf..ca4e82bd8 100644 --- a/apps/studio/src/components/Editor/EditorSidebar.tsx +++ b/apps/studio/src/components/Editor/EditorSidebar.tsx @@ -4,7 +4,7 @@ import { useFilesState } from '../../state'; import { ShareButton } from './ShareButton'; import { ImportDropdown } from './ImportDropdown'; import { GenerateDropdown } from './GenerateDropdown'; -import { SaveDropdown } from './SaveDropdown'; +import { SaveButton } from './SaveDropdown'; import { ConvertDropdown } from './ConvertDropdown'; interface EditorSidebarProps {} @@ -12,17 +12,22 @@ interface EditorSidebarProps {} export const EditorSidebar: React.FunctionComponent< EditorSidebarProps > = () => { - const { source, from } = useFilesState((state) => state.files['asyncapi']); + const { source, from, localPath, uri } = useFilesState((state) => state.files['asyncapi']); let documentFromText = ''; if (from === 'storage') { documentFromText = 'From localStorage'; + } else if (from === 'file') { + const path = source || localPath || uri; + documentFromText = path ? `From local folder: ${path}` : 'From local folder'; + } else if (from === 'url') { + documentFromText = `From URL ${source || uri || ''}`.trim(); } else if (from === 'base64') { documentFromText = 'From Base64'; } else if (from === 'share') { documentFromText = 'From Shared'; } else { - documentFromText = `From URL ${source}`; + documentFromText = source || uri ? `From ${source || uri}` : 'From unknown source'; } return ( @@ -49,7 +54,7 @@ export const EditorSidebar: React.FunctionComponent<
  • - +
  • diff --git a/apps/studio/src/components/Editor/GenerateDropdown.tsx b/apps/studio/src/components/Editor/GenerateDropdown.tsx index 11954ba78..f045204bc 100644 --- a/apps/studio/src/components/Editor/GenerateDropdown.tsx +++ b/apps/studio/src/components/Editor/GenerateDropdown.tsx @@ -10,7 +10,7 @@ import { useServices } from '@/services'; export const GenerateDropdown: React.FC = () => { const isInvalidDocument = !useDocumentsState(state => - state.documents['asyncapi'].valid + state.documents['asyncapi']?.valid ); const { editorSvc } = useServices(); @@ -18,9 +18,9 @@ export const GenerateDropdown: React.FC = () => { - +
  • } buttonHoverClassName="text-gray-500 hover:text-white" @@ -65,4 +65,5 @@ export const GenerateDropdown: React.FC = () => {
    ); -}; \ No newline at end of file +}; + diff --git a/apps/studio/src/components/Editor/ImportDropdown.tsx b/apps/studio/src/components/Editor/ImportDropdown.tsx index 61981f6b7..7f183eef8 100644 --- a/apps/studio/src/components/Editor/ImportDropdown.tsx +++ b/apps/studio/src/components/Editor/ImportDropdown.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import toast from 'react-hot-toast'; import { show } from '@ebay/nice-modal-react'; import { FaFileImport } from 'react-icons/fa'; @@ -7,6 +7,7 @@ import { ImportURLModal, ImportBase64Modal, ImportUUIDModal, + OpenFolderModal, } from '../Modals'; import { Dropdown, Tooltip } from '../common'; @@ -14,14 +15,15 @@ import { useServices } from '@/services'; export const ImportDropdown: React.FC = () => { const { editorSvc } = useServices(); + const fileInputRef = useRef(null); return ( - + } buttonHoverClassName="text-gray-500 hover:text-white" @@ -35,7 +37,18 @@ export const ImportDropdown: React.FC = () => { title="Import from URL" onClick={() => show(ImportURLModal)} > - Import from URL + Import from URL + + + +
  • +
  • @@ -45,8 +58,9 @@ export const ImportDropdown: React.FC = () => { title="Import File" > { toast.promise(editorSvc.importFile(event.target.files), { @@ -66,6 +80,8 @@ export const ImportDropdown: React.FC = () => { ), }); + // Reset so the same file can be re-imported + if (fileInputRef.current) fileInputRef.current.value = ''; }} /> Import File diff --git a/apps/studio/src/components/Editor/MonacoWrapper.tsx b/apps/studio/src/components/Editor/MonacoWrapper.tsx index 0c47b4a18..b9c9af311 100644 --- a/apps/studio/src/components/Editor/MonacoWrapper.tsx +++ b/apps/studio/src/components/Editor/MonacoWrapper.tsx @@ -4,29 +4,36 @@ import MonacoEditor from '@monaco-editor/react'; import { debounce } from '@/helpers'; import { useServices } from '@/services'; -import { useFilesState, useSettingsState } from '@/state'; +import { useFilesState } from '@/state'; import type { EditorProps as MonacoEditorProps } from '@monaco-editor/react'; export const MonacoWrapper: FunctionComponent = ({ ...props }) => { - const { editorSvc, parserSvc } = useServices(); - const { autoSaving, savingDelay } = useSettingsState(state => state.editor); + const { editorSvc } = useServices(); const file = useFilesState(state => state.files['asyncapi']); + const editorChangeDebounceMs = 300; const onChange = useMemo(() => { return debounce((v: string) => { - editorSvc.updateState({ content: v, file: { from: 'storage', source: undefined } }); - autoSaving && editorSvc.saveToLocalStorage(v, false); - parserSvc.parse('asyncapi', v); - }, savingDelay); - }, [autoSaving, savingDelay]); + // Preserve the current source URL instead of setting to undefined + const currentSource = file?.source; + editorSvc.updateState({ content: v, file: { source: currentSource, modified: true } }); + // subscribeToFiles will trigger a re-parse when content changes + }, editorChangeDebounceMs); + }, [file?.source, editorSvc]); + + if (!file) { + return null; + } return ( { +export const SaveButton: React.FC = () => { const { editorSvc } = useServices(); - const isInvalidDocument = !useDocumentsState(state => - state.documents['asyncapi'].valid - ); - const language = useFilesState(state => state.files['asyncapi'].language); + const file = useFilesState(state => state.files['asyncapi']); + const isDirectSave = file?.from === 'file' && !!file?.fileHandle; + const baseLabel = isDirectSave ? 'Save' : 'Export'; + const tooltipText = file?.modified ? baseLabel : `${baseLabel} (saved)`; return ( - - - - } - buttonHoverClassName="text-gray-500 hover:text-white" - dataTest="button-save-dropdown" - > -
      -
    • - -
    • -
    • - -
    • - -
    -
    + + + ); -}; \ No newline at end of file +}; + diff --git a/apps/studio/src/components/FileTreeView.tsx b/apps/studio/src/components/FileTreeView.tsx new file mode 100644 index 000000000..4f086db67 --- /dev/null +++ b/apps/studio/src/components/FileTreeView.tsx @@ -0,0 +1,282 @@ +import React, { useMemo, useState } from 'react'; +import { VscChevronDown, VscChevronRight, VscFile, VscFolder, VscFolderOpened, VscGlobe } from 'react-icons/vsc'; + +import { useServices } from '@/services'; +import { useFilesState } from '@/state'; + +type TreeNode = { + name: string; + path: string; + type: 'file' | 'folder'; + uri?: string; + title?: string; + isRemoteGroup?: boolean; + children?: TreeNode[]; +}; + +type TreeEntry = { + key: string; + path: string; + uri: string; + title?: string; +}; + +function buildTree(entries: TreeEntry[]): TreeNode[] { + type RawNode = TreeNode & { childrenMap: Record }; + const root: Record = {}; + + for (const entry of entries) { + const parts = entry.path.split('/').filter(Boolean); + if (parts.length === 0) continue; + + let level = root; + let currentPath = ''; + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isFile = index === parts.length - 1; + if (!level[part]) { + level[part] = { + name: part, + path: `${entry.key}:${currentPath}`, + type: isFile ? 'file' : 'folder', + uri: isFile ? entry.uri : undefined, + title: isFile ? entry.title : undefined, + children: isFile ? undefined : [], + childrenMap: {}, + }; + } + if (!isFile) { + level[part].type = 'folder'; + level = level[part].childrenMap; + } + }); + } + + const normalize = (map: Record): TreeNode[] => + Object.values(map) + .map((node) => { + const { childrenMap, ...rest } = node; + return { + ...rest, + children: Object.keys(childrenMap).length > 0 ? normalize(childrenMap) : undefined, + }; + }) + .sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + return normalize(root); +} + +function getFileTypeColor(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'text-blue-400'; + if (lower.endsWith('.json')) return 'text-yellow-400'; + if (lower.endsWith('.avsc')) return 'text-green-400'; + if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text-purple-400'; + return 'text-gray-300'; +} + +function truncateExternalUrl(url: string): string { + try { + const parsed = new URL(url); + const parts = parsed.pathname.split('/').filter(Boolean); + const fileName = parts[parts.length - 1] || parsed.host; + return `${parsed.protocol}//.../${fileName}`; + } catch { + return url.length > 32 ? `${url.slice(0, 16)}...${url.slice(-12)}` : url; + } +} + +function formatRemoteGroupLabel(url: string): string { + try { + const parsed = new URL(url); + const host = parsed.host; + const port = parsed.port; + const fullhost = port ? `${host}:${port}` : host; + const parts = parsed.pathname.split('/').filter(Boolean); + const tail = parts.length > 0 ? parts[parts.length - 1] : parsed.host; + return `${parsed.protocol}//${fullhost}/.../${tail}/`; + } catch { + return truncateExternalUrl(url); + } +} + +export const FileTreeView: React.FC = () => { + const { editorSvc } = useServices(); + const [collapsed, setCollapsed] = useState(false); + const [expandedFolders, setExpandedFolders] = useState>({}); + + const { files, activeFileUri, fileTreeMode, projectRoot, fileTreeLoading } = useFilesState((state) => ({ + files: state.files, + activeFileUri: state.activeFileUri, + fileTreeMode: state.fileTreeMode, + projectRoot: state.projectRoot, + fileTreeLoading: state.fileTreeLoading, + })); + + const projectFiles = useMemo( + () => Object.entries(files).filter(([uri]) => uri !== 'asyncapi').map(([, file]) => file).sort((a, b) => a.uri.localeCompare(b.uri)), + [files], + ); + + const activeFile = useMemo(() => files['asyncapi'], [files]); + + const localTree = useMemo( + () => + buildTree( + projectFiles.map((file) => ({ + key: 'local', + path: file.uri, + uri: file.uri, + })), + ), + [projectFiles], + ); + + const remoteTree = useMemo(() => { + const remoteFiles = projectFiles.filter((file) => (/^https?:\/\//).test(file.uri)); + const mainRemote = remoteFiles.find((file) => file.isAsyncApiDocument) || remoteFiles[0]; + const baseDir = (() => { + if (!mainRemote) return undefined; + try { + return new URL('.', mainRemote.uri).href; + } catch { + return undefined; + } + })(); + + const baseEntries: TreeEntry[] = []; + const groups = new Map(); + + for (const file of remoteFiles) { + if (baseDir && file.uri.startsWith(baseDir)) { + baseEntries.push({ + key: 'remote-base', + path: file.uri.slice(baseDir.length), + uri: file.uri, + }); + continue; + } + + let baseUrl = file.uri; + let fileName = file.uri; + + try { + const parsed = new URL(file.uri); + baseUrl = new URL('.', parsed.href).href; + const parts = parsed.pathname.split('/').filter(Boolean); + fileName = parts[parts.length - 1] || parsed.host; + } catch { + fileName = truncateExternalUrl(file.uri); + } + + const groupPath = `remote-group:${baseUrl}`; + if (!groups.has(baseUrl)) { + groups.set(baseUrl, { + name: formatRemoteGroupLabel(baseUrl), + path: groupPath, + type: 'folder', + title: baseUrl, + isRemoteGroup: true, + children: [], + }); + } + + const group = groups.get(baseUrl); + group?.children?.push({ + name: fileName, + path: `${groupPath}/${fileName}`, + type: 'file', + uri: file.uri, + title: file.uri, + }); + } + + const groupedNodes = Array.from(groups.values()) + .map((group) => ({ + ...group, + children: group.children?.slice().sort((a, b) => a.name.localeCompare(b.name)), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + const baseNodes = buildTree(baseEntries); + const baseFolders = baseNodes.filter((node) => node.type === 'folder'); + const baseFiles = baseNodes.filter((node) => node.type === 'file'); + + return [...groupedNodes, ...baseFolders, ...baseFiles]; + }, [projectFiles]); + + const renderNode = (node: TreeNode) => { + if (node.type === 'folder') { + const isExpanded = expandedFolders[node.path] ?? true; + const isRemoteGroup = !!node.isRemoteGroup || node.path.startsWith('remote-group:'); + return ( +
    + + {isExpanded && node.children &&
    {node.children.map((child) => renderNode(child))}
    } +
    + ); + } + + const fileUri = node.uri || node.path; + const isActive = activeFileUri === fileUri || activeFile?.uri === fileUri; + return ( + + ); + }; + + if (fileTreeMode === 'none') { + return null; + } + + const treeNodes = fileTreeMode === 'local' ? localTree : remoteTree; + + return ( +
    + + {!collapsed && ( +
    + {projectFiles.length === 0 &&
    No project files available.
    } + {projectFiles.length > 0 &&
    {treeNodes.map((node) => renderNode(node))}
    } + {fileTreeLoading &&
    Loading file...
    } +
    + )} +
    + ); +}; diff --git a/apps/studio/src/components/Modals/BrowserNotSupportedModal.tsx b/apps/studio/src/components/Modals/BrowserNotSupportedModal.tsx new file mode 100644 index 000000000..014da6489 --- /dev/null +++ b/apps/studio/src/components/Modals/BrowserNotSupportedModal.tsx @@ -0,0 +1,46 @@ +import { create } from '@ebay/nice-modal-react'; + +import { ConfirmModal } from './ConfirmModal'; + +interface BrowserNotSupportedModalProps { + isBrave?: boolean; +} + +export const BrowserNotSupportedModal = create(({ isBrave = false }) => { + return ( + +
    +

    + The Open Folder feature requires the File System Access API, which is not available in your current browser context. +

    +
    +

    Browser support

    +
      +
    • ✅ Chrome
    • +
    • ✅ Edge
    • +
    • ✅ Brave (requires manual flag enablement)
    • +
    • ❌ Firefox (not supported)
    • +
    • ❌ Safari (not supported)
    • +
    +
    + {isBrave && ( +
    +

    You are using Brave browser. To enable this feature:

    +
      +
    1. + Navigate to brave://flags/#file-system-access-api +
    2. +
    3. Enable the "File System Access API" flag
    4. +
    5. Restart Brave browser
    6. +
    7. Try again
    8. +
    +
    + )} +
    +
    + ); +}); diff --git a/apps/studio/src/components/Modals/OpenFolderModal.tsx b/apps/studio/src/components/Modals/OpenFolderModal.tsx new file mode 100644 index 000000000..9c3e9c78e --- /dev/null +++ b/apps/studio/src/components/Modals/OpenFolderModal.tsx @@ -0,0 +1,56 @@ +import toast from 'react-hot-toast'; +import { create } from '@ebay/nice-modal-react'; + +import { ConfirmModal } from './ConfirmModal'; + +import { debugError, debugLog } from '@/helpers/debug'; +import { useServices } from '../../services'; + +export const OpenFolderModal = create(() => { + const { editorSvc } = useServices(); + + const onSubmit = async () => { + const toastId = 'open-folder'; + debugLog('ui', 'Open Folder Continue clicked'); + toast.loading('Granting folder access...', { id: toastId }); + try { + const granted = await editorSvc.grantFolderAccess(); + if (granted) { + toast.success('Folder access granted! File references will now be resolved.', { id: toastId }); + } else { + toast.dismiss(toastId); + } + } catch (err: unknown) { + debugError('ui', 'Open Folder failed', err); + const message = err instanceof Error ? err.message : 'Unknown error'; + toast.error(`Failed to grant folder access: ${message}`, { id: toastId }); + } + }; + + return ( + +
    +

    + This will allow Studio to resolve local $ref references + (e.g., Avro schemas, JSON schemas) relative to your project folder. +

    +

    You will be prompted in two steps:

    +
      +
    1. + Select the root folder containing your AsyncAPI project files +
    2. +
    3. + Select the AsyncAPI file (.yaml,{' '} + .yml, or{' '} + .json) within that folder +
    4. +
    +
    +
    + ); +}); diff --git a/apps/studio/src/components/Modals/RedirectedModal.tsx b/apps/studio/src/components/Modals/RedirectedModal.tsx index 6846d09a4..98db2eaff 100644 --- a/apps/studio/src/components/Modals/RedirectedModal.tsx +++ b/apps/studio/src/components/Modals/RedirectedModal.tsx @@ -7,9 +7,14 @@ import { Markdown } from '../common'; const CHANGES = ` Below are the changes compared to the old AsyncAPI Playground: -- There is no preview for markdown. +- Markdown files now have preview support, including Mermaid code blocks. - Studio supports the same query parameters except **template**. - To download an AsyncAPI document from an external source use the editor menu and select **Import from URL**. There is also an option to use a local file, base64 saved file, convert a given version of AsyncAPI document to a newer one as well as change the format from YAML to JSON and vice versa. There is also option to download AsyncAPI document as file. +- **Local Folder Access**: Grant folder access to resolve local file references (e.g., \`./schema.avsc\`, \`../common/types.yaml\`). Use **Import** → **Open Folder** to select your project root. +- **Schema Editing**: Edit referenced schema files directly. Changes are automatically reflected in validation. +- **File Saving**: Files opened from folders can be saved to their original location. Other files use **Save As**. +- **Markdown Preview**: View documentation files with full Markdown rendering, including Mermaid diagrams. +- **Avro Schema Viewer**: Visualize Avro schemas with automatically generated Mermaid diagrams. - To generate the template, please click on the **Generate code/docs** item in the menu at the top right corner of the editor, enter (needed) the parameters and click **Generate**. - The left navigation is used to open/close the panels. - Errors in the AsyncAPI document are shown in a panel at the bottom of the editor. The panel is expandable. diff --git a/apps/studio/src/components/Modals/Settings/SettingsModal.tsx b/apps/studio/src/components/Modals/Settings/SettingsModal.tsx index 5ee4a9338..29e56f8fc 100644 --- a/apps/studio/src/components/Modals/Settings/SettingsModal.tsx +++ b/apps/studio/src/components/Modals/Settings/SettingsModal.tsx @@ -55,8 +55,6 @@ export const SettingsModal = create(({ activeTab = 'editor' const settings = settingsSvc.get(); const modal = useModal(); - const [autoSaving, setAutoSaving] = useState(settings.editor.autoSaving); - const [savingDelay, setSavingDelay] = useState(settings.editor.savingDelay); const [governanceWarnings, setGovernanceWarnings] = useState(settings.governance.show.warnings); const [governanceInformations, setGovernanceInformations] = useState(settings.governance.show.informations); const [governanceHints, setGovernanceHints] = useState(settings.governance.show.hints); @@ -65,10 +63,6 @@ export const SettingsModal = create(({ activeTab = 'editor' const createNewState = (): SettingsState => { return { - editor: { - autoSaving, - savingDelay, - }, governance: { show: { warnings: governanceWarnings, @@ -86,7 +80,7 @@ export const SettingsModal = create(({ activeTab = 'editor' const newState = createNewState(); const isThisSameObjects = settingsSvc.isEqual(newState); setConfirmDisabled(isThisSameObjects); - }, [autoSaving, savingDelay, autoRendering, governanceWarnings, governanceInformations, governanceHints]); + }, [autoRendering, governanceWarnings, governanceInformations, governanceHints]); const onCancel = useCallback(() => { modal.hide(); @@ -112,49 +106,8 @@ export const SettingsModal = create(({ activeTab = 'editor' tab: Editor, content: (
    -
    -
    - - setAutoSaving(v)} - /> -
    -
    - Save automatically after each change in the document or manually. -
    -
    -
    -
    - - -
    -
    - Delay in saving the modified document. -
    +
    + No editor settings available.
    ), diff --git a/apps/studio/src/components/Modals/index.tsx b/apps/studio/src/components/Modals/index.tsx index 06cedfbcb..96a154517 100644 --- a/apps/studio/src/components/Modals/index.tsx +++ b/apps/studio/src/components/Modals/index.tsx @@ -1,12 +1,15 @@ export * from './Generator/GeneratorModal'; export * from './Settings/SettingsModal'; +export * from './BrowserNotSupportedModal'; export * from './ConfirmModal'; export * from './ConvertModal'; export * from './ConvertToLatestModal'; export * from './ImportBase64Modal'; export * from './ImportURLModal'; export * from './ImportUUIDModal'; + export * from './NewFileModal'; +export * from './OpenFolderModal'; export * from './RedirectedModal'; -export * from './ConfirmNewFileModal'; \ No newline at end of file +export * from './ConfirmNewFileModal'; diff --git a/apps/studio/src/components/Navigation.tsx b/apps/studio/src/components/Navigation.tsx index ba98c9f5b..7906938a0 100644 --- a/apps/studio/src/components/Navigation.tsx +++ b/apps/studio/src/components/Navigation.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react'; import { useServices } from '@/services'; import { useDocumentsState, useFilesState } from '@/state'; import { NAVIGATION_SECTION_STYLE, NAVIGATION_SUB_SECTION_STYLE } from './Navigationv3'; +import { FileTreeView } from './FileTreeView'; import type { AsyncAPIDocumentInterface } from '@asyncapi/parser'; interface NavigationProps { @@ -314,9 +315,12 @@ export const Navigation: React.FunctionComponent = ({ const [hash, setHash] = useState(window.location.hash); const [loading, setloading] = useState(false); - const { navigationSvc } = useServices(); + const { navigationSvc, formatSvc } = useServices(); const rawSpec = useFilesState(state => state.files['asyncapi']?.content); const document = useDocumentsState(state => state.documents['asyncapi']?.document); + const emptyStateMessage = formatSvc.detectSpecType(rawSpec || '') === 'openapi' + ? 'OpenAPI document detected. AsyncAPI navigation is not available for this file.' + : 'Empty or invalid document. Please fix errors/define AsyncAPI document.'; useEffect(() => { const fn = () => { @@ -343,13 +347,16 @@ export const Navigation: React.FunctionComponent = ({ if (!rawSpec || !document) { return ( -
    - {loading ?( -
    - ) : ( -

    Empty or invalid document. Please fix errors/define AsyncAPI document.

    - ) - } +
    + +
    + {loading ?( +
    + ) : ( +

    {emptyStateMessage}

    + ) + } +
    ); } @@ -357,6 +364,7 @@ export const Navigation: React.FunctionComponent = ({ const components = document.components(); return (
    +
    • = ({ }) => { const [hash, setHash] = useState(window.location.hash); - const { navigationSvc } = useServices(); + const { navigationSvc, formatSvc } = useServices(); const rawSpec = useFilesState(state => state.files['asyncapi']?.content); const document = useDocumentsState(state => state.documents['asyncapi']?.document); + const emptyStateMessage = formatSvc.detectSpecType(rawSpec || '') === 'openapi' + ? 'OpenAPI document detected. AsyncAPI navigation is not available for this file.' + : 'Empty or invalid document. Please fix errors/define AsyncAPI document.'; useEffect(() => { const fn = () => { @@ -398,8 +402,11 @@ export const Navigationv3: React.FunctionComponent = ({ if (!rawSpec || !document) { return ( -
      - Empty or invalid document. Please fix errors/define AsyncAPI document. + ); } @@ -407,6 +414,7 @@ export const Navigationv3: React.FunctionComponent = ({ const components = document.components(); return (