You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ma...@apache.org on 2026/08/25 15:19:55 UTC
(camel-karavan) 30/42: Karavan-app UI Page Command Palette
This is an automated email from the ASF dual-hosted git repository.
mgubaidullin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
commit 444ff792f38c3148228b06b8a7651d88303ca2c2
Author: Marat Gubaidullin <ma...@talismancloud.io>
AuthorDate: Mon Aug 24 17:58:29 2026 -0400
Karavan-app UI Page Command Palette
---
.../src/ui/command-palette/CommandEventBus.ts | 25 +++
.../CommandPaletteCamelStepsGallery.tsx | 49 ++++++
.../command-palette/CommandPaletteDslElement.tsx | 66 +++++++
.../command-palette/CommandPaletteDslSelected.tsx | 19 ++
.../CommandPaletteDslSelectedCard.tsx | 75 ++++++++
.../ui/command-palette/CommandPaletteEditor.css | 50 ++++++
.../ui/command-palette/CommandPaletteEditor.tsx | 87 ++++++++++
.../CommandPaletteFilenameInput.tsx | 47 +++++
.../ui/command-palette/CommandPaletteFooter.tsx | 58 +++++++
.../src/ui/command-palette/CommandPaletteModal.tsx | 60 +++++++
.../src/ui/command-palette/CommandPalettePanel.css | 191 +++++++++++++++++++++
.../src/ui/command-palette/CommandPalettePanel.tsx | 58 +++++++
.../src/ui/command-palette/CommandPaletteUtils.tsx | 32 ++++
.../src/ui/command-palette/useCommandHook.tsx | 161 +++++++++++++++++
.../ui/command-palette/useCommandPaletteStore.ts | 104 +++++++++++
15 files changed, 1082 insertions(+)
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandEventBus.ts b/karavan-app/src/main/webui/src/ui/command-palette/CommandEventBus.ts
new file mode 100644
index 00000000..2573af15
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandEventBus.ts
@@ -0,0 +1,25 @@
+import {Subject} from 'rxjs';
+import {useEffect} from "react";
+
+const cmdKEvents = new Subject<void>();
+
+export const CommandEventBus = {
+ sendCmdK: () => cmdKEvents.next(),
+ onCmdK: () => cmdKEvents.asObservable(),
+};
+
+export function useGlobalShortcuts() {
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
+ event.preventDefault();
+ CommandEventBus.sendCmdK();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, []);
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteCamelStepsGallery.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteCamelStepsGallery.tsx
new file mode 100644
index 00000000..aa85774e
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteCamelStepsGallery.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import './CommandPalettePanel.css';
+import {CamelUi} from "@designer/utils/CamelUi";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {CommandPaletteDslElement} from "./CommandPaletteDslElement";
+import {Gallery} from "@patternfly/react-core";
+
+export function CommandPaletteCamelStepsGallery() {
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+ const elements = useCommandPaletteStore((s) => s.elements);
+ const filter = useCommandPaletteStore((s) => s.filter);
+
+ // 1. Filter elements using the existing logic
+ const filteredElements: DslMetaModel[] = selectedDsl
+ ? [selectedDsl]
+ : elements.filter(d => CamelUi.checkFilter(d, filter));
+
+
+ // 2. Sort the filtered elements based on keyword order in the filter
+ if (!selectedDsl && filter && filter.trim().length > 0) {
+ // Extract individual keywords in the exact order they were typed
+ const keywords = filter.toLowerCase().split(/\s+/).filter(Boolean);
+
+ // Helper function to assign a rank based on the earliest keyword match
+ const getRank = (dsl: DslMetaModel) => {
+ // Combine searchable fields into a single lowercase string
+ const searchText = `${dsl.name || ''} ${dsl.title || ''} ${dsl.description || ''}`.toLowerCase();
+
+ // Find the index of the FIRST keyword in the filter string that matches this element
+ const matchIndex = keywords.findIndex(kw => searchText.includes(kw));
+
+ // If matched, return its index (0 is best rank). If no match (unlikely since it passed filter), put it at the end.
+ return matchIndex === -1 ? keywords.length : matchIndex;
+ };
+
+ // Sort elements: lower rank (earlier keyword match) comes first
+ filteredElements.sort((a, b) => getRank(a) - getRank(b));
+ }
+
+ return (
+ <Gallery key={"gallery"} hasGutter className="dsl-gallery" minWidths={{default: '170px'}}>
+ {filteredElements.map((dsl: DslMetaModel, index: number) =>
+ <CommandPaletteDslElement key={dsl.name + ":" + index} dsl={dsl} index={index}/>
+ )}
+ </Gallery>
+
+ );
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslElement.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslElement.tsx
new file mode 100644
index 00000000..e1b59cc9
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslElement.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import {Badge, capitalize, Card, CardBody, CardHeader, HelperText, HelperTextItem, Tooltip} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import '@designer/property/property/ComponentPropertyField.css';
+import {CamelUi} from "@designer/utils/CamelUi";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+import {useCommandHook} from "./useCommandHook";
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {highlightText} from "./CommandPaletteUtils";
+
+interface Props {
+ dsl: DslMetaModel;
+ index: number;
+}
+
+export function CommandPaletteDslElement(props: Props) {
+ const {dsl, index} = props;
+ const {dslCardClick} = useCommandHook();
+ const filter = useCommandPaletteStore((s) => s.filter);
+ const navigation = dsl.navigation === 'eip' ? 'Processor' : capitalize(dsl.navigation);
+ const classNameBadge = "navigation-label label-" + dsl.navigation + ((dsl.navigation === 'eip' || dsl?.supportLevel.toLowerCase() === 'stable') ? '' : '-preview');
+
+ // Add a keyboard handler for Enter and Space keys
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault(); // Prevent page scrolling if Space is pressed
+ dslCardClick(event, dsl);
+ }
+ };
+
+ return (
+ <Card
+ key={dsl.dsl + index}
+ isCompact
+ className="dsl-card"
+ style={{width: '100%'}}
+ tabIndex={0}
+ onKeyDown={handleKeyDown}
+ onClick={e => dslCardClick(e, dsl)}>
+ <CardHeader>
+ <Tooltip content={navigation} position="right">
+ <Badge className={classNameBadge}>{navigation?.substring(0, 1)}</Badge>
+ </Tooltip>
+ <div className="dsl-element">
+ <div className={"header"}>
+ <div className={"icon-wrapper"}>
+ {CamelUi.getIconForDsl(dsl)}
+ </div>
+ <p className='dsl-element-title'>
+ {highlightText(dsl.title, filter)}
+ </p>
+ </div>
+ </div>
+ </CardHeader>
+ <CardBody onClick={e => dslCardClick(e, dsl)}>
+ <div className="dsl-element-body-description">
+ <Tooltip content={dsl.description}>
+ <HelperText>
+ <HelperTextItem className={"dsl-element-text-helper"}>{highlightText(dsl.description, filter)}</HelperTextItem>
+ </HelperText>
+ </Tooltip>
+ </div>
+ </CardBody>
+ </Card>
+ );
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelected.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelected.tsx
new file mode 100644
index 00000000..42124ef6
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelected.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {Bullseye} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {CommandPaletteDslSelectedCard} from "./CommandPaletteDslSelectedCard";
+
+
+export function CommandPaletteDslSelected() {
+
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+
+ return (
+ <Bullseye>
+ <div style={{display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: '16px', minWidth: "400px"}}>
+ <CommandPaletteDslSelectedCard key={selectedDsl.name} dsl={selectedDsl} index={0}/>
+ </div>
+ </Bullseye>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelectedCard.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelectedCard.tsx
new file mode 100644
index 00000000..54861ce4
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteDslSelectedCard.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import {Card, CardBody, CardHeader, Form,} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import '@designer/property/property/ComponentPropertyField.css';
+import {CamelUi} from "@designer/utils/CamelUi";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+import {ComponentApi} from "@core/api/ComponentApi";
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {ComponentPropertyField} from "@designer/property/property/ComponentPropertyField";
+import {ExpressionEditor} from "@designer/property/expression/ExpressionEditor";
+import {ComponentProperty} from "@core/model/ComponentModels";
+import {highlightText} from "./CommandPaletteUtils";
+
+interface Props {
+ dsl: DslMetaModel,
+ index: number
+}
+
+export function CommandPaletteDslSelectedCard(props: Props) {
+
+ const {dsl, index} = props;
+ const filter = useCommandPaletteStore((s) => s.filter);
+ const showProperties = useCommandPaletteStore((s) => s.showProperties);
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+ const setSelectedDsl = useCommandPaletteStore((s) => s.setSelectedDsl);
+
+ const componentProperties = showProperties && dsl?.uri
+ ? ComponentApi.getComponentProperties(dsl.uri, 'consumer').filter(p => p.kind === 'path')
+ : [];
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ return (
+ <Card key={dsl.dsl + index} className="dsl-card" style={{width: '100%'}} tabIndex={0} onKeyDown={onKeyDown}>
+ <CardHeader className="header-labels">
+ <div className="dsl-element">
+ <div className={"header"}>
+ {CamelUi.getIconForDsl(dsl)}
+ <p className='dsl-element-title'>
+ {highlightText(dsl.title, filter)}
+ </p>
+ </div>
+ </div>
+ </CardHeader>
+ {showProperties && selectedDsl &&
+ <CardBody className="dsl-card-body-properties">
+ <Form autoComplete="off" className='properties'>
+ {componentProperties.map((kp: ComponentProperty) =>
+ <ComponentPropertyField
+ hideConfigSelector={true}
+ key={kp.name}
+ property={kp}
+ value={selectedDsl.properties?.[kp.name]}
+ expressionEditor={ExpressionEditor}
+ onParameterChange={(parameter, value, pathParameter, newRoute) => {
+ setSelectedDsl({
+ ...selectedDsl,
+ properties: {
+ ...(selectedDsl.properties ?? {}),
+ [parameter]: value
+ }
+ });
+ }}
+ />
+ )}
+ </Form>
+ </CardBody>
+ }
+ </Card>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.css b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.css
new file mode 100644
index 00000000..11d26a6f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.css
@@ -0,0 +1,50 @@
+.command-palette-editor {
+ position: relative;
+ width: 100%;
+ padding-top: 4px;
+ border-radius: 8px;
+ overflow: hidden;
+
+ .empty-editor-overlay {
+ position: absolute;
+ top: 4px;
+ left: 12px;
+ color: var(--pf-t--global--text--color--regular);
+ pointer-events: none;
+ font-size: 12px;
+ font-family: Menlo, Monaco, "Courier New", monospace;
+ z-index: 10;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ }
+}
+
+.command-palette-editor::after {
+ content: "";
+ position: absolute;
+ /* Added a fallback of 0px just in case the PatternFly variable is missing */
+ inset: var(--pf-v6-c-button--border--offset, 0px);
+ pointer-events: none;
+ border: 2px solid transparent;
+
+ /* Good practice to inherit border-radius so the gradient hugs the corners */
+ border-radius: 8px;
+ transition: inherit;
+
+ /* The gradient border colors */
+ background: linear-gradient(
+ to right,
+ rgba(0, 102, 204, 1) 0%,
+ rgba(67, 148, 229, 1) 25%,
+ rgb(219, 91, 4) 100%
+ ) border-box;
+
+ /* Correct Masking implementation */
+ -webkit-mask:
+ linear-gradient(#fff 0 0) padding-box,
+ linear-gradient(#fff 0 0);
+ -webkit-mask-composite: destination-out;
+ mask-composite: exclude;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.tsx
new file mode 100644
index 00000000..d3eb9c2f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteEditor.tsx
@@ -0,0 +1,87 @@
+import React, {useEffect, useRef, useState} from 'react';
+import './CommandPaletteEditor.css';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {useDebounceValue} from 'usehooks-ts';
+import {MonacoEditorWrapper} from "@developer/monaco/MonacoEditorWrapper";
+import type * as monaco from "monaco-editor";
+
+const editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ scrollbar: {
+ useShadows: false,
+ vertical: 'auto',
+ horizontal: 'hidden'
+ },
+ selectOnLineNumbers: true,
+ automaticLayout: true,
+ lineNumbers: "off",
+ folding: false,
+ lineNumbersMinChars: 0,
+ showUnused: false,
+ fontSize: 12,
+ fixedOverflowWidgets: false,
+ wordWrap: "on",
+ wordBasedSuggestions: "off",
+ quickSuggestions: false,
+ snippetSuggestions: "none",
+ suggestOnTriggerCharacters: false,
+ suggest: {
+ showKeywords: false,
+ showStatusBar: false,
+ showIcons: false,
+ preview: false,
+ showSnippets: false,
+ },
+};
+
+const LINE_HEIGHT = 18;
+const MIN_LINES = 3;
+const MIN_HEIGHT = LINE_HEIGHT * MIN_LINES;
+
+function clampHeight(contentHeight: number, minHeight: number, maxHeight: number) {
+ return Math.min(maxHeight, Math.max(minHeight, contentHeight));
+}
+
+export function CommandPaletteEditor() {
+ const parentDsl = useCommandPaletteStore((s) => s.parentDsl);
+ const setStoreFilter = useCommandPaletteStore((s) => s.setFilter);
+ const [editorReady, setEditorReady] = useState(false);
+ const [localFilter, setLocalFilter] = useState('');
+ const [debouncedFilter] = useDebounceValue(localFilter, 300);
+
+ const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
+ const hasAppliedTemplate = useRef(false);
+ const isPastedContent = useRef(false);
+
+ useEffect(() => {
+ setStoreFilter(debouncedFilter);
+ }, [debouncedFilter, setStoreFilter]);
+
+ function onChange(value: string) {
+ setLocalFilter(value || '');
+ }
+
+ const elementType = parentDsl === undefined || parentDsl === '' ? 'starting' : 'next';
+ const camelStepsPlaceholder = "Search " + elementType + " step from the list below 👇";
+ let placeholderText = camelStepsPlaceholder;
+
+ // The editor always starts empty - templates are applied through executeEdits
+ const isEditorEmpty = localFilter.trim().length === 0;
+
+ return (
+ <div className={"command-palette-editor"}>
+ {isEditorEmpty && (
+ <div className={"empty-editor-overlay"}>{placeholderText}</div>
+ )}
+ <MonacoEditorWrapper
+ key={'modal-special-focus'}
+ height={`${MIN_HEIGHT}px`}
+ language="markdown"
+ editorOptions={editorOptions}
+ initialCode={""}
+ onChange={(value) => onChange(value?.toString() || "")}
+ />
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFilenameInput.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFilenameInput.tsx
new file mode 100644
index 00000000..2732f7a4
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFilenameInput.tsx
@@ -0,0 +1,47 @@
+import React, {useEffect} from 'react';
+import {Content, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {useCommandHook} from "./useCommandHook";
+import {ExclamationCircleIcon} from "@patternfly/react-icons";
+
+export function CommandPaletteFilenameInput() {
+
+ const {validated, generateRouteFileName} = useCommandHook()
+
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+ const fileName = useCommandPaletteStore((s) => s.fileName);
+ const setFileName = useCommandPaletteStore((s) => s.setFileName);
+
+ useEffect(() => {
+ if (selectedDsl && !fileName) {
+ const f = generateRouteFileName(selectedDsl)
+ setFileName(f)
+ } else if (!selectedDsl && !fileName) {
+ const f = generateRouteFileName(undefined)
+ setFileName(f)
+ }
+ }, [selectedDsl]);
+
+ return (
+ <div style={{display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: '8px'}}>
+ <Content style={{textWrap: 'nowrap', margin: 0, fontWeight: 'bold'}} component='p'>File name: </Content>
+ <TextInputGroup className="search">
+ <TextInputGroupMain
+ style={{textAlign: 'right'}}
+ value={fileName ?? ''}
+ onChange={(_event, value) => setFileName(value)}
+ type="text"
+ aria-label="invalid text input example"
+ />
+ <TextInputGroupUtilities>
+ <Content style={{textWrap: 'nowrap', padding: '3px'}} component='p'>.camel.yaml</Content>
+ </TextInputGroupUtilities>
+ <TextInputGroupUtilities>
+ {!validated() &&
+ <ExclamationCircleIcon color='var(--pf-t--global--icon--color--status--danger--default)' style={{textWrap: 'nowrap', marginRight: '3px'}}/>}
+ </TextInputGroupUtilities>
+ </TextInputGroup>
+ </div>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFooter.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFooter.tsx
new file mode 100644
index 00000000..0c744022
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteFooter.tsx
@@ -0,0 +1,58 @@
+import React, {useEffect} from 'react';
+import {Button, ModalFooter} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {useCommandHook} from "./useCommandHook";
+import {CommandPaletteFilenameInput} from "./CommandPaletteFilenameInput";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+import {CommandEventBus} from "./CommandEventBus";
+
+interface Props {
+ onClose?: () => void
+ onBeforeSave?: (dsl: DslMetaModel) => void
+}
+
+export function CommandPaletteFooter(props: Props) {
+
+ const {onClose, onBeforeSave} = props;
+ const {afterSelect, validated, selectedDsl, isTopology} = useCommandHook();
+ const showProperties = useCommandPaletteStore((s) => s.showProperties);
+ const setSelectedDsl = useCommandPaletteStore((s) => s.setSelectedDsl);
+
+ function getButtons() {
+ return (
+ <div style={{display: 'flex', flexDirection: 'row', justifyContent: 'flex-end', gap: 6}}>
+ {/*<Button variant='link' isDanger onClick={_ => {*/}
+ {/* close();*/}
+ {/* onClose?.();*/}
+ {/*}}>Close</Button>*/}
+ {showProperties && selectedDsl &&
+ <Button variant='secondary'
+ onClick={_ => setSelectedDsl(undefined)}
+ >
+ Back
+ </Button>
+ }
+ {showProperties && selectedDsl &&
+ <Button variant='primary'
+ isDisabled={!validated()}
+ onClick={_ => {
+ onBeforeSave?.(selectedDsl);
+ afterSelect(selectedDsl);
+ }}
+ >
+ Save
+ </Button>
+ }
+ </div>
+ )
+ }
+
+ return (
+ <ModalFooter className="dsl-footer">
+ {isTopology && selectedDsl && <CommandPaletteFilenameInput/>}
+ <div style={{flex: 1}}></div>
+ {getButtons()}
+ </ModalFooter>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteModal.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteModal.tsx
new file mode 100644
index 00000000..f0f96d2c
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteModal.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {Button, ClipboardCopy, Content, Divider, Modal, ModalBody, ModalHeader} from '@patternfly/react-core';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {useCommandHook} from "./useCommandHook";
+import {CommandPalettePanel} from "./CommandPalettePanel";
+import {CommandPaletteFooter} from "./CommandPaletteFooter";
+import "./CommandPalettePanel.css"
+import {TimesIcon} from "@patternfly/react-icons";
+
+export function CommandPaletteModal() {
+
+ const {afterSelect, close, isFileSelected, file, project} = useCommandHook()
+ const showPalette = useCommandPaletteStore((s) => s.showPalette);
+ const showProperties = useCommandPaletteStore((s) => s.showProperties);
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+ const filter = useCommandPaletteStore((s) => s.filter);
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Enter' && showProperties && selectedDsl) {
+ afterSelect(selectedDsl)
+ } else if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ const currentContext = <ClipboardCopy
+ className="filename-text-clipboard"
+ hoverTip="Copy"
+ clickTip="Copied"
+ variant="inline-compact"
+ isCode
+ >
+ {file?.name || project?.projectId}
+ </ClipboardCopy>
+
+ return (
+ <Modal
+ width={'70%'}
+ className='command-palette command-palette-modal'
+ position="top"
+ isOpen={showPalette}
+ onKeyDown={onKeyDown}
+ >
+ <ModalHeader className={"command-palette-modal-header"}>
+ <Content style={{margin: 0}} component={"h6"}>Select for</Content>
+ {(isFileSelected || project?.projectId) && currentContext}
+ <div style={{display:"flex", justifyContent: "flex-end", alignItems: "center", flex: 1}}>
+ <Button variant='link' isDanger className={"close-button"} onClick={_ => close()}>
+ <TimesIcon/>
+ </Button>
+ </div>
+ </ModalHeader>
+ <ModalBody style={{padding: 0}}>
+ <CommandPalettePanel/>
+ </ModalBody>
+ <Divider/>
+ <CommandPaletteFooter onClose={() => close()}/>
+ </Modal>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.css b/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.css
new file mode 100644
index 00000000..799cd324
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.css
@@ -0,0 +1,191 @@
+.command-palette-modal {
+ min-height: 30%;
+ overflow: hidden;
+ border-radius: var(--pf-t--global--border--radius--medium);
+ .command-palette-modal-header {
+ padding: 6px 6px 0 16px;
+ /*height: 1.7em;*/
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ position: relative;
+ gap: 6px;
+ .filename-text-clipboard {
+ background-color: transparent;
+ }
+ }
+}
+
+.command-palette .pf-v6-c-form-control > :is(input, select, textarea) {
+ padding-inline-start: var(--pf-t--global--spacer--sm);
+ padding-inline-end: var(--pf-t--global--spacer--sm);
+}
+
+.command-palette .command-palette-body {
+ display: block;
+ padding-top: 1em;
+ padding-inline-start: 1em;
+ padding-inline-end: 1em;
+ padding-bottom: 1em;
+ flex-basis: auto;
+ flex-grow: 1;
+ flex-shrink: 1;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
+
+.command-palette .dsl-element {
+ position: relative;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-wrap: nowrap;
+ gap: 16px;
+ padding: 4px 4px;
+}
+
+.command-palette .dsl-element .header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 2px;
+}
+
+.command-palette .dsl-element .icon-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 30px; /* Increased slightly to give the icon breathing room */
+ width: 30px; /* Width MUST match height for a perfect circle */
+ border-radius: 50%; /* This creates the circle shape */
+ margin-right: 6px;
+ flex-shrink: 0; /* Prevents the circle from squishing if the container gets tight */
+}
+
+.pf-v6-theme-dark .command-palette .dsl-element .icon-wrapper {
+ background-color: var(--pf-t--global--text--color--subtle);
+}
+
+.command-palette .dsl-element .icon {
+ height: 20px;
+ width: auto;
+ border: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+}
+
+.command-palette .dsl-element .highlight-match {
+ color: var(--platform-color);
+}
+
+.command-palette .dsl-element .dsl-element-title {
+ color: var(--pf-t--global--text--color--regular);
+ font-weight: bold;
+ text-wrap: wrap;
+}
+
+.command-palette .dsl-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+.command-palette .dsl-footer {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding-block-start: 1em;
+ padding-block-end: 1em;
+ padding-inline-start: 1em;
+ padding-inline-end: 1em;
+}
+
+.command-palette .command-palette-header {
+ display: flex;
+ flex-direction: column;
+ flex-basis: auto;
+ flex-shrink: 1;
+ padding-block-start: 1em;
+ padding-inline-start: 1em;
+ padding-inline-end: 1em;
+}
+
+.command-palette .pf-v6-c-modal-box__close {
+ inset-block-start: -2px;
+ inset-inline-end: -2px;
+ overflow: hidden;
+}
+
+.command-palette .dsl-card .properties .pf-v6-c-form__group-label,
+.command-palette .dsl-card .properties .pf-v6-c-form__label {
+ display: flex;
+}
+
+.command-palette .pf-chatbot__message-bar .sdx-design-button {
+ background: var(--pf-t--global--background--color--primary--default);
+ svg {
+ fill: var(--platform-color);
+ }
+}
+.command-palette .pf-chatbot__message-bar .sdx-design-button::after {
+ position: absolute;
+ inset: var(--pf-v6-c-button--border--offset);
+ pointer-events: none;
+ content: "";
+ border: 2px solid transparent;
+ border-radius: 9999px; /* Overriding the inherit to force stadium shape */
+ transition: inherit;
+
+ background-image: linear-gradient(
+ to right,
+ rgba(0, 102, 204, 1) 0%,
+ rgba(67, 148, 229, 1) 25%,
+ rgb(219, 91, 4) 100%
+ );
+ background-origin: border-box;
+
+ -webkit-mask: linear-gradient(#000 0 0) padding-box exclude, linear-gradient(#000 0 0) border-box;
+ mask: linear-gradient(#000 0 0) padding-box exclude, linear-gradient(#000 0 0) border-box;
+}
+
+.command-palette .dsl-gallery {
+ .dsl-card {
+ cursor: pointer;
+ .pf-v6-c-card__header {
+ padding: 0.5em;
+ .navigation-label {
+ position: absolute;
+ right: 4px;
+ top: 4px;
+ padding: 0;
+ margin: 0;
+ width: 8px;
+ min-width: 16px;
+ }
+ .navigation-label::after {
+ border: none;
+ }
+ }
+ .pf-v6-c-card__body {
+ .dsl-element-body-description {
+ overflow: hidden;
+ .pf-v6-c-helper-text__item-text {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ color: var(--pf-t--global--text--color--subtle);
+ }
+ }
+ }
+ }
+}
+
+.command-palette .decorationsOverviewRuler {
+ visibility: hidden;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.tsx
new file mode 100644
index 00000000..82f3baae
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPalettePanel.tsx
@@ -0,0 +1,58 @@
+import React, {useEffect, useState} from 'react';
+import {Skeleton} from '@patternfly/react-core';
+import './CommandPalettePanel.css';
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {CommandPaletteEditor} from "./CommandPaletteEditor";
+import {useCommandHook} from "./useCommandHook";
+import {CommandPaletteCamelStepsGallery} from "./CommandPaletteCamelStepsGallery";
+import {CommandPaletteDslSelected} from "./CommandPaletteDslSelected";
+
+export function CommandPalettePanel() {
+
+ const {afterSelect, setAllElements, close, selectedDsl, showCamelSteps} = useCommandHook()
+ const showProperties = useCommandPaletteStore((s) => s.showProperties);
+ const setShowProperties = useCommandPaletteStore((s) => s.setShowProperties);
+ const setSelectedDsl = useCommandPaletteStore((s) => s.setSelectedDsl);
+ const [ready, setReady] = useState<boolean>(true);
+
+ useEffect(() => {
+ setAllElements();
+ setReady(true);
+ setShowProperties(false);
+ setSelectedDsl(undefined);
+ return () => {
+ setShowProperties(false);
+ setSelectedDsl(undefined);
+ }
+ }, []);
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Enter' && showProperties && selectedDsl) {
+ afterSelect(selectedDsl)
+ } else if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ function getNotReady() {
+ return !ready && [1, 2, 3, 4, 5, 6, 7, 8, 9].map(i =>
+ <React.Fragment key={i}>
+ <Skeleton key={i} width={i * 10 + '%'} screenreaderText="Loading..."/>
+ <br/>
+ </React.Fragment>
+ )
+ }
+
+ return (
+ <div onKeyDown={onKeyDown} className={"command-palette"} style={{display: 'flex', flexDirection: 'column', height: "100%", width: "100%"}}>
+ <div className="command-palette-header">
+ {ready && <CommandPaletteEditor/>}
+ </div>
+ <div className={"command-palette-body"}>
+ {getNotReady()}
+ {ready && showCamelSteps && <CommandPaletteCamelStepsGallery/>}
+ {ready && showProperties && selectedDsl && <CommandPaletteDslSelected/>}
+ </div>
+ </div>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteUtils.tsx b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteUtils.tsx
new file mode 100644
index 00000000..180ccdbe
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/CommandPaletteUtils.tsx
@@ -0,0 +1,32 @@
+// Helper function to escape special characters for the regex
+import React, {ReactNode} from "react";
+
+export const escapeRegExp = (text: string) => {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+};
+
+// Helper function to highlight filter keywords in a given text
+export const highlightText = (text: string, filter: string): ReactNode => {
+ if (!filter || !filter.trim()) return text;
+
+ // Split filter into individual words and remove empty strings
+ const keywords = filter.split(/\s+/).filter(Boolean);
+ if (keywords.length === 0) return text;
+
+ // Create a regex to match any of the keywords (case-insensitive)
+ const escapedKeywords = keywords.map(escapeRegExp);
+ const regex = new RegExp(`(${escapedKeywords.join('|')})`, 'gi');
+
+ // Split text by the regex (the capturing group '()' ensures matches are kept in the array)
+ const parts = text.split(regex);
+
+ return parts.map((part, i) => {
+ // If the current part matches any of our keywords, wrap it in a span with the special class
+ const isMatch = keywords.some(kw => kw.toLowerCase() === part.toLowerCase());
+ return isMatch ? (
+ <span key={i} className="highlight-match">{part}</span>
+ ) : (
+ <React.Fragment key={i}>{part}</React.Fragment>
+ );
+ });
+};
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/useCommandHook.tsx b/karavan-app/src/main/webui/src/ui/command-palette/useCommandHook.tsx
new file mode 100644
index 00000000..de08004e
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/useCommandHook.tsx
@@ -0,0 +1,161 @@
+import {useCommandPaletteStore} from "./useCommandPaletteStore";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+import {FILE_WORDS_SEPARATOR, KARAVAN_DOT_EXTENSION, KARAVAN_FILENAME, MARKDOWN_EXTENSION} from "@core/contants";
+import {useFilesStore, useFileStore, useProjectStore} from "@stores/ProjectStore";
+import React from "react";
+import {CamelUi} from "@designer/utils/CamelUi";
+import {v4 as uuidv4} from "uuid";
+import {toSpecialRouteId} from "@designer/utils/ValidatorUtils";
+import {ComponentApi} from "@core/api/ComponentApi";
+import {ProjectFunctionHook} from "@page-project/ProjectFunctionHook";
+import {useRouteDesignerHook} from "@designer/route/useRouteDesignerHook";
+import {useUIStore} from "@stores/useUIStore";
+import {useTemplatesStore} from "@stores/SettingsStore";
+
+export function useCommandHook() {
+
+ const parentDsl = useCommandPaletteStore((s) => s.parentDsl);
+ const showSteps = useCommandPaletteStore((s) => s.showSteps);
+ const parentId = useCommandPaletteStore((s) => s.parentId);
+ const setShowSelector = useCommandPaletteStore((s) => s.setShowPalette);
+ const selectedPosition = useCommandPaletteStore((s) => s.selectedPosition);
+ const setShowProperties = useCommandPaletteStore((s) => s.setShowProperties);
+ const setSelectedDsl = useCommandPaletteStore((s) => s.setSelectedDsl);
+ const isRouteTemplate = useCommandPaletteStore((s) => s.isRouteTemplate);
+ const setElements = useCommandPaletteStore((s) => s.setElements);
+ const setStoreFilter = useCommandPaletteStore((state) => state.setFilter);
+ const filter = useCommandPaletteStore((state) => state.filter);
+ const setFileName = useCommandPaletteStore((state) => state.setFileName);
+ const selectedDsl = useCommandPaletteStore((s) => s.selectedDsl);
+ const fileName = useCommandPaletteStore((state) => state.fileName);
+ const showProperties = useCommandPaletteStore((s) => s.showProperties);
+ const files = useFilesStore((s) => s.files);
+ const file = useFileStore((s) => s.file);
+ const project = useProjectStore((s) => s.project);
+ const tabIndex = useProjectStore(s => s.tabIndex);
+ const templateFiles = useTemplatesStore((s) => s.templateFiles);
+ const pageId = useUIStore(s => s.pageId);
+ const {createNewRouteFile} = ProjectFunctionHook();
+ const {onAddNewRouteStep} = useRouteDesignerHook();
+ const onDslSelect = file === undefined ? createNewRouteFile : onAddNewRouteStep;
+
+ function afterSelect(dsl: DslMetaModel) {
+ setStoreFilter('');
+ setShowSelector(false);
+ onDslSelect(dsl, parentId, selectedPosition, fileName);
+ setFileName(undefined);
+ }
+
+ function validated(): boolean {
+ return files.find(f => f.name === `${fileName}${KARAVAN_DOT_EXTENSION.CAMEL_YAML}`) === undefined;
+ }
+
+ function close() {
+ setStoreFilter('');
+ setFileName(undefined);
+ setShowSelector(false);
+ }
+
+ function dslCardClick(evt: React.MouseEvent | React.KeyboardEvent<HTMLDivElement>, dsl: any) {
+ evt.stopPropagation();
+ if (parentId?.length > 0) {
+ afterSelect(dsl as DslMetaModel);
+ } else {
+ setSelectedDsl(dsl as DslMetaModel);
+ setShowProperties(true)
+ }
+ }
+
+ function setAllElements() {
+ const blockedComponents = ComponentApi.getBlockedComponentNames();
+ const eipE = CamelUi.getSelectorModelsForParentFiltered(parentDsl, 'eip', showSteps);
+ const cE = CamelUi.getSelectorModelsForParentFiltered(parentDsl, 'component', showSteps)
+ .filter(dsl => (!blockedComponents.includes(dsl.uri || dsl.name)));
+ const e: DslMetaModel[] = [];
+ if (parentDsl !== undefined) {
+ e.push(...eipE)
+ }
+ e.push(...cE)
+ const kE = CamelUi.getSelectorModelsForParentFiltered(parentDsl, 'kamelet', showSteps);
+ e.push(...kE);
+ setElements(e);
+ }
+
+ function generateParamUri(dsl: DslMetaModel) {
+ const uuid = uuidv4().substring(0, 3)
+ const uri = dsl.uri + FILE_WORDS_SEPARATOR +
+ (dsl.properties && Object.keys(dsl.properties).length > 0
+ ? Object.values(dsl.properties).join(FILE_WORDS_SEPARATOR)
+ : uuid);
+ return uri
+ .replace(/[^a-zA-Z0-9]/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ }
+
+ function generateRouteFileName(dsl: DslMetaModel): string {
+ if (dsl === undefined) {
+ return "from-" + uuidv4().substring(0, 3);
+ }
+ const paramsUri = generateParamUri(dsl);
+ const fullUri = `${FILE_WORDS_SEPARATOR}${paramsUri}`;
+ if (isRouteTemplate) {
+ return toSpecialRouteId(`${FILE_WORDS_SEPARATOR}${fullUri}-route-template`);
+ } else {
+ return toSpecialRouteId(`${FILE_WORDS_SEPARATOR}${fullUri}`);
+ }
+ }
+
+ const isFileSelected = file?.name !== undefined && project?.projectId !== undefined;
+ const isFileCamel = isFileSelected && file?.name.endsWith(KARAVAN_DOT_EXTENSION.CAMEL_YAML);
+ const isFileGroovy = isFileSelected && file?.name.endsWith(KARAVAN_DOT_EXTENSION.GROOVY);
+ const isApplicationProperties = isFileSelected && file?.name === KARAVAN_FILENAME.APP_PROPERTIES;
+ const isFileMarkdown = isFileSelected && file?.name.endsWith(MARKDOWN_EXTENSION);
+ const isTopology = tabIndex === 'topology' && !isFileSelected && project?.projectId !== undefined;
+ const isDashboard = pageId === 'dashboard' && !isFileSelected && project?.projectId === undefined;
+ const isProjects = pageId === 'projects';
+ const isLogOpen = project?.projectId !== undefined && tabIndex === 'log';
+
+ const showCamelSteps = React.useMemo(() => {
+ if (showProperties) {
+ return false;
+ }
+ if (isDashboard || isTopology || isFileCamel) {
+ return true;
+ }
+ if (isProjects && !isFileSelected) {
+ return true;
+ }
+ return false;
+ }, [isDashboard,
+ isProjects,
+ isTopology,
+ isFileCamel,
+ isLogOpen,
+ isApplicationProperties,
+ isFileMarkdown,
+ showProperties,
+ file?.name,
+ project?.projectId,
+ ]);
+
+
+ return {
+ afterSelect, validated, close, dslCardClick, setAllElements, generateRouteFileName, onDslSelect, selectedDsl,
+ isFileSelected,
+ isFileCamel,
+ isFileGroovy,
+ isFileMarkdown,
+ isTopology,
+ isDashboard,
+ isLogOpen,
+ isProjects,
+ file,
+ files,
+ project,
+ pageId,
+ tabIndex,
+ isApplicationProperties,
+ showCamelSteps,
+ };
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/command-palette/useCommandPaletteStore.ts b/karavan-app/src/main/webui/src/ui/command-palette/useCommandPaletteStore.ts
new file mode 100644
index 00000000..6e69d176
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/command-palette/useCommandPaletteStore.ts
@@ -0,0 +1,104 @@
+/*
+ * 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 {createWithEqualityFn} from "zustand/traditional";
+import {shallow} from "zustand/shallow";
+import {DslMetaModel} from "@designer/utils/DslMetaModel";
+
+interface CommandPaletteState {
+ fileName: string;
+ showPalette: boolean;
+ showProperties: boolean;
+ showSteps: boolean;
+ parentDsl?: string;
+ parentId: string;
+ selectorTabIndex?: string | number
+ selectedPosition?: number;
+ routeId?: string;
+ filter: string;
+ isRouteTemplate?: boolean;
+ selectedDsl?: DslMetaModel;
+ elements: DslMetaModel[];
+
+ setFileName: (fileName: string) => void;
+ setShowPalette: (showPalette: boolean) => void;
+ setShowProperties: (showProperties: boolean) => void;
+ setSelectedDsl: (selectedDsl?: DslMetaModel) => void;
+ setShowSteps: (showSteps: boolean) => void;
+ setParentDsl: (parentDsl?: string) => void;
+ setParentId: (parentId: string) => void;
+ setSelectorTabIndex: (selectorTabIndex?: string | number) => void;
+ setSelectedPosition: (selectedPosition?: number) => void;
+ setRouteId: (routeId: string) => void;
+ setIsRouteTemplate: (isRouteTemplate: boolean) => void;
+ setFilter: (filter: string) => void;
+ setElements: (elements: DslMetaModel[]) => void;
+}
+
+export const useCommandPaletteStore = createWithEqualityFn<CommandPaletteState>((set) => ({
+ showPalette: false,
+ showProperties: false,
+ deleteMessage: '',
+ parentId: '',
+ showSteps: true,
+ isRouteTemplate: false,
+ filter: '',
+ elements: [],
+ fileName: undefined,
+ setSelectorTabIndex: (selectorTabIndex?: string | number) => {
+ set({selectorTabIndex: selectorTabIndex})
+ },
+ setParentDsl: (parentDsl?: string) => {
+ set({parentDsl: parentDsl})
+ },
+ setSelectedDsl: (selectedDsl?: DslMetaModel) => {
+ set(state => ({
+ selectedDsl: selectedDsl,
+ showProperties: selectedDsl ? state.showProperties : false
+ }))
+ },
+ setShowPalette: (showPalette: boolean) => {
+ set({showPalette: showPalette})
+ },
+ setShowProperties: (showProperties: boolean) => {
+ set({showProperties: showProperties})
+ },
+ setShowSteps: (showSteps: boolean) => {
+ set({showSteps: showSteps})
+ },
+ setParentId: (parentId: string) => {
+ set({parentId: parentId})
+ },
+ setSelectedPosition: (selectedPosition?: number) => {
+ set({selectedPosition: selectedPosition})
+ },
+ setRouteId: (routeId: string) => {
+ set({routeId: routeId})
+ },
+ setIsRouteTemplate: (isRouteTemplate: boolean) => {
+ set({isRouteTemplate: isRouteTemplate})
+ },
+ setFilter: (filter: string) => {
+ set({ filter: filter })
+ },
+ setElements: (elements: DslMetaModel[]) => {
+ set({ elements: elements })
+ },
+ setFileName: (fileName: string) => {
+ set({ fileName: fileName })
+ },
+}), shallow)