likes
comments
collection
share

猎豹Cheetah插件开发 - Raycast 篇

作者站长头像
站长
· 阅读数 0

大家好,本文将继续讲解 Cheetah for Raycast 扩展的开发流程以及 Raycast 开发扩展的一些注意事项。

Raycast 介绍

Raycast 是一个速度极快、完全可扩展的启动器,它可以快速搜索本地应用、文件等等,通过安装第三方扩展实现各种各样的功能。

Raycast 还可以自定义快速连接、脚本命令等,完成一系列自动化操作。

Raycast 的扩展开发文档非常详细,让开发者清晰地了解开发扩展相关的知识、流程,快速上手。

官网:www.raycast.com

创建项目

让我们来创建一个 Raycast 扩展吧~

Create Extension

Raycast 提供了方便快捷的命令帮助开发者创建扩展,在 Raycast 输入框内输入 Create Extension 回车即可打开创建面板。

猎豹Cheetah插件开发 - Raycast 篇

猎豹Cheetah插件开发 - Raycast 篇

Template:选择一个扩展模板,当前选择的是一个在 npm 官网搜索依赖库的例子。 Categories:扩展的分类。 Location:是扩展项目存放的目录。 Command Name:是扩展内命令的名称,在 Raycast 输入框中搜索即可运行此命令。

填写完信息后点击 Create Extension 即可完成项目创建,生成项目结构如下:

Cheetah
├─ .eslintrc.json
├─ .gitignore
├─ .prettierrc
├─ CHANGELOG.md
├─ README.md
├─ assets
│  ├─ command-icon.png
│  └─ list-icon.png
├─ package.json
├─ src
│  └─ index.tsx
└─ tsconfig.json

终端进入项目后执行:

npm install
npm run dev

此时如果已经打开 Raycast,会自动唤出输入面板并且本地扩展中的命令会置顶展示。

猎豹Cheetah插件开发 - Raycast 篇

命令配置

Raycast 的扩展配置集成在 package.json 内,包括基本信息、命令入口、偏好设置等等。

{
  "$schema": "https://www.raycast.com/schemas/extension.json",
  "version": "1.0.0",
  "name": "cheetah",
  "title": "Cheetah",
  "description": "Search for a local Git project and open it with the specified application.",
  "icon": "logo.png",
  "author": "ronglecat",
  "categories": [
    "Developer Tools"
  ],
  "license": "MIT",
  "commands": [ // 本扩展包含的所有命令
    {
      "name": "editor", // 命令的唯一标识,运行命令时匹配 src 目录下的同名文件并执行。
      "title": "Open With Editor", // 用户看到的命令名称
      "description": "Open the specified project using the editor.", // 命令的描述
      "mode": "view", // 为 “views” 时命令运行后展示可视化界面,入口文件为 tsx
      "icon": "command/editor.png", // 命令专属icon
      "keywords": [ // 除了输入title外,这些关键词也可以匹配到当前命令
        "editor",
        "Editor"
      ]
    },
    {
      "name": "git",
      "title": "Open With Git GUI",
      "description": "Open the specified project using the Git GUI.",
      "mode": "view",
      "icon": "command/git.png",
      "keywords": [
        "git",
        "Git"
      ]
    },
    {
      "name": "terminal",
      "title": "Open With Terminal",
      "description": "Open the specified project using the terminal.",
      "mode": "view",
      "icon": "command/terminal.png",
      "keywords": [
        "terminal",
        "Terminal"
      ]
    },
    {
      "name": "finder",
      "title": "Open With Finder",
      "description": "Open the specified project using the Finder.",
      "mode": "view",
      "icon": "command/finder.png",
      "keywords": [
        "finder",
        "Finder"
      ]
    },
    {
      "name": "chooseApp",
      "title": "Choose Application",
      "description": "Specifying applications for projects.",
      "mode": "view",
      "icon": "command/app.png"
    },
    {
      "name": "editCache",
      "title": "Edit Cache File",
      "description": "Open the cache file using the default editor.",
      "mode": "no-view", // 为 “no-view” 时,表示仅执行,无可视化界面反馈,入口文件 ts 即可
      "icon": "command/editCache.png"
    },
    {
      "name": "clearCache",
      "title": "Clear Cache",
      "description": "Clear cache files.",
      "mode": "no-view",
      "icon": "command/clearCache.png"
    }
  ],
  "preferences": [ // 偏好设置
    {
      "name": "workspaces", // 最后存储的 key 值,使用 getPreferenceValues API 读取
      "description": "The tool will search for projects in the working directory you set, with multiple directories separated by commas, Need to configure the absolute path of the directory, This directory should contain projects managed with Git.", 
      // 此配置的详细说明
      "required": true, // 是否为强制要求
      "type": "textfield", // 输入的类型,此处为文本框
      "title": "Setting working directory" // 用户看到的标题
    },
    {
      "name": "defaultEditor",
      "description": "The default editor used to open the project.",
      "required": false,
      "type": "appPicker", // Raycast 提供的应用选择器
      "title": "Choose default Editor"
    },
    {
      "name": "defaultGitApp",
      "description": "The Git GUI application used to open the project.",
      "required": false,
      "type": "appPicker",
      "title": "Choose Git GUI"
    },
    {
      "name": "defaultTerminalApp",
      "description": "The terminal application used to open the project.",
      "required": false,
      "type": "appPicker",
      "title": "Choose Terminal App"
    }
  ],
  "dependencies": {
    ...
  },
  "devDependencies": {
   ...
  },
  "scripts": {
    ...
  }
}

