diff --git a/.gitignore b/.gitignore index 36a7756..ad92c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -322,3 +322,6 @@ certificates/*.crt certificates/*.p12 certificates/*.pfx !certificates/.gitkeep + +# Claude +CLAUDE.md diff --git a/src/components/tools/JsonPathFinder.tsx b/src/components/tools/JsonPathFinder.tsx index aebaf52..48a61e0 100644 --- a/src/components/tools/JsonPathFinder.tsx +++ b/src/components/tools/JsonPathFinder.tsx @@ -8,13 +8,16 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Input } from '@/components/ui/input'; import { JsonTreeView } from '@/components/ui/json-tree-view'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { DEFAULT_JSON_PATH_OPTIONS, JSON_PATH_COMMON_PATTERNS, JSON_PATH_EXAMPLES, + JSON_PATH_SORT_OPTIONS, type JsonPathFinderOptions } from '@/config/json-path-finder-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; +import { sortObjectKeys } from '@/libs/json-formatter'; import { evaluateJsonPath, formatJsonPathResults, @@ -22,8 +25,8 @@ import { type JsonPathResult } from '@/libs/json-path-finder'; import { cn } from '@/libs/utils'; -import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowDownTrayIcon, ArrowPathIcon, ChevronDownIcon, DocumentArrowUpIcon, LinkIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; interface JsonPathFinderProps { className?: string; @@ -45,6 +48,14 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { const [activeTab, setActiveTab] = useState('tree'); const [getExpandedJson, setGetExpandedJson] = useState<(() => string) | null>(null); + // URL loader state + const [urlInputVisible, setUrlInputVisible] = useState(false); + const [urlValue, setUrlValue] = useState(''); + const [isFetchingUrl, setIsFetchingUrl] = useState(false); + + // File upload ref + const fileInputRef = useRef(null); + // Editor settings const [theme] = useCodeEditorTheme('basicDark'); const [inputWrapText, setInputWrapText] = useState(true); @@ -133,6 +144,11 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { return; } + // Apply key sorting if enabled + if (options.sortKeys !== 'none') { + jsonData = sortObjectKeys(jsonData, options.sortKeys); + } + // Simulate async operation for better UX await new Promise(resolve => setTimeout(resolve, 300)); @@ -166,7 +182,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { const handleLoadPattern = (pattern: typeof JSON_PATH_COMMON_PATTERNS[0]) => { setPathInput(pattern.example); - // Focus on path input setTimeout(() => { const pathInputElement = document.querySelector('input[placeholder*="JSONPath"]') as HTMLInputElement; if (pathInputElement) { @@ -175,32 +190,85 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { }, 100); }; - const getCharacterCount = (text: string): number => { - return text.length; - }; + const handleFileUpload = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const content = ev.target?.result as string; + setJsonInput(content); + setError(''); + setOutput(''); + setResult(null); + }; + reader.onerror = () => setError('Failed to read file'); + reader.readAsText(file); + // Reset so the same file can be re-uploaded + e.target.value = ''; + }, []); + + const handleLoadUrl = useCallback(async () => { + const url = urlValue.trim(); + if (!url) return; + setIsFetchingUrl(true); + setError(''); + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`); + const text = await res.text(); + setJsonInput(text); + setOutput(''); + setResult(null); + setUrlInputVisible(false); + setUrlValue(''); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to fetch URL'; + setError( + msg.includes('Failed to fetch') || msg.includes('NetworkError') + ? `Could not fetch URL — the server may not allow cross-origin requests (CORS). Try downloading the file and uploading it instead.` + : msg + ); + } finally { + setIsFetchingUrl(false); + } + }, [urlValue]); + + const handleDownloadResults = useCallback(() => { + if (!output) return; + const blob = new Blob([output], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'jsonpath-results.json'; + a.click(); + URL.revokeObjectURL(url); + }, [output]); + + const getCharacterCount = (text: string): number => text.length; const getLineCount = (text: string): number => { if (!text) return 0; return text.split('\n').length; }; - // Parse JSON safely + // Parse JSON safely, applying sort if configured const parsedJsonData = useMemo(() => { if (!jsonInput.trim()) return null; try { - return JSON.parse(jsonInput); + const parsed = JSON.parse(jsonInput); + return options.sortKeys !== 'none' ? sortObjectKeys(parsed, options.sortKeys) : parsed; } catch { return null; } - }, [jsonInput]); + }, [jsonInput, options.sortKeys]); // Create output tabs - Always show both tabs const outputTabs: CodeOutputTab[] = useMemo(() => { - const tabs: CodeOutputTab[] = [ + return [ { id: 'tree', label: 'Tree View', - value: '', // Not used for tree view + value: '', language: 'json' }, { @@ -210,19 +278,14 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { language: 'json' } ]; - - return tabs; }, [output]); // Ensure activeTab is valid when outputTabs change - // Default to 'tree' if available, otherwise use first available tab useEffect(() => { if (outputTabs.length > 0) { const treeTab = outputTabs.find(tab => tab.id === 'tree'); const currentTab = outputTabs.find(tab => tab.id === activeTab); - if (!currentTab) { - // If current tab is not available, switch to tree if available, otherwise first tab setActiveTab(treeTab ? 'tree' : outputTabs[0].id); } } @@ -231,7 +294,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { // Custom tab content renderer const renderCustomTabContent = (tabId: string): React.ReactNode => { if (tabId === 'tree') { - // Show tree view if JSON is valid, otherwise show empty state message if (parsedJsonData !== null) { return (
@@ -239,7 +301,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { data={parsedJsonData} highlightedPaths={result?.paths || []} onPathClick={(path) => { - // Copy path to clipboard navigator.clipboard.writeText(path).catch(console.error); }} onGetExpandedJson={(fn: () => string) => setGetExpandedJson(() => fn)} @@ -249,7 +310,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) {
); } - // Show empty state when no JSON is provided return (
@@ -272,10 +332,9 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { return null; } } - // Function not ready yet, but button should still be enabled return ''; } - return null; // Use default copy behavior for other tabs + return null; }, [activeTab, getExpandedJson]); return ( @@ -358,8 +417,55 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) {

+ {/* URL Loader (shown when "From URL" is active) */} + {urlInputVisible && ( +
+ setUrlValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLoadUrl(); + if (e.key === 'Escape') { setUrlInputVisible(false); setUrlValue(''); } + }} + className="flex-1 font-mono text-sm h-9" + autoFocus + /> + + +
+ )} + {/* Side-by-side Editor Panels */}
+ {/* Hidden file input */} + + {/* Input Panel */} - - - - - {JSON_PATH_EXAMPLES.map((example, index) => ( - handleLoadExample(example)} - className="flex flex-col items-start gap-1" - > - {example.name} - - {example.path} - - - {example.description} - - - ))} - - +
+ + + + + + {JSON_PATH_EXAMPLES.map((example, index) => ( + handleLoadExample(example)} + className="flex flex-col items-start gap-1" + > + {example.name} + + {example.path} + + + {example.description} + + + ))} + + + + + + +
} footerLeftContent={ {getCharacterCount(jsonInput)} characters @@ -421,6 +545,40 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { wrapText={outputWrapText} onWrapTextChange={setOutputWrapText} showWrapToggle={activeTab === 'results'} + headerActions={ + activeTab === 'results' && output ? ( + + ) : undefined + } + footerRightContent={ + activeTab === 'tree' ? ( + + ) : undefined + } footerLeftContent={ <> {activeTab === 'tree' && result && ( diff --git a/src/components/ui/json-tree-view.tsx b/src/components/ui/json-tree-view.tsx index 5b60805..ad8ee40 100644 --- a/src/components/ui/json-tree-view.tsx +++ b/src/components/ui/json-tree-view.tsx @@ -24,6 +24,8 @@ export interface JsonTreeNode { isExpanded?: boolean; children?: JsonTreeNode[]; parent?: JsonTreeNode; + isLastChild: boolean; + continuations: boolean[]; // per ancestor slot: true = draw vertical continuation line } export interface JsonTreeViewProps { @@ -43,10 +45,17 @@ function transformToTreeNodes( path: string = '$', level: number = 0, maxDepth: number = 3, - parent?: JsonTreeNode + parent?: JsonTreeNode, + isLastChild: boolean = true, + continuations: boolean[] = [] ): JsonTreeNode[] { const nodes: JsonTreeNode[] = []; + // Continuations to pass down to children of this node: + // - root (level 0) children get [] (no ancestor slots needed yet) + // - deeper children get [...parent.continuations, !parent.isLastChild] + const childContinuations = level === 0 ? [] : [...continuations, !isLastChild]; + if (data === null || data === undefined) { nodes.push({ key, @@ -56,6 +65,8 @@ function transformToTreeNodes( level, isExpanded: false, parent, + isLastChild, + continuations, }); return nodes; } @@ -70,9 +81,12 @@ function transformToTreeNodes( isExpanded: level < maxDepth, parent, children: [], + isLastChild, + continuations, }; node.children = data.map((item, index) => { - const childNodes = transformToTreeNodes(item, index, `${path}[${index}]`, level + 1, maxDepth, node); + const childIsLast = index === data.length - 1; + const childNodes = transformToTreeNodes(item, index, `${path}[${index}]`, level + 1, maxDepth, node, childIsLast, childContinuations); return childNodes[0]; }); nodes.push(node); @@ -87,9 +101,12 @@ function transformToTreeNodes( isExpanded: level < maxDepth, parent, children: [], + isLastChild, + continuations, }; - node.children = keys.map((k) => { - const childNodes = transformToTreeNodes(data[k], k, `${path}.${k}`, level + 1, maxDepth, node); + node.children = keys.map((k, index) => { + const childIsLast = index === keys.length - 1; + const childNodes = transformToTreeNodes(data[k], k, `${path}.${k}`, level + 1, maxDepth, node, childIsLast, childContinuations); return childNodes[0]; }); nodes.push(node); @@ -103,6 +120,8 @@ function transformToTreeNodes( level, isExpanded: false, parent, + isLastChild, + continuations, }); } @@ -210,13 +229,12 @@ function TreeNodeItem({ isCopied, }: TreeNodeItemProps) { const hasChildren = node.children && node.children.length > 0; - const indent = node.level * 20; const isRoot = node.key === 'root' && node.level === 0; const displayKey = isRoot ? (node.type === 'array' ? '[]' : node.type === 'object' ? '{}' : '') : (typeof node.key === 'number' ? `[${node.key}]` : String(node.key)); const displayValue = node.type === 'object' || node.type === 'array' - ? `${node.type === 'array' ? 'Array' : 'Object'} (${node.children?.length || 0} ${node.children?.length === 1 ? 'item' : 'items'})` + ? node.type === 'array' ? `[${node.children?.length || 0}]` : `{${node.children?.length || 0}}` : formatValue(node.value, node.type); const handleCopy = (e: React.MouseEvent) => { @@ -239,12 +257,56 @@ function TreeNodeItem({ return (
+ {/* Tree Lines */} + {node.level === 0 ? ( +
+ ) : ( +
+ {/* Ancestor continuation slots */} + {node.continuations.map((hasContinuation, i) => ( +
+ {hasContinuation && ( +
+ )} +
+ ))} + {/* Current-level connector (├ or └ shape) */} +
+ {/* Vertical segment: full height if not last child, half if last */} +
+ {/* Horizontal segment */} +
+
+
+ )} + + {/* Copy Button (left of expand, shows path as tooltip on hover) */} +
+ + {/* Path tooltip */} +
+ {node.path} +
+
+ {/* Expand/Collapse Button */}
); } diff --git a/src/config/json-path-finder-config.ts b/src/config/json-path-finder-config.ts index 5e4ef3c..056e2c8 100644 --- a/src/config/json-path-finder-config.ts +++ b/src/config/json-path-finder-config.ts @@ -2,14 +2,22 @@ export interface JsonPathFinderOptions { returnPaths: boolean; returnValues: boolean; formatOutput: boolean; + sortKeys: 'none' | 'asc' | 'desc'; } export const DEFAULT_JSON_PATH_OPTIONS: JsonPathFinderOptions = { returnPaths: true, returnValues: true, - formatOutput: true + formatOutput: true, + sortKeys: 'none', }; +export const JSON_PATH_SORT_OPTIONS = [ + { value: 'none', label: 'Keep original order' }, + { value: 'asc', label: 'Ascending (A-Z)' }, + { value: 'desc', label: 'Descending (Z-A)' }, +] as const; + export const JSON_PATH_EXAMPLES = [ { name: 'Simple Object', diff --git a/src/libs/json-formatter.ts b/src/libs/json-formatter.ts index e1e2f8b..b17c4fa 100644 --- a/src/libs/json-formatter.ts +++ b/src/libs/json-formatter.ts @@ -85,7 +85,7 @@ export function validateJson(jsonString: string): { isValid: boolean; error?: st /** * Sort object keys recursively */ -function sortObjectKeys(obj: any, sortOrder: 'asc' | 'desc'): any { +export function sortObjectKeys(obj: any, sortOrder: 'asc' | 'desc'): any { if (obj === null || typeof obj !== 'object') { return obj; }