Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
.vscode-test/**
out/**
node_modules/**
!node_modules/better-sqlite3/**
!node_modules/bindings/**
!node_modules/file-uri-to-path/**
src/**
.gitignore
.yarnrc
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@
"@commitlint/config-angular": "^19.1.0",
"@release-it/conventional-changelog": "^8.0.1",
"@swc/core": "^1.4.8",
"@types/better-sqlite3": "^7.6.13",
"@types/glob": "^8.1.0",
"@types/lodash": "^4.14.199",
"@types/mocha": "^10.0.1",
Expand All @@ -921,6 +922,8 @@
},
"dependencies": {
"@vscode/l10n": "^0.0.16",
"better-sqlite3": "^12.9.0",
"drizzle-orm": "^0.45.2",
"lodash": "^4.17.21",
"mobx": "^6.12.0",
"mobx-state-tree": "^5.4.1",
Expand Down
126 changes: 119 additions & 7 deletions src/controllers/BookmarksController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,21 @@ export default class BookmarksController implements IController {
this._resolveDataFromStoreFile();
// 当从 `bookmark-manager.json`文件中读取, 直接刷新返回
if (!this.configuration.createJsonFile) {
// 从state中读取数据
// 优先从 SQLite 数据库中读取数据
try {
store = this.workspaceState.get<any>(EXTENSION_ID);
if (!store) {
store = this._store;
const loaded = this._loadFromDatabase();
if (loaded) {
applySnapshot(this._store, this._sm.migrateService.migrate(loaded as any) as any);
} else {
// 兼容旧版: 尝试从 workspaceState 迁移数据
store = this.workspaceState.get<any>(EXTENSION_ID);
if (store) {
applySnapshot(this._store, isProxy(store) ? getSnapshot(store) : this._sm.migrateService.migrate(store));
// 迁移完成后清空 workspaceState
this.workspaceState.update(EXTENSION_ID, null);
}
}

applySnapshot(this._store, isProxy(store) ? getSnapshot(store) : this._sm.migrateService.migrate(store));

if (!this._store.groups.length) {
this._store.addGroups([]);
}
Expand Down Expand Up @@ -324,7 +330,8 @@ export default class BookmarksController implements IController {
if (this.configuration.createJsonFile) {
this._saveToDisk();
} else {
this.workspaceState.update(EXTENSION_ID, this._store);
this._saveToDatabase();
this.workspaceState.update(EXTENSION_ID, null);
}
this.refresh();
}
Expand Down Expand Up @@ -533,6 +540,111 @@ export default class BookmarksController implements IController {
return JSON.stringify(storeInfo);
}

/**
* @zh 将书签数据保存到 SQLite 数据库中
*/
private _saveToDatabase() {
if (env.appHost !== 'desktop') {
return;
}
const workspaceFolders = workspace.workspaceFolders || [];
if (!workspaceFolders.length) {
return;
}
try {
for (const ws of workspaceFolders) {
const saveBookmarks = this._store.bookmarks.filter(
it => it.wsFolder?.uri.fsPath === ws.uri.fsPath,
);
const _usedGroupIds = saveBookmarks.map(it => it.groupId);
if (!_usedGroupIds.includes(DEFAULT_BOOKMARK_GROUP_ID)) {
_usedGroupIds.push(DEFAULT_BOOKMARK_GROUP_ID);
}
const groups = this._store.groups.filter(
it =>
!it.workspace ||
it.workspace === ws.name ||
_usedGroupIds.includes(it.id),
);
const storeInfo: IBookmarkStoreInfo = {
version: process.env.version!,
workspace: ws.name,
updatedDate: new Date().toLocaleString(),
updatedDateTimespan: Date.now(),
viewType: this._store.viewType,
groupView: this._store.groupView,
sortedType: this._store.sortedType,
bookmarks: saveBookmarks as any,
groups,
groupInfo: this._store.groupInfo,
};
this._sm.databaseService.save(ws.name, storeInfo);
}
} catch (error) {
this._logger.error('Failed to save bookmarks to SQLite database', error);
}
}

