提效80%的Git 项目启动工具开发思路

公众号名片 作者名片

前言

你还在为项目繁多找不到目录而烦恼吗?在 终端SourceTreeFinder 中打开项目的繁琐操作有让你感到痛苦吗?

今天,你(Mac 用户)将和这些烦恼彻底告别。

书接上回《多此一举生成器》,今天我们继续使用 Alfred Workflows 开发一个能够搜索本地 Git 仓库,并快速使用指定应用打开仓库目录的工具。

省流助手

# 项目开源地址,现已支持 Alfred、uTools(插件市场审核中),Raycast 扩展将于 Q2 内完成开发
# Alfred 用户请进入 cheetah-for-alfred 项目的 release 下载 .alfredworkflow 直接导入使用。
https://github.com/cheetah-extension
复制代码

Show Time

看看使用效果

为了给大家节省流量,录制的质量调低了一些,操作的速度也加快了。

演示中都完成了以下操作:

  1. 使用默认编辑器打开指定项目。
  2. 使用指定的 Git GUI 应用打开项目。
  3. 在项目目录下打开终端。
  4. Finder 中打开项目目录。
  5. 为项目指定编辑器。
  6. 重新执行步骤 1,打开项目的编辑器为步骤 5 设置的编辑器。

可能单个操作都不复杂,但是在工作中需要频繁切换项目,或者要操作项目文件的时候,一点点优化积累起来就是对效率的重大提升。
tip:上面的操作都可以自定义快捷键。

用到的技术

  • txiki
  • AnyScript(滑稽)
  • AppleScript
  • rollup
  • Alfred Workflows

txiki.js 是啥?

txiki.js 是一个小巧而强大的 JavaScript 运行时。

为什么选择 txiki.js 而不是 Node.js

假设选用 Node.js,需要用户设备上已经配置好了 Node.js 环境,对于前端朋友来说是标配,但是其他的工种的朋友就不好说了。这无疑增加了用户的使用成本。

txiki.js 可以看做一个精简版的 Node.js,编译后的可执行文件不到 2MB,打包在 .alfredworkflow 文件内,可以做到开箱即用,总大小进一步压缩到 800+KB,降低了用户的使用成本,推广传播也更加方便。

下面老裁缝带你做针线活儿,手把手教你把这些东西缝合在一起。

txiki.js 的缺点

打包在 .alfredworkflow 文件内的可执行文件,在首次运行时,Mac OS 会给出安全警告,需要在 系统偏好设置 -> 安全与隐私 中允许运行,如果担心安全问题可以下载 txiki.js 源码构建可执行文件,替换到 Alfred workflows 文件夹的 runtime 文件夹中。

环境变量

配置在 Alfred Workflows 中,代码在执行时可以读取。

idePath

用于开启项目的应用名称,在 /Applications 目录下的应用可以直接填入名称,以 .app 结尾(经测试可以不加 .app 但是需要保证 App 名称单词拼写是正确的)。当应用路径为空时,将在 Finder 中打开项目文件夹。

如果应用不在 /Applications 目录下则需要填入其绝对路径。

workspace

项目存放的目录,距离项目的层级越近越好,层级越多,搜索速度会越慢。默认目录为 用户文件夹下的 Documents,比如 /Users/ronglecat/Documents

现已支持多目录配置,以英文逗号分隔。

查找本地 Git 项目

要完成这个工具,首先要找到本地都有哪些使用 Git 管理的项目(对不起了,用 SVN 的朋友)。

怎么判断文件夹是否是一个项目呢?
很简单,只要判断目录下是否包含 .git 文件夹即可。核心机密如下:

// 在指定目录中查找项目
export async function findProject(dirPath: string): Promise<Project[]> {
  const result: Project[] = [];
  const currentChildren: ChildInfo[] = [];
  let dirIter;

  try {
    // tjs 为 txiki.js 的全局 api
    dirIter = await tjs.fs.readdir(dirPath);
  } catch (error) {
    return result;
  }

  // 获取当前文件夹下的所有文件、文件夹
  for await (const item of dirIter) {
    const { name, type }: { name: string; type: number } = item;
    currentChildren.push({
      name,
      isDir: type === 2,
      path: path.join(dirPath, name),
    });
  }

  // 判断是否为 Git 项目
  const isGitProject = currentChildren.some(
    ({ name }: { name: string }) => name === '.git'
  );

  // 判断目录下是否包含 submodule
  const hasSubmodules = currentChildren.some(
    ({ name }: { name: string }) => name === '.gitmodules'
  );

  // 将项目添加到结果列表中
  if (isGitProject) {
    result.push({
      name: path.basename(dirPath), // 项目的文件名称
      path: dirPath, // 项目所在的系统绝对路径
      type: await projectTypeParse(currentChildren), // 根据项目下的文件内容判断项目类型
      hits: 0, // 被翻牌的次数
      idePath: '', // 这个项目有自己的编辑器设置
    });
  }

  // 筛选子目录
  let nextLevelDir: ChildInfo[] = [];
  if (!isGitProject) {
    nextLevelDir = currentChildren.filter(
      ({ isDir }: { isDir: boolean }) => isDir
    );
  }

  // 如果是包含 submodule 的项目,将 submodule 的目录也找到
  if (isGitProject && hasSubmodules) {
    nextLevelDir = await findSubmodules(path.join(dirPath, '.gitmodules'));
  }

  // 递归查找项目
  for (let i = 0; i < nextLevelDir.length; i += 1) {
    const dir = nextLevelDir[i];
    result.push(...(await findProject(path.join(dirPath, dir.name))));
  }

  return result;
}

// 查找项目内的 submodule
export async function findSubmodules(filePath: string): Promise<ChildInfo[]> {
  // 读取 .gitmodules 文件内容
  const fileContent = await readFile(filePath);
  // 匹配 Submodule 名称、路径,进入下一轮递归,因为 Submodule 项目目录下也会有 .git 文件夹,所以可以被判断为 Git 项目
  const matchModules = fileContent.match(/(?<=path = )([\S]*)(?=\n)/g) ?? [];
  return matchModules.map((module) => {
    return {
      name: module,
      isDir: true,
      path: path.join(path.dirname(filePath), module),
    };
  });
}
复制代码

这两个函数,可以在指定的文件路径下查找所有 GitGit Submodule 项目,并获取项目的名称、绝对路径、项目类型。

判断项目类型

上面提到了判断项目类型,其实这还是一个不完全的功能,因为笔者知识的局限性,很多其他语言的项目应该怎么判断并不是很明确,目前只做了部分可以确定的类型。代码如下:

// 判断项目下的文件列表是否包含需要搜索的文件列表
function findFileFromProject(
  allFile: ChildInfo[],
  fileNames: string[]
): boolean {
  const reg = new RegExp(`^(${fileNames.join('|')})$`, 'i');
  const findFileList = allFile.filter(({ name }: { name: string }) =>
    reg.test(name)
  );

  return findFileList.length === fileNames.length;
}

// 判断 npm 依赖列表中是否包含指定的 npm 包名称
function findDependFromPackage(
  allDependList: string[],
  dependList: string[]
): boolean {
  const reg = new RegExp(`^(${dependList.join('|')})$`, 'i');
  const findDependList = allDependList.filter((item: string) => reg.test(item));

  return findDependList.length >= dependList.length;
}

// 获取 package.json 内的 npm 依赖列表
async function getDependList(allFile: ChildInfo[]): Promise<string[]> {
  const packageJsonFilePath =
    allFile.find(({ name }) => name === 'package.json')?.path ?? '';
  if (!packageJsonFilePath) {
    return [];
  }
  const { dependencies = [], devDependencies = [] } = JSON.parse(
    await readFile(packageJsonFilePath)
  );
  const dependList = { ...dependencies, ...devDependencies };
  return Object.keys(dependList);
}

