Skip to content

Commit 71d34e3

Browse files
feat(scanner): enhance project scanning with JetBrains and VSCode recent projects detection
1 parent e447947 commit 71d34e3

27 files changed

Lines changed: 703 additions & 231 deletions

‎electron/ipc.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ ipcMain.handle('project:import', () => projectSvc.importProjects())
3232
ipcMain.handle('project:export', () => projectSvc.exportProjects())
3333

3434
// scanner
35-
ipcMain.handle('scanner:scan', (_e, payload) => scanner.scan(payload))
3635
ipcMain.handle('scanner:start', (e, payload) => scanner.start(e, payload))
3736
ipcMain.handle('scanner:stop', (_e, sessionId) => scanner.stop(sessionId))
37+
ipcMain.handle('scanner:detect-jb-config-root-path', () => scanner.detectJetBrainsConfigRootPath())
3838
ipcMain.handle('scanner:detect-vsc-state-db-path', () => scanner.detectVscodeStateDbPath())
3939

4040
// system

‎electron/main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
55

66
import { app, BrowserWindow, globalShortcut, Menu } from 'electron'
77

8-
// @ts-expect-error -- 知道要改tsconfig但不想改
8+
// @ts-expect-error -- ignore
99
const __filename = fileURLToPath(import.meta.url)
1010
const __dirname = path.dirname(__filename)
1111

‎electron/preload.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface ProjectAnalyzeProgress {
1919
stage: ProjectAnalyzeStage
2020
}
2121

