344 lines
10 KiB
Go
344 lines
10 KiB
Go
|
|
// 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
|
|||
|
|
}
|