diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index f8960e1..0000000 --- a/.eslintignore +++ /dev/null @@ -1,31 +0,0 @@ -# Build output -build/ -dist/ -.svelte-kit/ -node_modules/ - -# Test reports -playwright-report/ -test-results/ -coverage/ - -# Config and generated files -*.config.js -*.config.ts -.DS_Store -*.min.js - -# Temporary files -*.tmp -*.log -.env -.env.* -!.env.example - -# IDE -.vscode/ -.idea/ - -# Documentation -CLAUDE.md -*.md \ No newline at end of file diff --git a/README-ko.md b/README-ko.md index 93f9c00..8e28e09 100644 --- a/README-ko.md +++ b/README-ko.md @@ -39,6 +39,19 @@ SvelteKit과 Svelte 5로 구축된 강력한 JSON 시각화 및 편집 도구입 - **더 보기**: 20개 이상의 항목을 가진 노드를 "더 보기" 기능과 함께 자동 축소 - **개별 토글**: 개별 참조 항목 확장/축소 +### 📑 다중 탭 지원 + +- **여러 문서**: 별도의 탭에서 여러 JSON 문서 작업 +- **탭 관리**: 탭 생성, 이름 변경, 복제 및 닫기 +- **가져오기/내보내기**: JSON 파일 가져오기 및 개별 탭 내보내기 +- **키보드 단축키**: + - `Ctrl/Cmd + T`: 새 탭 + - `Ctrl/Cmd + W`: 현재 탭 닫기 + - `Ctrl/Cmd + Tab`: 다음 탭 + - `Ctrl/Cmd + Shift + Tab`: 이전 탭 + - `Ctrl/Cmd + 1-9`: 특정 탭으로 전환 +- **자동 저장**: 탭이 localStorage에 자동 저장됨 + ### 🌐 다국어 지원 - **다중 언어**: 영어 및 한국어 지원 diff --git a/README.md b/README.md index 6960418..a1e9c31 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,19 @@ A powerful JSON visualization and editing tool built with SvelteKit and Svelte 5 - **Show More**: Automatically collapse nodes with 20+ items with "show more" functionality - **Individual Toggles**: Expand/collapse individual reference items +### 📑 Multi-Tab Support + +- **Multiple Documents**: Work with multiple JSON documents in separate tabs +- **Tab Management**: Create, rename, duplicate, and close tabs +- **Import/Export**: Import JSON files and export individual tabs +- **Keyboard Shortcuts**: + - `Ctrl/Cmd + T`: New tab + - `Ctrl/Cmd + W`: Close current tab + - `Ctrl/Cmd + Tab`: Next tab + - `Ctrl/Cmd + Shift + Tab`: Previous tab + - `Ctrl/Cmd + 1-9`: Switch to specific tab +- **Auto-save**: Tabs are automatically saved to localStorage + ### 🌐 Internationalization - **Multi-language**: English and Korean support diff --git a/eslint.config.js b/eslint.config.js index 1311bc1..9192952 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -60,15 +60,35 @@ export default [ }, { ignores: [ + // Build output '.svelte-kit/**', 'build/**', 'dist/**', 'node_modules/**', + // Test reports + 'playwright-report/**', + 'test-results/**', + 'coverage/**', + // Config and generated files + '*.config.js', + '*.config.ts', + '.DS_Store', + '*.min.js', + // Temporary files + '*.tmp', + '*.log', '.env', '.env.*', '!.env.example', 'vite.config.js.timestamp-*', 'vite.config.ts.timestamp-*', + // IDE + '.vscode/**', + '.idea/**', + // Documentation + 'CLAUDE.md', + '*.md', + // Generated i18n files 'src/i18n/i18n-*.ts' ] } diff --git a/package-lock.json b/package-lock.json index f6724c9..1e010d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.1", + "@faker-js/faker": "^9.9.0", "@lucide/svelte": "^0.515.0", "@xyflow/svelte": "^1.2.2", "class-variance-authority": "^0.7.1", @@ -830,6 +831,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", + "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", diff --git a/package.json b/package.json index 71f3957..37993da 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.1", + "@faker-js/faker": "^9.9.0", "@lucide/svelte": "^0.515.0", "@xyflow/svelte": "^1.2.2", "class-variance-authority": "^0.7.1", diff --git a/screenshots/pdjsoneditor.png b/screenshots/pdjsoneditor.png index e1115ad..4342590 100644 Binary files a/screenshots/pdjsoneditor.png and b/screenshots/pdjsoneditor.png differ diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index dc96a3b..508ea94 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -35,7 +35,10 @@ const en: BaseTranslation = { clearAllConfirm: 'Are you sure you want to clear all settings? This will remove all saved headers, body, and URL from storage.', cancel: 'Cancel', - save: 'Save' + save: 'Save', + regenerate: 'Regenerate', + regenerateTooltip: 'Regenerate values while preserving JSON structure', + regenerateSuccess: 'JSON values regenerated successfully' }, graph: { showMore: 'Show {count} more', @@ -51,6 +54,15 @@ const en: BaseTranslation = { }, footer: { ready: 'Ready' + }, + tabs: { + rename: 'Rename', + duplicate: 'Duplicate', + exportJson: 'Export JSON', + closeTab: 'Close Tab', + importJsonFile: 'Import JSON File', + newTab: 'New Tab', + import: 'Import' } } satisfies BaseTranslation; diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 702cb16..746d9d1 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -143,6 +143,18 @@ type RootTranslation = { * S​a​v​e */ save: string; + /** + * R​e​g​e​n​e​r​a​t​e + */ + regenerate: string; + /** + * R​e​g​e​n​e​r​a​t​e​ ​v​a​l​u​e​s​ ​w​h​i​l​e​ ​p​r​e​s​e​r​v​i​n​g​ ​J​S​O​N​ ​s​t​r​u​c​t​u​r​e + */ + regenerateTooltip: string; + /** + * J​S​O​N​ ​v​a​l​u​e​s​ ​r​e​g​e​n​e​r​a​t​e​d​ ​s​u​c​c​e​s​s​f​u​l​l​y + */ + regenerateSuccess: string; }; graph: { /** @@ -187,6 +199,36 @@ type RootTranslation = { */ ready: string; }; + tabs: { + /** + * R​e​n​a​m​e + */ + rename: string; + /** + * D​u​p​l​i​c​a​t​e + */ + duplicate: string; + /** + * E​x​p​o​r​t​ ​J​S​O​N + */ + exportJson: string; + /** + * C​l​o​s​e​ ​T​a​b + */ + closeTab: string; + /** + * I​m​p​o​r​t​ ​J​S​O​N​ ​F​i​l​e + */ + importJsonFile: string; + /** + * N​e​w​ ​T​a​b + */ + newTab: string; + /** + * I​m​p​o​r​t + */ + import: string; + }; }; export type TranslationFunctions = { @@ -317,6 +359,18 @@ export type TranslationFunctions = { * Save */ save: () => LocalizedString; + /** + * Regenerate + */ + regenerate: () => LocalizedString; + /** + * Regenerate values while preserving JSON structure + */ + regenerateTooltip: () => LocalizedString; + /** + * JSON values regenerated successfully + */ + regenerateSuccess: () => LocalizedString; }; graph: { /** @@ -360,6 +414,36 @@ export type TranslationFunctions = { */ ready: () => LocalizedString; }; + tabs: { + /** + * Rename + */ + rename: () => LocalizedString; + /** + * Duplicate + */ + duplicate: () => LocalizedString; + /** + * Export JSON + */ + exportJson: () => LocalizedString; + /** + * Close Tab + */ + closeTab: () => LocalizedString; + /** + * Import JSON File + */ + importJsonFile: () => LocalizedString; + /** + * New Tab + */ + newTab: () => LocalizedString; + /** + * Import + */ + import: () => LocalizedString; + }; }; export type Formatters = {}; diff --git a/src/i18n/ko/index.ts b/src/i18n/ko/index.ts index 6af2ca0..4434827 100644 --- a/src/i18n/ko/index.ts +++ b/src/i18n/ko/index.ts @@ -34,7 +34,10 @@ const ko: Translation = { clearAll: '모두 지우기', clearAllConfirm: '모든 설정을 지우시겠습니까? 저장된 헤더, 본문, URL이 모두 삭제됩니다.', cancel: '취소', - save: '저장' + save: '저장', + regenerate: '재생성', + regenerateTooltip: 'JSON 구조를 유지하면서 값만 재생성', + regenerateSuccess: 'JSON 값이 성공적으로 재생성되었습니다' }, graph: { showMore: '{count} 개 더 보기', @@ -50,6 +53,15 @@ const ko: Translation = { }, footer: { ready: '준비됨' + }, + tabs: { + rename: '이름 변경', + duplicate: '복제', + exportJson: 'JSON 내보내기', + closeTab: '탭 닫기', + importJsonFile: 'JSON 파일 가져오기', + newTab: '새 탭', + import: '가져오기' } } satisfies Translation; diff --git a/src/lib/components/JsonEditor.svelte b/src/lib/components/JsonEditor.svelte index 857af58..8e8cd1d 100644 --- a/src/lib/components/JsonEditor.svelte +++ b/src/lib/components/JsonEditor.svelte @@ -4,6 +4,7 @@ import { EditorState } from '@codemirror/state'; import { json } from '@codemirror/lang-json'; import { syntaxTree } from '@codemirror/language'; + import type { TreeCursor } from '@lezer/common'; import { oneDark } from '@codemirror/theme-one-dark'; import { mode } from 'mode-watcher'; import { logger } from '$lib/logger'; @@ -26,7 +27,7 @@ } // Recursive function to traverse with path context - function traverse(cursor: any, path: string[] = []) { + function traverse(cursor: TreeCursor, path: string[] = []) { do { const nodeName = cursor.name; diff --git a/src/lib/components/JsonGraph.svelte b/src/lib/components/JsonGraph.svelte index 496d44e..8c7b6e1 100644 --- a/src/lib/components/JsonGraph.svelte +++ b/src/lib/components/JsonGraph.svelte @@ -192,7 +192,7 @@ async function performLayoutWithWorker(): Promise { // Instantiate bundled worker via Vite's ?worker plugin - const worker: Worker = new (GraphLayoutWorker as any)(); + const worker: Worker = new (GraphLayoutWorker as unknown as new () => Worker)(); const LARGE_GRAPH_NODE_THRESHOLD = 400; const isLarge = tempNodes.length >= LARGE_GRAPH_NODE_THRESHOLD; const cfg = { @@ -213,16 +213,27 @@ return new Promise((resolve, reject) => { worker.onmessage = (e: MessageEvent) => { - const data: any = e.data; + const data = e.data as { + type: string; + nodes?: Node[]; + edges?: Edge[]; + progress?: number; + message?: string; + phase?: 'build' | 'layout' | 'finalize'; + }; if (data.type === 'progress') { // Stop fake progress when real progress starts arriving if (fakeProgressTimer) { clearInterval(fakeProgressTimer); fakeProgressTimer = null; } - graphLoading.set({ active: true, phase: data.phase, progress: data.progress }); + graphLoading.set({ + active: true, + phase: data.phase || 'build', + progress: data.progress || 0 + }); } else if (data.type === 'done') { - tempNodes = data.nodes; + tempNodes = data.nodes || []; graphLoading.set({ active: true, phase: 'finalize', progress: 1 }); worker.terminate(); resolve(); @@ -333,7 +344,7 @@ function getJsonAtPath(root: JsonValue, pathStr: string): JsonValue | null { if (!pathStr) return root; const parts = pathStr.split('.').filter(Boolean); - let cur: any = root as any; + let cur = root as JsonValue; for (const p of parts) { if (cur == null) return null; if (Array.isArray(cur)) { @@ -341,7 +352,7 @@ if (Number.isNaN(idx)) return null; cur = cur[idx]; } else if (typeof cur === 'object') { - cur = (cur as any)[p]; + cur = (cur as Record)[p]; } else { return null; } @@ -372,36 +383,46 @@ tempNodes = []; tempEdges = []; - refItems.forEach((refItem: any, idx: number) => { - const visible = nodeShowsAll || idx < LAYOUT_CONFIG.MAX_DISPLAY_ITEMS; - const referenceKey = `${parentId}-${refItem.key}`; - const isRefExpanded = expandedReferences.has(referenceKey) || true; // default expanded - if (!visible || !isRefExpanded) return; - const childId: string = refItem.targetNodeId || `node-${++nodeId}`; - const childPath = parentPath ? `${parentPath}.${refItem.key}` : refItem.key; - const childJson = getJsonAtPath(jsonData as JsonValue, childPath); - // Build subtree starting from this child - createCompactGraph( - childJson as JsonValue, - parentId, - refItem.key, - 0, - parentPath ? parentPath.split('.') : [], - true, - childId - ); - // Connect parent to child with reference edge - tempEdges.push({ - id: `edge-${parentId}-${refItem.key}-${childId}`, - source: parentId, - sourceHandle: `${parentId}-${refItem.key}`, - target: childId, - type: 'bezier', - animated: false, - style: 'stroke: #9ca3af; stroke-width: 1' - } as any); - expandedReferences.add(referenceKey); - }); + refItems.forEach( + ( + refItem: { + key: string; + value: JsonValue; + isReferenceExpanded?: boolean; + targetNodeId?: string; + }, + idx: number + ) => { + const visible = nodeShowsAll || idx < LAYOUT_CONFIG.MAX_DISPLAY_ITEMS; + const referenceKey = `${parentId}-${refItem.key}`; + const isRefExpanded = expandedReferences.has(referenceKey) || true; // default expanded + if (!visible || !isRefExpanded) return; + const childId: string = refItem.targetNodeId || `node-${++nodeId}`; + const childPath = parentPath ? `${parentPath}.${refItem.key}` : refItem.key; + const childJson = getJsonAtPath(jsonData as JsonValue, childPath); + // Build subtree starting from this child + createCompactGraph( + childJson as JsonValue, + parentId, + refItem.key, + 0, + parentPath ? parentPath.split('.') : [], + true, + childId + ); + // Connect parent to child with reference edge + tempEdges.push({ + id: `edge-${parentId}-${refItem.key}-${childId}`, + source: parentId, + sourceHandle: `${parentId}-${refItem.key}`, + target: childId, + type: 'bezier', + animated: false, + style: 'stroke: #9ca3af; stroke-width: 1' + } as Edge); + expandedReferences.add(referenceKey); + } + ); // Merge new nodes/edges if (tempNodes.length > 0) { diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte new file mode 100644 index 0000000..636dde5 --- /dev/null +++ b/src/lib/components/TabBar.svelte @@ -0,0 +1,278 @@ + + +
+
+ + + {#each tabsStore.tabs as tab (tab.id)} + + {#if renamingTabId === tab.id} + e.stopPropagation()} + onkeydown={(e) => { + if (e.key === 'Enter') { + confirmRename(); + } else if (e.key === 'Escape') { + cancelRename(); + } + }} + onblur={confirmRename} + autofocus + /> + {:else} +
+ + {tab.name} + {#if tab.metadata?.lastModified} + + {/if} +
+ {/if} + +
+ + e.stopPropagation()}> + {#snippet child({ props })} +
+ +
+ {/snippet} +
+ + startRename(tab.id)}> + + {$LL.tabs.rename()} + + handleDuplicateTab(tab.id)}> + + {$LL.tabs.duplicate()} + + handleExportTab(tab.id)}> + + {$LL.tabs.exportJson()} + + + tabsStore.closeTab(tab.id)} + disabled={tabsStore.tabs.length === 1} + class="text-destructive focus:text-destructive" + > + + {$LL.tabs.closeTab()} + + +
+ + {#if tabsStore.tabs.length > 1} + + {/if} +
+
+ {/each} +
+
+ +
+ + + + + + + + fileInput?.click()}> + + {$LL.tabs.importJsonFile()} + + + +
+
+
+ + diff --git a/src/lib/components/ui/tabs/index.ts b/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..d2a7939 --- /dev/null +++ b/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from './tabs.svelte'; +import Content from './tabs-content.svelte'; +import List from './tabs-list.svelte'; +import Trigger from './tabs-trigger.svelte'; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger +}; diff --git a/src/lib/components/ui/tabs/tabs-content.svelte b/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..92044c8 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..e875fe4 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..bf5b0de --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs.svelte b/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..b275bda --- /dev/null +++ b/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/services/http.ts b/src/lib/services/http.ts index 3d7cd99..32f5be6 100644 --- a/src/lib/services/http.ts +++ b/src/lib/services/http.ts @@ -52,11 +52,11 @@ export async function requestJson(opts: RequestJsonOptions): Promise k.toLowerCase() === 'content-type'); if (hasCT) { diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts new file mode 100644 index 0000000..4d372ff --- /dev/null +++ b/src/lib/stores/tabs.svelte.ts @@ -0,0 +1,396 @@ +import { logger } from '$lib/logger'; +import { generateSampleJSON } from '$lib/utils/faker-generator'; + +// Generate sample JSON data for new tabs using faker +function getDefaultJSON(): string { + return JSON.stringify(generateSampleJSON(), null, 2); +} + +// Tab data interface +export interface TabData { + id: string; + name: string; + jsonContent: string; + parsedJson?: unknown; // Cache parsed JSON for faster tab switching + editorState?: { + cursorPosition?: number; + selection?: { from: number; to: number }; + scrollPosition?: number; + }; + graphState?: { + expandedNodes: Set; + showAllItemsNodes: Set; + zoom?: number; + pan?: { x: number; y: number }; + }; + metadata?: { + source?: 'file' | 'url' | 'paste' | 'manual'; + lastModified?: Date; + }; + requestSettings?: { + url: string; + method: string; + headers: Array<{ key: string; value: string }>; + body: string; + sendAsRawText: boolean; + useEditorContent: boolean; + }; +} + +// Tabs store class using Svelte 5 runes +class TabsStore { + tabs = $state([]); + activeTabId = $state(''); + private saveTimeout: ReturnType | null = null; + + constructor() { + // Only initialize on client side + if (typeof window !== 'undefined') { + // Initialize with one default tab + this.addTab('Tab 1', getDefaultJSON()); + } + } + + // Add a new tab + addTab(name?: string, content?: string) { + const jsonContent = content || getDefaultJSON(); + + // Try to parse the JSON content + let parsedJson = null; + try { + parsedJson = JSON.parse(jsonContent); + } catch { + // Ignore parse errors + } + + const newTab: TabData = { + id: crypto.randomUUID(), + name: name || `Tab ${this.tabs.length + 1}`, + jsonContent, + parsedJson, + graphState: { + expandedNodes: new Set(), + showAllItemsNodes: new Set() + }, + requestSettings: { + url: 'https://jsonplaceholder.typicode.com/todos/1', + method: 'GET', + headers: [], + body: '', + sendAsRawText: false, + useEditorContent: false + } + }; + + this.tabs.push(newTab); + this.activeTabId = newTab.id; + logger.debug(`[TabsStore] Added new tab: ${newTab.name} (${newTab.id})`); + + // Trigger graph update for the new tab + this.triggerGraphUpdate(); + + // Save to localStorage after adding + this.debouncedSave(); + + return newTab.id; + } + + // Switch to a different tab + switchTab(id: string) { + const tab = this.tabs.find((t) => t.id === id); + if (!tab) { + logger.warn(`[TabsStore] Tab not found: ${id}`); + return; + } + + // Save current tab state before switching + this.saveCurrentTabState(); + + // Switch to new tab + this.activeTabId = id; + logger.debug(`[TabsStore] Switched to tab: ${tab.name} (${id})`); + + // Trigger graph update for the new tab + this.triggerGraphUpdate(); + + // Save to localStorage after switching (to persist activeTabId) + this.debouncedSave(); + } + + // Close a tab + closeTab(id: string) { + const index = this.tabs.findIndex((t) => t.id === id); + if (index === -1) { + logger.warn(`[TabsStore] Cannot close tab - not found: ${id}`); + return; + } + + // Don't allow closing the last tab + if (this.tabs.length === 1) { + logger.warn('[TabsStore] Cannot close the last tab'); + return; + } + + const tabName = this.tabs[index].name; + this.tabs.splice(index, 1); + logger.debug(`[TabsStore] Closed tab: ${tabName} (${id})`); + + // If closing active tab, switch to adjacent tab + if (this.activeTabId === id && this.tabs.length > 0) { + const newIndex = Math.min(index, this.tabs.length - 1); + this.activeTabId = this.tabs[newIndex].id; + logger.debug(`[TabsStore] Switched to tab: ${this.tabs[newIndex].name}`); + this.triggerGraphUpdate(); + } + + // Save to localStorage after closing + this.debouncedSave(); + } + + // Rename a tab + renameTab(id: string, newName: string) { + const tab = this.tabs.find((t) => t.id === id); + if (!tab) { + logger.warn(`[TabsStore] Cannot rename tab - not found: ${id}`); + return; + } + + const oldName = tab.name; + tab.name = newName; + logger.debug(`[TabsStore] Renamed tab from "${oldName}" to "${newName}" (${id})`); + + // Save to localStorage after renaming + this.debouncedSave(); + } + + // Duplicate a tab + duplicateTab(id: string) { + const tab = this.tabs.find((t) => t.id === id); + if (!tab) { + logger.warn(`[TabsStore] Cannot duplicate tab - not found: ${id}`); + return; + } + + const newTab: TabData = { + ...tab, + id: crypto.randomUUID(), + name: `${tab.name} (copy)`, + parsedJson: tab.parsedJson, // Copy the cached parsed JSON + graphState: tab.graphState + ? { + expandedNodes: new Set(tab.graphState.expandedNodes), + showAllItemsNodes: new Set(tab.graphState.showAllItemsNodes), + zoom: tab.graphState.zoom, + pan: tab.graphState.pan ? { ...tab.graphState.pan } : undefined + } + : { + expandedNodes: new Set(), + showAllItemsNodes: new Set() + }, + requestSettings: tab.requestSettings + ? { + ...tab.requestSettings, + headers: tab.requestSettings.headers ? [...tab.requestSettings.headers] : [] + } + : { + url: 'https://jsonplaceholder.typicode.com/todos/1', + method: 'GET', + headers: [], + body: '', + sendAsRawText: false, + useEditorContent: false + } + }; + + this.tabs.push(newTab); + this.activeTabId = newTab.id; + logger.debug(`[TabsStore] Duplicated tab: ${tab.name} -> ${newTab.name} (${newTab.id})`); + this.triggerGraphUpdate(); + + // Save to localStorage after duplicating + this.debouncedSave(); + + return newTab.id; + } + + // Update JSON content of active tab + updateActiveTabContent(content: string) { + const tab = this.getActiveTab(); + if (!tab) return; + + tab.jsonContent = content; + + // Try to parse and cache the JSON + try { + tab.parsedJson = JSON.parse(content); + } catch { + tab.parsedJson = null; + } + + tab.metadata = { + ...tab.metadata, + lastModified: new Date() + }; + + // Auto-save after content update + this.debouncedSave(); + } + + // Update request settings for active tab + updateActiveTabRequestSettings(settings: Partial) { + const tab = this.getActiveTab(); + if (!tab) return; + + tab.requestSettings = { + ...tab.requestSettings, + ...settings + } as TabData['requestSettings']; + + this.debouncedSave(); + } + + // Debounced save to localStorage + private debouncedSave() { + if (typeof window === 'undefined') return; + + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + + this.saveTimeout = setTimeout(() => { + this.saveToStorage(); + }, 1000); + } + + // Save current tab state (called before switching tabs) + saveCurrentTabState() { + const tab = this.getActiveTab(); + if (!tab) return; + + // The editor and graph components will handle saving their states + // through event listeners + logger.debug(`[TabsStore] Saved state for tab: ${tab.name}`); + } + + // Get active tab + getActiveTab(): TabData | undefined { + return this.tabs.find((t) => t.id === this.activeTabId); + } + + // Get tab by ID + getTab(id: string): TabData | undefined { + return this.tabs.find((t) => t.id === id); + } + + // Update graph state for active tab + updateActiveTabGraphState(graphState: Partial) { + const tab = this.getActiveTab(); + if (!tab) return; + + tab.graphState = { + ...tab.graphState, + ...graphState + } as TabData['graphState']; + } + + // Update editor state for active tab + updateActiveTabEditorState(editorState: Partial) { + const tab = this.getActiveTab(); + if (!tab) return; + + tab.editorState = { + ...tab.editorState, + ...editorState + }; + } + + // Trigger graph update event + private triggerGraphUpdate() { + // Dispatch custom event to notify graph component to re-render + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('tabChanged')); + } + } + + // Clear all tabs and reset + reset() { + this.tabs = []; + this.activeTabId = ''; + this.addTab('Tab 1', getDefaultJSON()); + logger.debug('[TabsStore] Store reset'); + } + + // Load tabs from localStorage (for persistence) + loadFromStorage() { + try { + const stored = localStorage.getItem('pdjsoneditor_tabs'); + if (!stored) return; + + const data = JSON.parse(stored); + if (!data.tabs || !Array.isArray(data.tabs)) return; + + // Reconstruct tabs with Set objects + this.tabs = data.tabs.map((tab: TabData) => ({ + ...tab, + graphState: tab.graphState + ? { + ...tab.graphState, + expandedNodes: new Set(tab.graphState.expandedNodes || []), + showAllItemsNodes: new Set(tab.graphState.showAllItemsNodes || []) + } + : { + expandedNodes: new Set(), + showAllItemsNodes: new Set() + } + })); + + if (data.activeTabId && this.tabs.find((t) => t.id === data.activeTabId)) { + this.activeTabId = data.activeTabId; + } else if (this.tabs.length > 0) { + this.activeTabId = this.tabs[0].id; + } + + logger.debug(`[TabsStore] Loaded ${this.tabs.length} tabs from storage`); + } catch (error) { + logger.error('[TabsStore] Failed to load from storage:', error); + } + } + + // Save tabs to localStorage + saveToStorage() { + try { + const data = { + tabs: this.tabs.map((tab) => ({ + ...tab, + graphState: tab.graphState + ? { + ...tab.graphState, + expandedNodes: Array.from(tab.graphState.expandedNodes), + showAllItemsNodes: Array.from(tab.graphState.showAllItemsNodes) + } + : undefined + })), + activeTabId: this.activeTabId + }; + + localStorage.setItem('pdjsoneditor_tabs', JSON.stringify(data)); + logger.debug('[TabsStore] Saved tabs to storage'); + } catch (error) { + logger.error('[TabsStore] Failed to save to storage:', error); + } + } +} + +// Create singleton instance +export const tabsStore = new TabsStore(); + +// Initialize on client side +if (typeof window !== 'undefined') { + // Load from storage on initialization + tabsStore.loadFromStorage(); + + // If no tabs after loading, create default tab + if (tabsStore.tabs.length === 0) { + tabsStore.addTab('Tab 1', getDefaultJSON()); + } +} diff --git a/src/lib/utils/faker-generator.ts b/src/lib/utils/faker-generator.ts new file mode 100644 index 0000000..0a3167e --- /dev/null +++ b/src/lib/utils/faker-generator.ts @@ -0,0 +1,203 @@ +import { faker } from '@faker-js/faker'; + +/** + * Generate a sample JSON with faker data + * This creates a realistic data structure with various types (about 30 lines) + */ +export function generateSampleJSON(): unknown { + return { + id: faker.string.uuid(), + user: { + name: faker.person.fullName(), + email: faker.internet.email(), + age: faker.number.int({ min: 18, max: 65 }), + isActive: faker.datatype.boolean() + }, + address: { + street: faker.location.streetAddress(), + city: faker.location.city(), + country: faker.location.country(), + zipCode: faker.location.zipCode() + }, + products: Array.from({ length: 3 }, () => ({ + id: faker.string.uuid(), + name: faker.commerce.productName(), + price: faker.number.float({ min: 10, max: 1000, fractionDigits: 2 }), + inStock: faker.datatype.boolean() + })), + tags: Array.from({ length: 5 }, () => faker.word.adjective()), + metadata: { + createdAt: faker.date.past({ years: 1 }).toISOString(), + updatedAt: faker.date.recent({ days: 7 }).toISOString(), + version: faker.system.semver() + }, + settings: { + theme: faker.helpers.arrayElement(['light', 'dark', 'auto']), + notifications: faker.datatype.boolean() + }, + nullValue: null, + emptyArray: [] + }; +} + +/** + * Regenerate JSON values while preserving the structure + * @param json - The original JSON object + * @returns A new JSON with the same structure but regenerated values + */ +export function regenerateJSONValues(json: unknown): unknown { + if (json === null) { + return faker.helpers.maybe(() => faker.lorem.word(), { probability: 0.5 }) ?? null; + } + + if (Array.isArray(json)) { + // Keep array length but regenerate values + return json.map((item) => regenerateJSONValues(item)); + } + + if (typeof json === 'object' && json !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(json)) { + result[key] = regenerateJSONValues(value); + } + return result; + } + + // Regenerate primitive values based on type and context + return generateValueByType(json); +} + +/** + * Generate a new value based on the type and characteristics of the original value + */ +function generateValueByType(value: unknown): unknown { + // Handle null + if (value === null) { + return faker.helpers.maybe(() => faker.lorem.word(), { probability: 0.5 }) ?? null; + } + + // Handle boolean + if (typeof value === 'boolean') { + return faker.datatype.boolean(); + } + + // Handle number + if (typeof value === 'number') { + if (Number.isInteger(value)) { + // Estimate range based on value magnitude + const magnitude = Math.abs(value); + if (magnitude <= 100) { + return faker.number.int({ min: 0, max: 100 }); + } else if (magnitude <= 1000) { + return faker.number.int({ min: 100, max: 1000 }); + } else if (magnitude <= 10000) { + return faker.number.int({ min: 1000, max: 10000 }); + } else { + return faker.number.int({ min: 10000, max: 1000000 }); + } + } else { + // Float number + const magnitude = Math.abs(value); + if (magnitude <= 1) { + return faker.number.float({ min: 0, max: 1, fractionDigits: 3 }); + } else if (magnitude <= 100) { + return faker.number.float({ min: 0, max: 100, fractionDigits: 2 }); + } else { + return faker.number.float({ min: 100, max: 10000, fractionDigits: 2 }); + } + } + } + + // Handle string - try to detect the type of string + if (typeof value === 'string') { + // UUID pattern + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { + return faker.string.uuid(); + } + + // Email pattern + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + return faker.internet.email(); + } + + // URL pattern + if (/^https?:\/\/.+/.test(value)) { + // Check if it's an image URL + if (/\.(jpg|jpeg|png|gif|svg|webp)/i.test(value)) { + return faker.image.url(); + } + return faker.internet.url(); + } + + // Date ISO string pattern + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) { + return faker.date.recent({ days: 365 }).toISOString(); + } + + // Phone number pattern + if (/^[\d\s\-()+ ]+$/.test(value) && value.length >= 10) { + return faker.phone.number(); + } + + // Zip code pattern + if (/^\d{5}(-\d{4})?$/.test(value)) { + return faker.location.zipCode(); + } + + // Currency code pattern (3 uppercase letters) + if (/^[A-Z]{3}$/.test(value)) { + return faker.finance.currencyCode(); + } + + // Version pattern + if (/^\d+\.\d+\.\d+/.test(value)) { + return faker.system.semver(); + } + + // Status-like strings + const statusPatterns = [ + 'pending', + 'completed', + 'active', + 'inactive', + 'failed', + 'success', + 'processing' + ]; + if (statusPatterns.includes(value.toLowerCase())) { + return faker.helpers.arrayElement(statusPatterns); + } + + // Theme-like strings + if (['light', 'dark', 'auto'].includes(value.toLowerCase())) { + return faker.helpers.arrayElement(['light', 'dark', 'auto']); + } + + // Language codes + if (/^[a-z]{2}(-[A-Z]{2})?$/.test(value)) { + return faker.helpers.arrayElement(['en', 'ko', 'ja', 'es', 'fr', 'de', 'zh', 'pt']); + } + + // Names (simple heuristic - capitalize first letter) + if (value.length < 20 && /^[A-Z][a-z]+(\s[A-Z][a-z]+)?$/.test(value)) { + return faker.person.fullName(); + } + + // Single word + if (value.length < 20 && !/\s/.test(value)) { + return faker.word.noun(); + } + + // Default to lorem text of similar length + if (value.length < 50) { + return faker.lorem.words(Math.ceil(value.length / 5)); + } else if (value.length < 200) { + return faker.lorem.sentence(); + } else { + return faker.lorem.paragraph(); + } + } + + // Default case - return the original value + return value; +} diff --git a/src/lib/workers/graphLayout.worker.ts b/src/lib/workers/graphLayout.worker.ts index 1290b11..e3ad604 100644 --- a/src/lib/workers/graphLayout.worker.ts +++ b/src/lib/workers/graphLayout.worker.ts @@ -6,8 +6,8 @@ import dagre from 'dagre'; type NodeData = { label: string; - items?: any[]; - allItems?: any[]; + items?: unknown[]; + allItems?: unknown[]; isExpanded?: boolean; isArray?: boolean; nodeId?: string; @@ -60,7 +60,7 @@ type LayoutRequest = { type ProgressMsg = { type: 'progress'; phase: 'build' | 'layout'; progress: number }; type DoneMsg = { type: 'done'; nodes: FlowNode[] }; -const ctx: any = self; +const ctx = self as unknown as Worker; function estimateDisplayItemCount(node: FlowNode, cfg: LayoutConfig, showAll: boolean): number { if (!node.data?.isExpanded) return 0; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7600406..fbb1237 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,6 +2,8 @@ import { onMount } from 'svelte'; import JsonEditor from '$lib/components/JsonEditor.svelte'; import JsonGraph from '$lib/components/JsonGraph.svelte'; + import TabBar from '$lib/components/TabBar.svelte'; + import { tabsStore } from '$lib/stores/tabs.svelte'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; @@ -22,7 +24,8 @@ Plus, Trash2, X, - Copy + Copy, + RefreshCw } from 'lucide-svelte'; import { mode, toggleMode } from 'mode-watcher'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; @@ -32,6 +35,7 @@ import { STORAGE_KEYS } from '$lib/constants'; import { requestJson, type HttpMethod } from '$lib/services/http'; import { logger } from '$lib/logger'; + import { regenerateJSONValues, generateSampleJSON } from '$lib/utils/faker-generator'; // let jsonValue = $state(`{ // "name": "John Doe", @@ -66,52 +70,75 @@ // } // }`); - let jsonValue = $state(`{ - "id": "0001", - "type": "donut", - "name": "Cake", - "ppu": 0.55, - "batters": - { - "batter": - [ - { "id": "1001", "type": "Regular" }, - { "id": "1002", "type": "Chocolate" }, - { "id": "1003", "type": "Blueberry" }, - { "id": "1004", "type": "Devil's Food" } - ] - }, - "topping": - [ - { "id": "5001", "type": "None" }, - { "id": "5002", "type": "Glazed" }, - { "id": "5005", "type": "Sugar" }, - { "id": "5007", "type": "Powdered Sugar" }, - { "id": "5006", "type": "Chocolate with Sprinkles" }, - { "id": "5003", "type": "Chocolate" }, - { "id": "5004", "type": "Maple" } - ] -}`); + // Get active tab's JSON value (not used directly, kept for reference) + // let jsonValue = $derived(tabsStore.getActiveTab()?.jsonContent || ''); + + // Create a local state for the editor that syncs with the active tab + let editorValue = $state(''); + + // Update editor value when tab changes + $effect(() => { + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + editorValue = activeTab.jsonContent; + // Use cached parsed JSON if available for instant switching + if (activeTab.parsedJson !== undefined) { + parsedJson = activeTab.parsedJson as JsonValue; + error = activeTab.parsedJson ? '' : $LL.editor.invalidJson(); + } else { + // Parse if not cached + try { + parsedJson = JSON.parse(activeTab.jsonContent); + error = ''; + } catch (e) { + error = e instanceof Error ? e.message : $LL.editor.invalidJson(); + parsedJson = null; + } + } + } + }); + + // Update tab content when editor changes + $effect(() => { + const activeTab = tabsStore.getActiveTab(); + if (activeTab && editorValue !== activeTab.jsonContent) { + tabsStore.updateActiveTabContent(editorValue); + } + }); let parsedJson = $state(null); let error = $state(''); - let editorRef: JsonEditor; + let editorRef = $state(null); let parseTimeout: ReturnType; // LocalStorage keys are centralized in $lib/constants - // Initialize with empty values (will be populated from localStorage in onMount) - let urlInput = $state('https://jsonplaceholder.typicode.com/todos/1'); + // Local state for URL input (for two-way binding) + let urlInputLocal = $state(''); + + // Get current tab's request settings (derived) + let httpMethod = $derived(tabsStore.getActiveTab()?.requestSettings?.method || 'GET'); + let customHeaders = $derived(tabsStore.getActiveTab()?.requestSettings?.headers || []); + let customBody = $derived(tabsStore.getActiveTab()?.requestSettings?.body || ''); + let sendAsRawText = $derived(tabsStore.getActiveTab()?.requestSettings?.sendAsRawText || false); + let useEditorContent = $derived( + tabsStore.getActiveTab()?.requestSettings?.useEditorContent || false + ); + + // Sync local URL input with tab's URL + $effect(() => { + const tabUrl = + tabsStore.getActiveTab()?.requestSettings?.url || + 'https://jsonplaceholder.typicode.com/todos/1'; + urlInputLocal = tabUrl; + }); + + // UI state (not tab-specific) let isLoading = $state(false); - let httpMethod = $state('GET'); let isDialogOpen = $state(false); - let customHeaders = $state>([]); let tempHeaders = $state>([]); - let customBody = $state(''); let tempBody = $state(''); - let sendAsRawText = $state(false); let tempSendAsRawText = $state(false); - let useEditorContent = $state(false); let tempUseEditorContent = $state(false); let httpStatusCode = $state(null); let responseTime = $state(null); @@ -135,17 +162,13 @@ } function saveSettings() { - // Save temp to actual - customHeaders = [...tempHeaders]; - customBody = tempBody; - sendAsRawText = tempSendAsRawText; - useEditorContent = tempUseEditorContent; - - // Save to localStorage - localStorage.setItem(STORAGE_KEYS.HEADERS, JSON.stringify(customHeaders)); - localStorage.setItem(STORAGE_KEYS.BODY, customBody); - localStorage.setItem(STORAGE_KEYS.RAW_BODY_MODE, JSON.stringify(sendAsRawText)); - localStorage.setItem(STORAGE_KEYS.USE_EDITOR_CONTENT, JSON.stringify(useEditorContent)); + // Save to active tab + tabsStore.updateActiveTabRequestSettings({ + headers: [...tempHeaders], + body: tempBody, + sendAsRawText: tempSendAsRawText, + useEditorContent: tempUseEditorContent + }); isDialogOpen = false; } @@ -166,13 +189,15 @@ localStorage.removeItem(key); }); - // Reset all values to defaults - urlInput = 'https://jsonplaceholder.typicode.com/todos/1'; - httpMethod = 'GET'; - customHeaders = []; - customBody = ''; - sendAsRawText = false; - useEditorContent = false; + // Reset tab's request settings to defaults + tabsStore.updateActiveTabRequestSettings({ + url: 'https://jsonplaceholder.typicode.com/todos/1', + method: 'GET', + headers: [], + body: '', + sendAsRawText: false, + useEditorContent: false + }); httpStatusCode = null; responseTime = null; error = ''; @@ -183,8 +208,8 @@ tempSendAsRawText = false; tempUseEditorContent = false; - // Save reset URL and method to localStorage - saveUrlAndMethod(); + // Close dialog + isDialogOpen = false; } } @@ -202,26 +227,37 @@ ); } - function saveUrlAndMethod() { - localStorage.setItem(STORAGE_KEYS.URL, urlInput); - localStorage.setItem(STORAGE_KEYS.METHOD, httpMethod); + function saveUrlAndMethod(url?: string, method?: string) { + const activeTab = tabsStore.getActiveTab(); + if (!activeTab) return; + + tabsStore.updateActiveTabRequestSettings({ + url: url ?? activeTab.requestSettings?.url ?? '', + method: method ?? activeTab.requestSettings?.method ?? 'GET' + }); } function clearJson() { - jsonValue = ''; + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + tabsStore.updateActiveTabContent(''); + // Update local state immediately + editorValue = ''; + parsedJson = null; + } error = ''; } async function copyJson() { try { - await navigator.clipboard.writeText(jsonValue); + await navigator.clipboard.writeText(editorValue); toast.success($LL.header.copySuccess()); } catch (e) { logger.error('Failed to copy to clipboard:', e); // Fallback for older browsers try { const textArea = document.createElement('textarea'); - textArea.value = jsonValue; + textArea.value = editorValue; document.body.appendChild(textArea); textArea.focus(); textArea.select(); @@ -237,9 +273,16 @@ function formatJson() { try { - const parsed = JSON.parse(jsonValue); - jsonValue = JSON.stringify(parsed, null, 2); - error = ''; + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + const parsed = JSON.parse(activeTab.jsonContent); + const formatted = JSON.stringify(parsed, null, 2); + tabsStore.updateActiveTabContent(formatted); + // Update local state immediately + editorValue = formatted; + parsedJson = parsed; + error = ''; + } } catch (e) { error = e instanceof Error ? e.message : $LL.editor.invalidJson(); } @@ -247,20 +290,66 @@ function minifyJson() { try { - const parsed = JSON.parse(jsonValue); - jsonValue = JSON.stringify(parsed); - error = ''; + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + const parsed = JSON.parse(activeTab.jsonContent); + const minified = JSON.stringify(parsed); + tabsStore.updateActiveTabContent(minified); + // Update local state immediately + editorValue = minified; + parsedJson = parsed; + error = ''; + } } catch (e) { error = e instanceof Error ? e.message : $LL.editor.invalidJson(); } } + function regenerateValues() { + try { + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + let regenerated; + + // Check if current JSON is empty or invalid + if (!activeTab.jsonContent || activeTab.jsonContent.trim() === '') { + // Generate new sample JSON + regenerated = generateSampleJSON(); + } else { + try { + // Try to parse existing JSON + const parsed = JSON.parse(activeTab.jsonContent); + // Regenerate values while preserving structure + regenerated = regenerateJSONValues(parsed); + } catch { + // If parsing fails, generate new sample JSON + regenerated = generateSampleJSON(); + } + } + + const formatted = JSON.stringify(regenerated, null, 2); + tabsStore.updateActiveTabContent(formatted); + // Update local state immediately + editorValue = formatted; + parsedJson = regenerated as JsonValue; + error = ''; + toast.success($LL.editor.regenerateSuccess()); + } + } catch (e) { + logger.error('Failed to regenerate JSON values:', e); + toast.error($LL.editor.invalidJson()); + } + } + async function fetchJsonFromUrl() { - if (!urlInput.trim()) { + if (!urlInputLocal.trim()) { error = $LL.editor.urlRequired(); return; } + // Save the URL before fetching + saveUrlAndMethod(urlInputLocal, undefined); + isLoading = true; // Only clear error if it's a fetch-related error if (error && (error.includes('fetch') || error.includes('HTTP'))) { @@ -279,9 +368,9 @@ const startTime = performance.now(); const res = await requestJson({ method: httpMethod as HttpMethod, - url: urlInput, + url: urlInputLocal, headers: customHeaders, - editorJson: jsonValue, + editorJson: editorValue, customBody, sendAsRawText, useEditorContent, @@ -292,17 +381,24 @@ httpStatusCode = res.status; if (res.data !== undefined) { - jsonValue = JSON.stringify(res.data, null, 2); + const formattedJson = JSON.stringify(res.data, null, 2); + tabsStore.updateActiveTabContent(formattedJson); + // Also update the local parsed JSON immediately + parsedJson = res.data as JsonValue; } else if (res.rawText !== undefined) { - // Non-JSON 응답은 텍스트로 보여줌 - jsonValue = JSON.stringify({ response: res.rawText }, null, 2); + // Non-JSON response shown as text + const responseObj = { response: res.rawText }; + const formattedJson = JSON.stringify(responseObj, null, 2); + tabsStore.updateActiveTabContent(formattedJson); + // Also update the local parsed JSON immediately + parsedJson = responseObj as JsonValue; } if (res.ok && error && (error.includes('fetch') || error.includes('HTTP'))) { error = ''; } } catch (e) { - if ((e as any)?.name === 'AbortError') { + if ((e as Error)?.name === 'AbortError') { // silently ignore aborted request return; } @@ -327,16 +423,15 @@ } function handleUrlBlur() { - saveUrlAndMethod(); + saveUrlAndMethod(urlInputLocal, undefined); } function handleMethodChange(newMethod: string) { - httpMethod = newMethod; - saveUrlAndMethod(); + saveUrlAndMethod(undefined, newMethod); } $effect(() => { - const currentJsonValue = jsonValue; + const currentJsonValue = editorValue; // console.log('[Page Effect] JSON value changed, length:', currentJsonValue.length); // Clear previous timeout @@ -353,9 +448,21 @@ mod.graphLoading.set({ active: true, phase: 'build', progress: 0 }); parsedJson = JSON.parse(currentJsonValue); error = ''; + + // Update cached parsed JSON in the active tab + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + activeTab.parsedJson = parsedJson; + } } catch (e) { error = e instanceof Error ? e.message : $LL.editor.invalidJson(); parsedJson = null; + + // Update cached parsed JSON in the active tab + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + activeTab.parsedJson = null; + } } }, 500); @@ -367,62 +474,6 @@ }); onMount(() => { - // Load saved values from localStorage - const savedUrl = localStorage.getItem(STORAGE_KEYS.URL); - const savedMethod = localStorage.getItem(STORAGE_KEYS.METHOD); - const savedHeaders = localStorage.getItem(STORAGE_KEYS.HEADERS); - const savedBody = localStorage.getItem(STORAGE_KEYS.BODY); - const savedRawBodyMode = localStorage.getItem(STORAGE_KEYS.RAW_BODY_MODE); - const savedUseEditorContent = localStorage.getItem(STORAGE_KEYS.USE_EDITOR_CONTENT); - - if (savedUrl) { - urlInput = savedUrl; - } - if (savedMethod && httpMethods.includes(savedMethod as any)) { - httpMethod = savedMethod; - } - if (savedHeaders) { - try { - const parsed = JSON.parse(savedHeaders); - if (Array.isArray(parsed) && parsed.length > 0) { - // Ensure it's a proper array assignment - customHeaders = [...parsed]; - } else { - // Keep empty if no headers saved - customHeaders = []; - } - } catch (e) { - logger.error('Failed to parse saved headers:', e); - // Keep empty on error - customHeaders = []; - } - } else { - // Keep empty if nothing saved - customHeaders = []; - } - - if (savedBody !== null && savedBody !== undefined && savedBody !== '') { - customBody = savedBody; - } - - if (savedRawBodyMode !== null) { - try { - sendAsRawText = JSON.parse(savedRawBodyMode); - } catch (e) { - logger.error('Failed to parse saved raw body mode:', e); - sendAsRawText = false; - } - } - - if (savedUseEditorContent !== null) { - try { - useEditorContent = JSON.parse(savedUseEditorContent); - } catch (e) { - logger.error('Failed to parse saved use editor content:', e); - useEditorContent = false; - } - } - const handleNodeClick = (e: CustomEvent) => { const path = e.detail; if (path && editorRef) { @@ -430,10 +481,34 @@ } }; + const handleTabChanged = () => { + // Force re-render of graph when tab changes + const activeTab = tabsStore.getActiveTab(); + if (activeTab) { + editorValue = activeTab.jsonContent; + // Use cached parsed JSON if available for instant switching + if (activeTab.parsedJson !== undefined) { + parsedJson = activeTab.parsedJson as JsonValue; + error = activeTab.parsedJson ? '' : $LL.editor.invalidJson(); + } else { + // Parse if not cached + try { + parsedJson = JSON.parse(activeTab.jsonContent); + error = ''; + } catch (e) { + error = e instanceof Error ? e.message : $LL.editor.invalidJson(); + parsedJson = null; + } + } + } + }; + window.addEventListener('nodeClick', handleNodeClick as EventListener); + window.addEventListener('tabChanged', handleTabChanged as EventListener); return () => { window.removeEventListener('nodeClick', handleNodeClick as EventListener); + window.removeEventListener('tabChanged', handleTabChanged as EventListener); }; }); @@ -454,17 +529,20 @@ + + +
- - + + {httpMethod} + {#each httpMethods as method} @@ -483,7 +561,7 @@ {$LL.header.minify()} +
- + {#key tabsStore.activeTabId} + + {/key}
@@ -565,13 +655,15 @@
- {#if parsedJson} - - {:else} -
- {$LL.editor.placeholder()} -
- {/if} + {#key tabsStore.activeTabId} + {#if parsedJson} + + {:else} +
+ {$LL.editor.placeholder()} +
+ {/if} + {/key}