From 429f04240d8a0e9b86699df32d1b32ae578f9af5 Mon Sep 17 00:00:00 2001 From: Ivan Garcia Sainz-Aja Date: Mon, 16 Feb 2026 17:24:32 +0100 Subject: [PATCH 01/23] feat: enhance "Import from URL" with remote $refs and URL tracking Merge Load/Import URL functionality into single "Import from URL" button that updates browser URL, supports remote $refs, and preserves source URL in localStorage for persistent remote references after editing. --- .../src/components/Editor/MonacoWrapper.tsx | 9 +- apps/studio/src/components/Modals/index.tsx | 1 + apps/studio/src/components/StudioWrapper.tsx | 36 ++++--- apps/studio/src/services/editor.service.tsx | 95 +++++++++++++++---- apps/studio/src/services/monaco.service.ts | 13 ++- apps/studio/src/state/files.state.ts | 29 +++++- 6 files changed, 142 insertions(+), 41 deletions(-) diff --git a/apps/studio/src/components/Editor/MonacoWrapper.tsx b/apps/studio/src/components/Editor/MonacoWrapper.tsx index 0c47b4a18..326fde537 100644 --- a/apps/studio/src/components/Editor/MonacoWrapper.tsx +++ b/apps/studio/src/components/Editor/MonacoWrapper.tsx @@ -17,11 +17,14 @@ export const MonacoWrapper: FunctionComponent = ({ const onChange = useMemo(() => { return debounce((v: string) => { - editorSvc.updateState({ content: v, file: { from: 'storage', source: undefined } }); + // Preserve the current source URL instead of setting to undefined + const currentSource = file?.source; + editorSvc.updateState({ content: v, file: { from: 'storage', source: currentSource } }); autoSaving && editorSvc.saveToLocalStorage(v, false); - parserSvc.parse('asyncapi', v); + // Pass source to parser to maintain remote $refs support + parserSvc.parse('asyncapi', v, { source: currentSource }); }, savingDelay); - }, [autoSaving, savingDelay]); + }, [autoSaving, savingDelay, file?.source]); return ( (); useEffect(() => { const fetchData = async () => { + // Configure Monaco environment BEFORE creating services + configureMonacoEnvironment(); const servicess = await createServices(); setServices(servicess); - configureMonacoEnvironment(); const alreadyVisitedSession = sessionStorage.getItem('alreadyVisited'); const alreadyVisitedLocal = localStorage.getItem('alreadyVisited'); if (!alreadyVisitedSession && !alreadyVisitedLocal) { diff --git a/apps/studio/src/services/editor.service.tsx b/apps/studio/src/services/editor.service.tsx index 21b0c9309..8cdde7fd4 100644 --- a/apps/studio/src/services/editor.service.tsx +++ b/apps/studio/src/services/editor.service.tsx @@ -103,24 +103,36 @@ export class EditorService extends AbstractService { } async importFromURL(url: string): Promise { - if (url) { - return fetch(url) - .then(res => res.text()) - .then(async text => { - this.updateState({ - content: text, - updateModel: true, - file: { - source: url, - from: 'url' - }, - }); - }) - .catch(err => { - console.error(err); - throw err; - }); + if (!url) { + throw new Error('URL is required'); } + + // Update browser URL with ?url= parameter (no page reload) + const currentUrl = window.location.href.split('?')[0]; + window.history.pushState({}, '', `${currentUrl}?url=${url}`); + + return fetch(url) + .then(res => res.text()) + .then(async text => { + const language = this.svcs.formatSvc.retrieveLangauge(text); + + this.updateState({ + content: text, + updateModel: true, + file: { + source: url, + from: 'url', + language, + }, + }); + + // Parse with source for remote $refs resolution + await this.svcs.parserSvc.parse('asyncapi', text, { source: url }); + }) + .catch(err => { + console.error(err); + throw err; + }); } async importFile(files: FileList | null) { @@ -277,12 +289,22 @@ export class EditorService extends AbstractService { saveToLocalStorage(editorValue?: string, notify = true) { editorValue = editorValue || this.value; - localStorage.setItem('document', editorValue); + + // Get current source URL to preserve it + const currentFile = filesState.getState().files['asyncapi']; + const source = currentFile?.source; + + // Store both content and source in localStorage + const documentData = { + content: editorValue, + source: source || undefined, + }; + localStorage.setItem('document', JSON.stringify(documentData)); const { updateFile } = filesState.getState(); updateFile('asyncapi', { from: 'storage', - source: undefined, + source, // Preserve the source URL modified: false, }); @@ -308,7 +330,40 @@ export class EditorService extends AbstractService { } getFromLocalStorage() { - return localStorage.getItem('document'); + const stored = localStorage.getItem('document'); + if (!stored) return null; + + try { + // Try to parse as JSON (new format) + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object' && 'content' in parsed) { + return parsed.content; // Return just the content for compatibility + } + } catch { + // If parsing fails, it's the old format (plain string) + return stored; + } + + // Fallback to treating as plain string + return stored; + } + + getSourceFromLocalStorage() { + const stored = localStorage.getItem('document'); + if (!stored) return undefined; + + try { + // Try to parse as JSON (new format) + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object' && 'source' in parsed) { + return parsed.source; + } + } catch { + // If parsing fails, it's the old format (no source) + return undefined; + } + + return undefined; } private applyMarkersAndDecorations(diagnostics: Diagnostic[] = []) { diff --git a/apps/studio/src/services/monaco.service.ts b/apps/studio/src/services/monaco.service.ts index aad59eaeb..d9edb5022 100644 --- a/apps/studio/src/services/monaco.service.ts +++ b/apps/studio/src/services/monaco.service.ts @@ -85,9 +85,16 @@ export class MonacoService extends AbstractService { if (process.env.NODE_ENV === 'test') { return; } - - const monaco = this.monacoInstance = await import('monaco-editor'); - loader.config({ monaco }); + + try { + // Use the loader to get Monaco instead of direct import + this.monacoInstance = await loader.init(); + } catch (error) { + console.error('Failed to load Monaco:', error); + // Fallback to direct import if loader fails + const monaco = this.monacoInstance = await import('monaco-editor'); + loader.config({ monaco }); + } } private setMonacoTheme() { diff --git a/apps/studio/src/state/files.state.ts b/apps/studio/src/state/files.state.ts index 2ccd359e3..a3f6cbc63 100644 --- a/apps/studio/src/state/files.state.ts +++ b/apps/studio/src/state/files.state.ts @@ -1,6 +1,31 @@ import { create } from 'zustand'; -const document = typeof window !== 'undefined' ? localStorage.getItem('document') : undefined +// Helper function to extract content and source from localStorage +const getDocumentFromLocalStorage = () => { + if (typeof window === 'undefined') return { content: undefined, source: undefined }; + + const stored = localStorage.getItem('document'); + if (!stored) return { content: undefined, source: undefined }; + + try { + // Try to parse as JSON (new format) + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object' && 'content' in parsed) { + return { + content: parsed.content, + source: parsed.source || undefined, + }; + } + } catch { + // If parsing fails, it's the old format (plain string) + return { content: stored, source: undefined }; + } + + // Fallback to treating as plain string + return { content: stored, source: undefined }; +}; + +const { content: document, source: documentSource } = getDocumentFromLocalStorage(); const schema = document || `asyncapi: 3.0.0 info: @@ -240,7 +265,7 @@ export const filesState = create(set => ({ name: 'asyncapi', content: schema, from: 'storage', - source: undefined, + source: documentSource, // Use source from localStorage if available language: schema.trimStart()[0] === '{' ? 'json' : 'yaml', modified: false, stat: { From dbbaab49d256149c846170f68f7cb7e871259a3c Mon Sep 17 00:00:00 2001 From: Ivan Garcia Sainz-Aja Date: Mon, 2 Mar 2026 21:21:06 +0100 Subject: [PATCH 02/23] wicg-file-system-access --- apps/studio/package.json | 1 + apps/studio/tsconfig.json | 2 +- pnpm-lock.yaml | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/studio/package.json b/apps/studio/package.json index fcb79c44c..dfc18d298 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -120,6 +120,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/tsconfig.json b/apps/studio/tsconfig.json index 47e8861dc..45fa5c5e4 100644 --- a/apps/studio/tsconfig.json +++ b/apps/studio/tsconfig.json @@ -29,7 +29,7 @@ "./public/*" ] }, - "types": ["cypress"] + "types": ["cypress", "wicg-file-system-access"] }, "include": [ "next-env.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bdf572e1..5d9ca31d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: '@types/json-schema': specifier: ^7.0.15 version: 7.0.15 + '@types/wicg-file-system-access': + specifier: ^2023.10.7 + version: 2023.10.7 assert: specifier: ^2.0.0 version: 2.0.0 @@ -4657,6 +4660,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/wicg-file-system-access@2023.10.7': + resolution: {integrity: sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -19171,6 +19177,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/wicg-file-system-access@2023.10.7': {} + '@types/ws@8.18.1': dependencies: '@types/node': 20.4.6 From e45d614ac40412cd07f352305582c0e05570027f Mon Sep 17 00:00:00 2001 From: Ivan Garcia Sainz-Aja Date: Tue, 3 Mar 2026 11:59:31 +0100 Subject: [PATCH 03/23] grants folder access --- .../src/components/Editor/EditorDropdown.tsx | 30 +++- .../src/components/Editor/ImportDropdown.tsx | 32 +++- .../src/components/Editor/MonacoWrapper.tsx | 7 +- .../src/components/Modals/OpenFolderModal.tsx | 47 ++++++ apps/studio/src/components/Modals/index.tsx | 1 + apps/studio/src/components/StudioWrapper.tsx | 43 +++--- apps/studio/src/services/app.service.ts | 4 +- apps/studio/src/services/editor.service.tsx | 107 +++++++++++-- .../src/services/local-file-resolver.ts | 143 ++++++++++++++++++ apps/studio/src/services/parser.service.ts | 76 +++++++++- apps/studio/src/state/files.state.ts | 19 ++- 11 files changed, 449 insertions(+), 60 deletions(-) create mode 100644 apps/studio/src/components/Modals/OpenFolderModal.tsx create mode 100644 apps/studio/src/services/local-file-resolver.ts diff --git a/apps/studio/src/components/Editor/EditorDropdown.tsx b/apps/studio/src/components/Editor/EditorDropdown.tsx index cc3506984..7aa63f0ac 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'; @@ -28,10 +29,10 @@ export const EditorDropdown: React.FunctionComponent = () = ); @@ -46,14 +47,17 @@ export const EditorDropdown: React.FunctionComponent = () = ); + const fileInputRef = React.useRef(null); + const importFileButton = ( ); + const openFolderButton = ( + + ); + const importBase64Button = ( + } buttonHoverClassName="text-gray-500 hover:text-white" @@ -32,21 +34,22 @@ export const ImportDropdown: React.FC = () => {
  • +
  • + +
  • +
  • + } buttonHoverClassName="text-gray-500 hover:text-white" diff --git a/apps/studio/src/components/Editor/GenerateDropdown.tsx b/apps/studio/src/components/Editor/GenerateDropdown.tsx index 11954ba78..ebfb5de57 100644 --- a/apps/studio/src/components/Editor/GenerateDropdown.tsx +++ b/apps/studio/src/components/Editor/GenerateDropdown.tsx @@ -18,9 +18,9 @@ export const GenerateDropdown: React.FC = () => { - + } buttonHoverClassName="text-gray-500 hover:text-white" @@ -65,4 +65,4 @@ export const GenerateDropdown: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/apps/studio/src/components/Editor/SaveDropdown.tsx b/apps/studio/src/components/Editor/SaveDropdown.tsx index f26142df8..ae34daa65 100644 --- a/apps/studio/src/components/Editor/SaveDropdown.tsx +++ b/apps/studio/src/components/Editor/SaveDropdown.tsx @@ -17,9 +17,9 @@ export const SaveDropdown: React.FC = () => { - + } buttonHoverClassName="text-gray-500 hover:text-white" @@ -98,4 +98,4 @@ export const SaveDropdown: React.FC = () => { ); -}; \ No newline at end of file +}; From fadc475f8d5d74dfa2816c77f77675376d60f495 Mon Sep 17 00:00:00 2001 From: Ivan Garcia Sainz-Aja Date: Tue, 3 Mar 2026 14:56:27 +0100 Subject: [PATCH 06/23] filetree frist working draft --- .../src/components/Editor/ConvertDropdown.tsx | 3 +- .../src/components/Editor/EditorDropdown.tsx | 3 +- .../components/Editor/GenerateDropdown.tsx | 3 +- .../src/components/Editor/MonacoWrapper.tsx | 6 +- .../src/components/Editor/SaveDropdown.tsx | 3 +- apps/studio/src/components/FileTreeView.tsx | 274 +++++++++++++++ apps/studio/src/components/Navigation.tsx | 19 +- apps/studio/src/components/Navigationv3.tsx | 9 +- .../src/components/Terminal/ProblemsTab.tsx | 15 +- .../src/components/Terminal/TerminalInfo.tsx | 4 + apps/studio/src/services/app.service.ts | 39 ++- apps/studio/src/services/editor.service.tsx | 328 ++++++++++++++---- .../src/services/local-file-resolver.ts | 17 +- apps/studio/src/services/parser.service.ts | 150 ++++++-- apps/studio/src/state/documents.state.ts | 32 +- apps/studio/src/state/files.state.ts | 85 ++++- 16 files changed, 849 insertions(+), 141 deletions(-) create mode 100644 apps/studio/src/components/FileTreeView.tsx diff --git a/apps/studio/src/components/Editor/ConvertDropdown.tsx b/apps/studio/src/components/Editor/ConvertDropdown.tsx index 076c413c6..b5480b576 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); @@ -78,3 +78,4 @@ export const ConvertDropdown: React.FC = () => { ); } + diff --git a/apps/studio/src/components/Editor/EditorDropdown.tsx b/apps/studio/src/components/Editor/EditorDropdown.tsx index 7aa63f0ac..327d5e559 100644 --- a/apps/studio/src/components/Editor/EditorDropdown.tsx +++ b/apps/studio/src/components/Editor/EditorDropdown.tsx @@ -21,7 +21,7 @@ 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); @@ -315,3 +315,4 @@ export const EditorDropdown: React.FunctionComponent = () = ); }; + diff --git a/apps/studio/src/components/Editor/GenerateDropdown.tsx b/apps/studio/src/components/Editor/GenerateDropdown.tsx index ebfb5de57..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(); @@ -66,3 +66,4 @@ export const GenerateDropdown: React.FC = () => { ); }; + diff --git a/apps/studio/src/components/Editor/MonacoWrapper.tsx b/apps/studio/src/components/Editor/MonacoWrapper.tsx index c235e5f57..46010d60c 100644 --- a/apps/studio/src/components/Editor/MonacoWrapper.tsx +++ b/apps/studio/src/components/Editor/MonacoWrapper.tsx @@ -14,6 +14,9 @@ export const MonacoWrapper: FunctionComponent = ({ const { editorSvc } = useServices(); const { autoSaving, savingDelay } = useSettingsState(state => state.editor); const file = useFilesState(state => state.files['asyncapi']); + if (!file) { + return null; + } const onChange = useMemo(() => { return debounce((v: string) => { @@ -27,8 +30,9 @@ export const MonacoWrapper: FunctionComponent = ({ return ( { const { editorSvc } = useServices(); const isInvalidDocument = !useDocumentsState(state => - state.documents['asyncapi'].valid + state.documents['asyncapi']?.valid ); const language = useFilesState(state => state.files['asyncapi'].language); @@ -99,3 +99,4 @@ export const SaveDropdown: React.FC = () => { ); }; + diff --git a/apps/studio/src/components/FileTreeView.tsx b/apps/studio/src/components/FileTreeView.tsx new file mode 100644 index 000000000..c4769355b --- /dev/null +++ b/apps/studio/src/components/FileTreeView.tsx @@ -0,0 +1,274 @@ +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'; + 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 { + return 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...
    } + {activeFile && activeFile.isAsyncApiDocument === false && ( +
    Referenced file (not an AsyncAPI document)
    + )} +
    + )} +
    + ); +}; diff --git a/apps/studio/src/components/Navigation.tsx b/apps/studio/src/components/Navigation.tsx index ba98c9f5b..8ceb9a292 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 { @@ -343,13 +344,16 @@ export const Navigation: React.FunctionComponent = ({ if (!rawSpec || !document) { return ( -
    - {loading ?( -
    - ) : ( -

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

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

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

    + ) + } +
    ); } @@ -357,6 +361,7 @@ export const Navigation: React.FunctionComponent = ({ const components = document.components(); return (
    +
    • = ({ if (!rawSpec || !document) { return ( -
      - Empty or invalid document. Please fix errors/define AsyncAPI document. + ); } @@ -407,6 +411,7 @@ export const Navigationv3: React.FunctionComponent = ({ const components = document.components(); return (