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 <[email protected]> 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)