/**
* @zh 从 SQLite 数据库中加载书签数据 (合并所有工作区间)
* @returns 合并后的书签存储信息, 若无数据则返回 null
*/
private _loadFromDatabase(): IBookmarkStoreInfo | null {
if (env.appHost !== 'desktop') {
return null;
}
const workspaceFolders = workspace.workspaceFolders || [];
if (!workspaceFolders.length) {
return null;
}
try {
let merged: IBookmarkStoreInfo | null = null;
for (const ws of workspaceFolders) {
const data = this._sm.databaseService.load(ws.name);
if (!data) {
continue;
}
if (!merged) {
merged = {...data};
} else {
merged.bookmarks = [...merged.bookmarks, ...(data.bookmarks || [])];
// 合并分组 (去重)
const existingGroupIds = new Set(merged.groups.map(g => g.id));
for (const group of data.groups || []) {
if (!existingGroupIds.has(group.id)) {
merged.groups.push(group);
existingGroupIds.add(group.id);
}
}
// 合并 groupInfo (去重)
const existingGroupInfoNames = new Set(
(merged.groupInfo || []).map(g => g.name),
);
for (const info of data.groupInfo || []) {
if (!existingGroupInfoNames.has(info.name)) {
merged.groupInfo = [...(merged.groupInfo || []), info];
existingGroupInfoNames.add(info.name);
} else {
const existing = merged.groupInfo?.find(g => g.name === info.name);
if (existing) {
const existingIds = new Set(existing.data.map((d: any) => d.id));
for (const item of info.data) {
if (!existingIds.has(item.id)) {
existing.data.push(item);
}
}
}
}
}
}
}
return merged;
} catch (error) {
this._logger.error('Failed to load bookmarks from SQLite database', error);
return null;
}
}

/**
* 当创建`bookmark-manager.json` 文件时, 根据`alwasIgnore`选项是否要将`bookmark-manger.json`的追加到`.gitignore` 文件中,同时弹出提示
* @param ws
Expand Down
80 changes: 80 additions & 0 deletions src/database/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Database from 'better-sqlite3';
import {drizzle, BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
import path from 'node:path';
import * as schema from './schema';
import {sql} from 'drizzle-orm';

export type BookmarkDatabase = BetterSQLite3Database<typeof schema>;

/**
* @zh 创建并初始化 SQLite 数据库连接, 同时确保所有表已创建
* @param dbPath SQLite 数据库文件路径
*/
export function createDatabase(dbPath: string): BookmarkDatabase {
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');

const db = drizzle(sqlite, {schema});
runMigrations(db);
return db;
}

/**
* @zh 运行数据库迁移, 创建所有需要的表
*/
function runMigrations(db: BookmarkDatabase) {
db.run(sql`
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
label TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default',
file_uri_path TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'line',
selection_content TEXT NOT NULL DEFAULT '',
language_id TEXT NOT NULL DEFAULT 'javascript',
workspace_folder_name TEXT NOT NULL,
workspace_folder_index INTEGER NOT NULL DEFAULT 0,
ranges_or_options TEXT NOT NULL,
created_at INTEGER NOT NULL,
group_id TEXT NOT NULL DEFAULT '-999999',
sorted_info TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '',
tag TEXT NOT NULL DEFAULT '{"name":"default","sortedIndex":-1}',
workspace TEXT NOT NULL
)
`);

db.run(sql`
CREATE TABLE IF NOT EXISTS bookmark_groups (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
sorted_index INTEGER NOT NULL DEFAULT 0,
color TEXT NOT NULL DEFAULT '',
active_status INTEGER NOT NULL DEFAULT 0,
workspace TEXT NOT NULL DEFAULT ''
)
`);

db.run(sql`
CREATE TABLE IF NOT EXISTS store_meta (
workspace TEXT PRIMARY KEY,
version TEXT NOT NULL DEFAULT '',
view_type TEXT NOT NULL DEFAULT 'tree',
group_view TEXT NOT NULL DEFAULT 'file',
sorted_type TEXT NOT NULL DEFAULT 'linenumber',
updated_at INTEGER NOT NULL
)
`);

db.run(sql`
CREATE TABLE IF NOT EXISTS group_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL,
group_name TEXT NOT NULL,
item_id TEXT NOT NULL,
sorted_index INTEGER NOT NULL DEFAULT -1
)
`);
}
93 changes: 93 additions & 0 deletions src/database/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {integer, sqliteTable, text} from 'drizzle-orm/sqlite-core';

