Files
Local-Cursor/internal/cursor/projects.go
FuDie0915 db73cf5a39 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
2026-11-10 01:24:28 +08:00

344 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}