Skip to content

Add plugin ide-open v1.0.0#280

Open
SvenZhao wants to merge 8 commits into
ZToolsCenter:mainfrom
SvenZhao:plugin/ide-open
Open

Add plugin ide-open v1.0.0#280
SvenZhao wants to merge 8 commits into
ZToolsCenter:mainfrom
SvenZhao:plugin/ide-open

Conversation

@SvenZhao

@SvenZhao SvenZhao commented Jun 30, 2026

Copy link
Copy Markdown

插件信息

  • 名称: ide-open
  • 插件ID: ide-open
  • 版本: 1.0.0
  • 描述: 统一管理 VSCode 系及 JetBrains 系编辑器的最近项目
  • 作者: svenzhao
  • 类型: 新增

本次变更

  • feat: ide-open plugin for ZTools — manage recent projects across VSCode/JetBrains editors
  • docs: rewrite README for ide-open plugin

截图 / 演示

image image image

自检清单

  • plugin.json 的 name / title / version / description / author 字段均已检查
  • 已移除调试日志、未使用文件、敏感信息(.env、token、密钥等)
  • 本次 PR 的 diff 仅涉及 plugins/ide-open/ 目录
  • 已在本地 ZTools 客户端实际加载并测试过此插件,主要功能正常
  • 同意以仓库声明的开源协议发布此插件

此 PR 由 ztools-plugin-cli 自动管理:每次 ztools publish 在分支上追加一个 commit,PR 链接保持不变。

- feat: ide-open plugin for ZTools — manage recent projects across VSCode/JetBrains editors
- docs: rewrite README for ide-open plugin

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the ide-open plugin for ZTools, allowing users to manage and quickly open recent projects from various VS Code and JetBrains editors. The implementation includes a React-based frontend and a Node.js preload script for reading local databases, JSON, and XML files. Feedback on the changes highlights several critical security and robustness issues in the preload services, including a command injection vulnerability in openProject, fragile XML parsing prone to ReDoS, potential file corruption during database updates, and unhandled URI decoding exceptions. Additionally, improvements were suggested to sort version directories for JetBrains editors, use standard fileURLToPath for path conversion, and remove redundant feature registration logic in the settings page.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +354 to +383
function openProject(command, uri, shell) {
const effectiveShell = shell || defaultShell
const isRemote = /^[a-z]+-remote:\/\//.test(uri)
const localPath = isRemote ? '' : uriToPath(uri)

const run = (cmd, timeout = 10000) => new Promise((resolve, reject) => {
const fullCmd = effectiveShell ? `${effectiveShell} '${cmd}'` : cmd
debugLog(`执行: ${fullCmd}`)
exec(fullCmd, { env: process.env, windowsHide: true, timeout }, (err) => {
if (err) {
debugLog(`命令失败: ${err.message}`)
reject(err)
} else resolve()
})
})

return new Promise((resolve, reject) => {
if (localPath) {
run(`${command} "${localPath}"`)
.then(() => resolve())
.catch(err => reject(new Error(`启动失败: ${err.message}`)))
return
}
const isWorkspace = uri.endsWith('.code-workspace')
const flag = isWorkspace ? '--file-uri' : '--folder-uri'
run(`${command} ${flag} "${uri}"`)
.then(() => resolve())
.catch(err => reject(new Error(`启动失败: ${err.message}`)))
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

安全风险:命令注入漏洞

openProject 函数中,启动命令和项目路径(localPathuri)直接通过字符串插值拼接到 shell 命令中执行。如果项目路径或 URI 中包含单引号、双引号或 shell 元字符(如 ;, &, |, $(), 反引号等),将会导致命令注入漏洞,允许执行任意系统命令。

改进建议:
在拼接命令前,对参数进行严格的 shell 转义。对于 macOS/Linux,使用单引号包裹并转义内部的单引号;对于 Windows,使用双引号包裹并转义内部的双引号。这样可以确保路径中的特殊字符被安全地视为字面量,防止命令注入。

function openProject(command, uri, shell) {
  const effectiveShell = shell || defaultShell
  const isRemote = /^[a-z]+-remote:\/\//.test(uri)
  const localPath = isRemote ? '' : uriToPath(uri)

  const escapeShellArg = (arg) => {
    if (process.platform === 'win32') {
      return `"${arg.replace(/"/g, '""')}"`
    }
    return `'${arg.replace(/'/g, "'\\''")}'`
  }

  const run = (cmd, timeout = 10000) => new Promise((resolve, reject) => {
    const fullCmd = effectiveShell ? `${effectiveShell} ${escapeShellArg(cmd)}` : cmd
    debugLog(`执行: ${fullCmd}`)
    exec(fullCmd, { env: process.env, windowsHide: true, timeout }, (err) => {
      if (err) {
        debugLog(`命令失败: ${err.message}`)
        reject(err)
      } else resolve()
    })
  })

  return new Promise((resolve, reject) => {
    if (localPath) {
      run(`${command} ${escapeShellArg(localPath)}`)
        .then(() => resolve())
        .catch(err => reject(new Error(`启动失败: ${err.message}`)))
      return
    }
    const isWorkspace = uri.endsWith('.code-workspace')
    const flag = isWorkspace ? '--file-uri' : '--folder-uri'
    run(`${command} ${flag} ${escapeShellArg(uri)}`)
        .then(() => resolve())
        .catch(err => reject(new Error(`启动失败: ${err.message}`)))
  })
}

Comment on lines +165 to +170
const nameByPath = {}
const entryRe = /<entry\s+key="([^"]+)"[^>]*>[\s\S]*?<option\s+name="projectName"\s+value="([^"]+)"\s*\/>[\s\S]*?<\/entry>/g
let n
while ((n = entryRe.exec(content)) !== null) {
nameByPath[n[1]] = n[2]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

代码健壮性与性能:避免使用复杂的正则表达式解析 XML

使用 [\s\S]*? 的正则表达式解析 XML 结构非常脆弱。如果某些 <entry> 标签中没有 projectName 选项,该正则会跨越多个 <entry> 标签进行匹配,导致解析出的项目名称与路径错配。此外,在处理大型 XML 文件时,这种懒惰匹配可能会导致灾难性回溯(ReDoS),影响性能。

改进建议:
先将 XML 内容按 <entry 标签分割,然后对每个 entry 块分别提取 keyprojectName。这样既简单、高效,又完全避免了跨标签错配和 ReDoS 风险。

  const nameByPath = {}
  const entries = content.split('<entry')
  for (const entry of entries) {
    const keyMatch = /key="([^"]+)"/.exec(entry)
    const nameMatch = /<option\s+name="projectName"\s+value="([^"]+)"/.exec(entry)
    if (keyMatch && nameMatch) {
      nameByPath[keyMatch[1]] = nameMatch[1]
    }
  }

