diff --git a/package-lock.json b/package-lock.json index ea52420..0f31584 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "electron-updater": "^6.3.9", + "fast-xml-parser": "^5.5.9", "highlight.js": "^11.11.1", "load-json-file": "^7.0.1", "make-dir": "^5.1.0", @@ -5708,6 +5709,41 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.9", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -7597,6 +7633,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -8923,6 +8974,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", diff --git a/package.json b/package.json index 1749825..2f93b4d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "electron-updater": "^6.3.9", + "fast-xml-parser": "^5.5.9", "highlight.js": "^11.11.1", "load-json-file": "^7.0.1", "make-dir": "^5.1.0", diff --git a/src/main/code-launchpad/ide-projects.ts b/src/main/code-launchpad/ide-projects.ts new file mode 100644 index 0000000..fe95fff --- /dev/null +++ b/src/main/code-launchpad/ide-projects.ts @@ -0,0 +1,111 @@ +import path from 'path' +import fs from 'fs/promises' +import os from 'os' +import type { IdeProjectsDto } from '@my-type/ide-projects' +import { loadJsonFile } from 'load-json-file' +import { XMLParser } from 'fast-xml-parser' +import { VSCodeGlobalStorageJson } from '@my-type/vscode-globalstorage-json' +import { JetBrainsIdeOptionsRecentProjects } from '@my-type/jetbrains-ide-options-recentProjects' +import { JetBrainsIDEDisplayNameEnum, JetBrainsProductCode, toProductCode } from '@my-type/jetbrains-state-tools' + +const xmlParser = new XMLParser({ ignoreAttributes: false }) + +// VSCode 用来保存打开过的工作区的文件路径 +const VSCODE_GLOBALSTORAGE_PATH = path.join( + os.homedir(), + 'AppData/Roaming/Code/User/globalStorage/storage.json' +) + +// JetBrains IDEs 的默认数据保存目录 +// 每个 IDE 的每个版本都在该目录中拥有一个子目录 +const JETBRAINS_IDES_DATA_PATH = path.join(os.homedir(), 'AppData/Roaming/JetBrains') + +/** + * 从 VSCode 的 GlobalStorage 中读取所有打开过的工作区,整理后返回。 + */ +export async function getVscodeProjects(): Promise { + const result: IdeProjectsDto = [] + const data: VSCodeGlobalStorageJson = await loadJsonFile(VSCODE_GLOBALSTORAGE_PATH) + for (const workspace of Object.entries(data.profileAssociations.workspaces)) { + // VSCode 存储的工作区的一个示例: + // "vscode-remote://wsl%2Bubuntu-24.04/home/mango/pythonTest123": "__default__profile__" + // 目前不清楚值的具体含义,但显然值对我们没有帮助。 + // 所以,将 path (项目路径)设置为键,然后取路径的最后一层目录为名称,构建数据并返回。 + + // JetBrains IDEs 的终端调用不支持 file:/// 命令,因此在此将协议名 file:/// 去除,方便用 JetBrains IDEs 打开它们。 + // 还有一种协议名是 vscode-remote:// 将会保留,以作为 VSCode Remote 的标识 + const path = decodeURIComponent(workspace[0]).replace('file:///', '') + const name = path.split('/').at(-1) + result.push({ name, path, ide: ['VSC'] }) + } + return result +} + +export async function getJetBrainsProjects(): Promise { + const result: IdeProjectsDto = [] + // 意外的是,JetBrains Toolbox 并不会自己保存 JetBrains IDEs 打开过的项目的历史记录,哪怕是在 Toolbox 中打开的。 + // 据 AI 总结,工具箱的项目列表系读取已安装的所有 JetBrains IDE 的项目历史,并综合列出的。 + // 所以,我们也要这么做。 + const items = await fs.readdir(JETBRAINS_IDES_DATA_PATH, { withFileTypes: true }) + const subDirs: string[] = [] + // 只要目录,不要文件 + for (const item of items) { + if (item.isDirectory()) { + subDirs.push(item.name) + } + } + + // 创建工具函数 + const _ = (subDir: string): JetBrainsProductCode | null => { + // 获取枚举成员的变量名称 + for (const ide in Object.keys(JetBrainsIDEDisplayNameEnum)) { + if (subDir.toLowerCase().includes(ide)) { + // 查找与之对应的产品代码并返回 + // 由于已经做过判定,所以可得返回结果非空 + return toProductCode(ide) + } + } + return null + } + + for (const subDir of subDirs) { + // 如果目录不受支持,直接排除 + // 不受支持的表现之一就是目录名称中不包含工具箱支持的 IDE 的名称 + const ide = _(subDir) + if (ide === null) continue + // 从目录中尝试读取 options/recentProjects.xml + const rpp = path.join(JETBRAINS_IDES_DATA_PATH, subDir, 'options/recentProjects.xml') + try { + // 从 xml 中解析数据 + const data: JetBrainsIdeOptionsRecentProjects = xmlParser.parse( + await fs.readFile(rpp, 'utf-8') + ) + for (const datum of data.application.component.option) { + for (const entry of datum.map.entry) { + const name = entry.value.RecentProjectMetaInfo['@_frameTitle'].split(' – ').at(0) + const path = entry['@_key'].replace('$USER_HOME$', os.homedir()) + // 认为包含此(安装/设置?)目录的是未保存的编辑,例如 light-edit 模式 + // 正常来说不会在这样的目录下保存项目的……吧?而且俺寻思 IDE 用这种变量的话也不像是人为刻意保存到此目录。 + if (path.includes('$APPLICATION_CONFIG_DIR$/')) continue + result.push({ + name, + path, + ide: [ide] + }) + } + } + } catch { + // 忽略 + } + } + return result +} + +/** + * 结合 {@link getVscodeProjects} 和 {@link getJetBrainsProjects} 的返回结果, + * 剔除重复项后,获取项目列表。 + */ +export async function getProjects(): Promise { + const result: IdeProjectsDto = [] + return result +} diff --git a/src/main/index.ts b/src/main/index.ts index 713d1bc..34c8c68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,6 +11,7 @@ import { } from './code-launchpad/ide-versions-check' import { fanToolsIcon } from './resources' import path from 'path' +import { getJetBrainsProjects, getVscodeProjects } from './code-launchpad/ide-projects' let mainWindow: BrowserWindow | null = null // @ts-ignore 保存引用,禁用报错 @@ -92,7 +93,7 @@ function createTray(): Tray { // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. -app.whenReady().then(() => { +app.whenReady().then(async () => { // Set app user model id for windows electronApp.setAppUserModelId('com.electron') @@ -128,7 +129,9 @@ app.whenReady().then(() => { ipcMain.handle('codeLaunchpad:checkIDEs', checkIDEs) ipcMain.handle('codeLaunchpad:getIDEsVersion', getIDEsVersion) ipcMain.handle('codeLaunchpad:checkIDEsVersion', checkIDEsVersion) + ipcMain.handle('codeLaunchpad:getVSCodeProjects', getVscodeProjects) + ipcMain.handle('codeLaunchpad:getJetBrainsProjects', getJetBrainsProjects) - checkIDEs() - checkIDEsVersion() + await checkIDEs() + await checkIDEsVersion() }) diff --git a/src/my-type/ide-projects.d.ts b/src/my-type/ide-projects.d.ts new file mode 100644 index 0000000..f7134d0 --- /dev/null +++ b/src/my-type/ide-projects.d.ts @@ -0,0 +1,11 @@ +import { JetBrainsProductCode } from '@my-type/jetbrains-state-tools' + +type Ide = JetBrainsProductCode | 'VSC' + +export interface IdeProjectDto { + name: string + path: string + ide: Ide[] +} + +export type IdeProjectsDto = IdeProjectDto[] diff --git a/src/my-type/jetbrains-ide-options-recentProjects.d.ts b/src/my-type/jetbrains-ide-options-recentProjects.d.ts new file mode 100644 index 0000000..61857c4 --- /dev/null +++ b/src/my-type/jetbrains-ide-options-recentProjects.d.ts @@ -0,0 +1,26 @@ +// 这些数据结构是由 XMLParser 解析成的 Javascript 对象,解析设置允许属性。 +// 不完整,因为我也看不懂很多东西是干嘛用的,写完整了也用不到,所以把用到的定义一下就酱了 + +export interface RecentProjectMetaInfo { + option: [] + frame: object + '@_frameTitle': string +} + +export interface JetBrainsIdeOptionsRecentProjects { + application: { + component: { + option: [ + { + map: { + entry: { + value: { RecentProjectMetaInfo: RecentProjectMetaInfo } + '@_key': string + }[] + } + } + ] + '@_name': 'RecentProjectsManager' + } + } +} diff --git a/src/my-type/jetbrains-state-tools.ts b/src/my-type/jetbrains-state-tools.ts index 19b3a64..44afb5a 100644 --- a/src/my-type/jetbrains-state-tools.ts +++ b/src/my-type/jetbrains-state-tools.ts @@ -25,3 +25,21 @@ export enum JetBrainsIDEDisplayNameEnum { phpstorm = 'PhpStorm', webstorm = 'WebStorm' } + +export function toProductCode(anything: string): JetBrainsProductCode | null { + if ([JetBrainsIDEDisplayNameEnum.pycharm, 'pycharm'].includes(anything)) return 'PY' + if ([JetBrainsIDEDisplayNameEnum.idea, 'idea'].includes(anything)) return 'IU' + if ([JetBrainsIDEDisplayNameEnum.clion, 'clion'].includes(anything)) return 'CL' + if ([JetBrainsIDEDisplayNameEnum.phpstorm, 'phpstorm'].includes(anything)) return 'PS' + if ([JetBrainsIDEDisplayNameEnum.webstorm, 'webstorm'].includes(anything)) return 'WS' + return null +} + +export function toProductDisplayName(anything: string): string | null { + if (['PY', 'pycharm'].includes(anything)) return JetBrainsIDEDisplayNameEnum.pycharm + if (['IU', 'idea'].includes(anything)) return JetBrainsIDEDisplayNameEnum.idea + if (['CL', 'clion'].includes(anything)) return JetBrainsIDEDisplayNameEnum.clion + if (['PS', 'phpstorm'].includes(anything)) return JetBrainsIDEDisplayNameEnum.phpstorm + if (['WS', 'webstorm'].includes(anything)) return JetBrainsIDEDisplayNameEnum.webstorm + return null +} diff --git a/src/my-type/vscode-globalstorage-json.d.ts b/src/my-type/vscode-globalstorage-json.d.ts new file mode 100644 index 0000000..a080a06 --- /dev/null +++ b/src/my-type/vscode-globalstorage-json.d.ts @@ -0,0 +1,9 @@ +export interface VSCodeGlobalStorageJson { + profileAssociations: { + /** 此键值对中项目越靠后,时间越新,我估计是的。
+ * key:远程开发链接为 `vscode-remote://`,本地文件(目录)为 `file:///`。
+ * value:难说到底有没有用,`__default__profile__`。 + */ + workspaces: Record + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5f7aefb..d4c0812 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,6 +1,7 @@ import { ElectronAPI } from '@electron-toolkit/preload' import { settingsDto, checkIDEsResultDto } from '@my-type/settings' import { checkIDEsVersionDto } from "../my-type/settings"; +import { IdeProjectsDto } from "../my-type/ide-projects"; // 此处只有签名 @@ -18,6 +19,8 @@ declare global { _checkIDEs: () => Promise _getIDEsVersion: () => Promise _checkIDEsVersion: () => Promise + _getVSCodeProjects: () => Promise + _getJetBrainsProjects: () => Promise } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index d09581e..da27fdc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,7 +15,9 @@ const codeLaunchpadApi = { _getIDEs: () => ipcRenderer.invoke('codeLaunchpad:getIDEs'), _checkIDEs: () => ipcRenderer.invoke('codeLaunchpad:checkIDEs'), _getIDEsVersion: () => ipcRenderer.invoke('codeLaunchpad:getIDEsVersion'), - _checkIDEsVersion: () => ipcRenderer.invoke('codeLaunchpad:checkIDEsVersion') + _checkIDEsVersion: () => ipcRenderer.invoke('codeLaunchpad:checkIDEsVersion'), + _getVSCodeProjects: () => ipcRenderer.invoke('codeLaunchpad:getVSCodeProjects'), + _getJetBrainsProjects: () => ipcRenderer.invoke('codeLaunchpad:getJetBrainsProjects') } // Use `contextBridge` APIs to expose Electron APIs to