聽說了麼?有個同事開啟專案太慢被優化了

百瓶技術發表於2022-04-19

公眾號名片
作者名片

前言

你還在為專案繁多找不到目錄而煩惱嗎?在 終端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 使用者也可以使用啦,待外掛稽核通過即可搜尋安裝,敬請期待。

總之,如果你有低效的重複勞作,大膽地嘗試一下開發一個自己的工具吧~

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章