// 解析项目类型
async function projectTypeParse(children: ChildInfo[]): Promise<string> {
  if (findFileFromProject(children, ['cargo.toml'])) {
    return 'rust';
  }
  if (findFileFromProject(children, ['pubspec.yaml'])) {
    return 'dart';
  }
  if (findFileFromProject(children, ['.*.xcodeproj'])) {
    return 'applescript';
  }
  if (findFileFromProject(children, ['app', 'gradle'])) {
    return 'android';
  }
  // js 项目还可以细分
  if (findFileFromProject(children, ['package.json'])) {
    if (findFileFromProject(children, ['nuxt.config.js'])) {
      return 'nuxt';
    }
    if (findFileFromProject(children, ['vue.config.js'])) {
      return 'vue';
    }
    if (findFileFromProject(children, ['.vscodeignore'])) {
      return 'vscode';
    }

    const isTS = findFileFromProject(children, ['tsconfig.json']);
    const dependList = await getDependList(children);

    if (findDependFromPackage(dependList, ['react'])) {
      return isTS ? 'react_ts' : 'react';
    }

    if (findDependFromPackage(dependList, ['hexo'])) {
      return 'hexo';
    }

    return isTS ? 'typescript' : 'javascript';
  }
  return 'unknown';
}
复制代码

拿到项目类型可以做什么呢?
目前应用的地方有两个:

  1. 搜索结果展示项目类型对应的图标 项目类型不同展示的图标不同
  2. 可以针对项目类型做不同的设置,目前可对不同类型项目设置不同的编辑器。

缓存文件

经过上面的步骤,我们已经拿到了指定目录下的所有 Git 项目,但是每次搜索还是会耗费较长的时间。

影响时间的因素有 2 个:

  1. 设备性能。
  2. 项目存放文件夹的层级、项目数量。

设备性能方面,只能靠用户自己解决啦,我们可以针对第二点做一些优化。

为了达到开箱即用的效果,当前默认设置的项目存放目录是 $HOME/Documents,目录层级较高,目录较为复杂,一次搜索时间可能会比较长。

建议配置距离项目最近的目录,将接收目录的字段改造一下,可以用逗号分隔多个路径,循环后再递归查找,可以略微优化搜索的时间。

// 在多个工作目录下搜索项目,工作目录以英文逗号分隔
// 例:/Users/caohaoxia/Documents/work,/Users/caohaoxia/Documents/document
async function batchFindProject() {
  const workspaces = workspace.split(/,|,/);
  const projectList: Project[] = [];
  for (let i = 0; i < workspaces.length; i += 1) {
    const dirPath = workspaces[i];
    const children = await findProject(dirPath);
    projectList.push(...children);
  }
  return projectList;
}
复制代码

上面虽然优化了一些时间,但是搜索的时候还是能感到明显的滞后,我们做这个工作的初衷是什么?快!用更快地速度打开项目!

在这里,我们重磅推出了 「缓存」文件!

首先我们来看看它的结构:

{
  "editor": {
    "typescript": "", // 可以配置一个专属于 typescript 项目的编辑器,所有 typescript 默认编辑器将会改变
    ...
  },
  "cache": [
    {
      "name": "fmcat-open-project",
      "path": "/Users/caohaoxia/Documents/work/self/fmcat-open-project",
      "type": "typescript",
      "hits": 52,
      "idePath": ""
    },
    ...
  ]
}
复制代码

cache

可以看到配置文件中包含了一个 cache 字段,用于存放搜索到的项目列表,每个项目有以下字段:

name:项目名称。
path:项目目录绝对路径。
type:项目类型。
hits:点击量,用于排序。
idePath:绑定的编辑器。

在执行项目搜索时,会优先匹配缓存列表中的项目,如果没有结果则执行文件夹递归搜索,将搜索到的结果合并到缓存列表,不用担心点击量和编辑器配置会消失。