Comment on lines +306 to +310
const updated = JSON.stringify(data)
db.run(`UPDATE ItemTable SET value = ? WHERE key = '${key}'`, [updated])
const out = db.export()
fs.writeFileSync(filePath, Buffer.from(out))
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

可靠性:使用原子写入防止文件损坏

直接使用 fs.writeFileSync(filePath, ...) 覆盖原始数据库文件。如果写入过程中发生异常、程序崩溃或断电,可能会导致 VS Code 的最近项目数据库文件损坏,丢失用户的历史记录。此外,db.run 中的 key 应该使用参数化查询以提高安全性。

改进建议:

  1. 使用参数化查询 UPDATE ItemTable SET value = ? WHERE key = ?
  2. 先将新数据写入临时文件(如 filePath.tmp),然后通过 fs.renameSync 原子地替换原文件。这样可以确保即使写入失败,原文件也完好无损。
        const updated = JSON.stringify(data)
        db.run('UPDATE ItemTable SET value = ? WHERE key = ?', [updated, key])
        const out = db.export()
        const tempPath = filePath + '.tmp'
        fs.writeFileSync(tempPath, Buffer.from(out))
        fs.renameSync(tempPath, filePath)
        return

Comment on lines +70 to +76
const entries = fs.readdirSync(dir)
for (const entry of entries) {
if (entry.startsWith(prefix)) {
const resolved = path.join(dir, entry) + after
if (fs.existsSync(resolved)) return resolved
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

正确性:对版本目录进行排序以确保匹配最新版本

fs.readdirSync(dir) 返回的目录项顺序是无序的(取决于文件系统)。如果用户安装了多个版本的 JetBrains 编辑器(例如 IntelliJIdea2023.1IntelliJIdea2023.3),直接遍历可能会匹配到旧版本的路径,而不是用户当前使用的最新版本。

改进建议:
在遍历前对目录项进行排序并反转(sort().reverse()),这样可以优先匹配到版本号更高的目录,确保读取到最新的项目历史记录。

Suggested change
const entries = fs.readdirSync(dir)
for (const entry of entries) {
if (entry.startsWith(prefix)) {
const resolved = path.join(dir, entry) + after
if (fs.existsSync(resolved)) return resolved
}
}
const entries = fs.readdirSync(dir).sort().reverse()
for (const entry of entries) {
if (entry.startsWith(prefix)) {
const resolved = path.join(dir, entry) + after
if (fs.existsSync(resolved)) return resolved
}
}

Comment on lines +183 to +222
function parseEntries(entries) {
debugLog(`parseEntries 输入: ${entries.length} 条`)
const result = entries
.map(e => {
if (e == null) return null
if (typeof e === 'string') {
if (!e) return null
const isRemote = /^[a-z]+-remote:\/\//.test(e)
const isWorkspace = e.endsWith('.code-workspace')
const localPath = isRemote ? '' : uriToPath(e)
return {
name: path.basename(decodeURIComponent(e).replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')) || '未命名',
path: localPath,
uri: e,
type: isRemote ? 'remote' : isWorkspace ? 'workspace' : 'folder',
label: ''
}
}
const uri = e.folderUri || e.fileUri || e.workspace?.configPath || ''
if (!uri) return null
const decoded = decodeURIComponent(uri)
const name = path.basename(
decoded.replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')
)
const isRemote = /^[a-z]+-remote:\/\//.test(uri)
const isWorkspace = uri.endsWith('.code-workspace')
const isFile = !!e.fileUri && !e.folderUri
const localPath = isRemote ? '' : uriToPath(uri)
return {
name: e.label || name || '未命名',
path: localPath,
uri,
type: isRemote ? 'remote' : isWorkspace ? 'workspace' : isFile ? 'file' : 'folder',
label: e.label || ''
}
})
.filter(Boolean)
debugLog(`parseEntries 输出: ${result.length} 条`)
return result
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

健壮性:防御性处理 decodeURIComponent 异常

如果最近项目数据库中的 URI 包含不规范的百分比编码(例如单个 % 后面没有跟随合法的十六进制数,或包含非 UTF-8 字符),decodeURIComponent 会抛出 URIError 异常,导致整个项目列表加载失败。

改进建议:
封装一个 safeDecodeURIComponent 函数,使用 try...catch 包裹 decodeURIComponent,在解析失败时安全地返回原始字符串,确保插件的鲁棒性。

function safeDecodeURIComponent(str) {
  try {
    return decodeURIComponent(str)
  } catch {
    return str
  }
}

function parseEntries(entries) {
  debugLog(`parseEntries 输入: ${entries.length} 条`)
  const result = entries
    .map(e => {
      if (e == null) return null
      if (typeof e === 'string') {
        if (!e) return null
        const isRemote = /^[a-z]+-remote:\/\//.test(e)
        const isWorkspace = e.endsWith('.code-workspace')
        const localPath = isRemote ? '' : uriToPath(e)
        return {
          name: path.basename(safeDecodeURIComponent(e).replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')) || '未命名',
          path: localPath,
          uri: e,
          type: isRemote ? 'remote' : isWorkspace ? 'workspace' : 'folder',
          label: ''
        }
      }
      const uri = e.folderUri || e.fileUri || e.workspace?.configPath || ''
      if (!uri) return null
      const decoded = safeDecodeURIComponent(uri)
      const name = path.basename(
        decoded.replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')
      )
      const isRemote = /^[a-z]+-remote:\/\//.test(uri)
      const isWorkspace = uri.endsWith('.code-workspace')
      const isFile = !!e.fileUri && !e.folderUri
      const localPath = isRemote ? '' : uriToPath(uri)
      return {
        name: e.label || name || '未命名',
        path: localPath,
        uri,
        type: isRemote ? 'remote' : isWorkspace ? 'workspace' : isFile ? 'file' : 'folder',
        label: e.label || ''
      }
    })
    .filter(Boolean)
  debugLog(`parseEntries 输出: ${result.length} 条`)
  return result
}

Comment on lines +224 to +231
function uriToPath(uri) {
try {
const url = new URL(uri)
return decodeURIComponent(url.pathname)
} catch {
return uri
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

正确性:使用标准的 fileURLToPath 转换本地路径

手动使用 new URL(uri).pathname 并在 Windows 上进行 decodeURIComponent 可能会导致路径格式不正确(例如 Windows 盘符前会多出一个斜杠 /C:/...)。

改进建议:
使用 Node.js 内置的 node:url 模块中的 fileURLToPath 方法。它是官方推荐的、跨平台最鲁棒的将 file:// 协议 URI 转换为本地绝对路径的方法。

Suggested change
function uriToPath(uri) {
try {
const url = new URL(uri)
return decodeURIComponent(url.pathname)
} catch {
return uri
}
}
function uriToPath(uri) {
try {
if (uri.startsWith('file://')) {
return require('node:url').fileURLToPath(uri)
}
const url = new URL(uri)
return decodeURIComponent(url.pathname)
} catch {
return uri
}
}

Comment thread plugins/ide-open/src/Settings/index.tsx Outdated
Comment on lines +29 to +63
const doRegister = (list: IDEItem[]) => {
list.forEach(ide => {
if (!ide.code) return
try { window.ztools.setFeature({ code: ide.code, explain: `打开 ${ide.name || ide.code} 最近项目`, cmds: [ide.code], icon: 'logo.png' }) }
catch {}
})
}

const save = () => {
if (!form.code.trim()) { alert('请输入别名'); return }
if (!form.command.trim()) { alert('请输入启动命令'); return }
if (!form.dbPath.trim()) { alert('请输入历史文件路径'); return }
const entry: IDEItem = { code: form.code.trim(), name: form.name.trim() || form.code.trim(), command: form.command.trim(), dbPath: form.dbPath.trim(), shell: form.shell.trim() }
let list
if (editingIdx >= 0) {
list = ides.map((item, i) => i === editingIdx ? entry : item)
} else {
list = [...ides, entry]
}
setIDEs(list)
saveIDEs(list)
doRegister(list)
setForm(emptyForm())
setEditingIdx(-1)
setShowForm(false)
}

const del = (idx: number) => {
const removed = ides[idx]
const list = ides.filter((_, i) => i !== idx)
setIDEs(list)
saveIDEs(list)
try { if (removed?.code) window.ztools.removeFeature(removed.code) } catch {}
doRegister(list)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

可维护性:移除冗余的快捷指令注册逻辑

store.ts 中,saveIDEs 已经调用了 window.services?.registerFeatures(),该方法会自动根据最新的 IDE 列表进行快捷指令的动态注册和旧指令的清理。
因此,在 Settings/index.tsx 中手动定义 doRegister、在 save 后调用 doRegister,以及在 del 中手动调用 removeFeaturedoRegister 都是完全冗余的,且容易因重复调用 API 导致潜在的控制台报错。

改进建议:
删除 doRegister 函数,并简化 savedel 方法,完全信赖 saveIDEs 内部的自动注册与清理机制。

  const save = () => {
    if (!form.code.trim()) { alert('请输入别名'); return }
    if (!form.command.trim()) { alert('请输入启动命令'); return }
    if (!form.dbPath.trim()) { alert('请输入历史文件路径'); return }
    const entry: IDEItem = { code: form.code.trim(), name: form.name.trim() || form.code.trim(), command: form.command.trim(), dbPath: form.dbPath.trim(), shell: form.shell.trim() }
    let list
    if (editingIdx >= 0) {
      list = ides.map((item, i) => i === editingIdx ? entry : item)
    } else {
      list = [...ides, entry]
    }
    setIDEs(list)
    saveIDEs(list)
    setForm(emptyForm())
    setEditingIdx(-1)
    setShowForm(false)
  }

  const del = (idx: number) => {
    const list = ides.filter((_, i) => i !== idx)
    setIDEs(list)
    saveIDEs(list)
  }

@SvenZhao SvenZhao marked this pull request as ready for review June 30, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant