Files

344 lines
10 KiB
Go
Raw Permalink Normal View History

// projects.go 读取 Cursor 的项目历史(用户打开过哪些项目)。
// 两个来源:
// 1. workspaceStorage/<hash>/workspace.json — 每个打开过的项目一个目录,mtime 即最后打开时间
// 2. state.vscdb 的 history.recentlyOpenedPathsList — 最近打开列表(与 Cursor UI 一致)
package cursor
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
_ "modernc.org/sqlite"
)
// ProjectEntry 表示一个用户打开过的项目。
type ProjectEntry struct {
Path string `json:"path"`
LastOpened int64 `json:"lastOpenedMs"`
}
// cursorUserDir 返回 Cursor 用户数据目录(.../Cursor/User)。
func cursorUserDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(homeDir, "Library", "Application Support", "Cursor", "User"), nil
case "windows":
appData := strings.TrimSpace(os.Getenv("APPDATA"))
if appData == "" {
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "User"), nil
case "linux":
configDir := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if configDir == "" {
configDir = filepath.Join(homeDir, ".config")
}
return filepath.Join(configDir, "Cursor", "User"), nil
default:
return "", fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
}
// ListRecentProjects 返回用户打开过的项目列表,按最后打开时间倒序。
// 只保留磁盘上仍然存在的路径。
func ListRecentProjects() ([]ProjectEntry, error) {
userDir, err := cursorUserDir()
if err != nil {
return nil, err
}
if _, err := os.Stat(userDir); err != nil {
return nil, fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
}
entries := make(map[string]ProjectEntry)
collectWorkspaceStorageProjects(userDir, entries)
collectRecentlyOpenedProjects(userDir, entries)
result := make([]ProjectEntry, 0, len(entries))
for _, entry := range entries {
if info, err := os.Stat(entry.Path); err == nil && info.IsDir() {
result = append(result, entry)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].LastOpened > result[j].LastOpened
})
return result, nil
}
// collectWorkspaceStorageProjects 从 workspaceStorage 目录收集项目。
func collectWorkspaceStorageProjects(userDir string, entries map[string]ProjectEntry) {
storageRoot := filepath.Join(userDir, "workspaceStorage")
dirEntries, err := os.ReadDir(storageRoot)
if err != nil {
return
}
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
continue
}
dirPath := filepath.Join(storageRoot, dirEntry.Name())
raw, err := os.ReadFile(filepath.Join(dirPath, "workspace.json"))
if err != nil {
continue
}
var payload struct {
Folder string `json:"folder"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
continue
}
path := projectPathFromURI(payload.Folder)
if path == "" {
continue
}
path = filepath.Clean(path)
lastOpened := projectDirModTime(dirPath, path)
if existing, ok := entries[path]; !ok || lastOpened > existing.LastOpened {
entries[path] = ProjectEntry{Path: path, LastOpened: lastOpened}
}
}
}
// collectRecentlyOpenedProjects 从 state.vscdb 读取最近打开列表。
func collectRecentlyOpenedProjects(userDir string, entries map[string]ProjectEntry) {
dbPath := filepath.Join(userDir, "globalStorage", "state.vscdb")
if _, err := os.Stat(dbPath); err != nil {
return
}
snapshot, err := copyToTemp(dbPath)
if err != nil {
return
}
defer os.Remove(snapshot)
db, err := sql.Open("sqlite", snapshot)
if err != nil {
return
}
defer db.Close()
var raw string
if err := db.QueryRow(
"SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'",
).Scan(&raw); err != nil {
return
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return
}
for _, item := range payload.Entries {
path := projectPathFromURI(item.FolderURI)
if path == "" {
continue
}
path = filepath.Clean(path)
lastOpened := projectDirModTime(path, path)
if existing, ok := entries[path]; !ok || lastOpened > existing.LastOpened {
entries[path] = ProjectEntry{Path: path, LastOpened: lastOpened}
}
}
}
// DeleteProjectRecord 从 Cursor 的项目记录中删除指定项目,清理两处:
// 1. workspaceStorage 中 folder 指向该项目的目录(整个 hash 目录,含该项目聊天记录)
// 2. state.vscdb 的 history.recentlyOpenedPathsList 对应条目
//
// Cursor 运行时直接拒绝:state.vscdb 为 Cursor 主数据库,运行中写入可能锁冲突或被回写覆盖。
func DeleteProjectRecord(projectPath string) error {
projectPath = filepath.Clean(strings.TrimSpace(projectPath))
if projectPath == "" || projectPath == "." {
return fmt.Errorf("无效的项目路径")
}
if IsCursorRunning() {
return fmt.Errorf("Cursor 正在运行,请先关闭 Cursor 后重试")
}
userDir, err := cursorUserDir()
if err != nil {
return err
}
if _, err := os.Stat(userDir); err != nil {
return fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
}
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
entryRemoved, entryErr := removeRecentlyOpenedEntry(userDir, projectPath)
if storageErr != nil || entryErr != nil {
return fmt.Errorf("删除不完整(workspaceStorage: %d 处, %v;最近打开列表: %d 处, %v)",
storageRemoved, storageErr, entryRemoved, entryErr)
}
if storageRemoved+entryRemoved == 0 {
return fmt.Errorf("未找到项目 %s 的记录", projectPath)
}
return nil
}
// removeWorkspaceStorageEntries 删除 folder 指向该项目的 workspaceStorage hash 目录。
// 单个目录删除失败不中断循环,错误聚合返回。
func removeWorkspaceStorageEntries(userDir string, projectPath string) (int, error) {
storageRoot := filepath.Join(userDir, "workspaceStorage")
dirEntries, err := os.ReadDir(storageRoot)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("读取 workspaceStorage 失败: %w", err)
}
removed := 0
var removeErrs []error
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
continue
}
dirPath := filepath.Join(storageRoot, dirEntry.Name())
raw, err := os.ReadFile(filepath.Join(dirPath, "workspace.json"))
if err != nil {
continue
}
var payload struct {
Folder string `json:"folder"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
continue
}
if path := projectPathFromURI(payload.Folder); path != "" && samePath(filepath.Clean(path), projectPath) {
if err := os.RemoveAll(dirPath); err != nil {
removeErrs = append(removeErrs, fmt.Errorf("删除 %s 失败: %w", dirPath, err))
continue
}
removed++
}
}
return removed, errors.Join(removeErrs...)
}
// removeRecentlyOpenedEntry 从 state.vscdb 的最近打开列表中移除该项目。
// 返回删除条目数;读取或写入失败时返回错误(如 Cursor 占用数据库导致的 SQLITE_BUSY)。
func removeRecentlyOpenedEntry(userDir string, projectPath string) (int, error) {
dbPath := filepath.Join(userDir, "globalStorage", "state.vscdb")
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("访问 state.vscdb 失败: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return 0, fmt.Errorf("打开 state.vscdb 失败: %w", err)
}
defer db.Close()
var raw string
if err := db.QueryRow(
"SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'",
).Scan(&raw); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return 0, fmt.Errorf("读取最近打开列表失败: %w", err)
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return 0, fmt.Errorf("解析最近打开列表失败: %w", err)
}
kept := make([]struct {
FolderURI string `json:"folderUri"`
}, 0, len(payload.Entries))
removed := 0
for _, item := range payload.Entries {
path := projectPathFromURI(item.FolderURI)
if path != "" && samePath(filepath.Clean(path), projectPath) {
removed++
continue
}
kept = append(kept, item)
}
if removed == 0 {
return 0, nil
}
updated, err := json.Marshal(map[string]any{"entries": kept})
if err != nil {
return 0, fmt.Errorf("序列化最近打开列表失败: %w", err)
}
if _, err := db.Exec(
"UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'",
string(updated),
); err != nil {
return 0, fmt.Errorf("写入 state.vscdb 失败(Cursor 可能占用数据库): %w", err)
}
return removed, nil
}
// projectPathFromURI 将 file:/// URI 转换为本地路径;非 file URI 返回空。
func projectPathFromURI(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "file://") {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
path, err := url.PathUnescape(parsed.Path)
if err != nil {
return ""
}
path = strings.TrimPrefix(path, "/")
if len(path) >= 2 && path[1] == ':' {
// Windows 盘符路径已就绪(如 d:/Project/x)。
return path
}
return "/" + path
}
// projectDirModTime 返回目录 mtime;失败时返回 0。
func projectDirModTime(dirs ...string) int64 {
for _, dir := range dirs {
if info, err := os.Stat(dir); err == nil {
return info.ModTime().UnixMilli()
}
}
return 0
}
// copyToTemp 复制文件到临时目录,供只读 SQLite 打开,避免源库 WAL 锁。
func copyToTemp(src string) (string, error) {
in, err := os.Open(src)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.CreateTemp("", "cursor-state-*.vscdb")
if err != nil {
return "", err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
os.Remove(out.Name())
return "", err
}
return out.Name(), nil
}