commands 数组内是当前扩展所包含的所有命令,其内字段含义可以看数组第一个元素中的注释,配置完成后 Raycast 中即可看到对应的命令了,别忘了在 src 下创建同名入口文件~

猎豹Cheetah插件开发 - Raycast 篇

preferences 数组内是扩展可配置的偏好设置,用户在配置后可以通过 getPreferenceValues API 获取。

调用扩展时设置项的 requiredtrue 且未配置的情况下会展示一个配置面板,提示用户完成配置。

猎豹Cheetah插件开发 - Raycast 篇

引入核心模块

与前面两篇一样,Cheetah for Raycast 也需要引入核心模块。

安装模块

npm install cheetah-core 
# or 
yarn add cheetah-core

初始化模块

在使用到项目搜索、缓存相关函数时需要提前初始化核心模块,这里使用了 React 的自定义 Effect 完成初始化:

// useInitCore.ts
// 初始化猎豹核心模块
import { init, HOME_PATH } from "cheetah-core";
import { cachePath } from "../constant";
import { errorHandle, getConfig } from "../utils";

export default async () => {
  const { workspaces } = getConfig();
  if (!workspaces) {
    errorHandle(new Error("103"));
    return;
  }

  init({
    cachePath,
    workspaces: workspaces.replace(/~/gm, HOME_PATH),
  });
};

React 初学者也不知道自定义 Effect 这么用规不规范,请各位老师指教。

功能实现

搜索项目并...

下面 5 个命令都用到了搜索项目,只是在选择项目后执行的操作不同,所以将搜索相关的逻辑封装了一下,各个入口调用下面的函数,返回一个 List 列表组件用于渲染:

// src/lib/components/searchList.tsx
import { List } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import { useEffect, useState } from "react";
import useInitCore from "../effects/useInitCore";
import useProjectFilter from "../effects/useProjectFilter";
import SearchListItem from "./searchListItem";

export default (command: COMMAND, appPath: string, forced = false) => {
  useInitCore();
  const [searchText, setSearchText] = useState("");
  const [data, loading, filterProject] = useProjectFilter();

  useEffect(() => {
    filterProject(searchText);
  }, [searchText]);

  return (
    <List
      isLoading={loading}
      onSearchTextChange={setSearchText}
      searchBarPlaceholder="Search your project..."
      throttle
      enableFiltering={false}
    >
      <List.Section title="Results" subtitle={data?.length + ""}>
        {data?.map((searchResult, index) => (
          <SearchListItem
            key={searchResult.name + index}
            searchResult={searchResult}
            appPath={appPath}
            forced={forced}
            filterProject={filterProject}
            commandType={command}
          />
        ))}
      </List.Section>
    </List>
  );
};

