From 8b14b94c7772be9fa4aa4e6c46254cf38ba789ac Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 13 Nov 2025 22:16:10 -0500 Subject: [PATCH] swe-mini-project-submission --- .../views/GraphView/CustomNode/ObjectNode.tsx | 65 +++++- .../views/GraphView/CustomNode/TextNode.tsx | 36 +++ .../views/GraphView/CustomNode/styles.tsx | 2 +- .../views/GraphView/lib/updateJsonAtPath.ts | 48 ++++ src/features/modals/EditNodeModal/index.tsx | 210 ++++++++++++++++++ src/features/modals/index.ts | 1 + src/store/useNodeEdit.ts | 92 ++++++++ 7 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 src/features/editor/views/GraphView/lib/updateJsonAtPath.ts create mode 100644 src/features/modals/EditNodeModal/index.tsx create mode 100644 src/store/useNodeEdit.ts diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..dd45828dcec 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -1,35 +1,92 @@ import React from "react"; +import styled from "styled-components"; import type { CustomNodeProps } from "."; import { NODE_DIMENSIONS } from "../../../../../constants/graph"; import type { NodeData } from "../../../../../types/graph"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useNodeEdit from "../../../../../store/useNodeEdit"; +import { useModal } from "../../../../../store/useModal"; type RowProps = { row: NodeData["text"][number]; x: number; y: number; index: number; + node: NodeData; }; -const Row = ({ row, x, y, index }: RowProps) => { +const StyledRowWrapper = styled.span` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 8px; + + .edit-button { + opacity: 0; + transition: opacity 0.2s; + } + + &:hover .edit-button { + opacity: 1; + } +`; + +const EditButton = styled.button` + background: transparent; + border: none; + color: ${({ theme }) => theme.INTERACTIVE_NORMAL}; + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + pointer-events: all; + flex-shrink: 0; + + &:hover { + color: ${({ theme }) => theme.TEXT_POSITIVE}; + } +`; + +const Row = ({ row, x, y, index, node }: RowProps) => { const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT; + const startEditNode = useNodeEdit(state => state.startEditNode); + const setVisible = useModal(state => state.setVisible); + const getRowText = () => { if (row.type === "object") return `{${row.childrenCount ?? 0} keys}`; if (row.type === "array") return `[${row.childrenCount ?? 0} items]`; return row.value; }; + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + startEditNode(node); + setVisible("EditNodeModal", true); + }; + + const canEdit = row.type !== "object" && row.type !== "array"; + return ( e.stopPropagation()} > - {row.key}: - {getRowText()} + + + {row.key}: + {getRowText()} + + {canEdit && ( + + ✎ + + )} + ); }; @@ -44,7 +101,7 @@ const Node = ({ node, x, y }: CustomNodeProps) => ( $isObject > {node.text.map((row, index) => ( - + ))} ); diff --git a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx index 718ced9d989..542bafaab99 100644 --- a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx @@ -5,6 +5,8 @@ import useConfig from "../../../../../store/useConfig"; import { isContentImage } from "../lib/utils/calculateNodeSize"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useNodeEdit from "../../../../../store/useNodeEdit"; +import { useModal } from "../../../../../store/useModal"; const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` display: flex; @@ -14,6 +16,11 @@ const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` width: 100%; overflow: hidden; padding: 0 10px; + position: relative; + + &:hover .edit-button { + opacity: 1; + } `; const StyledImageWrapper = styled.div` @@ -26,12 +33,38 @@ const StyledImage = styled.img` background: ${({ theme }) => theme.BACKGROUND_MODIFIER_ACCENT}; `; +const EditButton = styled.button` + margin-left: 8px; + background: transparent; + border: none; + color: ${({ theme }) => theme.INTERACTIVE_NORMAL}; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: all; + + &:hover { + color: ${({ theme }) => theme.TEXT_POSITIVE}; + } +`; + const Node = ({ node, x, y }: CustomNodeProps) => { const { text, width, height } = node; const imagePreviewEnabled = useConfig(state => state.imagePreviewEnabled); const isImage = imagePreviewEnabled && isContentImage(JSON.stringify(text[0].value)); const value = text[0].value; + const startEditNode = useNodeEdit(state => state.startEditNode); + const setVisible = useModal(state => state.setVisible); + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + startEditNode(node); + setVisible("EditNodeModal", true); + }; + return ( { {value} + + ✎ + )} diff --git a/src/features/editor/views/GraphView/CustomNode/styles.tsx b/src/features/editor/views/GraphView/CustomNode/styles.tsx index 175b4524b50..c9bd2dbd3fd 100644 --- a/src/features/editor/views/GraphView/CustomNode/styles.tsx +++ b/src/features/editor/views/GraphView/CustomNode/styles.tsx @@ -30,7 +30,7 @@ export const StyledForeignObject = styled.foreignObject<{ $isObject?: boolean }> font-size: 12px; font-weight: 500; overflow: hidden; - pointer-events: none; + pointer-events: all; &.searched { background: rgba(27, 255, 0, 0.1); diff --git a/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts b/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts new file mode 100644 index 00000000000..eb828916169 --- /dev/null +++ b/src/features/editor/views/GraphView/lib/updateJsonAtPath.ts @@ -0,0 +1,48 @@ +import { modify, applyEdits } from "jsonc-parser"; + +export function parseEditedValue(input: string, originalType?: string) { + const trimmed = input?.trim(); + + if (originalType === "string") return String(input); + + if (trimmed === "null") return null; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + + // number detection + if (/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(trimmed)) { + try { + return Number(trimmed); + } catch (e) { + return String(input); + } + } + + // try JSON parse for arrays or objects + if (trimmed && (trimmed.startsWith("[") || trimmed.startsWith("{"))) { + try { + return JSON.parse(trimmed); + } catch (e) { + // fallthrough to string + } + } + + // fallback to string + return String(input); +} + +export function updateJsonAtPath(json: string, path: Array, value: unknown) { + try { + const edits = modify(json, path as any, value, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + + const newJson = applyEdits(json, edits); + return newJson; + } catch (e) { + // rethrow so callers can show errors + throw e; + } +} + +export default updateJsonAtPath; diff --git a/src/features/modals/EditNodeModal/index.tsx b/src/features/modals/EditNodeModal/index.tsx new file mode 100644 index 00000000000..8ad310d0d5b --- /dev/null +++ b/src/features/modals/EditNodeModal/index.tsx @@ -0,0 +1,210 @@ +import React, { useCallback, useMemo } from "react"; +import type { ModalProps } from "@mantine/core"; +import { + Modal, + Stack, + Text, + TextInput, + Textarea, + Flex, + CloseButton, + Button, + Group, + ScrollArea, + Divider, +} from "@mantine/core"; +import useNodeEdit from "../../../store/useNodeEdit"; +import useJson from "../../../store/useJson"; +import useFile from "../../../store/useFile"; +import updateJsonAtPath, { parseEditedValue } from "../../editor/views/GraphView/lib/updateJsonAtPath"; +import toast from "react-hot-toast"; + +// Helper to get JSON path string representation +const jsonPathToString = (path?: Array) => { + if (!path || path.length === 0) return "$"; + const segments = path.map(seg => (typeof seg === "number" ? seg : `"${seg}"`)); + return `$[${segments.join("][")}]`; +}; + +export const EditNodeModal = ({ opened, onClose }: ModalProps) => { + const editingNode = useNodeEdit(state => state.editingNode); + const editedItems = useNodeEdit(state => state.editedItems); + const updateEditedValue = useNodeEdit(state => state.updateEditedValue); + const resetEditedValues = useNodeEdit(state => state.resetEditedValues); + const clearEdit = useNodeEdit(state => state.clearEdit); + + // group editedItems by their group property (undefined grouped as top-level) + const grouped = useMemo(() => { + const groups: Record = {}; + editedItems.forEach(item => { + const g = item.group ?? "__root__"; + if (!groups[g]) groups[g] = []; + groups[g].push(item); + }); + return groups; + }, [editedItems]); + + const handleSave = useCallback(() => { + try { + if (!editingNode) return; + + let updatedJson = useJson.getState().json; + + // Apply all edits to the JSON + for (const item of editedItems) { + if (item.value === item.original) continue; // skip unchanged + + const parsed = parseEditedValue(item.value, item.type as any); + try { + updatedJson = updateJsonAtPath(updatedJson, item.path, parsed); + } catch (err) { + console.warn(`Failed to update path ${item.path}`, err); + throw new Error(`Failed to update ${item.label || "value"}: ${(err as Error).message}`); + } + } + + // Update stores with the final JSON + useFile.getState().setContents({ contents: updatedJson, hasChanges: true, skipUpdate: true }); + useJson.getState().setJson(updatedJson); + + const changedCount = editedItems.filter(item => item.value !== item.original).length; + + if (changedCount > 0) { + toast.success(`Updated ${changedCount} ${changedCount === 1 ? "property" : "properties"}`); + } else { + toast("No changes made"); + } + + clearEdit(); + onClose?.(); + } catch (err: any) { + console.warn("Failed to save edits", err); + toast.error(`Failed to save: ${err?.message || "Unknown error"}`); + } + }, [editingNode, editedItems, clearEdit, onClose]); + + const handleCancel = useCallback(() => { + resetEditedValues && resetEditedValues(); + clearEdit(); + onClose?.(); + }, [resetEditedValues, clearEdit, onClose]); + const hasChanges = useMemo(() => editedItems.some(item => item.value !== item.original), [editedItems]); + + if (!editingNode || editedItems.length === 0) return null; + + return ( + + + {/* Header */} + + + Content + + + + + {/* Editable Fields */} + + + {/* Render root group first */} + {grouped["__root__"]?.map(item => { + const isLong = item.value.length > 50 || item.value.includes("\n"); + return ( +
+ + {item.label} + + {isLong ? ( +