Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions companion/lib/Controls/Entities/EntityInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,9 +570,9 @@ export class ControlEntityInstance {
/**
* Add a child entity to this entity
*/
addChild(groupId: string, entityModel: SomeEntityModel): ControlEntityInstance {
addChild(groupId: string, entityModel: SomeEntityModel, isCloned?: boolean): ControlEntityInstance {
const childGroup = this.#getOrCreateChildGroup(groupId)
return childGroup.addEntity(entityModel)
return childGroup.addEntity(entityModel, isCloned)
}

/**
Expand Down
35 changes: 35 additions & 0 deletions companion/lib/Controls/Entities/EntityListPoolBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,41 @@ export abstract class ControlEntityListPoolBase {
return true
}

/**
* Paste one or more entities into this control, regenerating IDs
*/
entityPaste(
listId: SomeSocketEntityLocation,
ownerId: EntityOwner | null,
...entityModels: SomeEntityModel[]
): boolean {
if (entityModels.length === 0) return false

const entityList = this.getEntityList(listId)
if (!entityList) return false

let newEntities: ControlEntityInstance[]
if (ownerId) {
const parent = entityList.findById(ownerId.parentId)
if (!parent) throw new Error(`Failed to find parent entity ${ownerId.parentId} when pasting child entity`)

newEntities = entityModels.map((entity) => parent.addChild(ownerId.childGroup, entity, true))
} else {
newEntities = entityModels.map((entity) => entityList.addEntity(entity, true))
}

// Inform relevant module
for (const entity of newEntities) {
entity.subscribe(true)
}

this.tryTriggerLocalVariablesChanged(...newEntities)

this.commitChange()

return true
}

/**
* Duplicate an entity on this control
*/
Expand Down
27 changes: 26 additions & 1 deletion companion/lib/Controls/EntitiesTrpcRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import z from 'zod'
import { publicProcedure, router } from '../UI/TRPC.js'
import type { SomeControl } from './IControlFragments.js'
import type { InstanceDefinitions } from '../Instance/Definitions.js'
import { EntityModelType, zodEntityLocation, type EntityOwner } from '@companion-app/shared/Model/EntityModel.js'
import {
EntityModelType,
zodEntityLocation,
zodSomeEntityModel,
type EntityOwner,
} from '@companion-app/shared/Model/EntityModel.js'
import type { ActiveLearningStore } from '../Resources/ActiveLearningStore.js'
import LogController from '../Log/Controller.js'
import type { VariableValues } from '@companion-app/shared/Model/Variables.js'
Expand Down Expand Up @@ -157,6 +162,26 @@ export function createEntitiesTrpcRouter(
return control.entities.entityDuplicate(entityLocation, entityId)
}),

paste: publicProcedure
.input(
z.object({
controlId: z.string(),
entityLocation: zodEntityLocation,
ownerId: zodEntityOwner.nullable(),
entities: z.array(zodSomeEntityModel),
})
)
.mutation(async ({ input }) => {
const { controlId, entityLocation, ownerId, entities } = input

const control = controlsMap.get(controlId)
if (!control) return false

if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`)

return control.entities.entityPaste(entityLocation, ownerId, ...entities)
}),

setOption: publicProcedure
.input(
z.object({
Expand Down
33 changes: 32 additions & 1 deletion shared-lib/lib/Model/EntityModel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import z from 'zod'
import type { ActionSetId } from './ActionModel.js'
import type { ButtonStyleProperties } from './StyleModel.js'
import type { ExpressionableOptionsObject, ExpressionOrValue } from './Options.js'
import {
ExpressionableOptionsObjectSchema,
createExpressionOrValueSchema,
type ExpressionableOptionsObject,
type ExpressionOrValue,
} from './Options.js'
import type { VariableValue } from './Variables.js'
import type { CompanionFeedbackButtonStyleResult } from '@companion-module/host'

Expand Down Expand Up @@ -119,3 +124,29 @@ export function stringifySocketEntityLocation(location: SomeSocketEntityLocation
if (typeof location === 'string') return location
return `${location.stepId}_${location.setId}`
}

const zodEntityModelBase = z.object({
id: z.string(),
definitionId: z.string(),
connectionId: z.string(),
headline: z.string().optional(),
options: ExpressionableOptionsObjectSchema,
disabled: z.boolean().optional(),
upgradeIndex: z.union([z.number(), z.undefined()]),
})

export const zodSomeEntityModel: z.ZodType<SomeEntityModel> = z.lazy(() =>
z.union([
zodEntityModelBase.extend({
type: z.literal(EntityModelType.Action),
children: z.record(z.string(), z.array(zodSomeEntityModel).optional()).optional(),
}),
zodEntityModelBase.extend({
type: z.literal(EntityModelType.Feedback),
isInverted: createExpressionOrValueSchema(z.boolean()).optional(),
variableName: z.string().optional(),
style: z.record(z.string(), z.any()).optional(),
children: z.record(z.string(), z.array(zodSomeEntityModel).optional()).optional(),
}),
])
)
3 changes: 3 additions & 0 deletions webui/src/ContextData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useModuleStoreRefreshProgressSubscription } from './Hooks/useModuleStor
import { useModuleStoreListSubscription } from './Hooks/useModuleStoreListSubscription.js'
import { HelpModal, type HelpModalRef } from './Instances/HelpModal.js'
import { ViewControlStore } from '~/Stores/ViewControlStore.js'
import { EntityClipboardStore } from '~/Stores/EntityClipboardStore.js'
import { WhatsNewModal, type WhatsNewModalRef } from './WhatsNewModal/WhatsNew.js'
import { useGenericCollectionsSubscription } from './Hooks/useCollectionsSubscription.js'
import { useCustomVariableCollectionsSubscription } from './Hooks/useCustomVariableCollectionsSubscription.js'
Expand Down Expand Up @@ -95,6 +96,8 @@ export function ContextData({ children }: Readonly<ContextDataProps>): React.JSX
showWizard: () => showWizardEvent.dispatchEvent(new Event('show')),

viewControl: new ViewControlStore(),

entityClipboard: new EntityClipboardStore(),
} satisfies RootAppStore
}, [notifierObj, helpModalRef, whatsNewModalRef])

Expand Down
30 changes: 26 additions & 4 deletions webui/src/Controls/Components/AddEntityPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { CButton } from '@coreui/react'
import { faFolderOpen } from '@fortawesome/free-solid-svg-icons'
import { faFolderOpen, faPaste } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { useCallback, useRef } from 'react'
import { useCallback, useContext, useRef } from 'react'
import { AddEntitiesModal, type AddEntitiesModalRef } from './AddEntitiesModal.js'
import { MyErrorBoundary } from '~/Resources/Error.js'
import type { EntityModelType, EntityOwner, FeedbackEntitySubType } from '@companion-app/shared/Model/EntityModel.js'
import { AddEntityDropdown } from './AddEntityDropdown.js'
import { usePanelCollapseHelperContext } from '~/Helpers/CollapseHelper.js'
import { useEntityEditorContext } from './EntityEditorContext.js'
import { observer } from 'mobx-react-lite'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'

interface AddEntityPanelProps {
ownerId: EntityOwner | null
Expand All @@ -16,13 +18,14 @@ interface AddEntityPanelProps {
entityTypeLabel: string
}

export function AddEntityPanel({
export const AddEntityPanel = observer(function AddEntityPanel({
ownerId,
entityType,
feedbackListType,
entityTypeLabel,
}: AddEntityPanelProps): React.JSX.Element {
const { serviceFactory, readonly } = useEntityEditorContext()
const { entityClipboard } = useContext(RootAppStoreContext)

const addEntitiesRef = useRef<AddEntitiesModalRef>(null)
const showAddModal = useCallback(() => addEntitiesRef.current?.show(), [])
Expand All @@ -46,6 +49,14 @@ export function AddEntityPanel({
[serviceFactory, entityType, ownerId, panelCollapseHelper]
)

const canPaste = entityClipboard.copiedEntityType === entityType

const handlePaste = useCallback(() => {
const entity = entityClipboard.copiedEntity
if (!entity) return
serviceFactory.performPaste(ownerId, [entity])
}, [entityClipboard, serviceFactory, ownerId])

return (
<div className="add-dropdown-wrapper">
<AddEntityDropdown
Expand All @@ -67,6 +78,17 @@ export function AddEntityPanel({
>
<FontAwesomeIcon icon={faFolderOpen} />
</CButton>
{canPaste && (
<CButton
color="success"
onClick={handlePaste}
disabled={readonly}
title={`Paste ${entityTypeLabel} from clipboard`}
style={{ marginLeft: 4 }}
>
<FontAwesomeIcon icon={faPaste} />
</CButton>
)}

<MyErrorBoundary>
<AddEntitiesModal
Expand All @@ -79,4 +101,4 @@ export function AddEntityPanel({
</MyErrorBoundary>
</div>
)
}
})
21 changes: 19 additions & 2 deletions webui/src/Controls/Components/EntityCellControls.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { useCallback } from 'react'
import { useCallback, useContext } from 'react'
import { CButtonGroup, CButton, CFormSwitch } from '@coreui/react'
import { faPencil, faExpandArrowsAlt, faCompressArrowsAlt, faClone, faTrash } from '@fortawesome/free-solid-svg-icons'
import {
faCopy,
faPencil,
faExpandArrowsAlt,
faCompressArrowsAlt,
faClone,
faTrash,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import type { IEntityEditorActionService } from '~/Services/Controls/ControlEntitiesService.js'
import { EntityModelType, type EntityOwner, type SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js'
import { TextInputField } from '~/Components/TextInputField.js'
import { observer } from 'mobx-react-lite'
import { RootAppStoreContext } from '~/Stores/RootAppStore.js'

interface EntityCellControlProps {
service: IEntityEditorActionService
Expand Down Expand Up @@ -36,6 +44,12 @@ export const EntityRowHeader = observer(function EntityRowHeader({
readonly,
localVariablePrefix,
}: EntityCellControlProps) {
const { entityClipboard } = useContext(RootAppStoreContext)

const handleCopy = useCallback(() => {
entityClipboard.copyEntity(entity)
}, [entityClipboard, entity])

const innerSetEnabled = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => service.setEnabled?.(e.target.checked),
[service]
Expand Down Expand Up @@ -82,6 +96,9 @@ export const EntityRowHeader = observer(function EntityRowHeader({
<FontAwesomeIcon icon={faCompressArrowsAlt} />
</CButton>
)}
<CButton size="sm" onClick={handleCopy} title={`Copy ${entityTypeLabel} to clipboard`}>
<FontAwesomeIcon icon={faCopy} />
</CButton>
<CButton
size="sm"
disabled={readonly}
Expand Down
4 changes: 4 additions & 0 deletions webui/src/Services/Controls/ControlActionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export function useActionRecorderActionService(sessionId: string): IEntityEditor
})
},

performPaste: (_ownerId: EntityOwner | null, _entities: SomeEntityModel[]) => {
// Not supported in action recorder
},
Comment on lines +71 to +73

@coderabbitai coderabbitai Bot Apr 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Paste is currently exposed as a no-op in action recorder.

On Line 71, this handler does nothing, so users can hit “Paste” and get no effect. For unsupported features in this service, it’s better to keep the action unavailable (or surface an explicit “not supported” notification).

Suggested fix
-			performPaste: (_ownerId: EntityOwner | null, _entities: SomeEntityModel[]) => {
-				// Not supported in action recorder
-			},
+			performPaste: undefined,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
performPaste: (_ownerId: EntityOwner | null, _entities: SomeEntityModel[]) => {
// Not supported in action recorder
},
performPaste: undefined,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't want to break with what seems to be the existing convention, all other functions that are not supported in that file are implemented like this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@njibhu, that's a fair point! Looking at the file more carefully, the convention here is indeed mixed — performLearn, setEnabled, and setHeadline are set to undefined, while setInverted, setVariableName, setVariableValue, and a few others use the no-op pattern you followed. Both approaches exist side by side, so your choice is completely reasonable. I'll defer to your judgment on keeping it consistent with the no-op style you were following. Thanks for explaining! 🐇


🧠 Learnings used
Learnt from: arikorn
Repo: bitfocus/companion PR: 3916
File: webui/src/Hooks/useShowSecondaryPanel.ts:27-29
Timestamp: 2026-03-24T00:26:41.745Z
Learning: In the bitfocus/companion repo’s TanStack Router configuration, setting `trailingSlash: 'never'` on `createRouter()` does not normalize/strip trailing slashes at runtime (it had no effect in testing). When reviewing route-matching issues related to trailing slashes, do not recommend `trailingSlash: 'never'` as a fix in this repo; look for other causes/solutions instead.

Learnt from: Julusian
Repo: bitfocus/companion PR: 4077
File: companion/test/Service/Satellite/SatelliteApi.test.ts:650-654
Timestamp: 2026-04-08T21:40:58.021Z
Learning: In the bitfocus/companion repo, avoid adding code review comments that only restate TypeScript compiler type errors (e.g., TS18048 “possibly undefined”, type mismatches). Rely on the TypeScript compiler/CI to catch these issues, and focus review comments on problems that are not already covered by compilation (logic, correctness, maintainability, security, etc.).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern here is that ui 'features' which are optional can be set to undefined, which is used by the ui to hide any ui for that feature.
some fo the editing is no-ops because the entities here are set to readonly, so in reality these can never be fired.

so maybe the performPaste should be undefined here, to let the ui use this to hide the paste button?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


performLearn: undefined,
setEnabled: undefined,
setHeadline: undefined,
Expand Down
16 changes: 16 additions & 0 deletions webui/src/Services/Controls/ControlEntitiesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface IEntityEditorService {
) => void
performDelete: (entityId: string, entityTypeLabel: string) => void
performDuplicate: (entityId: string) => void
performPaste: (ownerId: EntityOwner | null, entities: SomeEntityModel[]) => void
setConnection: (entityId: string, connectionId: string) => void
moveCard: (
dragListId: SomeSocketEntityLocation,
Expand Down Expand Up @@ -76,6 +77,7 @@ export function useControlEntitiesEditorService(
const setConnectionMutation = useMutationExt(trpc.controls.entities.setConnection.mutationOptions())
const removeMutation = useMutationExt(trpc.controls.entities.remove.mutationOptions())
const duplicateMutation = useMutationExt(trpc.controls.entities.duplicate.mutationOptions())
const pasteMutation = useMutationExt(trpc.controls.entities.paste.mutationOptions())
const learnOptionsMutation = useMutationExt(trpc.controls.entities.learnOptions.mutationOptions())
const setEnabledMutation = useMutationExt(trpc.controls.entities.setEnabled.mutationOptions())
const setHeadlineMutation = useMutationExt(trpc.controls.entities.setHeadline.mutationOptions())
Expand Down Expand Up @@ -186,6 +188,19 @@ export function useControlEntitiesEditorService(
})
},

performPaste: (ownerId: EntityOwner | null, entities: SomeEntityModel[]) => {
pasteMutation
.mutateAsync({
controlId,
entityLocation: listId,
ownerId,
entities,
})
.catch((e) => {
console.error('Failed to paste control entity', e)
})
},

performLearn: (entityId: string) => {
learnOptionsMutation
.mutateAsync({
Expand Down Expand Up @@ -296,6 +311,7 @@ export function useControlEntitiesEditorService(
setConnectionMutation,
removeMutation,
duplicateMutation,
pasteMutation,
learnOptionsMutation,
setEnabledMutation,
setHeadlineMutation,
Expand Down
28 changes: 28 additions & 0 deletions webui/src/Stores/EntityClipboardStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { action, makeObservable, observable } from 'mobx'
import type { SomeEntityModel, EntityModelType } from '@companion-app/shared/Model/EntityModel.js'

export class EntityClipboardStore {
protected _copiedEntity: SomeEntityModel | null = null

constructor() {
makeObservable<EntityClipboardStore, '_copiedEntity'>(this, {
_copiedEntity: observable,
})
}

get copiedEntity(): SomeEntityModel | null {
return this._copiedEntity
}

get copiedEntityType(): EntityModelType | null {
return this._copiedEntity?.type ?? null
}

copyEntity = action((entity: SomeEntityModel): void => {
this._copiedEntity = structuredClone(entity)
})

clear = action((): void => {
this._copiedEntity = null
})
}
3 changes: 3 additions & 0 deletions webui/src/Stores/RootAppStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { VariablesStore } from './VariablesStore.js'
import type { ConnectionsStore } from './ConnectionsStore.js'
import type { HelpModalRef } from '~/Instances/HelpModal.js'
import type { ViewControlStore } from './ViewControlStore.js'
import type { EntityClipboardStore } from './EntityClipboardStore.js'
import type { WhatsNewModalRef } from '~/WhatsNewModal/WhatsNew.js'
import type { ExpressionVariablesListStore } from './ExpressionVariablesListStore.js'
import type { SurfaceInstancesStore } from './SurfaceInstancesStore.js'
Expand Down Expand Up @@ -51,4 +52,6 @@ export interface RootAppStore {
readonly showWizard: () => void

readonly viewControl: ViewControlStore

readonly entityClipboard: EntityClipboardStore
}