/**
* @zh 书签表
*/
export const bookmarksTable = sqliteTable('bookmarks', {
id: text('id').primaryKey(),
label: text('label').notNull().default(''),
description: text('description').notNull().default(''),
color: text('color').notNull().default('default'),
/**
* @zh 书签所在文件的相对路径
*/
fileUriPath: text('file_uri_path').notNull(),
type: text('type').notNull().default('line'),
selectionContent: text('selection_content').notNull().default(''),
languageId: text('language_id').notNull().default('javascript'),
/**
* @zh 工作区间名称
*/
workspaceFolderName: text('workspace_folder_name').notNull(),
/**
* @zh 工作区间索引
*/
workspaceFolderIndex: integer('workspace_folder_index').notNull().default(0),
/**
* @zh 装饰器范围信息 (JSON 序列化)
*/
rangesOrOptions: text('ranges_or_options').notNull(),
createdAt: integer('created_at', {mode: 'timestamp_ms'})
.notNull()
.$defaultFn(() => new Date()),
groupId: text('group_id').notNull().default('-999999'),
/**
* @zh 各个分组情况下的排序信息 (JSON 序列化)
*/
sortedInfo: text('sorted_info').notNull(),
icon: text('icon').notNull().default(''),
/**
* @zh 标签信息 (JSON 序列化)
*/
tag: text('tag').notNull().default('{"name":"default","sortedIndex":-1}'),
/**
* @zh 所属工作区间名称 (用于多工作区间区分)
*/
workspace: text('workspace').notNull(),
});

/**
* @zh 书签分组表
*/
export const bookmarkGroupsTable = sqliteTable('bookmark_groups', {
id: text('id').primaryKey(),
label: text('label').notNull(),
sortedIndex: integer('sorted_index').notNull().default(0),
color: text('color').notNull().default(''),
activeStatus: integer('active_status', {mode: 'boolean'})
.notNull()
.default(false),
workspace: text('workspace').notNull().default(''),
});

/**
* @zh 存储元数据表 (每个工作区间一条记录)
*/
export const storeMetaTable = sqliteTable('store_meta', {
workspace: text('workspace').primaryKey(),
version: text('version').notNull().default(''),
viewType: text('view_type').notNull().default('tree'),
groupView: text('group_view').notNull().default('file'),
sortedType: text('sorted_type').notNull().default('linenumber'),
updatedAt: integer('updated_at', {mode: 'timestamp_ms'})
.notNull()
.$defaultFn(() => new Date()),
});

/**
* @zh 分组视图信息表 (groupInfo)
*/
export const groupInfoTable = sqliteTable('group_info', {
id: integer('id').primaryKey({autoIncrement: true}),
workspace: text('workspace').notNull(),
groupName: text('group_name').notNull(),
itemId: text('item_id').notNull(),
sortedIndex: integer('sorted_index').notNull().default(-1),
});

export type BookmarkRow = typeof bookmarksTable.$inferSelect;
export type NewBookmarkRow = typeof bookmarksTable.$inferInsert;
export type BookmarkGroupRow = typeof bookmarkGroupsTable.$inferSelect;
export type NewBookmarkGroupRow = typeof bookmarkGroupsTable.$inferInsert;
export type StoreMetaRow = typeof storeMetaTable.$inferSelect;
export type GroupInfoRow = typeof groupInfoTable.$inferSelect;
Loading