// 更新缓存时合并项目点击数、编辑器配置
async function combinedCache(newCache: Project[]): Promise<Project[]> {
  // 从缓存文件内读取 cache
  const { cache } = await readCache();
  // 筛选有点击记录和编辑器配置的项目
  const needMergeList = {} as { [key: string]: Project };
  cache
    .filter((item: Project) => item.hits > 0 || item.idePath)
    .forEach((item: Project) => {
      needMergeList[item.path] = item;
    });
  // 合并点击数
  newCache.forEach((item: Project) => {
    const cacheItem = needMergeList[item.path] ?? {};
    const { hits = 0, idePath = '' } = cacheItem;
    item.hits = item.hits > hits ? item.hits : hits;
    item.idePath = idePath;
  });
  return newCache;
}

// 写入缓存
export async function writeCache(newCache: Project[]): Promise<void> {
  try {
    const { editor } = await readCache();
    const cacheFile = await tjs.fs.open(cachePath, 'rw', 0o666);
    const newEditorList = combinedEditorList(editor, newCache);
    const newConfig = { editor: newEditorList, cache: newCache };
    const historyString = JSON.stringify(newConfig, null, 2);
    await cacheFile.write(historyString);
    cacheFile.close();
  } catch (error: any) {
    console.log(error.message);
  }
}

// 从搜索结果中过滤
export async function filterWithSearchResult(
  keyword: string
): Promise<ResultItem[]> {
  const projectList: Project[] = await batchFindProject();
  writeCache(await combinedCache(projectList));
  return output(filterProject(projectList, keyword));
}
复制代码

editor

在写入缓存函数 writeCache 中,会调用一个合并编辑器配置的函数,将项目所有的类型都列举出来,并和缓存文件中的 editor 字段合并。

// 合并编辑器
function combinedEditorList(
  editor: { [key: string]: string },
  cache: Project[]
) {
  const newEditor = { ...editor };
  const currentEditor = Object.keys(newEditor);
  cache.forEach(({ type }: Project) => {
    if (!currentEditor.includes(type)) {
      newEditor[type] = '';
    }
  });
  return newEditor;
}
复制代码

更新缓存

当本地项目移动、删除、新增以后,缓存文件就变得不可靠了。有哪些方式可以刷新缓存呢?

  1. 输一个本地不可能存在的项目关键字,缓存匹配结果为空会触发文件夹递归搜索。
  2. 结果列表的最下方添加一项忽略缓存继续搜索,直接触发文件夹递归搜索。
  3. ⚠️禁术⚠️ 删除缓存文件,下一次搜索会重建缓存文件,但是项目点击量、编辑器配置会丢失。

排序

返回项目候选列表前,需要先做个排序,这里分了三种情况,根据优先级排列如下:

  1. 搜索关键字与项目名称全等。
  2. 项目名称头部与关键词匹配。
  3. 仅包含关键词。

三种情况再根据项目的 hits 降序排列,最后合并为一个数组输出给 Alfred Workflows

// 过滤项目
export function filterProject(
  projectList: Project[],
  keyword: string
): Project[] {
  const reg = new RegExp(keyword, 'i');
  const result = projectList.filter(({ name }: { name: string }) => {
    return reg.test(name);
  });

  // 排序规则:项目名称以关键词开头的权重最高,剩余的以点击量降序排序
  const congruentMatch: Project[] = []; // 全等匹配
  const startMatch: Project[] = []; // 头部匹配
  const otherMatch: Project[] = []; // 包含匹配
  result.forEach((item) => {
    if (item.name.toLocaleLowerCase() === keyword.toLocaleLowerCase()) {
      congruentMatch.push(item);
    } else if (item.name.startsWith(keyword)) {
      startMatch.push(item);
    } else {
      otherMatch.push(item);
    }
  });

  return [
    ...congruentMatch.sort((a: Project, b: Project) => b.hits - a.hits),
    ...startMatch.sort((a: Project, b: Project) => b.hits - a.hits),
    ...otherMatch.sort((a: Project, b: Project) => b.hits - a.hits),
  ];
}

// 输出待选列表给 Alfred
export function output(projectList: Project[]): ResultItem[] {
  const result = projectList.map(
    ({ name, path, type }: { name: string; path: string; type: string }) => {
      return {
        title: name,
        subtitle: path,
        arg: path,
        valid: true,
        icon: {
          path: `assets/${type}.png`,
        },
      };
    }
  );
  return result;
}