列表函数中调用了 useProjectFilter 自定义函数,用来搜索项目。

// src/lib/effects/useProjectFilter.ts
// 查找与关键词匹配的项目
import { useState } from "react";
import { Project, filterWithSearchResult, filterWithCache } from "cheetah-core";
import { refreshKeyword } from "../constant";
import { ResultItem } from "../types";
import { output } from "../core";
import { environment } from "@raycast/api";
import { errorHandle } from "../utils";

export default (): [
  ResultItem[],
  boolean,
  (keyword: string) => Promise<void>
] => {
  const [resultList, setResultList] = useState<ResultItem[]>([]);
  const [loading, setLoading] = useState(true);

  /**
   * @description: 查找项目
   * @param {string} keyword 用户输入的关键词
   * @return {*}
   */
  async function filterProject(keyword: string): Promise<void> {
    try {
      const needRefresh: boolean = keyword.includes(refreshKeyword);
      const searchKeyword = keyword.replace(refreshKeyword, "");
      setLoading(true);
      let projects: Project[] = await filterWithCache(searchKeyword);
      let fromCache = true;
      // 如果缓存结果为空或者需要刷新缓存,则重新搜索
      if (!projects.length || needRefresh) {
        projects = await filterWithSearchResult(searchKeyword);
        fromCache = false;
      }

      const result: ResultItem[] = await output(projects);

      if (fromCache) {
        result.push({
          name: "Ignore cache re search",
          description:
            "Ignore the cache and search for items in the working directory again",
          icon: `${environment.assetsPath}/refresh.png`,
          arg: searchKeyword,
          refresh: true,
        });
      }

      setResultList(result);
      setLoading(false);
    } catch (error: unknown) {
      errorHandle(error);
    }
  }

  return [resultList, loading, filterProject];
};

输入框内关键词发生变化后会执行 useProjectFilter 返回的回调函数,触发搜索并返回筛选后的项目合集,searchList 根据项目集合循环渲染 searchListItem

猎豹Cheetah插件开发 - Raycast 篇

红框是整个 searchList,蓝框是 searchListItem,组件代码如下:

// src/lib/components/searchListItem.tsx
import { List } from "@raycast/api";
import { COMMAND, HOME_PATH } from "cheetah-core";
import { ResultItem } from "../types";
import Actions from "./actions";

export default ({
  searchResult,
  appPath,
  forced,
  commandType,
  filterProject,
}: {
  searchResult: ResultItem;
  appPath: string;
  forced: boolean;
  commandType: COMMAND;
  filterProject: (keyword: string) => Promise<void>;
}) => {
  const finalAppPath =
    (forced ? appPath : searchResult.idePath || appPath) || "Finder";

  return (
    <List.Item
      title={searchResult.name}
      subtitle={
        searchResult.path?.replace(HOME_PATH, "~") || searchResult.description
      }
      accessoryTitle={searchResult.hits}
      icon={searchResult.icon}
      actions={
        <Actions
          searchResult={searchResult}
          finalAppPath={finalAppPath}
          filterProject={filterProject}
          commandType={commandType}
        />
      }
    />
  );
};

注意看,searchListItem 中还引用了一个 Actions 组件,用于渲染行为面板。

猎豹Cheetah插件开发 - Raycast 篇

使用 Command + K 组合键可以打开 List.Item 组件关联的行为面板,行为面板中的第一项会出现在上图蓝框位置,直接按回车即可执行。

// src/lib/components/actions.tsx
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Action, ActionPanel, useNavigation, Icon } from "@raycast/api";
import { COMMAND, updateHits } from "cheetah-core";
import { refreshKeyword } from "../constant";
import { ResultItem } from "../types";
import ApplicationList from "./applicationList";

