16 Commits

Author SHA1 Message Date
541e4e96cf Merge pull request #14 from landaiqing/dev
♻️ Refactor cursor position cache
2025-12-17 23:30:20 +08:00
401eb3ab39 ⬆️ Upgrade dependencies 2025-12-17 23:19:50 +08:00
d3eba96a29 🐛 Fixed assignment issues 2025-12-17 22:55:52 +08:00
81c02db00d Merge pull request #15 from fossabot/add-license-scan-badge
Add license scan report and status
2025-12-17 10:58:59 +08:00
fossabot
9cb2ccbb4e Add license scan report and status
Signed off by: fossabot <badges@fossa.com>
2025-12-16 21:39:50 -05:00
8a10b8fe0f ♻️ Refactor cursor position cache 2025-12-17 00:12:59 +08:00
8fce8bdca4 ♻️ Refactor backup service complete.
Some checks failed
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (c-cpp) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
2025-12-16 23:20:40 +08:00
1ab934cee9 Merge pull request #13 from landaiqing/alert-autofix-1
Some checks failed
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (c-cpp) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (rust) (push) Has been cancelled
Potential fix for code scanning alert no. 1: Workflow does not contain permissions
2025-12-16 15:20:27 +08:00
6659ac6fad 🐛 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 15:15:36 +08:00
3a5ab1c614 Merge pull request #12 from landaiqing/alert-autofix-2
Potential fix for code scanning alert no. 2: Workflow does not contain permissions
2025-12-16 14:44:06 +08:00
1e07e1f833 🐛 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 14:36:57 +08:00
e1e91a3683 👷 Change build mode for C/C++ in CodeQL workflow 2025-12-16 14:28:00 +08:00
c30d95a3e0 🐛 Potential fix for code scanning alert no. 3: Replacement of a substring with itself
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-16 14:14:52 +08:00
97f6fa843c 👷 Add CodeQL analysis workflow configuration 2025-12-16 14:01:02 +08:00
f43fc47539 Merge pull request #11 from landaiqing/dependabot/npm_and_yarn/frontend/mdast-util-to-hast-13.2.1
⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
2025-12-16 13:48:54 +08:00
dependabot[bot]
c330de52fa ⬆️ Bump mdast-util-to-hast from 13.2.0 to 13.2.1 in /frontend
Bumps [mdast-util-to-hast](https://github.com/syntax-tree/mdast-util-to-hast) from 13.2.0 to 13.2.1.
- [Release notes](https://github.com/syntax-tree/mdast-util-to-hast/releases)
- [Commits](https://github.com/syntax-tree/mdast-util-to-hast/compare/13.2.0...13.2.1)

---
updated-dependencies:
- dependency-name: mdast-util-to-hast
  dependency-version: 13.2.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-16 05:46:59 +00:00
41 changed files with 970 additions and 1544 deletions

View File

@@ -28,6 +28,7 @@ env:
jobs:
# 准备构建配置
prepare:
permissions: {}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -84,6 +85,8 @@ jobs:
fi
build:
permissions:
contents: read
needs: prepare
if: ${{ fromJson(needs.prepare.outputs.matrix).include[0] != null }}
strategy:

109
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,109 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '29 8 * * 3'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: c-cpp
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
- language: rust
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -159,5 +159,8 @@ Welcome to Fork, Star, and contribute code.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![GitHub stars](https://img.shields.io/github/stars/landaiqing/voidraft.svg?style=social&label=Star)](https://github.com/yourusername/voidraft)
[![GitHub forks](https://img.shields.io/github/forks/landaiqing/voidraft.svg?style=social&label=Fork)](https://github.com/yourusername/voidraft)
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Flandaiqing%2Fvoidraft.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Flandaiqing%2Fvoidraft?ref=badge_shield)
*Made with ❤️ by landaiqing*
*Made with ❤️ by landaiqing*
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Flandaiqing%2Fvoidraft.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Flandaiqing%2Fvoidraft?ref=badge_large)

View File

@@ -61,5 +61,3 @@ export class ServiceOptions {
return new ServiceOptions($$parsedSource as Partial<ServiceOptions>);
}
}
export type Window = any;

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,17 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* CancelFunc 取消订阅函数
* 调用此函数可以取消对配置的监听
*/
export type CancelFunc = any;
/**
* ObserverCallback 观察者回调函数
*/
export type ObserverCallback = any;

View File

@@ -21,7 +21,7 @@ export class Document {
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
"uuid": string | null;
/**
* creation time
@@ -56,7 +56,7 @@ export class Document {
/** Creates a new Document instance. */
constructor($$source: Partial<Document> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
this["uuid"] = null;
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
@@ -98,7 +98,7 @@ export class Extension {
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
"uuid": string | null;
/**
* creation time
@@ -133,7 +133,7 @@ export class Extension {
/** Creates a new Extension instance. */
constructor($$source: Partial<Extension> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
this["uuid"] = null;
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
@@ -179,7 +179,7 @@ export class KeyBinding {
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
"uuid": string | null;
/**
* creation time
@@ -219,7 +219,7 @@ export class KeyBinding {
/** Creates a new KeyBinding instance. */
constructor($$source: Partial<KeyBinding> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
this["uuid"] = null;
}
if (!("created_at" in $$source)) {
this["created_at"] = "";
@@ -261,7 +261,7 @@ export class Theme {
/**
* UUID for cross-device sync (UUIDv7)
*/
"uuid": string;
"uuid": string | null;
/**
* creation time
@@ -296,7 +296,7 @@ export class Theme {
/** Creates a new Theme instance. */
constructor($$source: Partial<Theme> = {}) {
if (!("uuid" in $$source)) {
this["uuid"] = "";
this["uuid"] = null;
}
if (!("created_at" in $$source)) {
this["created_at"] = "";

View File

@@ -15,11 +15,10 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
import * as helper$0 from "../common/helper/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
import * as models$0 from "../models/models.js";
/**
* Get 获取配置项
@@ -50,7 +49,7 @@ export function MigrateConfig(): Promise<void> & { cancel(): void } {
}
/**
* ResetConfig 强制重置所有配置为默认值
* ResetConfig 重置所有配置为默认值
*/
export function ResetConfig(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3593047389) as any;
@@ -66,7 +65,7 @@ export function ServiceShutdown(): Promise<void> & { cancel(): void } {
}
/**
* ServiceStartup initializes the service when the application starts
* ServiceStartup 服务启动时初始化
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3311949428, options) as any;
@@ -84,7 +83,7 @@ export function Set(key: string, value: any): Promise<void> & { cancel(): void }
/**
* Watch 注册配置变更监听器
*/
export function Watch(path: string, callback: $models.ObserverCallback): Promise<$models.CancelFunc> & { cancel(): void } {
export function Watch(path: string, callback: helper$0.ObserverCallback): Promise<helper$0.CancelFunc> & { cancel(): void } {
let $resultPromise = $Call.ByID(1143583035, path, callback) as any;
return $resultPromise;
}
@@ -92,7 +91,7 @@ export function Watch(path: string, callback: $models.ObserverCallback): Promise
/**
* WatchWithContext 使用 Context 注册监听器
*/
export function WatchWithContext(path: string, callback: $models.ObserverCallback): Promise<void> & { cancel(): void } {
export function WatchWithContext(path: string, callback: helper$0.ObserverCallback): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1454973098, path, callback) as any;
return $resultPromise;
}

View File

@@ -18,12 +18,12 @@ import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/applic
import * as models$0 from "../models/models.js";
/**
* GetCurrentHotkey 获取当前热键
* GetSupportedKeys 返回系统支持的快捷键列表
*/
export function GetCurrentHotkey(): Promise<models$0.HotkeyCombo | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(2572811187) as any;
export function GetSupportedKeys(): Promise<string[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1511528650) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
return $$createType0($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
@@ -86,5 +86,4 @@ export function UpdateHotkey(enable: boolean, combo: models$0.HotkeyCombo | null
}
// Private type creation functions
const $$createType0 = models$0.HotkeyCombo.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType0 = $Create.Array($Create.Any);

View File

@@ -12,12 +12,6 @@ import * as http$0 from "../../../net/http/models.js";
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
/**
* CancelFunc 取消订阅函数
* 调用此函数可以取消对配置的监听
*/
export type CancelFunc = any;
/**
* HttpRequest HTTP请求结构
*/
@@ -251,11 +245,6 @@ export class OSInfo {
}
}
/**
* ObserverCallback 观察者回调函数
*/
export type ObserverCallback = any;
/**
* SelfUpdateResult 自我更新结果
*/

View File

@@ -14,18 +14,6 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* GetOpenWindows 获取所有打开的文档窗口
*/
export function GetOpenWindows(): Promise<application$0.Window[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(1464997251) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType0($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* IsDocumentWindowOpen 检查指定文档的窗口是否已打开
*/
@@ -57,6 +45,3 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
let $resultPromise = $Call.ByID(2432987694, options) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $Create.Array($Create.Any);

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
@@ -34,7 +34,7 @@
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.2",
"@codemirror/lang-liquid": "^6.3.0",
"@codemirror/lang-liquid": "^6.3.1",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
@@ -50,10 +50,10 @@
"@codemirror/lint": "^6.9.2",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.8",
"@codemirror/view": "^6.39.4",
"@cospaia/prettier-plugin-clojure": "^0.0.2",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.4",
"@lezer/lr": "^1.4.5",
"@prettier/plugin-xml": "^3.4.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@toml-tools/lexer": "^1.0.0",
@@ -61,45 +61,45 @@
"@types/katex": "^0.16.7",
"codemirror": "^6.0.2",
"codemirror-lang-elixir": "^4.0.0",
"colors-named": "^1.0.2",
"colors-named-hex": "^1.0.2",
"colors-named": "^1.0.4",
"colors-named-hex": "^1.0.3",
"groovy-beautify": "^0.0.17",
"hsl-matcher": "^1.2.4",
"java-parser": "^3.0.1",
"katex": "^0.16.25",
"linguist-languages": "^9.1.0",
"katex": "^0.16.27",
"linguist-languages": "^9.1.11",
"marked": "^17.0.1",
"mermaid": "^11.12.1",
"mermaid": "^11.12.2",
"php-parser": "^3.2.5",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"prettier": "^3.7.2",
"sass": "^1.94.2",
"prettier": "^3.7.4",
"sass": "^1.97.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.6.3"
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.2",
"@lezer/generator": "^1.8.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@wailsio/runtime": "latest",
"@types/node": "^25.0.3",
"@vitejs/plugin-vue": "^6.0.3",
"@wailsio/runtime": "^3.0.0-alpha.76",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0",
"happy-dom": "^20.0.11",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"typescript-eslint": "^8.50.0",
"unplugin-vue-components": "^30.0.0",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-node-polyfills": "^0.24.0",
"vitepress": "^2.0.0-alpha.12",
"vitest": "^4.0.14",
"vitest": "^4.0.16",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.1.8"
},
"overrides": {
"vite": "npm:rolldown-vite@latest"

View File

@@ -248,7 +248,7 @@ export const useConfigStore = defineStore('config', () => {
setFontWeight: (value: string) => updateConfig('fontWeight', value),
// 路径操作
setDataPath: (value: string) => updateConfig('dataPath', value),
setDataPath: (value: string) => updateConfigLocal('dataPath', value),
// 保存配置相关方法
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),

View File

@@ -70,12 +70,11 @@ export const useDocumentStore = defineStore('document', () => {
// 在新窗口中打开文档
const openDocumentInNewWindow = async (docId: number): Promise<boolean> => {
try {
await OpenDocumentWindow(docId);
const tabStore = useTabStore();
if (tabStore.isTabsEnabled && tabStore.hasTab(docId)) {
tabStore.closeTab(docId);
}
await OpenDocumentWindow(docId);
return true;
} catch (error) {
console.error('Failed to open document in new window:', error);

View File

@@ -13,6 +13,7 @@ import {createFontExtensionFromBackend, updateFontConfig} from '@/views/editor/b
import {createStatsUpdateExtension} from '@/views/editor/basic/statsExtension';
import {createContentChangePlugin} from '@/views/editor/basic/contentChangeExtension';
import {createWheelZoomExtension} from '@/views/editor/basic/wheelZoomExtension';
import {createCursorPositionExtension, scrollToCursor} from '@/views/editor/basic/cursorPositionExtension';
import {createDynamicKeymapExtension, updateKeymapExtension} from '@/views/editor/keymap';
import {
createDynamicExtensions,
@@ -21,7 +22,7 @@ import {
setExtensionManagerView
} from '@/views/editor/manager';
import {useExtensionStore} from './extensionStore';
import createCodeBlockExtension, {blockState} from "@/views/editor/extensions/codeblock";
import createCodeBlockExtension from "@/views/editor/extensions/codeblock";
import {LruCache} from '@/common/utils/lruCache';
import {AsyncManager} from '@/common/utils/asyncManager';
import {generateContentHash} from "@/common/utils/hashUtils";
@@ -35,7 +36,7 @@ export interface DocumentStats {
selectedCharacters: number;
}
// 修复:只保存光标位置,恢复时自动滚动到光标处(更简单可靠)
// 修复:只保存光标位置,恢复时自动滚动到光标处
export interface EditorViewState {
cursorPos: number;
}
@@ -180,6 +181,9 @@ export const useEditorStore = defineStore('editor', () => {
enableAutoDetection: true
});
// 光标位置持久化扩展
const cursorPositionExtension = createCursorPositionExtension(documentId);
// 再次检查操作有效性
if (!operationManager.isOperationValid(operationId, documentId)) {
throw new Error('Operation cancelled');
@@ -212,13 +216,23 @@ export const useEditorStore = defineStore('editor', () => {
statsExtension,
contentChangeExtension,
codeBlockExtension,
cursorPositionExtension,
...dynamicExtensions,
];
// 创建编辑器状态
// 获取保存的光标位置
const savedState = documentStore.documentStates[documentId];
const docLength = content.length;
const initialCursorPos = savedState?.cursorPos !== undefined
? Math.min(savedState.cursorPos, docLength)
: docLength;
// 创建编辑器状态,设置初始光标位置
const state = EditorState.create({
doc: content,
extensions
extensions,
selection: { anchor: initialCursorPos, head: initialCursorPos }
});
return new EditorView({
@@ -316,6 +330,9 @@ export const useEditorStore = defineStore('editor', () => {
//使用 nextTick + requestAnimationFrame 确保 DOM 完全渲染
nextTick(() => {
requestAnimationFrame(() => {
// 滚动到当前光标位置
scrollToCursor(instance.view);
// 聚焦编辑器
instance.view.focus();
@@ -487,15 +504,6 @@ export const useEditorStore = defineStore('editor', () => {
await saveEditorContent(documentId);
}
// 保存光标位置
if (instance.view && instance.view.state) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 保存到 documentStore 用于持久化
documentStore.documentStates[documentId] = currentState;
}
// 清除自动保存定时器
instance.autoSaveTimer.clear();
@@ -578,22 +586,10 @@ export const useEditorStore = defineStore('editor', () => {
operationManager.cancelAllOperations();
editorCache.clear((_documentId, instance) => {
// 修复:清空前只保存光标位置
if (instance.view) {
const currentState: EditorViewState = {
cursorPos: instance.view.state.selection.main.head
};
// 同时保存到实例和 documentStore
instance.editorState = currentState;
documentStore.documentStates[instance.documentId] = currentState;
}
// 清除自动保存定时器
instance.autoSaveTimer.clear();
// 从扩展管理器移除
removeExtensionManagerView(instance.documentId);
// 移除DOM元素
if (instance.view.dom.parentElement) {
instance.view.dom.remove();

View File

@@ -156,10 +156,10 @@ export const useTabStore = defineStore('tab', () => {
*/
const validateTabs = () => {
const validDocIds = Object.keys(documentStore.documents).map(Number);
// 找出无效的标签页(文档已被删除)
const invalidTabIds = tabOrder.value.filter(docId => !validDocIds.includes(docId));
if (invalidTabIds.length > 0) {
// 批量清理无效标签页
invalidTabIds.forEach(docId => {
@@ -175,7 +175,7 @@ export const useTabStore = defineStore('tab', () => {
const initializeTab = () => {
// 先验证并清理无效的标签页(处理持久化的脏数据)
validateTabs();
if (isTabsEnabled.value) {
const currentDoc = documentStore.currentDocument;
if (currentDoc) {
@@ -254,7 +254,7 @@ export const useTabStore = defineStore('tab', () => {
return {
tabsMap,
tabOrder,
// 状态
tabs: readonly(tabs),
draggedTabId,
@@ -283,9 +283,5 @@ export const useTabStore = defineStore('tab', () => {
getTab
};
}, {
persist: {
key: 'voidraft-tabs',
storage: localStorage,
pick: ['tabsMap', 'tabOrder'],
},
persist: false,
});

View File

@@ -0,0 +1,68 @@
import {EditorView, ViewPlugin, ViewUpdate} from '@codemirror/view';
import {useDocumentStore} from '@/stores/documentStore';
import {createDebounce} from '@/common/utils/debounce';
/**
* 光标位置持久化扩展
* 实时监听光标位置变化并持久化到 documentStore
*/
export function createCursorPositionExtension(documentId: number) {
return ViewPlugin.fromClass(
class CursorPositionPlugin {
private readonly documentStore = useDocumentStore();
private readonly debouncedSave;
constructor(private view: EditorView) {
const {debouncedFn, flush} = createDebounce(
() => this.saveCursorPosition(),
{delay: 400}
);
this.debouncedSave = {fn: debouncedFn, flush};
// 初始化时保存一次光标位置
this.saveCursorPosition();
}
update(update: ViewUpdate) {
// 只在选择变化时触发
if (!update.selectionSet) {
return;
}
// 防抖保存光标位置
this.debouncedSave.fn();
}
destroy() {
// 销毁时立即执行待保存的操作
this.debouncedSave.flush();
// 再保存一次确保最新状态
this.saveCursorPosition();
}
private saveCursorPosition() {
const cursorPos = this.view.state.selection.main.head;
if (!this.documentStore.documentStates[documentId]) {
this.documentStore.documentStates[documentId] = {cursorPos};
} else {
this.documentStore.documentStates[documentId].cursorPos = cursorPos;
}
}
}
);
}
/**
* 滚动到当前光标位置(视口中心)
* @param view 编辑器视图
*/
export function scrollToCursor(view: EditorView) {
const cursorPos = view.state.selection.main.head;
view.dispatch({
effects: EditorView.scrollIntoView(cursorPos, {
y: 'center',
x: 'center'
})
});
}

View File

@@ -26,9 +26,7 @@ function formatKeyBinding(keyBinding: string): string {
return keyBinding
.replace("Mod", isMac ? "Cmd" : "Ctrl")
.replace("Shift", "Shift")
.replace("Alt", isMac ? "Option" : "Alt")
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
.replace(/-/g, " + ");
}

View File

@@ -2,16 +2,28 @@
import {useConfigStore} from '@/stores/configStore';
import {useTabStore} from '@/stores/tabStore';
import {useI18n} from 'vue-i18n';
import {computed, ref} from 'vue';
import {computed, ref, onMounted} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import {DialogService, MigrationService} from '@/../bindings/voidraft/internal/services';
import {DialogService, HotkeyService, MigrationService} from '@/../bindings/voidraft/internal/services';
import {useSystemStore} from "@/stores/systemStore";
import {useConfirm, usePolling} from '@/composables';
const {t} = useI18n();
const configStore = useConfigStore();
const {
config: {general},
resetConfig,
setAlwaysOnTop,
setDataPath,
setEnableGlobalHotkey,
setEnableLoadingAnimation,
setEnableSystemTray,
setEnableTabs,
setEnableWindowSnap,
setGlobalHotkey,
setStartAtLogin
} = useConfigStore();
const systemStore = useSystemStore();
const tabStore = useTabStore();
@@ -60,57 +72,57 @@ const hideAll = () => {
const {isConfirming: isResetConfirming, requestConfirm: requestResetConfirm} = useConfirm({
timeout: 3000,
onConfirm: async () => {
await configStore.resetConfig();
await resetConfig();
}
});
// 可选键列表
const keyOptions = [
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12'
];
// 可选键列表 - 从后端获取系统支持的快捷键
const keyOptions = ref<string[]>([]);
// 初始化时从后端获取支持的键列表
onMounted(async () => {
keyOptions.value = await HotkeyService.GetSupportedKeys();
});
// 计算属性 - 启用全局热键
const enableGlobalHotkey = computed({
get: () => configStore.config.general.enableGlobalHotkey,
set: (value: boolean) => configStore.setEnableGlobalHotkey(value)
get: () => general.enableGlobalHotkey,
set: (value: boolean) => setEnableGlobalHotkey(value)
});
// 计算属性 - 窗口始终置顶
const alwaysOnTop = computed({
get: () => configStore.config.general.alwaysOnTop,
get: () => general.alwaysOnTop,
set: async (value: boolean) => {
// 先更新配置
await configStore.setAlwaysOnTop(value);
await setAlwaysOnTop(value);
await systemStore.setWindowOnTop(value);
}
});
// 计算属性 - 启用系统托盘
const enableSystemTray = computed({
get: () => configStore.config.general.enableSystemTray,
set: (value: boolean) => configStore.setEnableSystemTray(value)
get: () => general.enableSystemTray,
set: (value: boolean) => setEnableSystemTray(value)
});
// 计算属性 - 启用窗口吸附
const enableWindowSnap = computed({
get: () => configStore.config.general.enableWindowSnap,
set: (value: boolean) => configStore.setEnableWindowSnap(value)
get: () => general.enableWindowSnap,
set: (value: boolean) => setEnableWindowSnap(value)
});
// 计算属性 - 启用加载动画
const enableLoadingAnimation = computed({
get: () => configStore.config.general.enableLoadingAnimation,
set: (value: boolean) => configStore.setEnableLoadingAnimation(value)
get: () => general.enableLoadingAnimation,
set: (value: boolean) => setEnableLoadingAnimation(value)
});
// 计算属性 - 启用标签页
const enableTabs = computed({
get: () => configStore.config.general.enableTabs,
get: () => general.enableTabs,
set: async (value: boolean) => {
await configStore.setEnableTabs(value);
await setEnableTabs(value);
if (value) {
// 开启tabs功能时初始化当前文档到标签页
tabStore.initializeTab();
@@ -123,33 +135,33 @@ const enableTabs = computed({
// 计算属性 - 开机启动
const startAtLogin = computed({
get: () => configStore.config.general.startAtLogin,
set: (value: boolean) => configStore.setStartAtLogin(value)
get: () => general.startAtLogin,
set: (value: boolean) => setStartAtLogin(value)
});
// 修饰键配置 - 只读计算属性
const modifierKeys = computed(() => ({
ctrl: configStore.config.general.globalHotkey.ctrl,
shift: configStore.config.general.globalHotkey.shift,
alt: configStore.config.general.globalHotkey.alt,
win: configStore.config.general.globalHotkey.win
ctrl: general.globalHotkey.ctrl,
shift: general.globalHotkey.shift,
alt: general.globalHotkey.alt,
win: general.globalHotkey.win
}));
// 主键配置
const selectedKey = computed(() => configStore.config.general.globalHotkey.key);
const selectedKey = computed(() => general.globalHotkey.key);
// 切换修饰键
const toggleModifier = (key: 'ctrl' | 'shift' | 'alt' | 'win') => {
const currentHotkey = configStore.config.general.globalHotkey;
const currentHotkey = general.globalHotkey;
const newHotkey = {...currentHotkey, [key]: !currentHotkey[key]};
configStore.setGlobalHotkey(newHotkey);
setGlobalHotkey(newHotkey);
};
// 更新选择的键
const updateSelectedKey = (event: Event) => {
const select = event.target as HTMLSelectElement;
const newHotkey = {...configStore.config.general.globalHotkey, key: select.value};
configStore.setGlobalHotkey(newHotkey);
const newHotkey = {...general.globalHotkey, key: select.value};
setGlobalHotkey(newHotkey);
};
@@ -157,7 +169,7 @@ const updateSelectedKey = (event: Event) => {
const hotkeyPreview = computed(() => {
if (!enableGlobalHotkey.value) return '';
const {ctrl, shift, alt, win, key} = configStore.config.general.globalHotkey;
const {ctrl, shift, alt, win, key} = general.globalHotkey;
const modifiers = [
ctrl && 'Ctrl',
shift && 'Shift',
@@ -170,7 +182,7 @@ const hotkeyPreview = computed(() => {
});
// 数据路径配置
const currentDataPath = computed(() => configStore.config.general.dataPath);
const currentDataPath = computed(() => general.dataPath);
// 选择数据存储目录
const selectDataDirectory = async () => {
@@ -189,7 +201,7 @@ const selectDataDirectory = async () => {
try {
await MigrationService.MigrateDirectory(oldPath, newPath);
await configStore.setDataPath(newPath);
await setDataPath(newPath);
} catch (e) {
stop();
// 设置手动捕获的错误(当轮询还没获取到错误时)
@@ -314,10 +326,6 @@ const selectDataDirectory = async () => {
</template>
<style scoped lang="scss">
.settings-page {
//max-width: 800px;
}
.hotkey-selector {
padding: 15px 0 5px 20px;
transition: all 0.3s ease;

35
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25
require (
entgo.io/ent v0.14.5
github.com/creativeprojects/go-selfupdate v1.5.1
github.com/go-git/go-git/v5 v5.16.3
github.com/go-git/go-git/v5 v5.16.4
github.com/google/uuid v1.6.0
github.com/knadh/koanf/parsers/json v1.0.0
github.com/knadh/koanf/providers/file v1.2.0
@@ -13,15 +13,15 @@ require (
github.com/knadh/koanf/v2 v2.3.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.41
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.31.0
resty.dev/v3 v3.0.0-beta.3
github.com/wailsapp/wails/v3 v3.0.0-alpha.48
golang.org/x/net v0.48.0
golang.org/x/sys v0.39.0
golang.org/x/text v0.32.0
resty.dev/v3 v3.0.0-beta.5
)
require (
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 // indirect
ariga.io/atlas v0.38.0 // indirect
code.gitea.io/sdk/gitea v0.22.1 // indirect
dario.cat/mergo v1.0.2 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
@@ -44,9 +44,9 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/inflect v0.19.0 // indirect
github.com/go-openapi/inflect v0.21.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -55,8 +55,8 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/hcl/v2 v2.24.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
@@ -72,7 +72,6 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
@@ -83,14 +82,16 @@ require (
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/go-gitlab v0.115.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty v1.17.0 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/image v0.34.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

76
go.sum
View File

@@ -1,5 +1,5 @@
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc=
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
ariga.io/atlas v0.38.0 h1:MwbtwVtDWJFq+ECyeTAz2ArvewDnpeiw/t/sgNdDsdo=
ariga.io/atlas v0.38.0/go.mod h1:D7XMK6ei3GvfDqvzk+2VId78j77LdqHrqPOWamn51/s=
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
@@ -62,16 +62,16 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
github.com/go-openapi/inflect v0.21.5 h1:M2RCq6PPS3YbIaL7CXosGL3BbzAcmfBAT0nC3YfesZA=
github.com/go-openapi/inflect v0.21.5/go.mod h1:GypUyi6bU880NYurWaEH2CmH84zFDNd+EhhmzroHmB4=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -97,10 +97,10 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
@@ -126,8 +126,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
@@ -182,38 +180,42 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.41 h1:DYcC1/vtO862sxnoyCOMfLLypbzpFWI257fR6zDYY+Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha.41/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.48 h1:m22PcankYJI/lKbv7NnNekxsEJYPbvIUnvHvH4WD1xQ=
github.com/wailsapp/wails/v3 v3.0.0-alpha.48/go.mod h1:yaz8baG0+YzoiN8J6osn0wKiEi0iUux0ZU5NsZFu6OQ=
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0=
github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -225,19 +227,21 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -252,5 +256,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=
resty.dev/v3 v3.0.0-beta.5 h1:NV1xbqOLzSq7XMTs1t/HLPvu7xrxoXzF90SR4OO6faQ=
resty.dev/v3 v3.0.0-beta.5/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=

View File

@@ -1,4 +1,4 @@
package services
package helper
import (
"context"
@@ -10,6 +10,8 @@ import (
"github.com/wailsapp/wails/v3/pkg/services/log"
)
const pathSeparator = '.'
// ObserverCallback 观察者回调函数
type ObserverCallback func(oldValue, newValue interface{})
@@ -49,6 +51,8 @@ func NewConfigObserver(logger *log.LogService) *ConfigObserver {
}
// Watch 注册配置变更监听器
// 支持前缀监听:注册 "generate" 可以监听 "generate.xxx"、"generate.yyy" 等所有子路径的变化
// 返回取消函数,调用后停止监听
func (co *ConfigObserver) Watch(path string, callback ObserverCallback) CancelFunc {
// 生成唯一ID
id := fmt.Sprintf("obs_%d", co.nextObserverID.Add(1))
@@ -103,36 +107,91 @@ func (co *ConfigObserver) removeObserver(path, id string) {
}
}
// Notify 通知指定路径的所有观察者
// Notify 通知指定路径及其所有父路径的观察者
// 支持前缀监听:当 "generate.xxx" 变化时,同时通知监听 "generate" 的观察者
// 通知顺序:精确匹配 -> 父路径(从近到远)
func (co *ConfigObserver) Notify(path string, oldValue, newValue interface{}) {
// 获取该路径的所有观察者(拷贝以避免并发问题)
co.observerMu.RLock()
observers := co.observers[path]
if len(observers) == 0 {
co.observerMu.RUnlock()
callbacks := co.collectCallbacks(path)
co.observerMu.RUnlock()
if len(callbacks) == 0 {
return
}
// 拷贝观察者列表
callbacks := make([]ObserverCallback, len(observers))
for i, obs := range observers {
callbacks[i] = obs.callback
}
co.observerMu.RUnlock()
// 在独立 goroutine 中执行回调
// 执行所有回调
for _, callback := range callbacks {
co.executeCallback(callback, oldValue, newValue)
}
}
// NotifyAll 通知所有匹配前缀的观察者
// collectCallbacks 收集指定路径及其所有父路径的观察者回调
// 调用者必须持有读锁
func (co *ConfigObserver) collectCallbacks(path string) []ObserverCallback {
if path == "" {
return nil
}
var callbacks []ObserverCallback
// 1. 收集精确匹配的观察者
if observers := co.observers[path]; len(observers) > 0 {
callbacks = make([]ObserverCallback, 0, len(observers)*2)
for _, obs := range observers {
callbacks = append(callbacks, obs.callback)
}
}
// 2. 收集父路径的观察者(从后向前遍历,避免 strings.Split 的内存分配)
for i := len(path) - 1; i >= 0; i-- {
if path[i] == pathSeparator {
parentPath := path[:i]
if observers := co.observers[parentPath]; len(observers) > 0 {
if callbacks == nil {
callbacks = make([]ObserverCallback, 0, len(observers))
}
for _, obs := range observers {
callbacks = append(callbacks, obs.callback)
}
}
}
}
return callbacks
}
// NotifyAll 批量通知所有匹配路径的观察者
func (co *ConfigObserver) NotifyAll(changes map[string]struct {
OldValue interface{}
NewValue interface{}
}) {
if len(changes) == 0 {
return
}
type callbackTask struct {
callback ObserverCallback
oldValue interface{}
newValue interface{}
}
// 只获取一次读锁,收集所有回调
co.observerMu.RLock()
var tasks []callbackTask
for path, change := range changes {
co.Notify(path, change.OldValue, change.NewValue)
for _, cb := range co.collectCallbacks(path) {
tasks = append(tasks, callbackTask{
callback: cb,
oldValue: change.OldValue,
newValue: change.NewValue,
})
}
}
co.observerMu.RUnlock()
// 执行所有回调
for _, task := range tasks {
co.executeCallback(task.callback, task.oldValue, task.newValue)
}
}

View File

@@ -17,7 +17,7 @@ type Document struct {
// ID of the ent.
ID int `json:"id,omitempty"`
// UUID for cross-device sync (UUIDv7)
UUID string `json:"uuid"`
UUID *string `json:"uuid"`
// creation time
CreatedAt string `json:"created_at"`
// update time
@@ -69,7 +69,8 @@ func (_m *Document) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field uuid", values[i])
} else if value.Valid {
_m.UUID = value.String
_m.UUID = new(string)
*_m.UUID = value.String
}
case document.FieldCreatedAt:
if value, ok := values[i].(*sql.NullString); !ok {
@@ -144,8 +145,10 @@ func (_m *Document) String() string {
var builder strings.Builder
builder.WriteString("Document(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("uuid=")
builder.WriteString(_m.UUID)
if v := _m.UUID; v != nil {
builder.WriteString("uuid=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt)

View File

@@ -225,7 +225,7 @@ func (_c *DocumentCreate) createSpec() (*Document, *sqlgraph.CreateSpec) {
)
if value, ok := _c.mutation.UUID(); ok {
_spec.SetField(document.FieldUUID, field.TypeString, value)
_node.UUID = value
_node.UUID = &value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(document.FieldCreatedAt, field.TypeString, value)

View File

@@ -18,7 +18,7 @@ type Extension struct {
// ID of the ent.
ID int `json:"id,omitempty"`
// UUID for cross-device sync (UUIDv7)
UUID string `json:"uuid"`
UUID *string `json:"uuid"`
// creation time
CreatedAt string `json:"created_at"`
// update time
@@ -72,7 +72,8 @@ func (_m *Extension) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field uuid", values[i])
} else if value.Valid {
_m.UUID = value.String
_m.UUID = new(string)
*_m.UUID = value.String
}
case extension.FieldCreatedAt:
if value, ok := values[i].(*sql.NullString); !ok {
@@ -149,8 +150,10 @@ func (_m *Extension) String() string {
var builder strings.Builder
builder.WriteString("Extension(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("uuid=")
builder.WriteString(_m.UUID)
if v := _m.UUID; v != nil {
builder.WriteString("uuid=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt)

View File

@@ -213,7 +213,7 @@ func (_c *ExtensionCreate) createSpec() (*Extension, *sqlgraph.CreateSpec) {
)
if value, ok := _c.mutation.UUID(); ok {
_spec.SetField(extension.FieldUUID, field.TypeString, value)
_node.UUID = value
_node.UUID = &value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(extension.FieldCreatedAt, field.TypeString, value)

View File

@@ -17,7 +17,7 @@ type KeyBinding struct {
// ID of the ent.
ID int `json:"id,omitempty"`
// UUID for cross-device sync (UUIDv7)
UUID string `json:"uuid"`
UUID *string `json:"uuid"`
// creation time
CreatedAt string `json:"created_at"`
// update time
@@ -71,7 +71,8 @@ func (_m *KeyBinding) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field uuid", values[i])
} else if value.Valid {
_m.UUID = value.String
_m.UUID = new(string)
*_m.UUID = value.String
}
case keybinding.FieldCreatedAt:
if value, ok := values[i].(*sql.NullString); !ok {
@@ -152,8 +153,10 @@ func (_m *KeyBinding) String() string {
var builder strings.Builder
builder.WriteString("KeyBinding(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("uuid=")
builder.WriteString(_m.UUID)
if v := _m.UUID; v != nil {
builder.WriteString("uuid=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt)

View File

@@ -240,7 +240,7 @@ func (_c *KeyBindingCreate) createSpec() (*KeyBinding, *sqlgraph.CreateSpec) {
)
if value, ok := _c.mutation.UUID(); ok {
_spec.SetField(keybinding.FieldUUID, field.TypeString, value)
_node.UUID = value
_node.UUID = &value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(keybinding.FieldCreatedAt, field.TypeString, value)

View File

@@ -166,7 +166,7 @@ func (m *DocumentMutation) UUID() (r string, exists bool) {
// OldUUID returns the old "uuid" field's value of the Document entity.
// If the Document object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *DocumentMutation) OldUUID(ctx context.Context) (v string, err error) {
func (m *DocumentMutation) OldUUID(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUUID is only allowed on UpdateOne operations")
}
@@ -876,7 +876,7 @@ func (m *ExtensionMutation) UUID() (r string, exists bool) {
// OldUUID returns the old "uuid" field's value of the Extension entity.
// If the Extension object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ExtensionMutation) OldUUID(ctx context.Context) (v string, err error) {
func (m *ExtensionMutation) OldUUID(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUUID is only allowed on UpdateOne operations")
}
@@ -1587,7 +1587,7 @@ func (m *KeyBindingMutation) UUID() (r string, exists bool) {
// OldUUID returns the old "uuid" field's value of the KeyBinding entity.
// If the KeyBinding object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *KeyBindingMutation) OldUUID(ctx context.Context) (v string, err error) {
func (m *KeyBindingMutation) OldUUID(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUUID is only allowed on UpdateOne operations")
}
@@ -2350,7 +2350,7 @@ func (m *ThemeMutation) UUID() (r string, exists bool) {
// OldUUID returns the old "uuid" field's value of the Theme entity.
// If the Theme object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ThemeMutation) OldUUID(ctx context.Context) (v string, err error) {
func (m *ThemeMutation) OldUUID(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUUID is only allowed on UpdateOne operations")
}

View File

@@ -18,7 +18,7 @@ type Theme struct {
// ID of the ent.
ID int `json:"id,omitempty"`
// UUID for cross-device sync (UUIDv7)
UUID string `json:"uuid"`
UUID *string `json:"uuid"`
// creation time
CreatedAt string `json:"created_at"`
// update time
@@ -70,7 +70,8 @@ func (_m *Theme) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field uuid", values[i])
} else if value.Valid {
_m.UUID = value.String
_m.UUID = new(string)
*_m.UUID = value.String
}
case theme.FieldCreatedAt:
if value, ok := values[i].(*sql.NullString); !ok {
@@ -147,8 +148,10 @@ func (_m *Theme) String() string {
var builder strings.Builder
builder.WriteString("Theme(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("uuid=")
builder.WriteString(_m.UUID)
if v := _m.UUID; v != nil {
builder.WriteString("uuid=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt)

View File

@@ -206,7 +206,7 @@ func (_c *ThemeCreate) createSpec() (*Theme, *sqlgraph.CreateSpec) {
)
if value, ok := _c.mutation.UUID(); ok {
_spec.SetField(theme.FieldUUID, field.TypeString, value)
_node.UUID = value
_node.UUID = &value
}
if value, ok := _c.mutation.CreatedAt(); ok {
_spec.SetField(theme.FieldCreatedAt, field.TypeString, value)

View File

@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"time"
"voidraft/internal/common/helper"
"github.com/go-git/go-git/v5"
gitConfig "github.com/go-git/go-git/v5/config"
@@ -61,7 +62,7 @@ type BackupService struct {
autoBackupStop chan bool
autoBackupWg sync.WaitGroup
mu sync.Mutex
cancelObserver CancelFunc
cancelObservers []helper.CancelFunc
}
// NewBackupService 创建新的备份服务实例
@@ -74,7 +75,11 @@ func NewBackupService(configService *ConfigService, dbService *DatabaseService,
}
func (s *BackupService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
s.cancelObserver = s.configService.Watch("backup.enabled", s.onBackupConfigChange)
// 监听 backup 配置变化
s.cancelObservers = []helper.CancelFunc{
s.configService.Watch("backup", s.onBackupConfigChange),
s.configService.Watch("general.dataPath", s.onDataPathChange),
}
if err := s.Initialize(); err != nil {
s.logger.Error("initializing backup service: %v", err)
}
@@ -89,6 +94,12 @@ func (s *BackupService) onBackupConfigChange(oldValue, newValue interface{}) {
_ = s.HandleConfigChange(&config.Backup)
}
func (s *BackupService) onDataPathChange(oldValue, newValue interface{}) {
if err := s.Reinitialize(); err != nil {
s.logger.Error("Failed to reinitialize backup service after data path change: %v", err)
}
}
// Initialize 初始化备份服务
func (s *BackupService) Initialize() error {
config, repoPath, err := s.getConfigAndPath()
@@ -100,7 +111,7 @@ func (s *BackupService) Initialize() error {
return nil
}
// 仓库地址为空时不初始化(等待用户配置)
// 仓库地址为空时不初始化
if strings.TrimSpace(config.RepoURL) == "" {
return nil
}
@@ -1196,8 +1207,10 @@ func (s *BackupService) HandleConfigChange(config *models.GitBackupConfig) error
// ServiceShutdown 服务关闭
func (s *BackupService) ServiceShutdown() {
if s.cancelObserver != nil {
s.cancelObserver()
for _, cancel := range s.cancelObservers {
if cancel != nil {
cancel()
}
}
s.StopAutoBackup()
}

View File

@@ -2,33 +2,31 @@ package services
import (
"context"
"encoding/json"
"fmt"
"github.com/wailsapp/wails/v3/pkg/application"
"os"
"path/filepath"
"strings"
"reflect"
"sync"
"time"
"voidraft/internal/common/helper"
"voidraft/internal/models"
jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// ConfigService 应用配置服务
type ConfigService struct {
koanf *koanf.Koanf // koanf 实例
logger *log.LogService // 日志服务
configDir string // 配置目录
settingsPath string // 设置文件路径
mu sync.RWMutex // 读写锁
fileProvider *file.File // 文件提供器,用于监听
observer *ConfigObserver
koanf *koanf.Koanf
logger *log.LogService
configDir string
settingsPath string
mu sync.RWMutex
observer *helper.ConfigObserver
// 配置迁移器
configMigrator *ConfigMigrator
@@ -36,49 +34,29 @@ type ConfigService struct {
// NewConfigService 创建新的配置服务实例
func NewConfigService(logger *log.LogService) *ConfigService {
// 获取用户主目录
homeDir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("unable to get the user's home directory: %w", err))
}
// 设置配置目录和设置文件路径
configDir := filepath.Join(homeDir, ".voidraft", "config")
settingsPath := filepath.Join(configDir, "settings.json")
observerService := NewConfigObserver(logger)
configMigrator := NewConfigMigrator(logger, configDir, "settings", settingsPath)
return &ConfigService{
logger: logger,
configDir: configDir,
settingsPath: settingsPath,
koanf: koanf.New("."),
observer: observerService,
configMigrator: configMigrator,
observer: helper.NewConfigObserver(logger),
configMigrator: NewConfigMigrator(logger, configDir, "settings", settingsPath),
}
}
// ServiceStartup initializes the service when the application starts
// ServiceStartup 服务启动时初始化
func (cs *ConfigService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
err := cs.initConfig()
if err != nil {
if err := cs.initConfig(); err != nil {
panic(err)
}
// 启动配置文件监听
cs.startWatching()
return nil
}
// setDefaults 设置默认配置
func (cs *ConfigService) setDefaults() error {
defaultConfig := models.NewDefaultAppConfig()
if err := cs.koanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
return err
}
return nil
}
@@ -87,15 +65,38 @@ func (cs *ConfigService) initConfig() error {
cs.mu.Lock()
defer cs.mu.Unlock()
// 检查配置文件是否存在
// 确保配置目录存在
if err := os.MkdirAll(cs.configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// 配置文件不存在,创建默认配置
if _, err := os.Stat(cs.settingsPath); os.IsNotExist(err) {
return cs.createDefaultConfig()
}
// 配置文件存在,直接加载现有配置
cs.fileProvider = file.Provider(cs.settingsPath)
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
return err
// 加载现有配置
if err := cs.koanf.Load(file.Provider(cs.settingsPath), jsonparser.Parser()); err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
return nil
}
// createDefaultConfig 创建默认配置
func (cs *ConfigService) createDefaultConfig() error {
// 重置 koanf 实例
cs.koanf = koanf.New(".")
// 加载默认配置
defaultConfig := models.NewDefaultAppConfig()
if err := cs.koanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
return fmt.Errorf("failed to load default config: %w", err)
}
// 写入配置文件
if err := cs.writeConfigToFile(); err != nil {
return fmt.Errorf("failed to write default config: %w", err)
}
return nil
@@ -107,65 +108,12 @@ func (cs *ConfigService) MigrateConfig() error {
return nil
}
cs.mu.Lock()
defer cs.mu.Unlock()
defaultConfig := models.NewDefaultAppConfig()
_, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
if err != nil {
return err
}
return nil
}
// createDefaultConfig 创建默认配置文件
func (cs *ConfigService) createDefaultConfig() error {
// 确保配置目录存在
if err := os.MkdirAll(cs.configDir, 0755); err != nil {
return err
}
if err := cs.setDefaults(); err != nil {
return err
}
if err := cs.writeConfigToFile(); err != nil {
return err
}
// 创建文件提供器
cs.fileProvider = file.Provider(cs.settingsPath)
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
return err
}
return nil
}
// startWatching 启动配置文件监听
func (cs *ConfigService) startWatching() {
if cs.fileProvider == nil {
return
}
cs.fileProvider.Watch(func(event interface{}, err error) {
if err != nil {
return
}
cs.mu.Lock()
oldSnapshot := cs.createConfigSnapshot()
cs.koanf.Load(cs.fileProvider, jsonparser.Parser())
newSnapshot := cs.createConfigSnapshot()
cs.mu.Unlock()
// 检测配置变更并通知观察者
cs.notifyChanges(oldSnapshot, newSnapshot)
})
}
// stopWatching 停止配置文件监听
func (cs *ConfigService) stopWatching() {
if cs.fileProvider != nil {
cs.fileProvider.Unwatch()
}
return err
}
// GetConfig 获取完整应用配置
@@ -177,47 +125,9 @@ func (cs *ConfigService) GetConfig() (*models.AppConfig, error) {
if err := cs.koanf.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil {
return nil, err
}
return &config, nil
}
// Set 设置配置项
func (cs *ConfigService) Set(key string, value interface{}) error {
cs.mu.Lock()
// 获取旧值用于回滚
oldValue := cs.koanf.Get(key)
// 设置值到koanf
cs.koanf.Set(key, value)
// 更新时间戳
newTimestamp := time.Now().Format(time.RFC3339)
cs.koanf.Set("metadata.lastUpdated", newTimestamp)
// 将配置写回文件
err := cs.writeConfigToFile()
if err != nil {
// 写文件失败,回滚内存状态
if oldValue != nil {
cs.koanf.Set(key, oldValue)
} else {
cs.koanf.Delete(key)
}
cs.mu.Unlock()
return err
}
cs.mu.Unlock()
if cs.observer != nil {
cs.observer.Notify(key, oldValue, value)
}
return nil
}
// Get 获取配置项
func (cs *ConfigService) Get(key string) interface{} {
cs.mu.RLock()
@@ -225,118 +135,113 @@ func (cs *ConfigService) Get(key string) interface{} {
return cs.koanf.Get(key)
}
// ResetConfig 强制重置所有配置为默认值
// Set 设置配置项
func (cs *ConfigService) Set(key string, value interface{}) error {
cs.mu.Lock()
// 获取旧值
oldValue := cs.koanf.Get(key)
// 值未变化,直接返回
if reflect.DeepEqual(oldValue, value) {
cs.mu.Unlock()
return nil
}
// 设置新值
err := cs.koanf.Set(key, value)
if err != nil {
cs.mu.Unlock()
return err
}
err = cs.koanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
if err != nil {
cs.mu.Unlock()
return err
}
// 写入文件
if err = cs.writeConfigToFile(); err != nil {
cs.mu.Unlock()
return fmt.Errorf("failed to write config: %w", err)
}
cs.mu.Unlock()
// 通知观察者
if cs.observer != nil {
cs.observer.Notify(key, oldValue, value)
} else {
cs.logger.Error("config observer is nil")
}
return nil
}
// ResetConfig 重置所有配置为默认值
func (cs *ConfigService) ResetConfig() error {
cs.mu.Lock()
// 保存旧配置快照
oldSnapshot := cs.createConfigSnapshot()
oldSnapshot := cs.createSnapshot()
// 停止文件监听
if cs.fileProvider != nil {
cs.fileProvider.Unwatch()
cs.fileProvider = nil
}
// 设置默认配置
if err := cs.setDefaults(); err != nil {
// 重置为默认配置
cs.koanf = koanf.New(".")
defaultConfig := models.NewDefaultAppConfig()
if err := cs.koanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
cs.mu.Unlock()
return err
return fmt.Errorf("failed to load default config: %w", err)
}
// 写入配置文件
if err := cs.writeConfigToFile(); err != nil {
cs.mu.Unlock()
return err
return fmt.Errorf("failed to write config: %w", err)
}
// 重新创建koanf实例
cs.koanf = koanf.New(".")
// 重新加载默认配置到koanf
if err := cs.setDefaults(); err != nil {
cs.mu.Unlock()
return err
}
// 重新创建文件提供器
cs.fileProvider = file.Provider(cs.settingsPath)
// 重新加载配置文件
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
cs.mu.Unlock()
return err
}
newSnapshot := cs.createConfigSnapshot()
newSnapshot := cs.createSnapshot()
cs.mu.Unlock()
// 重新启动文件监听
cs.startWatching()
// 检测配置变更并通知观察者
// 通知配置变更
cs.notifyChanges(oldSnapshot, newSnapshot)
return nil
}
// writeConfigToFile 将配置写回JSON文件
// writeConfigToFile 将配置写文件
func (cs *ConfigService) writeConfigToFile() error {
configBytes, err := cs.koanf.Marshal(jsonparser.Parser())
if err != nil {
return err
}
if err := os.WriteFile(cs.settingsPath, configBytes, 0644); err != nil {
return err
}
return nil
return os.WriteFile(cs.settingsPath, configBytes, 0644)
}
// Watch 注册配置变更监听器
func (cs *ConfigService) Watch(path string, callback ObserverCallback) CancelFunc {
func (cs *ConfigService) Watch(path string, callback helper.ObserverCallback) helper.CancelFunc {
return cs.observer.Watch(path, callback)
}
// WatchWithContext 使用 Context 注册监听器
func (cs *ConfigService) WatchWithContext(ctx context.Context, path string, callback ObserverCallback) {
func (cs *ConfigService) WatchWithContext(ctx context.Context, path string, callback helper.ObserverCallback) {
cs.observer.WatchWithContext(ctx, path, callback)
}
// createConfigSnapshot 创建当前配置快照(调用者需确保已持有锁)
func (cs *ConfigService) createConfigSnapshot() map[string]interface{} {
// createSnapshotLocked 创建配置快照
func (cs *ConfigService) createSnapshot() map[string]interface{} {
snapshot := make(map[string]interface{})
allKeys := cs.koanf.All()
flattenMap("", allKeys, snapshot)
return snapshot
}
// flattenMap 递归展平嵌套的 map使用 strings.Builder 优化字符串拼接)
func flattenMap(prefix string, data map[string]interface{}, result map[string]interface{}) {
var builder strings.Builder
for key, value := range data {
builder.Reset()
if prefix != "" {
builder.WriteString(prefix)
builder.WriteString(".")
}
builder.WriteString(key)
fullKey := builder.String()
if valueMap, ok := value.(map[string]interface{}); ok {
// 递归处理嵌套 map
flattenMap(fullKey, valueMap, result)
} else {
// 保存叶子节点
result[fullKey] = value
}
for _, key := range cs.koanf.Keys() {
snapshot[key] = cs.koanf.Get(key)
}
return snapshot
}
// notifyChanges 检测配置变更并通知观察者
func (cs *ConfigService) notifyChanges(oldSnapshot, newSnapshot map[string]interface{}) {
// 检测变更
if cs.observer == nil {
return
}
changes := make(map[string]struct {
OldValue interface{}
NewValue interface{}
@@ -345,14 +250,11 @@ func (cs *ConfigService) notifyChanges(oldSnapshot, newSnapshot map[string]inter
// 检查新增和修改的键
for key, newValue := range newSnapshot {
oldValue, exists := oldSnapshot[key]
if !exists || !isEqual(oldValue, newValue) {
if !exists || !reflect.DeepEqual(oldValue, newValue) {
changes[key] = struct {
OldValue interface{}
NewValue interface{}
}{
OldValue: oldValue,
NewValue: newValue,
}
}{oldValue, newValue}
}
}
@@ -362,29 +264,18 @@ func (cs *ConfigService) notifyChanges(oldSnapshot, newSnapshot map[string]inter
changes[key] = struct {
OldValue interface{}
NewValue interface{}
}{
OldValue: oldValue,
NewValue: nil,
}
}{oldValue, nil}
}
}
// 通知所有变更
if cs.observer != nil && len(changes) > 0 {
// 批量通知
if len(changes) > 0 {
cs.observer.NotifyAll(changes)
}
}
// isEqual 值相等比较
func isEqual(a, b interface{}) bool {
aJSON, _ := json.Marshal(a)
bJSON, _ := json.Marshal(b)
return string(aJSON) == string(bJSON)
}
// ServiceShutdown 关闭服务
func (cs *ConfigService) ServiceShutdown() error {
cs.stopWatching()
if cs.observer != nil {
cs.observer.Shutdown()
}

View File

@@ -24,9 +24,10 @@ type MigrationProgress struct {
// MigrationService 迁移服务
type MigrationService struct {
logger *log.LogService
dbService *DatabaseService
progress atomic.Value // stores MigrationProgress
logger *log.LogService
dbService *DatabaseService
configService *ConfigService
progress atomic.Value // stores MigrationProgress
mu sync.Mutex
ctx context.Context
@@ -34,13 +35,14 @@ type MigrationService struct {
}
// NewMigrationService 创建迁移服务
func NewMigrationService(dbService *DatabaseService, logger *log.LogService) *MigrationService {
func NewMigrationService(dbService *DatabaseService, configService *ConfigService, logger *log.LogService) *MigrationService {
if logger == nil {
logger = log.New()
}
ms := &MigrationService{
logger: logger,
dbService: dbService,
logger: logger,
dbService: dbService,
configService: configService,
}
ms.progress.Store(MigrationProgress{})
return ms
@@ -94,9 +96,12 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
if err := ms.dbService.ServiceShutdown(); err != nil {
ms.logger.Error("Failed to close database connection", "error", err)
}
// 等待文件句柄释放Windows 特有问题)
time.Sleep(200 * time.Millisecond)
}
// 确保失败时恢复数据库连接
// 确保恢复数据库连接
defer func() {
if ms.dbService != nil {
if err := ms.dbService.ServiceStartup(ctx, application.ServiceOptions{}); err != nil {
@@ -110,17 +115,59 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
return ms.fail(err)
}
// 迁移成功后,立即更新配置到新路径
if ms.configService != nil {
if err := ms.configService.Set("general.dataPath", dstPath); err != nil {
return ms.fail(fmt.Errorf("migration succeeded but failed to update config: %w", err))
}
}
ms.setProgress(100)
return nil
}
// preCheck 预检查,返回是否需要迁移
func (ms *MigrationService) preCheck(srcPath, dstPath string) (bool, error) {
// 源目录不存在,无需迁移
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
// 检查源目录状态
srcStat, srcErr := os.Stat(srcPath)
srcNotExist := os.IsNotExist(srcErr)
// 检查目标目录状态
dstStat, dstErr := os.Stat(dstPath)
dstNotExist := os.IsNotExist(dstErr)
// 1源目录不存在
if srcNotExist {
// 如果目标目录存在且有内容,说明迁移已经完成
if !dstNotExist && dstStat.IsDir() {
isEmpty, err := isDirEmpty(dstPath)
if err == nil && !isEmpty {
ms.logger.Info("Migration already completed, source not exist but target has content", "dst", dstPath)
return false, nil // 无需迁移
}
}
// 源不存在且目标也不存在/为空,无需迁移
return false, nil
}
// 2. 源目录存在但为空
if srcStat.IsDir() {
srcEmpty, err := isDirEmpty(srcPath)
if err == nil && srcEmpty {
// 源为空,目标有内容 → 迁移已完成
if !dstNotExist && dstStat.IsDir() {
dstEmpty, _ := isDirEmpty(dstPath)
if !dstEmpty {
ms.logger.Info("Migration already completed, source is empty but target has content", "dst", dstPath)
return false, nil
}
}
// 源为空,目标也为空 → 无需迁移
ms.logger.Info("Both source and target are empty, no migration needed")
return false, nil
}
}
// 路径相同,无需迁移
srcAbs, _ := filepath.Abs(srcPath)
dstAbs, _ := filepath.Abs(dstPath)

View File

@@ -26,7 +26,7 @@ func NewDialogService(logger *log.LogService) *DialogService {
// SelectDirectory 打开目录选择对话框
func (ds *DialogService) SelectDirectory() (string, error) {
dialog := application.OpenFileDialog()
dialog := application.OpenFileDialogStruct{}
dialog.SetOptions(&application.OpenFileDialogOptions{
// 目录选择配置
CanChooseDirectories: true, // 允许选择目录
@@ -67,7 +67,7 @@ func (ds *DialogService) SelectDirectory() (string, error) {
// SelectFile 打开文件选择对话框
func (ds *DialogService) SelectFile() (string, error) {
dialog := application.OpenFileDialog()
dialog := application.OpenFileDialogStruct{}
dialog.SetOptions(&application.OpenFileDialogOptions{
// 目录选择配置
CanChooseDirectories: false, // 允许选择目录

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"sync"
"sync/atomic"
"time"
"voidraft/internal/common/helper"
"voidraft/internal/common/hotkey"
"voidraft/internal/models"
@@ -33,7 +32,7 @@ type HotkeyService struct {
isShutdown atomic.Bool
// 配置观察者取消函数
cancelObservers []CancelFunc
cancelObservers []helper.CancelFunc
}
// NewHotkeyService 创建热键服务实例
@@ -61,7 +60,7 @@ func (hs *HotkeyService) ServiceStartup(ctx context.Context, options application
// Initialize 初始化热键服务
func (hs *HotkeyService) Initialize() error {
// 注册配置监听
hs.cancelObservers = []CancelFunc{
hs.cancelObservers = []helper.CancelFunc{
hs.configService.Watch("general.enableGlobalHotkey", hs.onHotkeyConfigChange),
hs.configService.Watch("general.globalHotkey", hs.onHotkeyConfigChange),
}
@@ -84,11 +83,14 @@ func (hs *HotkeyService) onHotkeyConfigChange(oldValue, newValue interface{}) {
// 重新加载配置
config, err := hs.configService.GetConfig()
if err != nil {
hs.logger.Error("failed to get config", "error", err)
return
}
// 更新热键
_ = hs.UpdateHotkey(config.General.EnableGlobalHotkey, &config.General.GlobalHotkey)
if err := hs.UpdateHotkey(config.General.EnableGlobalHotkey, &config.General.GlobalHotkey); err != nil {
hs.logger.Error("failed to update hotkey", "error", err)
}
}
// RegisterHotkey 注册全局热键
@@ -101,22 +103,34 @@ func (hs *HotkeyService) RegisterHotkey(combo *models.HotkeyCombo) error {
return errors.New("invalid hotkey combination")
}
// 如果已注册,先取消
if hs.registered.Load() {
_ = hs.UnregisterHotkey()
}
// 转换为 hotkey 库的格式
key, mods, err := hs.convertHotkey(combo)
if err != nil {
return fmt.Errorf("convert hotkey: %w", err)
}
hs.mu.Lock()
// 如果已注册,异步清理旧热键
if hs.registered.Load() {
hs.mu.RLock()
oldHk := hs.hk
hs.mu.RUnlock()
if oldHk != nil {
// 异步清理,不阻塞当前流程
go func() {
if err := oldHk.Close(); err != nil {
hs.logger.Error("failed to close old hotkey (ignored)", "error", err)
}
}()
}
}
// 创建新的热键实例
hs.mu.Lock()
hs.hk = hotkey.New(mods, key)
if err := hs.hk.Register(); err != nil {
hs.mu.Unlock()
hs.logger.Error("failed to register hotkey", "error", err)
return fmt.Errorf("register hotkey: %w", err)
}
@@ -141,35 +155,23 @@ func (hs *HotkeyService) UnregisterHotkey() error {
hs.registered.Store(false)
// 获取热键实例的引用
hs.mu.RLock()
hs.mu.Lock()
hk := hs.hk
hs.mu.RUnlock()
hs.hk = nil
hs.currentHotkey = nil
hs.mu.Unlock()
if hk == nil {
return nil
}
// 调用 Close() 确保完全清理
_ = hk.Close()
// 等待监听 goroutine 退出
done := make(chan struct{})
// 异步清理
go func() {
hs.wg.Wait()
close(done)
if err := hk.Close(); err != nil {
hs.logger.Error("failed to close hotkey (ignored)", "error", err)
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
}
// 清理状态
hs.mu.Lock()
hs.hk = nil
hs.currentHotkey = nil
hs.mu.Unlock()
return nil
}
@@ -321,6 +323,24 @@ func (hs *HotkeyService) showAllWindows() {
}
}
// GetSupportedKeys 返回系统支持的快捷键列表
func (hs *HotkeyService) GetSupportedKeys() []string {
// 返回当前系统支持的所有键
// 这个列表与 convertKey 方法中的 keyMap 保持一致
return []string{
// 字母键
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
// 数字键
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
// 功能键
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
// 特殊键
"Space", "Tab", "Enter", "Escape", "Delete",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
}
}
// isValidHotkey 验证热键组合
func (hs *HotkeyService) isValidHotkey(combo *models.HotkeyCombo) bool {
if combo == nil || combo.Key == "" {
@@ -333,24 +353,6 @@ func (hs *HotkeyService) isValidHotkey(combo *models.HotkeyCombo) bool {
return true
}
// GetCurrentHotkey 获取当前热键
func (hs *HotkeyService) GetCurrentHotkey() *models.HotkeyCombo {
hs.mu.RLock()
defer hs.mu.RUnlock()
if hs.currentHotkey == nil {
return nil
}
return &models.HotkeyCombo{
Ctrl: hs.currentHotkey.Ctrl,
Shift: hs.currentHotkey.Shift,
Alt: hs.currentHotkey.Alt,
Win: hs.currentHotkey.Win,
Key: hs.currentHotkey.Key,
}
}
// IsRegistered 检查是否已注册
func (hs *HotkeyService) IsRegistered() bool {
return hs.registered.Load()

View File

@@ -1,387 +0,0 @@
package services
import (
"testing"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// TestHotkeyServiceCreation 测试服务创建
func TestHotkeyServiceCreation(t *testing.T) {
logger := log.New()
configService := &ConfigService{} // Mock
service := NewHotkeyService(configService, logger)
if service == nil {
t.Fatal("Failed to create hotkey service")
}
if service.logger == nil {
t.Error("Logger should not be nil")
}
if service.registered.Load() {
t.Error("Service should not have registered hotkey initially")
}
}
// TestHotkeyValidation 测试热键验证
func TestHotkeyValidation(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
tests := []struct {
name string
combo *models.HotkeyCombo
valid bool
}{
{
name: "Nil combo",
combo: nil,
valid: false,
},
{
name: "Empty key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "",
},
valid: false,
},
{
name: "No modifiers",
combo: &models.HotkeyCombo{
Key: "A",
},
valid: false,
},
{
name: "Valid: Ctrl+A",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "A",
},
valid: true,
},
{
name: "Valid: Ctrl+Shift+F1",
combo: &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "F1",
},
valid: true,
},
{
name: "Valid: Alt+Space",
combo: &models.HotkeyCombo{
Alt: true,
Key: "Space",
},
valid: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.isValidHotkey(tt.combo)
if result != tt.valid {
t.Errorf("Expected valid=%v, got %v", tt.valid, result)
}
})
}
}
// TestHotkeyConversion 测试热键转换
func TestHotkeyConversion(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
tests := []struct {
name string
combo *models.HotkeyCombo
wantErr bool
}{
{
name: "Valid letter key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "A",
},
wantErr: false,
},
{
name: "Valid number key",
combo: &models.HotkeyCombo{
Shift: true,
Key: "1",
},
wantErr: false,
},
{
name: "Valid function key",
combo: &models.HotkeyCombo{
Alt: true,
Key: "F5",
},
wantErr: false,
},
{
name: "Invalid key",
combo: &models.HotkeyCombo{
Ctrl: true,
Key: "InvalidKey",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, mods, err := service.convertHotkey(tt.combo)
if tt.wantErr {
if err == nil {
t.Error("Expected error, got nil")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if key == 0 {
t.Error("Key should not be 0")
}
if len(mods) == 0 {
t.Error("Should have at least one modifier")
}
})
}
}
// TestHotkeyRegisterUnregister 测试注册和注销
func TestHotkeyRegisterUnregister(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "F10",
}
// 测试注册
err := service.RegisterHotkey(combo)
if err != nil {
t.Logf("Register failed (may be expected in test environment): %v", err)
return
}
if !service.IsRegistered() {
t.Error("Service should be registered")
}
// 验证当前热键
current := service.GetCurrentHotkey()
if current == nil {
t.Error("Current hotkey should not be nil")
}
if current.Key != combo.Key {
t.Errorf("Expected key %s, got %s", combo.Key, current.Key)
}
// 测试注销
err = service.UnregisterHotkey()
if err != nil {
t.Fatalf("Unregister failed: %v", err)
}
if service.IsRegistered() {
t.Error("Service should not be registered after unregister")
}
current = service.GetCurrentHotkey()
if current != nil {
t.Error("Current hotkey should be nil after unregister")
}
}
// TestHotkeyUpdate 测试更新热键
func TestHotkeyUpdate(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo1 := &models.HotkeyCombo{
Ctrl: true,
Key: "F11",
}
// 启用热键
err := service.UpdateHotkey(true, combo1)
if err != nil {
t.Logf("Update (enable) failed: %v", err)
return
}
defer service.UnregisterHotkey()
if !service.IsRegistered() {
t.Error("Should be registered after enable")
}
// 禁用热键
err = service.UpdateHotkey(false, combo1)
if err != nil {
t.Fatalf("Update (disable) failed: %v", err)
}
if service.IsRegistered() {
t.Error("Should not be registered after disable")
}
}
// TestHotkeyDoubleRegister 测试重复注册
func TestHotkeyDoubleRegister(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Alt: true,
Key: "F12",
}
err := service.RegisterHotkey(combo)
if err != nil {
t.Skip("First registration failed")
}
defer service.UnregisterHotkey()
// 第二次注册应该先取消第一次注册,然后重新注册
combo2 := &models.HotkeyCombo{
Shift: true,
Key: "F12",
}
err = service.RegisterHotkey(combo2)
if err != nil {
t.Logf("Second registration failed: %v", err)
}
// 验证当前热键是新的
current := service.GetCurrentHotkey()
if current != nil && current.Shift != combo2.Shift {
t.Error("Should have updated to new hotkey")
}
}
// TestHotkeyConcurrentAccess 测试并发访问
func TestHotkeyConcurrentAccess(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Key: "G",
}
const goroutines = 10
done := make(chan bool, goroutines)
// 并发读取
for i := 0; i < goroutines; i++ {
go func() {
for j := 0; j < 100; j++ {
_ = service.IsRegistered()
_ = service.GetCurrentHotkey()
time.Sleep(time.Millisecond)
}
done <- true
}()
}
// 主协程进行注册/注销操作
go func() {
for i := 0; i < 5; i++ {
service.RegisterHotkey(combo)
time.Sleep(50 * time.Millisecond)
service.UnregisterHotkey()
time.Sleep(50 * time.Millisecond)
}
}()
// 等待所有 goroutine 完成
for i := 0; i < goroutines; i++ {
<-done
}
t.Log("Concurrent access test completed without panics")
}
// TestHotkeyServiceShutdown 测试服务关闭
func TestHotkeyServiceShutdown(t *testing.T) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Key: "H",
}
err := service.RegisterHotkey(combo)
if err != nil {
t.Skip("Registration failed")
}
// 测试 ServiceShutdown
err = service.ServiceShutdown()
if err != nil {
t.Fatalf("ServiceShutdown failed: %v", err)
}
if service.IsRegistered() {
t.Error("Should not be registered after shutdown")
}
}
// BenchmarkHotkeyRegistration 基准测试:热键注册
func BenchmarkHotkeyRegistration(b *testing.B) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Key: "B",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
service.RegisterHotkey(combo)
service.UnregisterHotkey()
}
}
// BenchmarkHotkeyConversion 基准测试:热键转换
func BenchmarkHotkeyConversion(b *testing.B) {
logger := log.New()
service := NewHotkeyService(&ConfigService{}, logger)
combo := &models.HotkeyCombo{
Ctrl: true,
Shift: true,
Alt: true,
Key: "F5",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
service.convertHotkey(combo)
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/wailsapp/wails/v3/pkg/services/dock"
"github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"log/slog"
)
// ServiceManager 服务管理器,负责协调各个服务
@@ -36,7 +37,9 @@ type ServiceManager struct {
// NewServiceManager 创建新的服务管理器实例
func NewServiceManager() *ServiceManager {
// 初始化日志服务
logger := log.New()
logger := log.NewWithConfig(&log.Config{
LogLevel: slog.LevelDebug,
})
// 初始化badge服务
badgeService := dock.New()
@@ -51,7 +54,7 @@ func NewServiceManager() *ServiceManager {
databaseService := NewDatabaseService(configService, logger)
// 初始化迁移服务
migrationService := NewMigrationService(databaseService, logger)
migrationService := NewMigrationService(databaseService, configService, logger)
// 初始化文档服务
documentService := NewDocumentService(databaseService, logger)

View File

@@ -113,7 +113,7 @@ func (ws *WindowService) onWindowClosing(documentID int64) {
}
// GetOpenWindows 获取所有打开的文档窗口
func (ws *WindowService) GetOpenWindows() []application.Window {
func (ws *WindowService) getOpenWindows() []application.Window {
app := application.Get()
return app.Window.GetAll()
}
@@ -130,7 +130,7 @@ func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool {
func (ws *WindowService) ServiceShutdown() error {
// 从吸附服务中取消注册所有窗口
if ws.windowSnapService != nil {
windows := ws.GetOpenWindows()
windows := ws.getOpenWindows()
for _, window := range windows {
if documentID, err := strconv.ParseInt(window.Name(), 10, 64); err == nil {
ws.windowSnapService.UnregisterWindow(documentID)

View File

@@ -91,7 +91,7 @@ type WindowSnapService struct {
windowMoveUnhooks map[int64]func() // documentID -> 子窗口移动监听清理函数
// 配置观察者取消函数
cancelObserver CancelFunc
cancelObserver helper.CancelFunc
}
// NewWindowSnapService 创建新的窗口吸附服务实例