import { createElement } from "react"; import { useTranslation } from "react-i18next"; import type { CommandItem } from "@/lib/commands/types"; import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color"; import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority"; import { useNestTask } from "@/hooks/use-nest-task"; import { useAppStore } from "./state-provider"; import { TASK_COLORS, TASK_COLOR_LABEL_KEYS, TASK_COLOR_BAR_CLASS } from "@/lib/task-colors"; import { TASK_PRIORITY_TOKENS, TASK_PRIORITY_LABEL_KEYS } from "@/lib/tasks/task-priority"; import { computeNestCandidates } from "@/lib/sidebar/nest-candidates"; import { resolvePluginIcon } from "@/lib/plugins/icons"; import { taskMoveOptions, type TaskMoveStep, type TaskMoveWorkflow, } from "./task/task-move-context-menu"; import { useTaskPluginLinkActions } from "./task/task-session-sidebar-link-actions"; import { useTaskPluginPrimaryMenuEntries } from "./task/task-switcher-plugin-menu-items"; import type { KanbanCardMenuEntry } from "./kanban-card-menu-items"; import { selectTaskLinkActions, taskLinkMenuOptions, type TaskLinkHandlers, } from "./task/task-switcher-link-menu"; import type { TaskSwitcherItem } from "./task/task-switcher-types"; const noop = () => {}; type ChoiceOptions = { task: TaskSwitcherItem; workflows: TaskMoveWorkflow[]; stepsByWorkflowId: Record; linkHandlers: TaskLinkHandlers; openMoveOptions: (step: TaskMoveStep) => void; moveImmediately: (step: TaskMoveStep) => void; }; /** * One command per *selectable* plugin menu entry. A plugin submenu has no * activation of its own (its `label` is only a trigger and it carries no * `onSelect`), so its item children become the commands -- flattening them * keeps a plugin's actions reachable from the command palette instead of * dropping the whole contribution, and mirrors what the search surface shows * for every other card action. */ export function pluginCommandChoices( entry: KanbanCardMenuEntry, group: string, parentLabel?: string, ): CommandItem[] { if (entry.kind === "submenu") { const label = typeof entry.label === "string" ? entry.label : parentLabel; return entry.children.flatMap((child) => pluginCommandChoices(child, group, label)); } if (entry.kind !== "item" || typeof entry.label !== "string") return []; return [ { // The entry key is unique across every plugin menu entry, including // flattened children whose key encodes the child id, and it doubles as // this row's React key and cmdk value. id: entry.key, label: entry.label, group, // A child's own label ("Blocked") is meaningless on its own in a palette // listing every action, so the trigger it came from is the entry's // context -- the same field the sidebar task commands use for the title. context: parentLabel, action: entry.onSelect, disabled: entry.disabled, icon: entry.icon, }, ]; } export function useTaskCommandChoices(options: ChoiceOptions) { const { task, linkHandlers } = options; const { t } = useTranslation(); const group = t("common:commandGroupTasks"); const setColor = useSetTaskColor(); const currentColor = useTaskColor(task.id); const updatePriority = useUpdateTaskPriority(); const pluginLinks = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []); const pluginEntries = useTaskPluginPrimaryMenuEntries(task); const item = (id: string, label: string, action?: () => void): CommandItem => ({ id, label, group, action, }); const colors: CommandItem[] = TASK_COLORS.map((color) => ({ ...item(`task-color-${color}`, t(TASK_COLOR_LABEL_KEYS[color]), () => setColor(task.id, color)), icon: ( ), })); colors.push({ ...item("task-color-none", t("task:groupNone"), () => setColor(task.id, null)), disabled: !currentColor, }); if (task.automaticColorSource) colors.unshift({ ...item( "task-color-source", t("task:automaticColorSource", { rule: task.automaticColorSource.label }), ), disabled: true, }); const priorities = TASK_PRIORITY_TOKENS.map((priority) => ({ ...item( `task-priority-${priority}`, t(TASK_PRIORITY_LABEL_KEYS[priority]), () => void updatePriority(task.id, priority), ), context: task.priority === priority ? t("kanban:current") : undefined, })); const links = taskLinkMenuOptions(selectTaskLinkActions(task, noop, linkHandlers)).map( ({ id, labelKey, Icon, onSelect }) => ({ ...item(`task-link-${id}`, t(labelKey), onSelect), icon: , }), ); links.push( ...pluginLinks.map((link) => ({ ...item(`task-link-plugin-${link.id}`, link.label, link.onSelect), icon: createElement(resolvePluginIcon(link.icon), { className: "size-3.5" }), })), ); const plugins = pluginEntries.flatMap((entry) => pluginCommandChoices(entry, group)); return { colors, priorities, links, plugins, nesting: useTaskNestChoices(task), ...useTaskMoveChoices(options), }; } function useTaskNestChoices(task: TaskSwitcherItem): CommandItem[] { const { t } = useTranslation(); const nestTask = useNestTask(); const tasks = useAppStore((state) => task.workflowId ? (state.kanbanMulti.snapshots[task.workflowId]?.tasks ?? (state.kanban.workflowId === task.workflowId ? state.kanban.tasks : undefined)) : undefined, ); const group = t("task:nestUnder"); const candidates = computeNestCandidates(tasks ?? [], task.id); const items: CommandItem[] = candidates.map((candidate) => ({ id: `task-nest-${candidate.id}`, label: candidate.title, group, action: () => { if (task.workflowId) void nestTask(task.id, task.workflowId, candidate.id); }, })); if (!items.length) items.push({ id: "task-nest-empty", label: t("task:noOtherTasks"), group, disabled: true }); if (task.parentTaskId) items.unshift({ id: "task-unnest", label: t("task:unNestRemoveParent"), group, action: () => { if (task.workflowId) void nestTask(task.id, task.workflowId, null); }, }); return items; } export function useTaskMoveChoices({ task, workflows, stepsByWorkflowId, openMoveOptions, moveImmediately, }: Omit) { const { t } = useTranslation(); const { currentSteps, targets } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId); const group = t("task:moveTo"); const choice = (step: TaskMoveStep, workflowId: string, group: string): CommandItem => { const target = { ...step, workflow_id: workflowId }; return { id: `task-move-${workflowId}-${step.id}`, label: step.title, group, icon: ( ), action: () => openMoveOptions(target), immediateAction: () => moveImmediately(target), }; }; const steps = currentSteps .filter((step) => step.id !== task.workflowStepId) .map((step) => choice(step, task.workflowId!, group)); const workflowChoices: CommandItem[] = targets .filter((workflow) => (stepsByWorkflowId[workflow.id]?.length ?? 0) > 0) .map((workflow) => ({ id: `task-workflow-${workflow.id}`, label: workflow.name, group: t("task:sendToWorkflow"), children: (stepsByWorkflowId[workflow.id] ?? []).map((step) => choice(step, workflow.id, workflow.name), ), })); return { steps, workflows: workflowChoices }; }