export default ({
  searchResult,
  finalAppPath,
  filterProject,
  commandType,
}: {
  searchResult: ResultItem;
  finalAppPath: string;
  filterProject: (keyword: string) => Promise<void>;
  commandType: COMMAND;
}) => {
  if (searchResult.refresh) {
    return (
      <ActionPanel>
        <Action
          title="Refresh Cache"
          icon={Icon.Repeat}
          onAction={() => {
            filterProject(`${refreshKeyword}${searchResult.arg}`);
          }}
        />
      </ActionPanel>
    );
  }
  if (commandType === COMMAND.SET_APPLICATION) {
    const { push } = useNavigation();
    return (
      <ActionPanel>
        <Action
          title="Choose Application"
          icon={Icon.Box}
          onAction={() =>
            push(<ApplicationList projectPath={searchResult.path!} />)
          }
        />
      </ActionPanel>
    );
  }
  return (
    <ActionPanel>
      <ActionPanel.Section>
        <Action.Open
          title={`Open in ${finalAppPath}`}
          target={searchResult.path!}
          application={finalAppPath}
          onOpen={async () => {
            await updateHits(searchResult.path!);
          }}
        />
      </ActionPanel.Section>
      <ActionPanel.Section>
        <Action.CopyToClipboard
          title="Copy Project Path"
          content={searchResult.path!}
          shortcut={{ modifiers: ["cmd"], key: "." }}
        />
      </ActionPanel.Section>
    </ActionPanel>
  );
};

Actions 内根据传入的 commandType 执行不同操作,面板中除了为项目设置这个命令外,都增加了复制项目路径的条目,方便用户使用。

Open With Editor

运行此命令将调用搜索列表,在返回的项目列表中选择项目回车,将使用编辑器打开项目。 最终使用的编辑器优先级为:缓存内项目信息中的 idePath > 缓存内项目类型编辑器 > 偏好设置内选择的 defaultEditor

入口文件代码如下:

// src/editor.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.OPEN; // 命令类型:编辑器打开
const forced = false; // 是否强制使用偏好设置内配置的应用

export default () => {
  const { defaultEditor }: { defaultEditor: Application } = getConfig();
  const appPath = defaultEditor?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("112"));
    return;
  }
  return searchList(command, appPath, forced);
};

传入 searchList 的参数含义为:

command:当前命令类型。 appPath:偏好设置内选择的编辑器应用名称。 forced:是否强制使用偏好设置内配置的应用。

下图红框处会展示最后将使用什么应用打开项目,当默认编辑器未配置时将按照错误代码 112 处理,错误处理后续详细说明。

猎豹Cheetah插件开发 - Raycast 篇

Action.Open 是 Raycast 提供的特殊组件,可以直接使用指定应用打开目标文件、文件夹。

// src/lib/components/actions.tsx 节选
···
<Action.Open
  title={`Open in ${finalAppPath}`}
  target={searchResult.path!}
  application={finalAppPath}
  onOpen={async () => {
    await updateHits(searchResult.path!);
  }}
/>
···

onOpenAction 回车执行后的回调,在此处调用 updateHits 即可完成对项目点击量的更新。

Open With Git GUI

此命令基本上与使用编辑器打开的功能一致,执行后将使用偏好设置内配置的 Git GUI 应用打开项目。

// src/git.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.GIT_GUI_OPEN; // 命令类型:Git GUI打开
const forced = true; // 是否强制使用偏好设置内配置的应用

export default () => {
  const { defaultGitApp }: { defaultGitApp: Application } = getConfig();
  const appPath = defaultGitApp?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("113"));
    return;
  }
  return searchList(command, appPath, forced);
};

appPathforced 的值有所不同,错误代码为 113

猎豹Cheetah插件开发 - Raycast 篇

Open With Terminal

此命令也类似,执行后将使用偏好设置内配置的终端应用打开项目。

// src/terminal.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.TERMINAL_OPEN; // 命令类型:终端打开
const forced = true; // 是否强制使用偏好设置内配置的应用

export default () => {
  const { defaultTerminalApp }: { defaultTerminalApp: Application } =
    getConfig();
  const appPath = defaultTerminalApp?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("114"));
    return;
  }
  return searchList(command, appPath, forced);
};

appPathforced 的值有所不同,错误代码为 114

Open With Finder

运行此命令将使用 Finder 打开项目。