22-
interface ScanStartPayload { roots: string[], existingPaths: string[] }
22+
interface ScanStartPayload { roots: string[], existingPaths: string[], projectPaths?: string[] }
2323
interface ScannerItemEvent { sessionId: number, item: any }
2424
interface ScannerDoneEvent { sessionId: number }
2525
interface ScannerErrorEvent { sessionId: number, error: string }
@@ -87,11 +87,7 @@ const api = {
8787
// ===== update =====
8888
checkUpdate: () => ipcRenderer.invoke('update:check'),
8989

90-
// ===== scanner (batch) =====
91-
scanProjects: (payload: ScanStartPayload) =>
92-
ipcRenderer.invoke('scanner:scan', payload),
93-
94-
// ===== scanner (stream) =====
90+
// ===== scanner =====
9591
startProjectScan: (payload: ScanStartPayload) =>
9692
ipcRenderer.invoke('scanner:start', payload),
9793

@@ -107,6 +103,9 @@ const api = {
107103
onScannerError: (cb: (data: ScannerErrorEvent) => void): Unlisten =>
108104
onIpc('scanner:error', cb),
109105

106+
detectJetBrainsConfigRootPath: () =>
107+
ipcRenderer.invoke('scanner:detect-jb-config-root-path'),
108+
110109
detectVscodeStateDbPath: () =>
111110
ipcRenderer.invoke('scanner:detect-vsc-state-db-path'),
112111

‎electron/services/projectService.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { LinguistResult } from '../utils/linguist'
1414
export async function analyzeProject(event: IpcMainInvokeEvent, folderPath: string): Promise<LinguistResult | { error: string }> {
1515
return await new Promise((resolve) => {
1616
try {
17-
// @ts-expect-error -- 知道要改tsconfig但不想改
17+
// @ts-expect-error -- ignore
1818
const workerUrl = new URL('../workers/linguistAnalyze.worker.js', import.meta.url)
1919
const worker = new Worker(workerUrl, { type: 'module', workerData: { folderPath } } as any)
2020

‎electron/services/scannerService.ts‎

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,56 +6,26 @@ import { Worker } from 'node:worker_threads'
66
import type { IpcMainInvokeEvent } from 'electron'
77
import { app } from 'electron'
88

9-
interface ScanPayload {
9+
export interface ScanPayload {
10+
// file system roots scanning
11+
rootsEnabled: boolean
1012
roots: string[]
11-
existingPaths: string[]
12-
}
13-
interface ScanResultItem {
14-
path: string
15-
name: string
16-
mainLang?: string
17-
mainLangColor?: `#${string}`
18-
langGroup?: Array<{ text: string, color: `#${string}`, percentage: number }>
19-
error?: string
20-
}
2113

22-
export async function scan(payload: ScanPayload): Promise<ScanResultItem[]> {
23-
return new Promise((resolve, reject) => {
24-
try {
25-
// @ts-expect-error -- 知道要改tsconfig但不想改
26-
const workerUrl = new URL('../workers/projectScanner.worker.js', import.meta.url)
27-
const worker = new Worker(workerUrl, { type: 'module', workerData: payload } as any)
28-
29-
const items: ScanResultItem[] = []
30-
const cleanup = () => worker.removeAllListeners()
31-
32-
worker.on('message', (msg: any) => {
33-
if (msg?.type === 'item' && msg.item) {
34-
items.push(msg.item as ScanResultItem)
35-
}
36-
else if (msg?.type === 'done') {
37-
cleanup()
38-
resolve(items)
39-
}
40-
else if (msg?.type === 'error') {
41-
cleanup()
42-
reject(new Error(msg.error || 'scan error'))
43-
}
44-
})
45-
46-
worker.once('error', (err) => {
47-
cleanup()
48-
reject(err)
49-
})
50-
worker.once('exit', (code) => {
51-
if (code !== 0)
52-
reject(new Error(`scanner worker exited with code ${code}`))
53-
})
54-
}
55-
catch (e) {
56-
reject(e)
57-
}
58-
})
14+
// IDE scanning toggles
15+
ideEnabled: boolean
16+
jetbrains: {
17+
enabled: boolean
18+
configRootPath: string
19+
}
20+
vscode: {
21+
enabled: boolean
22+
stateDbPath: string
23+
}
24+
visualStudio: {
25+
enabled: boolean
26+
}
27+
28+
existingPaths: string[]
5929
}
6030

6131
// 流式扫描:创建会话,逐条回推
@@ -64,7 +34,7 @@ let nextSessionId = 1
6434

6535
export function start(event: IpcMainInvokeEvent, payload: ScanPayload) {
6636
const sessionId = nextSessionId++
67-
// @ts-expect-error -- 知道要改tsconfig但不想改
37+
// @ts-expect-error -- ignore
6838
const workerUrl = new URL('../workers/projectScanner.worker.js', import.meta.url)
6939
const worker = new Worker(workerUrl, { type: 'module', workerData: payload } as any)
7040

@@ -121,6 +91,48 @@ export function stop(sessionId: number) {
12191
return { stopped: false }
12292
}
12393

94+
/**
95+
* 自动检测 JetBrains 配置根目录
96+
* 返回例如:
97+
* - Windows: C:\\Users\\<user>\\AppData\\Roaming\\JetBrains
98+
* - macOS: ~/Library/Application Support/JetBrains
99+
* - Linux: ~/.config/JetBrains 或 ~/.JetBrains(优先 .config/JetBrains)
100+
*/
101+
export function detectJetBrainsConfigRootPath() {
102+
const candidates: string[] = []
103+
104+
switch (process.platform) {
105+
case 'win32': {
106+
// Roaming AppData
107+
candidates.push(path.join(app.getPath('appData'), 'JetBrains'))
108+
break
109+
}
110+
case 'darwin': {
111+
candidates.push(path.join(app.getPath('home'), 'Library', 'Application Support', 'JetBrains'))
112+
break
113+
}
114+
default: {
115+
// linux
116+
const home = app.getPath('home')
117+
candidates.push(path.join(home, '.config', 'JetBrains'))
118+
candidates.push(path.join(home, '.JetBrains')) // 一些老版本
119+
break
120+
}
121+
}
122+
123+
for (const p of candidates) {
124+
try {
125+
if (fs.existsSync(p) && fs.statSync(p).isDirectory())
126+
return p
127+
}
128+
catch {
129+
// ignore
130+
}
131+
}
132+
133+
return null
134+
}
135+
124136
/**
125137
* 自动检测 VSCode state.vscdb 路径
126138
* 全平台通用
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import process from 'node:process'
5+
6+
import { XMLParser } from 'fast-xml-parser'
7+
8+
import { uniqExistingDirs } from './shared'
9+
10+
export interface JetbrainsRecentEntry {
11+
path: string
12+
ide: string // 例如 "PyCharm", "IntelliJIdea"
13+
}
14+
15+
const ideNameMap = {
16+
'IntelliJIdea': 'intellij-idea',
17+
'PyCharm': 'pycharm',
18+
'PhpStorm': 'phpstorm',
19+
'GoLand': 'goLand',
20+
'Rider': 'rider',
21+
'CLion': 'clion',
22+
'RustRover': 'rust-rover',
23+
'WebStorm': 'webstorm',
24+
'RubyMine': 'rubymine',
25+
'Aqua': 'aqua',
26+
'Fleet': 'fleet',
27+
'AndroidStudio': 'android-studio',
28+
} as const
29+
30+
/**
31+
* 获取 JetBrains 所有 IDE 的最近项目列表
32+
* @param configRoot JetBrains 配置根目录(例如 C:\Users\xxx\AppData\Roaming\JetBrains)
33+
*/
34+
function getAllJetbrainsRecentProjects(
35+
configRoot: string,
36+
): JetbrainsRecentEntry[] {
37+
if (!fs.existsSync(configRoot)) {
38+
throw new Error(`JetBrains config root not found: ${configRoot}`)
39+
}
40+
41+
const parser = new XMLParser({
42+
ignoreAttributes: false,
43+
attributeNamePrefix: '',
44+
})
45+
46+
const userHome = os.homedir()
47+
const normalizePath = (p: string): string =>
48+
p.replace(/\$USER_HOME\$/g, userHome).replace(/\\/g, '/')
49+
50+
const entries: JetbrainsRecentEntry[] = []
51+
52+
// 遍历 JetBrains 目录下的所有子目录
53+
for (const subdir of fs.readdirSync(configRoot)) {
54+
const fullDir = path.join(configRoot, subdir)
55+
if (!fs.statSync(fullDir).isDirectory())
56+
continue
57+
58+
const xmlPath = path.join(fullDir, 'options', 'recentProjects.xml')
59+
if (!fs.existsSync(xmlPath))
60+
continue
61+
62+
const xmlContent = fs.readFileSync(xmlPath, 'utf-8')
63+
const json = parser.parse(xmlContent)
64+
65+
const ideNameRaw = subdir.replace(/\d.*$/, '') // 去掉版本号,比如 "PyCharm2025.1" -> "PyCharm"
66+
const ideName = ideNameMap[ideNameRaw] ?? null
67+
68+
const options = json?.application?.component?.option
69+
if (!options)
70+
continue
71+
72+
// 可能是数组,也可能是单个对象
73+
const optionList = Array.isArray(options) ? options : [options]
74+
75+
const additionalInfo = optionList.find(opt => opt.name === 'additionalInfo')
76+
if (!additionalInfo?.map?.entry)
77+
continue
78+
79+
const projectEntries = additionalInfo.map.entry
80+
81+
if (Array.isArray(projectEntries)) {
82+
for (const e of projectEntries) {
83+
if (e.key)
84+
entries.push({ path: normalizePath(e.key), ide: ideName })
85+
}
86+
}
87+
else if (projectEntries?.key) {
88+
entries.push({ path: normalizePath(projectEntries.key), ide: ideName })
89+
}
90+
}
91+
92+
// 去重:按 path 唯一
93+
const seen = new Set<string>()
94+
const uniqueEntries: JetbrainsRecentEntry[] = []
95+
for (const e of entries) {
96+
if (!seen.has(e.path)) {
97+
seen.add(e.path)
98+
uniqueEntries.push(e)
99+
}
100+
}
101+
102+
return uniqueEntries
103+
}
104+
105+
export function collectFromJetbrains(configRoot: string | null): JetbrainsRecentEntry[] {
106+
if (!configRoot) {
107+
return []
108+
}
109+
let entries: JetbrainsRecentEntry[] = []
110+
try {
111+
entries = getAllJetbrainsRecentProjects(configRoot)
112+
}
113+
catch {
114+
return []
115+
}
116+
const candidates: JetbrainsRecentEntry[] = []
117+
for (const e of entries) {
118+
const p = e.path
119+
// Skip placeholders and JetBrains light-edit/scratches etc.
120+
if (!p || /\$[A-Z_]+\$/.test(p) || /light-edit|scratches/i.test(p))
121+
continue
122+
const norm = process.platform === 'win32' ? p.replace(/\//g, '\\') : p
123+
candidates.push({ path: norm, ide: e.ide })
124+
}
125+
return uniqExistingDirs(candidates)
126+
}

‎electron/utils/recent/shared.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
export function uniqExistingDirs(
5+
paths: Iterable<{ path: string, ide: string | null }>,
6+
): { path: string, ide: string | null }[] {
7+
const seen = new Set<string>()
8+
const out: { path: string, ide: string | null }[] = []
9+
for (const item of paths) {
10+
const p = item.path
11+
if (!p)
12+
continue
13+
let fp = p
14+
// Normalize windows backslashes, remove trailing slashes
15+
fp = path.normalize(fp)
16+
17+
try {
18+
if (fs.existsSync(fp) && fs.statSync(fp).isDirectory()) {
19+
// 统一大小写来判断唯一性(比如统一转小写)
20+
const key = fp.toLowerCase()
21+
22+
if (!seen.has(key)) {
23+
seen.add(key)
24+
25+
// 输出时修正盘符大写(只改第一个字母)
26+
if (/^[a-z]:/.test(fp)) {
27+
fp = fp.charAt(0).toUpperCase() + fp.slice(1)
28+
}
29+
30+
out.push({ path: fp, ide: item.ide })
31+
}
32+
}
33+
}
34+
catch {}
35+
}
36+
return out
37+
}

0 commit comments

Comments
 (0)