Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
"@typescript-eslint/parser": "^8.39.0",
"autoprefixer": "^10.4.21",
"bits-ui": "^2.9.1",
"cors": "^2.8.5",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-svelte": "^3.11.0",
"express": "^5.1.0",
"globals": "^16.3.0",
"paneforge": "^1.0.2",
"postcss": "^8.5.6",
Expand All @@ -51,12 +53,11 @@
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.6",
"typescript": "^5.0.0",
"vite": "^7.0.4",
"express": "^5.1.0",
"cors": "^2.8.5"
"vite": "^7.0.4"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.1",
Expand All @@ -69,7 +70,7 @@
"lucide-svelte": "^0.536.0",
"mode-watcher": "^1.1.0",
"tailwind-merge": "^3.3.1",
"typesafe-i18n": "^5.26.2",
"tslog": "^4.9.3"
"tslog": "^4.9.3",
"typesafe-i18n": "^5.26.2"
}
}
230 changes: 208 additions & 22 deletions src/lib/components/JsonEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { json } from '@codemirror/lang-json';
import { syntaxTree } from '@codemirror/language';
import { oneDark } from '@codemirror/theme-one-dark';
import { mode } from 'mode-watcher';
import { logger } from '$lib/logger';
Expand All @@ -14,33 +15,218 @@

let { value = $bindable(''), class: className = '' }: Props = $props();

// Build a map of JSON paths to their character positions using CodeMirror's syntax tree
function buildPathToPositionMap(state: EditorState): Map<string, { start: number; end: number }> {
const pathMap = new Map<string, { start: number; end: number }>();
const tree = syntaxTree(state);

// Helper to get text content of a node
function getNodeText(from: number, to: number): string {
return state.doc.sliceString(from, to);
}

// Recursive function to traverse with path context
function traverse(cursor: any, path: string[] = []) {
do {
const nodeName = cursor.name;

if (nodeName === 'Property') {
// Handle object properties
let propertyKey = '';
let valueStart = -1;
let valueEnd = -1;
let valueType = '';

if (cursor.firstChild()) {
// Get property name
if (cursor.name === 'PropertyName') {
const keyText = getNodeText(cursor.from, cursor.to);
propertyKey = keyText.replace(/^"|"$/g, '');
}

// Skip to value (past the colon)
while (cursor.nextSibling()) {
if (cursor.name !== ':') {
valueStart = cursor.from;
valueEnd = cursor.to;
valueType = cursor.name;
break;
}
}

// Store the path and position
if (propertyKey && valueStart !== -1) {
const valuePath = [...path, propertyKey];
const pathStr = valuePath.join('.');

if (pathStr) {
pathMap.set(pathStr, { start: valueStart, end: valueEnd });
}

// If value is an array, handle array elements specially
if (valueType === 'Array') {
if (cursor.firstChild()) {
// We're now inside the array, process its elements
let index = 0;
do {
if (cursor.name === 'Object') {
const elementPath = [...valuePath, index.toString()];
const elementPathStr = elementPath.join('.');

if (elementPathStr) {
pathMap.set(elementPathStr, { start: cursor.from, end: cursor.to });
}

// Traverse into the object
if (cursor.firstChild()) {
traverse(cursor, elementPath);
cursor.parent();
}

index++;
} else if (cursor.name === 'Array') {
const elementPath = [...valuePath, index.toString()];
const elementPathStr = elementPath.join('.');

if (elementPathStr) {
pathMap.set(elementPathStr, { start: cursor.from, end: cursor.to });
}

// Recursive array
if (cursor.firstChild()) {
traverse(cursor, elementPath);
cursor.parent();
}

index++;
} else if (cursor.name !== '[' && cursor.name !== ']' && cursor.name !== ',' && cursor.name !== '⚠') {
// Primitive values in array
const elementPath = [...valuePath, index.toString()];
const elementPathStr = elementPath.join('.');

if (elementPathStr) {
pathMap.set(elementPathStr, { start: cursor.from, end: cursor.to });
}

index++;
}
} while (cursor.nextSibling());
cursor.parent();
}
} else if (valueType === 'Object') {
// If value is an object, traverse it normally
if (cursor.firstChild()) {
traverse(cursor, valuePath);
cursor.parent();
}
}
}

cursor.parent();
}
} else if (nodeName === 'Array') {
// Handle array elements
let index = 0;
if (cursor.firstChild()) {
do {
// Only process actual elements (skip syntax tokens)
if (cursor.name === 'Object') {
// For object elements, store the indexed path and traverse
const elementPath = [...path, index.toString()];
const pathStr = elementPath.join('.');

// Store the position of this array element
if (pathStr) {
pathMap.set(pathStr, { start: cursor.from, end: cursor.to });
}

// Traverse into the object to get its properties
if (cursor.firstChild()) {
traverse(cursor, elementPath);
cursor.parent();
}

index++;
} else if (cursor.name === 'Array') {
// For nested arrays
const elementPath = [...path, index.toString()];
const pathStr = elementPath.join('.');

if (pathStr) {
pathMap.set(pathStr, { start: cursor.from, end: cursor.to });
}

// Traverse into the nested array
if (cursor.firstChild()) {
traverse(cursor, elementPath);
cursor.parent();
}

index++;
} else if (cursor.name !== '[' && cursor.name !== ']' && cursor.name !== ',' && cursor.name !== '⚠') {
// For primitive values
const elementPath = [...path, index.toString()];
const pathStr = elementPath.join('.');

if (pathStr) {
pathMap.set(pathStr, { start: cursor.from, end: cursor.to });
}

index++;
}
} while (cursor.nextSibling());
cursor.parent();
}
} else if (nodeName === 'Object' && path.length > 0) {
// For nested objects in arrays, traverse their properties
if (cursor.firstChild()) {
traverse(cursor, path);
cursor.parent();
}
} else if (nodeName === 'JsonText') {
// Root of JSON document
if (cursor.firstChild()) {
// Root can be object or array
if (cursor.name === 'Object' || cursor.name === 'Array') {
if (cursor.firstChild()) {
traverse(cursor, []);
cursor.parent();
}
}
cursor.parent();
}
}
} while (cursor.nextSibling());
}

// Start traversal
const cursor = tree.cursor();
traverse(cursor);

return pathMap;
}

export function navigateToPath(path: string) {
if (!view) return;

const doc = view.state.doc;
const text = doc.toString();

try {
JSON.parse(text); // Validate JSON
const pathParts = path.split('.');
let line = 1;

// Simple line counting - find the line containing the path
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (pathParts.length > 0 && lines[i].includes(`"${pathParts[pathParts.length - 1]}"`)) {
line = i + 1;
break;
}
// Build the path to position map using syntax tree
const pathMap = buildPathToPositionMap(view.state);

// Look up the position for this path
const position = pathMap.get(path);

if (position) {
// Navigate to the found position
view.dispatch({
selection: { anchor: position.start, head: position.end },
scrollIntoView: true
});
view.focus();
logger.debug(`[JsonEditor] Navigated to path: ${path} at position ${position.start}-${position.end}`);
} else {
logger.warn(`[JsonEditor] Path not found: ${path}`);
}

// Scroll to line
const lineInfo = doc.line(line);
view.dispatch({
selection: { anchor: lineInfo.from, head: lineInfo.to },
scrollIntoView: true
});
view.focus();
} catch (e) {
logger.error('Failed to navigate to path:', e);
}
Expand Down
Loading