diff --git a/src/components/tools/DiffChecker.tsx b/src/components/tools/DiffChecker.tsx index 7808593..3ce8f81 100644 --- a/src/components/tools/DiffChecker.tsx +++ b/src/components/tools/DiffChecker.tsx @@ -4,6 +4,7 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { LoadFileButton } from '@/components/ui/load-file-button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; import { DEFAULT_DIFF_OPTIONS, DIFF_CHECKER_OPTIONS, DIFF_EXAMPLES } from '@/config/diff-checker-config'; @@ -38,6 +39,18 @@ interface InlineDecoration { type: 'added' | 'removed'; } +const EXTENSION_LANGUAGE_MAP: Record = { + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', + ts: 'typescript', tsx: 'typescript', + json: 'json', html: 'html', htm: 'html', + css: 'css', scss: 'css', less: 'css', + py: 'python', java: 'java', cs: 'csharp', + cpp: 'cpp', cc: 'cpp', go: 'go', rs: 'rust', + sql: 'sql', xml: 'xml', svg: 'xml', + yaml: 'yaml', yml: 'yaml', md: 'markdown', + sh: 'shell', bash: 'shell', zsh: 'shell', +}; + export function DiffChecker({ className, instanceId }: DiffCheckerProps) { const { toolState, updateToolState } = useToolState('diff-checker', instanceId); @@ -57,6 +70,10 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { const originalDecorationsRef = useRef([]); const modifiedDecorationsRef = useRef([]); + // View zone IDs for alignment padding + const originalViewZoneIdsRef = useRef([]); + const modifiedViewZoneIdsRef = useRef([]); + // Scroll sync refs const isSyncingScrollRef = useRef(false); const originalScrollDisposableRef = useRef(null); @@ -98,6 +115,11 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { } }, [toolState, isHydrated]); + interface ViewZoneData { + afterLineNumber: number; + heightInLines: number; + } + // Calculate diff and apply decorations with character-level highlighting const calculateDiffAndDecorate = useCallback(() => { const lineChanges: Change[] = diffLines(originalText, modifiedText, { @@ -110,6 +132,8 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { const modifiedLineDecorations: any[] = []; const originalInlineDecorations: InlineDecoration[] = []; const modifiedInlineDecorations: InlineDecoration[] = []; + const originalViewZones: ViewZoneData[] = []; + const modifiedViewZones: ViewZoneData[] = []; let originalLine = 1; let modifiedLine = 1; @@ -120,11 +144,7 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { const lineCount = change.count || 0; if (change.added) { - // Check if previous was removed (paired change for inline diff) - const prevChange = i > 0 ? lineChanges[i - 1] : null; - if (prevChange && prevChange.removed) { - // Already handled in removed case - } + // Standalone addition (not preceded by a removal that already handled it) additions += lineCount; const lines = (change.value || '').split('\n').filter((_, idx, arr) => idx < arr.length - 1 || arr[idx] !== ''); lines.forEach((_, idx) => { @@ -133,6 +153,8 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { type: 'added', }); }); + // Pad original side so unchanged lines below stay aligned + originalViewZones.push({ afterLineNumber: originalLine - 1, heightInLines: lineCount }); modifiedLine += lineCount; } else if (change.removed) { // Check if next is added (paired change for inline diff) @@ -201,17 +223,33 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { }); } - additions += nextChange.count || 0; - modifiedLine += nextChange.count || 0; + const addedCount = nextChange.count || 0; + additions += addedCount; + + // Add alignment padding on the shorter side + if (removedLines.length > addedLines.length) { + modifiedViewZones.push({ + afterLineNumber: modifiedLine + addedLines.length - 1, + heightInLines: removedLines.length - addedLines.length, + }); + } else if (addedLines.length > removedLines.length) { + originalViewZones.push({ + afterLineNumber: originalLine + removedLines.length - 1, + heightInLines: addedLines.length - removedLines.length, + }); + } + + modifiedLine += addedCount; i++; // Skip the next (added) change since we handled it } else { - // Unpaired removal + // Isolated removal — pad modified side removedLines.forEach((_, idx) => { originalLineDecorations.push({ lineNumber: originalLine + idx, type: 'removed', }); }); + modifiedViewZones.push({ afterLineNumber: modifiedLine - 1, heightInLines: lineCount }); } originalLine += lineCount; } else { @@ -256,6 +294,22 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { originalDecorationsRef.current, decorations ); + + // Apply alignment view zones + editor.changeViewZones((accessor: any) => { + originalViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id)); + originalViewZoneIdsRef.current = []; + originalViewZones.forEach((zone) => { + const domNode = document.createElement('div'); + domNode.className = 'diff-placeholder-zone'; + const id = accessor.addZone({ + afterLineNumber: zone.afterLineNumber, + heightInLines: zone.heightInLines, + domNode, + }); + originalViewZoneIdsRef.current.push(id); + }); + }); } if (modifiedEditorRef.current) { @@ -282,6 +336,22 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { modifiedDecorationsRef.current, decorations ); + + // Apply alignment view zones + editor.changeViewZones((accessor: any) => { + modifiedViewZoneIdsRef.current.forEach((id) => accessor.removeZone(id)); + modifiedViewZoneIdsRef.current = []; + modifiedViewZones.forEach((zone) => { + const domNode = document.createElement('div'); + domNode.className = 'diff-placeholder-zone'; + const id = accessor.addZone({ + afterLineNumber: zone.afterLineNumber, + heightInLines: zone.heightInLines, + domNode, + }); + modifiedViewZoneIdsRef.current.push(id); + }); + }); } }, [originalText, modifiedText, options.ignoreWhitespace]); @@ -461,6 +531,15 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { background-color: rgba(34, 197, 94, 0.4) !important; border-radius: 2px; } + .diff-placeholder-zone { + background: repeating-linear-gradient( + 45deg, + rgba(128, 128, 128, 0.08) 0px, + rgba(128, 128, 128, 0.08) 4px, + transparent 4px, + transparent 8px + ); + } `} {/* Header Section */} @@ -539,22 +618,32 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { showClearButton={true} showCopyButton={true} headerActions={ - - - - - - handleLoadExample('original', 'javascript')}> - Load JavaScript Example - - handleLoadExample('original', 'python')}> - Load Python Example - - - + <> + { + setOriginalText(content); + const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; + const lang = EXTENSION_LANGUAGE_MAP[ext]; + if (lang) setOptions((prev) => ({ ...prev, language: lang })); + }} + /> + + + + + + handleLoadExample('original', 'javascript')}> + Load JavaScript Example + + handleLoadExample('original', 'python')}> + Load Python Example + + + + } footerLeftContent={ <> @@ -578,22 +667,32 @@ export function DiffChecker({ className, instanceId }: DiffCheckerProps) { showClearButton={true} showCopyButton={true} headerActions={ - - - - - - handleLoadExample('modified', 'javascript')}> - Load JavaScript Example - - handleLoadExample('modified', 'python')}> - Load Python Example - - - + <> + { + setModifiedText(content); + const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; + const lang = EXTENSION_LANGUAGE_MAP[ext]; + if (lang) setOptions((prev) => ({ ...prev, language: lang })); + }} + /> + + + + + + handleLoadExample('modified', 'javascript')}> + Load JavaScript Example + + handleLoadExample('modified', 'python')}> + Load Python Example + + + + } footerLeftContent={ <> diff --git a/src/components/tools/JsonFormatter.tsx b/src/components/tools/JsonFormatter.tsx index db23376..57d321d 100644 --- a/src/components/tools/JsonFormatter.tsx +++ b/src/components/tools/JsonFormatter.tsx @@ -4,6 +4,7 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { LoadFileButton } from '@/components/ui/load-file-button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { DEFAULT_JSON_OPTIONS, JSON_EXAMPLES, JSON_FORMAT_OPTIONS } from '@/config/json-formatter-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; @@ -215,29 +216,38 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { showCopyButton={false} showClearButton={true} headerActions={ - - - - - - handleLoadExample('valid')}> - Load Valid Example - - handleLoadExample('minified')}> - Load Minified Example - - handleLoadExample('invalid')}> - Load Invalid Example - - - + <> + { + setInput(content); + setError(''); + }} + /> + + + + + + handleLoadExample('valid')}> + Load Valid Example + + handleLoadExample('minified')}> + Load Minified Example + + handleLoadExample('invalid')}> + Load Invalid Example + + + + } footerLeftContent={ {getCharacterCount(input)} characters diff --git a/src/components/tools/JsonPathFinder.tsx b/src/components/tools/JsonPathFinder.tsx index 48a61e0..7c629e3 100644 --- a/src/components/tools/JsonPathFinder.tsx +++ b/src/components/tools/JsonPathFinder.tsx @@ -8,6 +8,7 @@ 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 { LoadFileButton } from '@/components/ui/load-file-button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { DEFAULT_JSON_PATH_OPTIONS, @@ -25,7 +26,7 @@ import { type JsonPathResult } from '@/libs/json-path-finder'; import { cn } from '@/libs/utils'; -import { ArrowDownTrayIcon, ArrowPathIcon, ChevronDownIcon, DocumentArrowUpIcon, LinkIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { ArrowDownTrayIcon, ArrowPathIcon, ChevronDownIcon, LinkIcon, XMarkIcon } from '@heroicons/react/24/outline'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; interface JsonPathFinderProps { @@ -53,9 +54,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { 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); @@ -190,23 +188,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { }, 100); }; - 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; @@ -457,15 +438,6 @@ export function JsonPathFinder({ className, instanceId }: JsonPathFinderProps) { {/* Side-by-side Editor Panels */}
- {/* Hidden file input */} - - {/* Input Panel */} - + { + setJsonInput(content); + setError(''); + setOutput(''); + setResult(null); + }} + /> - - - handleLoadExample('valid')}> - Load Valid Example - - handleLoadExample('minified')}> - Load Minified Example - - handleLoadExample('invalid')}> - Load Invalid Example - - - + <> + { + setInput(content); + setError(''); + }} + /> + + + + + + handleLoadExample('valid')}> + Load Valid Example + + handleLoadExample('minified')}> + Load Minified Example + + handleLoadExample('invalid')}> + Load Invalid Example + + + + } footerLeftContent={ {getCharacterCount(input)} characters diff --git a/src/components/ui/load-file-button.tsx b/src/components/ui/load-file-button.tsx new file mode 100644 index 0000000..4d3a013 --- /dev/null +++ b/src/components/ui/load-file-button.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/libs/utils'; +import { ArrowUpTrayIcon } from '@heroicons/react/24/outline'; +import { useRef } from 'react'; + +export interface LoadFileButtonProps { + onFileLoad: (content: string, file: File) => void; + accept?: string; + label?: string; + className?: string; +} + +export function LoadFileButton({ + onFileLoad, + accept = '*/*', + label = 'Load File', + className, +}: LoadFileButtonProps) { + const inputRef = useRef(null); + + const handleChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + onFileLoad(event.target?.result as string, file); + }; + reader.readAsText(file); + e.target.value = ''; + }; + + return ( + <> + + + + ); +}