Ctrl K
ComunidadGuías…

app/actions/index.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { LocationDescriptor } from "history";
import { toast } from "sonner";
import type { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import type {
  ActionContext,
  Action,
  ActionGroup,
  ActionSeparator as TActionSeparator,
  ActionVariant,
  ActionWithChildren,
  ExternalLinkAction,
  InternalLinkAction,
  MenuItem,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { ActionImpl, Action as KbarAction } from "kbar";

/** A command bar action, with the additional properties that Outline renders. */
export type CommandBarAction = KbarAction & {
  badge?: React.ReactNode;
};

/** A registered command bar action, as handed back by the command bar. */
export type CommandBarActionImpl = ActionImpl & {
  badge?: React.ReactNode;
};

export function resolve<T>(value: unknown, context: ActionContext): T {
  return (
    typeof value === "function"
      ? (value as (context: ActionContext) => T)(context)
      : value
  ) as T;
}

export const ActionSeparator: TActionSeparator = {
  type: "action_separator",
};

export function createAction(
  definition: Optional<Omit<Action, "type" | "variant">, "id">
): Action {
  return {
    ...definition,
    type: "action",
    variant: "action",
    perform: definition.perform ?? (() => {}),
    id: definition.id ?? uuidv4(),
  };
}

export function createInternalLinkAction(
  definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
): InternalLinkAction {
  return {
    ...definition,
    type: "action",
    variant: "internal_link",
    id: definition.id ?? uuidv4(),
  };
}

export function createExternalLinkAction(
  definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
): ExternalLinkAction {
  return {
    ...definition,
    type: "action",
    variant: "external_link",
    id: definition.id ?? uuidv4(),
  };
}

export function createActionWithChildren(
  definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
): ActionWithChildren {
  return {
    ...definition,
    type: "action",
    variant: "action_with_children",
    id: definition.id ?? uuidv4(),
  };
}

export function createActionGroup(
  definition: Omit<ActionGroup, "type">
): ActionGroup {
  return {
    ...definition,
    type: "action_group",
  };
}

export function createRootMenuAction(
  actions: (ActionVariant | ActionGroup | TActionSeparator)[]
): ActionWithChildren {
  return {
    id: uuidv4(),
    type: "action",
    variant: "action_with_children",
    name: "root_action",
    section: "Root",
    children: actions,
  };
}

/**
 * Determines whether any of the given actions are visible in the context,
 * without resolving the remainder of the menu items.
 *
 * @param actions - the actions to check.
 * @param context - the context to resolve visibility against.
 * @returns true if at least one action is visible.
 */
export function hasVisibleActions(
  actions: (ActionVariant | ActionGroup | TActionSeparator)[],
  context: ActionContext
): boolean {
  return actions.some((action) => {
    switch (action.type) {
      case "action": {
        if (resolve<boolean>(action.visible, context) === false) {
          return false;
        }
        if (action.variant === "action_with_children") {
          const children = resolve<
            (ActionVariant | ActionGroup | TActionSeparator)[]
          >(action.children, context);
          return hasVisibleActions(children, context);
        }
        return true;
      }

      case "action_group":
        return hasVisibleActions(action.actions, context);

      case "action_separator":
        return false;
    }
  });
}

export function actionToMenuItem(
  action: ActionVariant | ActionGroup | TActionSeparator,
  context: ActionContext
): MenuItem {
  switch (action.type) {
    case "action": {
      const title = resolve<string>(action.name, context);
      const visible = resolve<boolean>(action.visible, context) ?? true;
      const disabled = resolve<boolean>(action.disabled, context);
      const shortcut = resolve<string[] | undefined>(action.shortcut, context);
      const badge = resolve<React.ReactNode>(action.badge, context);
      const icon =
        !!action.icon && action.iconInContextMenu !== false
          ? resolve<React.ReactNode>(action.icon, context)
          : undefined;

      switch (action.variant) {
        case "action":
          return {
            type: "button",
            title,
            icon,
            visible,
            disabled,
            tooltip: resolve<React.ReactNode>(action.tooltip, context),
            selected: resolve<boolean>(action.selected, context),
            dangerous: action.dangerous,
            shortcut,
            badge,
            onClick: () => performAction(action, context),
          };

        case "internal_link": {
          const to = resolve<LocationDescriptor>(action.to, context);
          return {
            type: "route",
            title,
            icon,
            visible,
            disabled,
            shortcut,
            badge,
            to,
          };
        }

        case "external_link":
          return {
            type: "link",
            title,
            icon,
            visible,
            disabled,
            shortcut,
            badge,
            href: action.target
              ? { url: action.url, target: action.target }
              : action.url,
          };

        case "action_with_children": {
          const children = resolve<
            (ActionVariant | ActionGroup | TActionSeparator)[]
          >(action.children, context);
          const subMenuItems = children.map((a) =>
            actionToMenuItem(a, context)
          );
          return {
            type: "submenu",
            title,
            icon,
            items: subMenuItems,
            disabled,
            visible: visible && hasVisibleItems(subMenuItems),
          };
        }

        default:
          throw Error("invalid action variant");
      }
    }

    case "action_group": {
      const groupItems = action.actions.map((a) =>
        actionToMenuItem(a, context)
      );
      return {
        type: "group",
        title: resolve<string>(action.name, context),
        visible: hasVisibleItems(groupItems),
        items: groupItems,
      };
    }

    case "action_separator":
      return { type: "separator" };
  }
}

export function actionToKBar(
  action: ActionVariant,
  context: ActionContext
): CommandBarAction[] {
  const visible = resolve<boolean>(action.visible, context);
  if (visible === false) {
    return [];
  }

  const name = resolve<string>(action.name, context);
  const shortcut = resolve<string[] | undefined>(action.shortcut, context);
  const icon = resolve<React.ReactElement>(action.icon, context);
  const badge = resolve<React.ReactNode>(action.badge, context);
  const section = resolve<string>(action.section, context);
  const subtitle = resolve<string>(action.description, context);

  // Sections are passed to the command bar as objects so that their declared
  // priority orders the sections themselves – given a bare string it would
  // instead order them by the match score of whichever result happens to come
  // first, which lets a section with an exact keyword match jump to the top.
  // The command bar falls back to that same match score when the priority is
  // falsy, so the offset keeps an undeclared priority of zero out of the way
  // while preserving the relative order of the declared values.
  const sectionWithPriority = {
    name: section,
    priority:
      sectionPriorityOffset +
      (typeof action.section !== "string" && "priority" in action.section
        ? ((action.section.priority as number) ?? 0)
        : 0),
  };

  const priority = 1 + (action.priority ?? 0);

  switch (action.variant) {
    case "action":
    case "internal_link":
    case "external_link": {
      return [
        {
          id: action.id,
          name,
          section: sectionWithPriority,
          keywords: action.keywords,
          shortcut,
          subtitle,
          icon,
          badge,
          priority,
          perform: () => performAction(action, context),
        },
      ];
    }

    case "action_with_children": {
      const resolvedChildren = resolve<ActionVariant[]>(
        action.children,
        context
      );
      const children = resolvedChildren
        .map((a) => actionToKBar(a, context))
        .flat()
        .filter(Boolean);

      return [
        {
          id: action.id,
          name,
          section: sectionWithPriority,
          keywords: action.keywords,
          shortcut,
          icon,
          badge,
          subtitle,
          priority,
        },
        ...children.map((child) => ({
          ...child,
          parent: child.parent ?? action.id,
        })),
      ];
    }

    default:
      throw Error("invalid action variant");
  }
}

export async function performAction(
  action: Exclude<ActionVariant, ActionWithChildren>,
  context: ActionContext
) {
  const perform =
    action.variant === "action"
      ? () => action.perform(context)
      : action.variant === "internal_link"
        ? () => history.push(resolve<LocationDescriptor>(action.to, context))
        : () => window.open(action.url, action.target);

  // We must use the specific analytics name here as the action name is
  // translated and potentially contains user strings.
  if (action.analyticsName) {
    Analytics.track("perform_action", action.analyticsName, {
      context: context.isButton
        ? "button"
        : context.isCommandBar
          ? "commandbar"
          : context.isMCP
            ? "webmcp"
            : "contextmenu",
    });
  }

  const result = perform();

  if (result instanceof Promise) {
    return result.catch((err: Error) => {
      // WebMCP callers surface errors to the agent instead of a toast.
      if (context.isMCP) {
        throw err;
      }
      toast.error(err.message);
    });
  }

  return result;
}

/**
 * Added to every section priority handed to the command bar, so that a section
 * which declares no priority is still ordered by its priority rather than by
 * match score. Must be larger than the largest declared priority in magnitude.
 */
const sectionPriorityOffset = 10;

function hasVisibleItems(items: MenuItem[]) {
  const applicableTypes = ["button", "link", "route", "group", "submenu"];
  return items.some(
    (item) => applicableTypes.includes(item.type) && item.visible
  );
}