前言
你还在为项目繁多找不到目录而烦恼吗?在 终端
、SourceTree
、Finder
中打开项目的繁琐操作有让你感到痛苦吗?
今天,你(Mac 用户)将和这些烦恼彻底告别。
书接上回《多此一举生成器》,今天我们继续使用 Alfred Workflows
开发一个能够搜索本地 Git
仓库,并快速使用指定应用打开仓库目录的工具。
省流助手
# 项目开源地址,现已支持 Alfred、uTools(插件市场审核中),Raycast 扩展将于 Q2 内完成开发
# Alfred 用户请进入 cheetah-for-alfred 项目的 release 下载 .alfredworkflow 直接导入使用。
https://github.com/cheetah-extension
Show Time
为了给大家节省流量,录制的质量调低了一些,操作的速度也加快了。
演示中都完成了以下操作:
- 使用默认编辑器打开指定项目。
- 使用指定的
Git GUI
应用打开项目。 - 在项目目录下打开终端。
- 在
Finder
中打开项目目录。 - 为项目指定编辑器。
- 重新执行步骤
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 {
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 {
// 读取 .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),
};
});
}
这两个函数,可以在指定的文件路径下查找所有 Git
、Git 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 {
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 {
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';
}
拿到项目类型可以做什么呢?
目前应用的地方有两个:
缓存文件
经过上面的步骤,我们已经拿到了指定目录下的所有 Git
项目,但是每次搜索还是会耗费较长的时间。
影响时间的因素有 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 {
// 从缓存文件内读取 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 {
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 {
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;
}
更新缓存
当本地项目移动、删除、新增以后,缓存文件就变得不可靠了。有哪些方式可以刷新缓存呢?
- 输一个本地不可能存在的项目关键字,缓存匹配结果为空会触发文件夹递归搜索。
- 结果列表的最下方添加一项忽略缓存继续搜索,直接触发文件夹递归搜索。
- ⚠️禁术⚠️ 删除缓存文件,下一次搜索会重建缓存文件,但是项目点击量、编辑器配置会丢失。
排序
返回项目候选列表前,需要先做个排序,这里分了三种情况,根据优先级排列如下:
- 搜索关键字与项目名称全等。
- 项目名称头部与关键词匹配。
- 仅包含关键词。
三种情况再根据项目的 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 {
const { cache } = await readCache();
return output(filterProject(cache, keyword));
}
// 从搜索结果中过滤
export async function filterWithSearchResult(
keyword: string
): Promise {
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 中打开项目目录
知道了快速打开项目的方法,结合上面我们拿到的项目地址,就可以做到指哪打哪了。
应用优先级
现在工具内有三个地方可以定义用于打开项目的应用:
- 环境变量中的
idePath
默认应用配置。 - 缓存文件中针对项目类型的应用配置。
- 缓存文件中每个项目的应用配置。
另外,为了实现快捷键与应用绑定,增加了一个环境变量 force
,使用方法如下:
最终的应用优先级为:
force
为 1
的默认应用配置 > 项目类型应用配置 > 项目应用配置 > 默认应用配置 > Finder
在未设置任何应用的情况下,兜底的应用是 Finder。
全家福
上面完成的功能通过 Alfred Workflows
串联在一起就完成了这个工具,篇幅原因,还有为项目指定开启应用、打开配置文件、备份配置文件这些功能的实现没有详细讲解,大家感兴趣的话可以下载体验一下。
Alfred Workflows
的配置很好理解,即是功能配置,也是整个项目的流程图。双击流程块可以打开配置的详情。
小结
这是一个笔者从自身痛点出发,分析需求,逐步落地的工具,命名为《猎豹》,希望它打开项目可以像猎豹奔跑一样迅速。
目前项目还处于内测阶段,团队内的小伙伴已经用上了,好评如潮。
也希望正在阅读的朋友可以尝试一下,有建议或者问题欢迎大家评论或者到开源项目下提 Issues
。
Alfred Workflows
的确是一个优秀的个人工作流工具,类似的工具流工具也层出不穷,比如 uTools
、Raycast
等等。 uTools
的迁移工作已经完成,支持 Alfred
版本的全部功能,Windows
用户也可以使用啦,待插件审核通过即可搜索安装,敬请期待。
总之,如果你有低效的重复劳作,大胆地尝试一下开发一个自己的工具吧~