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
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useCallback, useState } from "react";
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useCallback, useState } from 'react';

import { cn } from "#/utils/cn";
import { cn } from '#/utils/cn';

import { DataclipViewer } from "../../../react/components/DataclipViewer";
import type { Dataclip } from "../../api/dataclips";
import { RENDER_MODES, type RenderMode } from "../../constants/panel";
import { Button } from "../Button";
import { DataclipViewer } from '../../../react/components/DataclipViewer';
import type { Dataclip } from '../../api/dataclips';
import { RENDER_MODES, type RenderMode } from '../../constants/panel';
import { Button } from '../Button';

interface SelectedDataclipViewProps {
dataclip: Dataclip;
Expand All @@ -26,13 +26,13 @@ export function SelectedDataclipView({
renderMode = RENDER_MODES.STANDALONE,
}: SelectedDataclipViewProps) {
const [isEditingName, setIsEditingName] = useState(false);
const [editedName, setEditedName] = useState(dataclip.name || "");
const [editedName, setEditedName] = useState(dataclip.name || '');
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleCancelEdit = useCallback(() => {
setIsEditingName(false);
setEditedName(dataclip.name || "");
setEditedName(dataclip.name || '');
setError(null);
}, [dataclip.name]);

Expand All @@ -51,7 +51,7 @@ export function SelectedDataclipView({
await onNameChange(dataclip.id, newName);
setIsEditingName(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save name");
setError(err instanceof Error ? err.message : 'Failed to save name');
} finally {
setIsSaving(false);
}
Expand All @@ -62,8 +62,8 @@ export function SelectedDataclipView({
{/* Header */}
<div
className={cn(
"flex items-center justify-between pb-4",
renderMode === RENDER_MODES.EMBEDDED ? "px-3 pt-3" : "px-6 pt-4"
'flex items-center justify-between pb-4',
renderMode === RENDER_MODES.EMBEDDED ? 'px-3 pt-3' : 'px-6 pt-4'
)}
>
<div className="flex-1">
Expand All @@ -74,10 +74,10 @@ export function SelectedDataclipView({
value={editedName}
onChange={e => setEditedName(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && !isSaving) {
if (e.key === 'Enter' && !isSaving) {
e.preventDefault();
void handleSaveName();
} else if (e.key === "Escape") {
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
Expand Down Expand Up @@ -109,7 +109,7 @@ export function SelectedDataclipView({
<>
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900">
{dataclip.name || "Unnamed"}
{dataclip.name || 'Unnamed'}
</h3>
{canEdit && (
<button
Expand All @@ -125,7 +125,7 @@ export function SelectedDataclipView({
text-gray-500 mt-1"
>
<span className="capitalize">
{dataclip.type.replace("_", " ")}
{dataclip.type.replace('_', ' ')}
</span>
<span>•</span>
<span>
Expand All @@ -148,8 +148,8 @@ export function SelectedDataclipView({
{isNextCronRun && (
<div
className={cn(
"alert-warning flex flex-col gap-1 px-3 py-2 rounded-md border mb-4",
renderMode === RENDER_MODES.EMBEDDED ? "mx-3" : "mx-6"
'alert-warning flex flex-col gap-1 px-3 py-2 rounded-md border mb-4',
renderMode === RENDER_MODES.EMBEDDED ? 'mx-3' : 'mx-6'
)}
>
<span className="text-sm font-medium">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// differing only in the dataclip field accessed (input_dataclip_id vs output_dataclip_id)
// and display text.

import { useMemo } from "react";
import { useMemo } from 'react';

import { DataclipViewer } from '../../../react/components/DataclipViewer';
import { useCurrentRun, useSelectedStep } from '../../hooks/useRun';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import type { ReactNode } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';

import { useCurrentRun, useRunStoreInstance } from '../../hooks/useRun';

Expand Down Expand Up @@ -45,7 +45,7 @@ export function StepViewerLayout({
<PanelResizeHandle className="h-1 bg-gray-200 hover:bg-blue-400 transition-colors cursor-row-resize" />

{/* Content viewer (logs, input, or output) */}
<Panel minSize={30} style={{ overflow: "unset" }}>
<Panel minSize={30} style={{ overflow: 'unset' }}>
<div className="h-full">{children}</div>
</Panel>
</PanelGroup>
Expand Down
65 changes: 44 additions & 21 deletions lib/mix/tasks/merge_projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ defmodule Mix.Tasks.Lightning.MergeProjects do

alias Lightning.Projects.MergeProjects

# Schema modules that must be loaded before atomizing JSON keys.
# These schemas define the field atoms used in project export files.
@required_schemas [
Lightning.Projects.Project,
Lightning.Workflows.Workflow,
Lightning.Workflows.Job,
Lightning.Workflows.Trigger,
Lightning.Workflows.Edge
]

@impl Mix.Task
def run(args) do
{opts, positional, invalid} =
Expand Down Expand Up @@ -90,39 +100,55 @@ defmodule Mix.Tasks.Lightning.MergeProjects do
end

source_project = read_state_file(source_file, "source")

target_project = read_state_file(target_file, "target")

ensure_schemas_loaded()

merged_project = perform_merge(source_project, target_project, uuid_map)

output = encode_json(merged_project)
write_output(output, output_path)
end

defp perform_merge(source_project, target_project, uuid_map) do
MergeProjects.merge_project(source_project, target_project, uuid_map)
defp ensure_schemas_loaded do
# IMPORTANT: Load schema modules to ensure their field atoms exist in memory.
# This enables safe String.to_existing_atom/1 conversion when atomizing JSON keys.
#
# When adding schemas referenced in project exports, add them to the
# @required_schemas list at the top of this module to prevent ArgumentError
# during merge operations.
Enum.each(@required_schemas, &Code.ensure_loaded/1)
end

defp perform_merge(source_data, target_data, uuid_map) do
MergeProjects.merge_project(
atomize(source_data),
atomize(target_data),
uuid_map
)
rescue
e in KeyError ->
ArgumentError ->
Mix.raise("""
Failed to merge projects - missing required field: #{inspect(e.key)}

#{Exception.message(e)}
Failed to merge projects - encountered unknown field in JSON

This may indicate incompatible or corrupted project state files.
Please verify both files are valid Lightning project exports.
This may indicate the JSON contains invalid or unexpected fields.
Please ensure both files are valid Lightning project exports.
""")
end

e ->
Mix.raise("""
Failed to merge projects

#{Exception.message(e)}
defp atomize(data) when is_map(data) do
Map.new(data, fn {key, value} ->
atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key
{atom_key, atomize(value)}
end)
end

This may indicate incompatible project structures or corrupted data.
Please verify both files are valid Lightning project exports.
""")
defp atomize(data) when is_list(data) do
Enum.map(data, &atomize/1)
end

defp atomize(data), do: data

defp parse_uuid_mappings(opts) do
opts
|> Keyword.get_values(:uuid)
Expand Down Expand Up @@ -274,11 +300,8 @@ defmodule Mix.Tasks.Lightning.MergeProjects do
""")
end

case Jason.decode(content, keys: :atoms) do
case Jason.decode(content) do
{:ok, data} ->
# Jason's keys: :atoms option converts all string keys to atoms
# This is safe for controlled JSON file input (not arbitrary user input)
# The merge_project function requires atom keys for dot notation access
data

{:error, %Jason.DecodeError{} = error} ->
Expand Down
Loading