// projects.go 读取 Cursor 的项目历史(用户打开过哪些项目)。 // 两个来源: // 1. workspaceStorage//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 }