feat: Enhanced developer tools UX with visual improvements

- Fixed StructuredEditor auto-type detection to only trigger on empty fields
- Made array keys readonly with proper index display and full-width value inputs
- Enhanced PHP unserialize to handle empty strings, NULL values, and complex data
- Added JSON to CSV support for single objects as Key-Value format
- Upgraded DiffTool with react-diff-view for professional GitHub-style diffs
- Added theme-synchronized diff colors with proper contrast in light/dark modes
- Implemented red/green text on matching backgrounds for optimal readability
This commit is contained in:
dwindown
2025-08-07 20:05:11 +07:00
parent bc7e2a8986
commit 97459ea313
7 changed files with 462 additions and 133 deletions

45
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-codemirror2": "^8.0.1",
"react-diff-view": "^3.3.2",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
@@ -6888,6 +6889,12 @@
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clean-css": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
@@ -10162,6 +10169,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gitdiff-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz",
"integrity": "sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -17508,6 +17521,23 @@
"node": ">=8"
}
},
"node_modules/react-diff-view": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/react-diff-view/-/react-diff-view-3.3.2.tgz",
"integrity": "sha512-wPVq4ktTcGOHbhnWKU/gHLtd3N2Xd+OZ/XQWcKA06dsxlSsESePAumQILwHtiak2nMCMiWcIfBpqZ5OiharUPA==",
"license": "MIT",
"dependencies": {
"classnames": "^2.3.2",
"diff-match-patch": "^1.0.5",
"gitdiff-parser": "^0.3.1",
"lodash": "^4.17.21",
"shallow-equal": "^3.1.0",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.14.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -18624,6 +18654,12 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shallow-equal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
"integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -20429,6 +20465,15 @@
"makeerror": "1.0.12"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",

View File

@@ -21,6 +21,7 @@
"papaparse": "^5.4.1",
"react": "^18.2.0",
"react-codemirror2": "^8.0.1",
"react-diff-view": "^3.3.2",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",

View File

@@ -78,8 +78,10 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
}
const key = pathParts[pathParts.length - 1];
const currentValue = current[key];
// Auto-detect type
// Auto-detect type only when current value is empty/null/undefined
if (currentValue === '' || currentValue === null || currentValue === undefined) {
if (value === 'true' || value === 'false') {
current[key] = value === 'true';
} else if (value === 'null') {
@@ -89,6 +91,10 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
} else {
current[key] = value;
}
} else {
// If current value exists, preserve as string unless explicitly changed via type dropdown
current[key] = value;
}
updateData(newData);
};
@@ -171,8 +177,18 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
const isExpanded = expandedNodes.has(path);
const canExpand = typeof value === 'object' && value !== null;
// Check if parent is an array by looking at the parent path
const isArrayItem = parentPath !== 'root' && (() => {
const parentPathParts = parentPath.split('.');
let current = data;
for (let i = 1; i < parentPathParts.length; i++) {
current = current[parentPathParts[i]];
}
return Array.isArray(current);
})();
return (
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 mb-2 overflow-hidden">
<div key={path} className="ml-4 border-l border-gray-200 dark:border-gray-700 pl-4 overflow-hidden">
<div className="flex items-center space-x-2 mb-2">
{canExpand && (
<button
@@ -189,10 +205,19 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
{!canExpand && <div className="w-6" />}
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 flex-1">
<div className="flex items-center space-x-2 flex-1">
{isArrayItem ? (
// Array items: icon + index span (compact)
<>
{getTypeIcon(value)}
<span className="px-2 py-1 text-sm text-gray-600 dark:text-gray-400 font-mono whitespace-nowrap">
[{key}]
</span>
</>
) : (
// Object properties: icon + editable key + colon (compact)
<>
{getTypeIcon(value)}
<input
type="text"
defaultValue={key}
@@ -207,12 +232,13 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
e.target.blur(); // Trigger blur to save changes
}
}}
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0 flex-1"
className="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0"
placeholder="Property name"
style={{width: '120px'}} // Fixed width for consistency
/>
<span className="text-gray-500 hidden sm:inline">:</span>
</div>
</>
)}
{!canExpand ? (
<input
@@ -223,11 +249,11 @@ const StructuredEditor = ({ onDataChange, initialData = {} }) => {
value.toString()
}
onChange={(e) => updateValue(e.target.value, path)}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 min-w-0"
placeholder="Value"
/>
) : (
<span className="text-sm text-gray-600 dark:text-gray-400">
<span className="flex-1 text-sm text-gray-600 dark:text-gray-400">
{Array.isArray(value) ? `Array (${value.length} items)` : `Object (${Object.keys(value).length} properties)`}
</span>
)}

View File

@@ -45,11 +45,10 @@ const CsvJsonTool = () => {
try {
const data = JSON.parse(input);
if (!Array.isArray(data)) {
setOutput('Error: JSON must be an array of objects');
return;
}
let csv = '';
if (Array.isArray(data)) {
// Handle array of objects (original functionality)
if (data.length === 0) {
setOutput('Error: Empty array');
return;
@@ -58,8 +57,6 @@ const CsvJsonTool = () => {
// Get headers from first object
const headers = Object.keys(data[0]);
let csv = '';
// Add headers if enabled
if (hasHeaders) {
csv += headers.join(delimiter) + '\n';
@@ -78,6 +75,48 @@ const CsvJsonTool = () => {
csv += values.join(delimiter) + '\n';
});
} else if (typeof data === 'object' && data !== null) {
// Handle single object as key-value pairs
// Add headers if enabled
if (hasHeaders) {
csv += `Key${delimiter}Value\n`;
}
// Add key-value rows
Object.entries(data).forEach(([key, value]) => {
// Format the key
let formattedKey = key;
if (typeof key === 'string' && (key.includes(delimiter) || key.includes('"') || key.includes('\n'))) {
formattedKey = `"${key.replace(/"/g, '""')}"`;
}
// Format the value
let formattedValue = '';
if (value === null) {
formattedValue = 'null';
} else if (value === undefined) {
formattedValue = 'undefined';
} else if (typeof value === 'object') {
// Convert objects/arrays to JSON string
formattedValue = JSON.stringify(value);
} else {
formattedValue = String(value);
}
// Escape value if needed
if (typeof formattedValue === 'string' && (formattedValue.includes(delimiter) || formattedValue.includes('"') || formattedValue.includes('\n'))) {
formattedValue = `"${formattedValue.replace(/"/g, '""')}"`;
}
csv += `${formattedKey}${delimiter}${formattedValue}\n`;
});
} else {
setOutput('Error: JSON must be an object or an array of objects');
return;
}
setOutput(csv.trim());
} catch (err) {
setOutput(`Error: ${err.message}`);

View File

@@ -1,70 +1,74 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { GitCompare, Upload } from 'lucide-react';
import { parseDiff, Diff, Hunk } from 'react-diff-view';
import 'react-diff-view/style/index.css';
import '../styles/diff-theme.css';
import ToolLayout from '../components/ToolLayout';
import CopyButton from '../components/CopyButton';
const DiffTool = () => {
// Enhanced diff tool with react-diff-view and theme support
const [leftText, setLeftText] = useState('');
const [rightText, setRightText] = useState('');
const [diffResult, setDiffResult] = useState('');
const [diffMode, setDiffMode] = useState('unified'); // 'unified' or 'side-by-side'
const [diffMode, setDiffMode] = useState('unified'); // 'unified' or 'split'
// Generate unified diff format for react-diff-view
const diffText = useMemo(() => {
if (!leftText && !rightText) return null;
// Simple diff implementation
const computeDiff = () => {
const leftLines = leftText.split('\n');
const rightLines = rightText.split('\n');
// Create a unified diff format
let diff = `--- Text A\n+++ Text B\n`;
const maxLines = Math.max(leftLines.length, rightLines.length);
let result = '';
let diffCount = 0;
if (diffMode === 'unified') {
result += `--- Text A\n+++ Text B\n`;
let hunkStart = 1;
let hunkLines = [];
for (let i = 0; i < maxLines; i++) {
const leftLine = leftLines[i] || '';
const rightLine = rightLines[i] || '';
const leftLine = leftLines[i];
const rightLine = rightLines[i];
if (leftLine !== rightLine) {
diffCount++;
if (leftLine && !rightLine) {
result += `- ${leftLine}\n`;
} else if (!leftLine && rightLine) {
result += `+ ${rightLine}\n`;
} else {
result += `- ${leftLine}\n+ ${rightLine}\n`;
if (leftLine === rightLine) {
// Unchanged line
if (leftLine !== undefined) {
hunkLines.push(` ${leftLine}`);
}
} else {
result += ` ${leftLine}\n`;
// Changed line
if (leftLine !== undefined) {
hunkLines.push(`-${leftLine}`);
}
}
} else {
// Side by side format
result += `${'Text A'.padEnd(50)} | Text B\n`;
result += `${'-'.repeat(50)} | ${'-'.repeat(50)}\n`;
for (let i = 0; i < maxLines; i++) {
const leftLine = leftLines[i] || '';
const rightLine = rightLines[i] || '';
if (leftLine !== rightLine) {
diffCount++;
const leftDisplay = leftLine.padEnd(50);
result += `${leftDisplay} | ${rightLine}\n`;
} else {
const leftDisplay = leftLine.padEnd(50);
result += `${leftDisplay} | ${rightLine}\n`;
if (rightLine !== undefined) {
hunkLines.push(`+${rightLine}`);
}
}
}
if (diffCount === 0) {
result = '✅ No differences found - texts are identical!';
} else {
result = `Found ${diffCount} difference(s):\n\n${result}`;
if (hunkLines.length > 0) {
diff += `@@ -${hunkStart},${leftLines.length} +${hunkStart},${rightLines.length} @@\n`;
diff += hunkLines.join('\n');
}
setDiffResult(result);
return diff;
}, [leftText, rightText]);
// Parse the diff for react-diff-view
const parsedDiff = useMemo(() => {
if (!diffText) return null;
try {
const files = parseDiff(diffText);
return files[0]; // We only have one file diff
} catch (error) {
console.error('Error parsing diff:', error);
return null;
}
}, [diffText]);
const computeDiff = () => {
// This function is now just a trigger since diff is computed in useMemo
// The actual diff computation happens automatically when leftText or rightText changes
};
const handleFileUpload = (side, event) => {
@@ -85,7 +89,6 @@ const DiffTool = () => {
const clearAll = () => {
setLeftText('');
setRightText('');
setDiffResult('');
};
const loadSample = () => {
@@ -144,9 +147,9 @@ const user = {
Unified Diff
</button>
<button
onClick={() => setDiffMode('side-by-side')}
onClick={() => setDiffMode('split')}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
diffMode === 'side-by-side'
diffMode === 'split'
? 'bg-white dark:bg-gray-700 text-primary-600 shadow-sm'
: 'text-gray-600 dark:text-gray-400'
}`}
@@ -234,13 +237,37 @@ const user = {
Comparison Result
</label>
<div className="relative">
<textarea
value={diffResult}
readOnly
placeholder="Comparison result will appear here..."
className="tool-input h-96 bg-gray-50 dark:bg-gray-800"
{parsedDiff && parsedDiff.hunks && parsedDiff.hunks.length > 0 ? (
<div className="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 relative">
<div className="diff-container max-h-96 overflow-auto">
<Diff
viewType={diffMode}
diffType="modify"
hunks={parsedDiff.hunks}
renderHunk={(hunk) => (
<Hunk key={hunk.content} hunk={hunk} />
)}
/>
{diffResult && <CopyButton text={diffResult} />}
</div>
{diffText && (
<div className="absolute top-2 right-2">
<CopyButton text={diffText} />
</div>
)}
</div>
) : leftText || rightText ? (
<div className="flex items-center justify-center h-32 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="text-center">
<div className="text-green-600 dark:text-green-400 text-lg mb-1"></div>
<p className="text-green-700 dark:text-green-300 font-medium">No differences found</p>
<p className="text-green-600 dark:text-green-400 text-sm">The texts are identical!</p>
</div>
</div>
) : (
<div className="flex items-center justify-center h-32 bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg">
<p className="text-gray-500 dark:text-gray-400">Enter text in both fields to see the comparison</p>
</div>
)}
</div>
</div>

View File

@@ -47,49 +47,107 @@ const SerializeTool = () => {
let index = 0;
const parseValue = () => {
if (index >= str.length) {
throw new Error('Unexpected end of string');
}
const type = str[index];
// Handle NULL case (no colon after N)
if (type === 'N') {
index += 2; // Skip 'N;'
return null;
}
// For all other types, expect colon after type
if (str[index + 1] !== ':') {
throw new Error(`Expected ':' after type '${type}' at position ${index + 1}`);
}
index += 2; // Skip type and ':'
switch (type) {
case 'N':
index++; // Skip ';'
return null;
case 'b':
const boolVal = str[index] === '1';
index += 2; // Skip value and ';'
return boolVal;
case 'i':
let intStr = '';
while (str[index] !== ';') {
while (index < str.length && str[index] !== ';') {
intStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing integer');
}
index++; // Skip ';'
return parseInt(intStr);
case 'd':
let floatStr = '';
while (str[index] !== ';') {
while (index < str.length && str[index] !== ';') {
floatStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing float');
}
index++; // Skip ';'
return parseFloat(floatStr);
case 's':
let lenStr = '';
while (str[index] !== ':') {
while (index < str.length && str[index] !== ':') {
lenStr += str[index++];
}
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing string length');
}
index++; // Skip ':'
index++; // Skip '"'
// Expect opening quote
if (str[index] !== '"') {
throw new Error(`Expected '"' at position ${index}`);
}
index++; // Skip opening '"'
const length = parseInt(lenStr);
const stringVal = str.substr(index, length);
index += length + 2; // Skip string and '";'
if (isNaN(length) || length < 0) {
throw new Error(`Invalid string length: ${lenStr}`);
}
// Extract string content
const stringVal = str.substring(index, index + length);
index += length;
// Expect closing quote and semicolon
if (index + 1 >= str.length || str[index] !== '"' || str[index + 1] !== ';') {
throw new Error(`Expected '";' after string at position ${index}`);
}
index += 2; // Skip closing '";'
return stringVal;
case 'a':
let arrayLenStr = '';
while (str[index] !== ':') {
while (index < str.length && str[index] !== ':') {
arrayLenStr += str[index++];
}
index += 2; // Skip ':{'
if (index >= str.length) {
throw new Error('Unexpected end of string while parsing array length');
}
index++; // Skip ':'
// Expect opening brace
if (str[index] !== '{') {
throw new Error(`Expected '{' at position ${index}`);
}
index++; // Skip '{'
const arrayLength = parseInt(arrayLenStr);
if (isNaN(arrayLength) || arrayLength < 0) {
throw new Error(`Invalid array length: ${arrayLenStr}`);
}
const result = {};
let isArray = true;
@@ -97,13 +155,20 @@ const SerializeTool = () => {
const key = parseValue();
const value = parseValue();
result[key] = value;
// Check if this looks like a sequential array
if (typeof key !== 'number' || key !== i) {
isArray = false;
}
}
// Expect closing brace
if (index >= str.length || str[index] !== '}') {
throw new Error(`Expected '}' at position ${index}`);
}
index++; // Skip '}'
// Convert to array if all keys are sequential integers
// Convert to array if all keys are sequential integers starting from 0
if (isArray && arrayLength > 0) {
const arr = [];
for (let i = 0; i < arrayLength; i++) {
@@ -113,12 +178,24 @@ const SerializeTool = () => {
}
return result;
default:
throw new Error(`Unknown type: ${type}`);
throw new Error(`Unknown type: '${type}' at position ${index - 2}`);
}
};
return parseValue();
try {
const result = parseValue();
// Check if there's unexpected trailing data
if (index < str.length) {
console.warn(`Warning: Trailing data after parsing: "${str.substring(index)}"`);
}
return result;
} catch (error) {
throw new Error(`Parse error at position ${index}: ${error.message}`);
}
};
const handleSerialize = () => {

114
src/styles/diff-theme.css Normal file
View File

@@ -0,0 +1,114 @@
/* Theme-aware styles for react-diff-view */
/* Using higher specificity selectors to override library defaults */
/* Light theme (default) */
.diff-container {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.4;
}
/* Target actual react-diff-view classes with high specificity */
.diff-container .diff-code-insert,
.diff-container .diff-line-insert,
.diff-container .diff-code-insert .diff-code-text,
.diff-container .diff-line-insert .diff-code-text {
background-color: #dcfce7 !important;
color: #15803d !important;
}
.diff-container .diff-code-delete,
.diff-container .diff-line-delete,
.diff-container .diff-code-delete .diff-code-text,
.diff-container .diff-line-delete .diff-code-text {
background-color: #fee2e2 !important;
color: #dc2626 !important;
}
.diff-container .diff-code-normal,
.diff-container .diff-line-normal,
.diff-container .diff-code-normal .diff-code-text,
.diff-container .diff-line-normal .diff-code-text {
background-color: #ffffff !important;
color: #1f2937 !important;
}
.diff-container .diff-gutter,
.diff-container .diff-gutter-normal,
.diff-container .diff-gutter-insert,
.diff-container .diff-gutter-delete {
background-color: #f9fafb !important;
color: #6b7280 !important;
border-color: #e5e7eb !important;
}
/* Dark theme with higher specificity */
.dark .diff-container .diff-code-insert,
.dark .diff-container .diff-line-insert,
.dark .diff-container .diff-code-insert .diff-code-text,
.dark .diff-container .diff-line-insert .diff-code-text {
background-color: #064e3b !important;
color: #10b981 !important;
}
.dark .diff-container .diff-code-delete,
.dark .diff-container .diff-line-delete,
.dark .diff-container .diff-code-delete .diff-code-text,
.dark .diff-container .diff-line-delete .diff-code-text {
background-color: #7f1d1d !important;
color: #f87171 !important;
}
.dark .diff-container .diff-code-normal,
.dark .diff-container .diff-line-normal,
.dark .diff-container .diff-code-normal .diff-code-text,
.dark .diff-container .diff-line-normal .diff-code-text {
background-color: #1f2937 !important;
color: #f9fafb !important;
}
.dark .diff-container .diff-gutter,
.dark .diff-container .diff-gutter-normal,
.dark .diff-container .diff-gutter-insert,
.dark .diff-container .diff-gutter-delete {
background-color: #374151 !important;
color: #9ca3af !important;
border-color: #4b5563 !important;
}
/* Additional styling for better appearance */
.diff-container .diff-hunk-header {
background: #f3f4f6;
color: #374151;
font-weight: 600;
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.dark .diff-container .diff-hunk-header {
background: #374151;
color: #d1d5db;
border-bottom: 1px solid #4b5563;
}
/* Ensure proper line spacing and alignment */
.diff-container .diff-line {
padding: 2px 8px;
border-left: 4px solid transparent;
}
.diff-container .diff-line-add {
border-left-color: #22c55e;
}
.diff-container .diff-line-delete {
border-left-color: #ef4444;
}
.dark .diff-container .diff-line-add {
border-left-color: #16a34a;
}
.dark .diff-container .diff-line-delete {
border-left-color: #dc2626;
}