chore: import Local-Cursor v1.1.8 source snapshot
Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage. Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
This commit is contained in:
343
internal/cursor/projects.go
Normal file
343
internal/cursor/projects.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user