前言
你還在為專案繁多找不到目錄而煩惱嗎?在 終端
、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<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),
};
});
}
這兩個函式,可以在指定的檔案路徑下查詢所有 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<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';
}
拿到專案型別可以做什麼呢?
目前應用的地方有兩個:
- 搜尋結果展示專案型別對應的圖示
- 可以針對專案型別做不同的設定,目前可對不同型別專案設定不同的編輯器。
快取檔案
經過上面的步驟,我們已經拿到了指定目錄下的所有 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<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;
}
更新快取
當本地專案移動、刪除、新增以後,快取檔案就變得不可靠了。有哪些方式可以重新整理快取呢?
- 輸一個本地不可能存在的專案關鍵字,快取匹配結果為空會觸發資料夾遞迴搜尋。
- 結果列表的最下方新增一項忽略快取繼續搜尋,直接觸發資料夾遞迴搜尋。
- ⚠️禁術⚠️ 刪除快取檔案,下一次搜尋會重建快取檔案,但是專案點選量、編輯器配置會丟失。
排序
返回專案候選列表前,需要先做個排序,這裡分了三種情況,根據優先順序排列如下:
- 搜尋關鍵字與專案名稱全等。
- 專案名稱頭部與關鍵詞匹配。
- 僅包含關鍵詞。
三種情況再根據專案的 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 中開啟專案目錄
知道了快速開啟專案的方法,結合上面我們拿到的專案地址,就可以做到指哪打哪了。
應用優先順序
現在工具內有三個地方可以定義用於開啟專案的應用:
- 環境變數中的
idePath
預設應用配置。 - 快取檔案中針對專案型別的應用配置。
- 快取檔案中每個專案的應用配置。
另外,為了實現快捷鍵與應用繫結,增加了一個環境變數 force
,使用方法如下:
最終的應用優先順序為:
force
為 1
的預設應用配置 > 專案型別應用配置 > 專案應用配置 > 預設應用配置 > Finder
在未設定任何應用的情況下,兜底的應用是 Finder。
全家福
上面完成的功能通過 Alfred Workflows
串聯在一起就完成了這個工具,篇幅原因,還有為專案指定開啟應用、開啟配置檔案、備份配置檔案這些功能的實現沒有詳細講解,大家感興趣的話可以下載體驗一下。
Alfred Workflows
的配置很好理解,即是功能配置,也是整個專案的流程圖。雙擊流程塊可以開啟配置的詳情。
小結
這是一個筆者從自身痛點出發,分析需求,逐步落地的工具,命名為《獵豹》,希望它開啟專案可以像獵豹奔跑一樣迅速。
目前專案還處於內測階段,團隊內的小夥伴已經用上了,好評如潮。
也希望正在閱讀的朋友可以嘗試一下,有建議或者問題歡迎大家評論或者到開源專案下提 Issues
。
Alfred Workflows
的確是一個優秀的個人工作流工具,類似的工具流工具也層出不窮,比如 uTools
、Raycast
等等。 uTools
的遷移工作已經完成,支援 Alfred
版本的全部功能,Windows
使用者也可以使用啦,待外掛稽核通過即可搜尋安裝,敬請期待。
總之,如果你有低效的重複勞作,大膽地嘗試一下開發一個自己的工具吧~