// src/finder.tsx
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
const command = COMMAND.FOLDER_OPEN; // 命令类型:Finder 打开
const forced = true; // 是否强制使用偏好设置内配置的应用

export default () => {
  return searchList(command, "Finder", forced);
};

Choose Application

运行此命令选择项目以后回车将渲染当前系统中安装的应用列表,选择应用后将选择的应用写入缓存中对应项目的 idePath 中。

通过 Raycast 提供的 API getApplications 可以获得当前系统中安装的应用合集,增加自定义 Effect 如下:

// src/lib/effects/useGetApplicationList.ts
// 获取系统已安装的应用列表
import { Application, getApplications } from "@raycast/api";
import { useState } from "react";

export default (): [Application[], boolean, () => Promise<void>] => {
  const [resultList, setResultList] = useState<Application[]>([]);
  const [loading, setLoading] = useState(true);

  async function getApplicationList() {
    setLoading(true);
    const applications: Application[] = await getApplications();
    setResultList(applications);
    setLoading(false);
  }

  return [resultList, loading, getApplicationList];
};

获取到应用列表后再通过 List 渲染,增加 applicationList 组件:

// src/lib/components/applicationList.tsx
import {
  ActionPanel,
  Action,
  List,
  Application,
  open,
  showHUD,
} from "@raycast/api";
import { setProjectApp } from "cheetah-core";
import { useEffect } from "react";
import useGetApplicationList from "../effects/useGetApplicationList";

export default ({ projectPath }: { projectPath: string }) => {
  const [applicationList, isLoading, getApplicationList] =
    useGetApplicationList();

  useEffect(() => {
    getApplicationList();
  }, []);

  return (
    <List
      isLoading={isLoading}
      searchBarPlaceholder="Search application name..."
      throttle
    >
      <List.Section title="Results" subtitle={applicationList?.length + ""}>
        {applicationList?.map((searchResult: Application) => (
          <SearchListItem
            key={searchResult.name}
            searchResult={searchResult}
            projectPath={projectPath}
          />
        ))}
      </List.Section>
    </List>
  );
};

function SearchListItem({
  searchResult,
  projectPath,
}: {
  searchResult: Application;
  projectPath: string;
}) {
  return (
    <List.Item
      title={searchResult.name}
      subtitle={searchResult.path}
      accessoryTitle={searchResult.bundleId}
      actions={
        <ActionPanel>
          <ActionPanel.Section>
            <Action
              title="Choose and Complete"
              onAction={async () => {
                await setProjectApp(projectPath, searchResult.name);
                await open(projectPath, searchResult.name);
                await showHUD("The application is set up and tries to open");
              }}
            />
          </ActionPanel.Section>
        </ActionPanel>
      }
    />
  );
}

searchListItemActions 组件中判断如果 command 类型为选择应用,则通过 Raycast 中页面栈操作 API useNavigationapplicationList 渲染出来。

// src/lib/components/actions.tsx 节选
...
const { push } = useNavigation();
...
<ActionPanel>
  <Action
    title="Choose Application"
    icon={Icon.Box}
    onAction={() =>
      push(<ApplicationList projectPath={searchResult.path!} />)
    }
  />
</ActionPanel>
...

下图为应用列表,选择应用后回车即可为项目设置应用。

猎豹Cheetah插件开发 - Raycast 篇

Edit Cache File

此命令为无视图命令,执行后将使用偏好设置中配置的默认编辑器打开配置文件。

// src/editCache.ts
import { open } from "@raycast/api";
import { errorHandle, getConfig } from "./lib/utils";
import { cachePath } from "./lib/constant";

export default async () => {
  const { defaultEditor } = getConfig();
  const appPath = defaultEditor?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("112"));
    return;
  }

  await open(cachePath, appPath);
};

除了 Action.Open 外,Raycast 还为开发者提供了使用指定应用打开文件、文件夹的 API open,只需要传入目标文件、文件夹名称,应用名称或绝对路径即可。

Clear Cache

此命令也是无视图命令,执行后将删除缓存文件,由核心模块提供支持。

