diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index b1be437f469d4..3ff306a93417e 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -43,6 +43,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-annotation": "^3.1.0", + "culori": "^4.0.2", "dayjs": "^1.11.19", "elkjs": "^0.11.1", "html-to-image": "^1.11.13", @@ -79,6 +80,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/culori": "^4.0.1", "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index e1e7d11f73cb7..d95dbde639817 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.5.1) + culori: + specifier: ^4.0.2 + version: 4.0.2 dayjs: specifier: ^1.11.19 version: 1.11.19 @@ -186,6 +189,9 @@ importers: '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.8.1) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/node': specifier: ^24.10.1 version: 24.10.3 @@ -1239,6 +1245,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/culori@4.0.1': + resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/d3-array@3.0.3': resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} @@ -2087,6 +2096,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + culori@4.0.2: + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + d3-array@3.2.1: resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} engines: {node: '>=12'} @@ -5403,6 +5416,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/culori@4.0.1': {} + '@types/d3-array@3.0.3': {} '@types/d3-color@3.1.0': {} @@ -6683,6 +6698,8 @@ snapshots: csstype@3.2.3: {} + culori@4.0.2: {} + d3-array@3.2.1: dependencies: internmap: 2.0.3 diff --git a/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx b/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx index 3b9cc6f4e01a4..806b73b7d2625 100644 --- a/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx +++ b/airflow-core/src/airflow/ui/src/components/JsonEditor.tsx @@ -19,7 +19,7 @@ import Editor, { type EditorProps } from "@monaco-editor/react"; import { useRef } from "react"; -import { useColorMode } from "src/context/colorMode"; +import { useMonacoTheme } from "src/context/colorMode"; type JsonEditorProps = { readonly editable?: boolean; @@ -39,7 +39,7 @@ export const JsonEditor = ({ value, ...rest }: JsonEditorProps) => { - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const onBlurRef = useRef(onBlur); onBlurRef.current = onBlur; @@ -55,8 +55,6 @@ export const JsonEditor = ({ scrollBeyondLastLine: false, }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - const handleChange = (val: string | undefined) => { onChange?.(val ?? ""); }; @@ -72,6 +70,7 @@ export const JsonEditor = ({ {...rest} > { const contentFormatted = JSON.stringify(content, undefined, 2); - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const lineCount = contentFormatted.split("\n").length; const expandedHeight = Math.min(Math.max(lineCount * 19 + 10, MIN_HEIGHT), MAX_HEIGHT); const [editorHeight, setEditorHeight] = useState(collapsed ? MIN_HEIGHT : expandedHeight); const [isReady, setIsReady] = useState(!collapsed); - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; const handleMount: OnMount = useCallback( (editorInstance) => { @@ -75,6 +74,7 @@ const RenderedJsonField = ({ collapsed = false, content, enableClipboard = true, {...rest} > { colorMode: "dark" | "light" | undefined }>(); + +vi.mock("./useColorMode", () => ({ + useColorMode: () => colorModeMock(), +})); + +// The hook registers Monaco themes exactly once via a module-level flag. We +// reset modules between tests so each test starts with a fresh flag state. +const loadHook = async () => { + const module = await import("./useMonacoTheme"); + + return module.useMonacoTheme; +}; + +const createFakeMonaco = () => { + const defineTheme = vi.fn(); + + return { defineTheme, monaco: { editor: { defineTheme } } as unknown as Monaco }; +}; + +// happy-dom does not resolve Chakra's CSS custom properties, so the hook's +// `getPropertyValue` calls would return empty strings. Stub it to return a +// parseable value — culori accepts plain hex, so the exact string doesn't +// matter as long as it's a valid CSS color the parser recognizes. +const stubComputedStyle = () => { + vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({ + getPropertyValue: () => "#abcdef", + } as unknown as CSSStyleDeclaration); +}; + +describe("useMonacoTheme", () => { + beforeEach(() => { + vi.resetModules(); + colorModeMock.mockReturnValue({ colorMode: "light" }); + stubComputedStyle(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the airflow-light theme name when color mode is light", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-light"); + }); + + it("returns the airflow-dark theme name when color mode is dark", async () => { + colorModeMock.mockReturnValue({ colorMode: "dark" }); + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-dark"); + }); + + it("falls back to airflow-light when color mode is undefined", async () => { + colorModeMock.mockReturnValue({ colorMode: undefined }); + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + + expect(result.current.theme).toBe("airflow-light"); + }); + + it("registers both airflow themes when beforeMount runs for the first time", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const { defineTheme, monaco } = createFakeMonaco(); + + result.current.beforeMount(monaco); + + expect(defineTheme).toHaveBeenCalledTimes(2); + expect(defineTheme).toHaveBeenCalledWith("airflow-light", expect.objectContaining({ base: "vs" })); + expect(defineTheme).toHaveBeenCalledWith("airflow-dark", expect.objectContaining({ base: "vs-dark" })); + }); + + it("does not re-register themes on subsequent beforeMount calls", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const first = createFakeMonaco(); + const second = createFakeMonaco(); + + result.current.beforeMount(first.monaco); + result.current.beforeMount(second.monaco); + + expect(first.defineTheme).toHaveBeenCalledTimes(2); + expect(second.defineTheme).not.toHaveBeenCalled(); + }); + + it("inherits from the base theme and adds no syntax token rules", async () => { + const useMonacoTheme = await loadHook(); + const { result } = renderHook(() => useMonacoTheme()); + const { defineTheme, monaco } = createFakeMonaco(); + + result.current.beforeMount(monaco); + + for (const call of defineTheme.mock.calls) { + const [, themeData] = call as [string, { inherit: boolean; rules: Array }]; + + expect(themeData.inherit).toBe(true); + expect(themeData.rules).toEqual([]); + } + }); +}); diff --git a/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts new file mode 100644 index 0000000000000..6ecb1f8b89050 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/context/colorMode/useMonacoTheme.ts @@ -0,0 +1,98 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { Monaco } from "@monaco-editor/react"; +import { formatHex, parse } from "culori"; + +import { useColorMode } from "./useColorMode"; + +const LIGHT_THEME_NAME = "airflow-light"; +const DARK_THEME_NAME = "airflow-dark"; + +let themesRegistered = false; + +// Convert any CSS color (including modern color spaces like OKLCH that Chakra +// UI uses) to a #rrggbb string that Monaco's `defineTheme` accepts. culori +// handles parsing and gamut mapping; we fall back to black for unset or +// unparsable values so Monaco never sees an invalid color. +const toHex = (cssVar: string): string => { + const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); + + return formatHex(parse(value)) ?? "#000000"; +}; + +const defineAirflowMonacoThemes = (monaco: Monaco) => { + if (themesRegistered) { + return; + } + + monaco.editor.defineTheme(LIGHT_THEME_NAME, { + base: "vs", + colors: { + "editor.background": toHex("--chakra-colors-gray-50"), + "editor.foreground": toHex("--chakra-colors-gray-900"), + "editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-200"), + "editor.lineHighlightBackground": toHex("--chakra-colors-gray-100"), + "editor.selectionBackground": toHex("--chakra-colors-brand-200"), + "editorGutter.background": toHex("--chakra-colors-gray-50"), + "editorLineNumber.activeForeground": toHex("--chakra-colors-gray-700"), + "editorLineNumber.foreground": toHex("--chakra-colors-gray-400"), + "editorSuggestWidget.background": toHex("--chakra-colors-gray-50"), + "editorWidget.background": toHex("--chakra-colors-gray-50"), + "editorWidget.border": toHex("--chakra-colors-gray-300"), + "scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`, + "scrollbarSlider.background": `${toHex("--chakra-colors-gray-300")}80`, + "scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-400")}a0`, + }, + inherit: true, + rules: [], + }); + + monaco.editor.defineTheme(DARK_THEME_NAME, { + base: "vs-dark", + colors: { + "editor.background": toHex("--chakra-colors-gray-900"), + "editor.foreground": toHex("--chakra-colors-gray-100"), + "editor.inactiveSelectionBackground": toHex("--chakra-colors-gray-800"), + "editor.lineHighlightBackground": toHex("--chakra-colors-gray-800"), + "editor.selectionBackground": toHex("--chakra-colors-brand-800"), + "editorGutter.background": toHex("--chakra-colors-gray-900"), + "editorLineNumber.activeForeground": toHex("--chakra-colors-gray-300"), + "editorLineNumber.foreground": toHex("--chakra-colors-gray-600"), + "editorSuggestWidget.background": toHex("--chakra-colors-gray-900"), + "editorWidget.background": toHex("--chakra-colors-gray-900"), + "editorWidget.border": toHex("--chakra-colors-gray-700"), + "scrollbarSlider.activeBackground": `${toHex("--chakra-colors-gray-500")}c0`, + "scrollbarSlider.background": `${toHex("--chakra-colors-gray-700")}80`, + "scrollbarSlider.hoverBackground": `${toHex("--chakra-colors-gray-600")}a0`, + }, + inherit: true, + rules: [], + }); + + themesRegistered = true; +}; + +export const useMonacoTheme = () => { + const { colorMode } = useColorMode(); + + return { + beforeMount: defineAirflowMonacoThemes, + theme: colorMode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME, + }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx index 3d023c9f0904f..a3dd224f890d9 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx @@ -38,7 +38,7 @@ import { ErrorAlert } from "src/components/ErrorAlert"; import Time from "src/components/Time"; import { ClipboardRoot, ClipboardButton, Tooltip } from "src/components/ui"; import { ProgressBar } from "src/components/ui"; -import { useColorMode } from "src/context/colorMode"; +import { useMonacoTheme } from "src/context/colorMode"; import useSelectedVersion from "src/hooks/useSelectedVersion"; import { useConfig } from "src/queries/useConfig"; import { renderDuration } from "src/utils"; @@ -115,7 +115,7 @@ export const Code = () => { setIsCompareDropdownOpen(false); }; - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); useHotkeys("w", toggleWrap); @@ -137,8 +137,6 @@ export const Code = () => { wordWrap: wrap ? "on" : "off", }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - const hasMultipleVersions = (dagVersions?.dag_versions.length ?? 0) >= 2; return ( @@ -278,6 +276,7 @@ export const Code = () => { )} { - const { colorMode } = useColorMode(); + const { beforeMount, theme } = useMonacoTheme(); const diffOptions: DiffEditorProps["options"] = { automaticLayout: true, @@ -62,8 +62,6 @@ export const CodeDiffViewer = ({ }, }; - const theme = colorMode === "dark" ? "vs-dark" : "vs-light"; - return (