// 从缓存中过滤
export async function filterWithCache(keyword: string): Promise<ResultItem[]> {
  const { cache } = await readCache();
  return output(filterProject(cache, keyword));
}

// 从搜索结果中过滤
export async function filterWithSearchResult(
  keyword: string
): Promise<ResultItem[]> {
  const projectList: Project[] = await batchFindProject();
  writeCache(await combinedCache(projectList));
  return output(filterProject(projectList, keyword));
}
复制代码

快捷打开

Mac OS 提供了一个快捷使用软件打开指定文件、目录的命令 ——— open

open .
# 使用 Finder 打开当前目录

open 目录路径
# 使用 Finder 打开指定目录

open 文件路径
# 使用文件类型对应的默认程序打开文件

open -a 应用名称 文件/目录路径
# 使用指定应用打开指定文件、目录
# 例:open -a "Visual Studio Code" /Users/caohaoxia/Documents/work/self/fmcat-open-project
# tip: 如果应用名称包含空格需要使用引号包裹
复制代码

open 命令是我们完成工具的核心,目前已经测试过支持以 open -a 语法调用的应用有:

编辑器/IDE

  • VSCode
  • Sublime
  • WebStorm
  • Atom
  • Android Studio
  • Xcode
  • Typora

Git GUI

  • SourceTree
  • Fork
  • GitHub Desktop

终端

  • Terminal(内建终端)
  • iTerm2

调用例子:

open -a "Visual Studio Code" /Users/caohaoxia/Documents/work/self/fmcat-open-project
# 使用 VSCode 打开项目

open -a SourceTree /Users/caohaoxia/Documents/work/self/fmcat-open-project
# 使用 SourceTree 打开项目

open -a iTerm2 /Users/caohaoxia/Documents/work/self/fmcat-open-project
# 打开 iTerm2 默认位置为项目目录

open /Users/caohaoxia/Documents/work/self/fmcat-open-project
# 在 Finder 中打开项目目录

复制代码

知道了快速打开项目的方法,结合上面我们拿到的项目地址,就可以做到指哪打哪了。

应用优先级

现在工具内有三个地方可以定义用于打开项目的应用:

  1. 环境变量中的 idePath 默认应用配置。
  2. 缓存文件中针对项目类型的应用配置。
  3. 缓存文件中每个项目的应用配置。

另外,为了实现快捷键与应用绑定,增加了一个环境变量 force,使用方法如下:

设置快捷键

设置 force 为 1

最终的应用优先级为:

force1 的默认应用配置 > 项目类型应用配置 > 项目应用配置 > 默认应用配置 > Finder

在未设置任何应用的情况下,兜底的应用是 Finder。

全家福

上面完成的功能通过 Alfred Workflows 串联在一起就完成了这个工具,篇幅原因,还有为项目指定开启应用、打开配置文件、备份配置文件这些功能的实现没有详细讲解,大家感兴趣的话可以下载体验一下。

Alfred Workflows 的配置很好理解,即是功能配置,也是整个项目的流程图。双击流程块可以打开配置的详情。

全家福

小结

这是一个笔者从自身痛点出发,分析需求,逐步落地的工具,命名为《猎豹》,希望它打开项目可以像猎豹奔跑一样迅速。
目前项目还处于内测阶段,团队内的小伙伴已经用上了,好评如潮。

也希望正在阅读的朋友可以尝试一下,有建议或者问题欢迎大家评论或者到开源项目下提 Issues

Alfred Workflows 的确是一个优秀的个人工作流工具,类似的工具流工具也层出不穷,比如 uToolsRaycast 等等。
uTools 的迁移工作已经完成,支持 Alfred 版本的全部功能,Windows 用户也可以使用啦,待插件审核通过即可搜索安装,敬请期待。

总之,如果你有低效的重复劳作,大胆地尝试一下开发一个自己的工具吧~

更多精彩请关注我们的公众号「百瓶技术」,有不定期福利呦!

猜你喜欢

转载自juejin.im/post/7087833978870169613