// src/clearCache.ts
import { confirmAlert, showHUD } from "@raycast/api";
import { clearCache } from "cheetah-core";
import { errorHandle } from "./lib/utils";
import useInitCore from "./lib/effects/useInitCore";

export default async () => {
  try {
    if (
      await confirmAlert({
        title: "Sure to clear the cache?",
        message:
          "After clearing the cache, the type application configuration, the project application configuration, and the number of hits will all disappear.",
      })
    ) {
      useInitCore();
      await clearCache();
      await showHUD("Cache cleared");
    }
  } catch (error) {
    errorHandle(error);
  }
};

删除缓存是个非常有破坏性的操作,所以加了二次确认,使用了 Raycast 提供的 confirmAlert API。

猎豹Cheetah插件开发 - Raycast 篇

错误处理

上面的命令中很多地方都使用了 errorHandle 函数,它的作用是根据代码运行中抛出的错误代码处理错误信息。

/**
 * @description: 错误处理并输出
 * @param {any} error
 * @return {*}
 */
export async function errorHandle(error: any): Promise<void> {
  const errorCode: string = error.message;
  const needHandleCodes = ["112", "113", "114", "103"];
  await showHUD(ErrorCodeMessage[errorCode]);
  popToRoot({ clearSearchBar: true });
  if (needHandleCodes.includes(errorCode)) {
    openExtensionPreferences();
  }
}

在错误代码为 103112113114 时表示偏好配置不完整,将跳转到扩展的偏好设置界面,方便用户进行配置。 虽然在核心模块中有定义过错误代码对应的报错信息,但是 Raycast 要求扩展最好使用单一语言,美式英语最佳,所以机翻了一份错误信息:

// 错误代码对应的文字提示
export const ErrorCodeMessage: { [code: string]: string } = {
  "100": "File read failure",
  "101": "File write failure",
  "102": "File deletion failed",
  "103": "Working directory not configured",
  "104": "Cache file path not configured",
  "105": "System platform not configured",
  "106": "Environment variable read failure",
  "107": "Cache file write failure",
  "108": "Failed to read folder",
  "109": "Unknown terminal program, downgraded to folder open",
  "110": "No such item in the cache",
  "111": "Application path is empty",
  "112": "Please configure the default editor first",
  "113": "Please configure the Git GUI application first",
  "114": "Please configure the terminal application first",
};

发布审核

完成功能开发后,需要将扩展发布到应用市场。

自检

发布前,先使用 Raycast 扩展项目中配置好的脚本命令进行检查。

// package.json
{
 ...
 "scripts": {
    "build": "ray build -e dist",
    "dev": "ray develop",
    "fix-lint": "ray lint --fix",
    "lint": "ray lint",
    "publish": "ray publish"
  }
  ...
}

// 终端运行
yarn fix-lint

猎豹Cheetah插件开发 - Raycast 篇

执行结果全部为 ready 时,发布前准备工作就算完成了

Pull Request

与 uTools 的发布模式不同,Raycast 使用 git 仓库管理扩展市场,开发者先 fork 仓库,将开发好的扩展添加到仓库内再发起 Pull Request,发起后 git 将运行 Checks,两项检查通过将标记此 Pull Request 为 new extension

详细的发布流程可以查阅官方文档

猎豹Cheetah插件开发 - Raycast 篇

猎豹Cheetah插件开发 - Raycast 篇

之后审核员将进行 Code Review,指出扩展的问题,修复完后审核员会将代码合并,合并后即可在 Raycast Store 中搜索扩展了。

tips:审核时间很长,需要耐心等待,Cheetah 扩展提审到上架大概花费了 1 周时间。

小结

U1S1,Raycast 的开发体验比 uTools 好了不少,但是审核的流程略拖沓,没有 uTools 直接艾特审核员这么方便(手动狗头)。

本篇为本系列正文的结束篇,希望看官们可以下载 Cheetah 试试效果,万一能提高一点点效率呢~

后续可能还有番外篇,想到了一个玩具项目,Cheetah for VSCode,是不是有点套娃的意思了,哈哈哈哈哈。

很感谢大家能够看到这里,有缘再见~