15 Commits

Author SHA1 Message Date
8e2bafba5f Optimize window snapping performance 2025-09-04 00:20:30 +08:00
6149bc133d 🐛 Fixed window pinning issue 2025-09-02 23:59:04 +08:00
5f22ee3b1f ♻️ Refactoring configuration migration service 2025-08-31 17:48:41 +08:00
fa72ff8061 Merge remote-tracking branch 'github/dependabot/go_modules/github.com/ulikunitz/xz-0.5.14' 2025-08-30 19:00:36 +08:00
65f24860e6 Added window snapping function toggle 2025-08-30 00:18:29 +08:00
dependabot[bot]
4881233211 ⬆️ Bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14
Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.12 to 0.5.14.
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14)

---
updated-dependencies:
- dependency-name: github.com/ulikunitz/xz
  dependency-version: 0.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-28 19:44:41 +00:00
bc01fdf362 Added window snapping feature 2025-08-24 16:07:48 +08:00
709998ff9c 🐛 Fixed hotkey crashes 2025-08-19 22:26:55 +08:00
6adeadeed4 🐛 Fixed document deadlock issue 2025-08-19 00:24:51 +08:00
7b70a39b23 🐛 Fixed hotkey service issues 2025-08-19 00:08:50 +08:00
873a3c0e60 Modify the theme storage schema 2025-08-17 19:34:35 +08:00
5b88efcfbe 🐛 Fixed the window toggle maximise issue 2025-08-17 14:51:39 +08:00
f37c659c89 🐛 Adjusted error message and icon clearing logic 2025-07-17 11:03:42 +08:00
9fff7bcfca Added the backup feature 2025-07-17 00:12:00 +08:00
b4b0ad9bba 🎨 Optimize storage logic 2025-07-13 22:32:58 +08:00
96 changed files with 5133 additions and 2173 deletions

View File

@@ -1,10 +1,10 @@
# <img src="./frontend/public/appicon.png" alt="VoidRaft Logo" width="32" height="32" style="vertical-align: middle;"> VoidRaft
# <img src="./frontend/public/appicon.png" alt="voidraft Logo" width="32" height="32" style="vertical-align: middle;"> voidraft
[中文](README_ZH.md) | **English**
> *An elegant text snippet recording tool designed for developers.*
VoidRaft is a modern developer-focused text editor that allows you to record, organize, and manage various text snippets anytime, anywhere. Whether it's temporary code snippets, API responses, meeting notes, or daily to-do lists, VoidRaft provides a smooth and elegant editing experience.
voidraft is a modern developer-focused text editor that allows you to record, organize, and manage various text snippets anytime, anywhere. Whether it's temporary code snippets, API responses, meeting notes, or daily to-do lists, voidraft provides a smooth and elegant editing experience.
## Core Features
@@ -87,7 +87,7 @@ After building, the executable will be generated in the `bin` directory.
## Project Structure
```
Voidraft/
voidraft/
├── frontend/ # Vue 3 frontend application
│ ├── src/
│ │ ├── views/editor/ # Editor core views
@@ -121,19 +121,19 @@ Voidraft/
### Planned Features
- ✅ Custom themes - Customize editor themes
- ✅ Multi-window support - Support editing multiple documents simultaneously
- ✅ Data synchronization - Cloud backup for documents
- [ ] Enhanced clipboard - Monitor and manage clipboard history
- [ ] Data synchronization - Cloud backup for configurations and documents
- [ ] Extension system - Support for custom plugins
## Acknowledgments
> Standing on the shoulders of giants, paying tribute to the open source spirit
The birth of VoidRaft is inseparable from the following excellent open source projects:
The birth of voidraft is inseparable from the following excellent open source projects:
### Special Thanks
- **[Heynote](https://github.com/heyman/heynote/)** - VoidRaft is a feature-enhanced version based on Heynote's concept
- **[Heynote](https://github.com/heyman/heynote/)** - voidraft is a feature-enhanced version based on Heynote's concept
- Inherits Heynote's elegant block editing philosophy
- Adds more practical features on the original foundation
- Rebuilt with modern technology stack
@@ -157,7 +157,7 @@ This project is open source under the [MIT License](LICENSE).
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)
[![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)
*Made with ❤️ by landaiqing*

View File

@@ -1,10 +1,10 @@
# <img src="./frontend/public/appicon.png" alt="Voidraft Logo" width="32" height="32" style="vertical-align: middle;"> Voidraft
# <img src="./frontend/public/appicon.png" alt="voidraft Logo" width="32" height="32" style="vertical-align: middle;"> voidraft
**中文** | [English](README.md)
> *一个专为开发者打造的优雅文本片段记录工具。*
Voidraft 是一个现代化的开发者专用文本编辑器让你能够随时随地记录、整理和管理各种文本片段。无论是临时的代码片段、API 响应、会议笔记,还是日常的待办事项,Voidraft 都能为你提供流畅而优雅的编辑体验。
voidraft 是一个现代化的开发者专用文本编辑器让你能够随时随地记录、整理和管理各种文本片段。无论是临时的代码片段、API 响应、会议笔记,还是日常的待办事项,voidraft 都能为你提供流畅而优雅的编辑体验。
## 核心特性
@@ -88,7 +88,7 @@ wails3 package
## 项目结构
```
Voidraft/
voidraft/
├── frontend/ # Vue 3 前端应用
│ ├── src/
│ │ ├── views/editor/ # 编辑器核心视图
@@ -122,8 +122,8 @@ Voidraft/
### 计划添加的功能
- ✅ 自定义主题 - 自定义编辑器主题
- ✅ 多窗口支持 - 支持同时编辑多个文档
- ✅ 数据同步 - 文档云端备份
- [ ] 剪切板增强 - 监听和管理剪切板历史
- [ ] 数据同步 - 配置和文档云端备份
- [ ] 扩展系统 - 支持自定义插件
@@ -131,11 +131,11 @@ Voidraft/
> 站在巨人的肩膀上,致敬开源精神
Voidraft 的诞生离不开以下优秀的开源项目:
voidraft 的诞生离不开以下优秀的开源项目:
### 特别感谢
- **[Heynote](https://github.com/heyman/heynote/)** - Voidraft 是基于 Heynote 概念的功能增强版本
- **[Heynote](https://github.com/heyman/heynote/)** - voidraft 是基于 Heynote 概念的功能增强版本
- 继承了 Heynote 优雅的块状编辑理念
- 在原有基础上增加了更多实用功能
- 采用现代化技术栈重新构建
@@ -159,7 +159,7 @@ Voidraft 的诞生离不开以下优秀的开源项目:
欢迎 Fork、Star 和贡献代码。
[![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)
[![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)
*Made with ❤️ by landaiqing*

View File

@@ -12,13 +12,25 @@ vars:
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
version:
summary: Generate version information
cmds:
- '{{if eq OS "windows"}}cmd /c ".\scripts\version.bat"{{else}}bash ./scripts/version.sh{{end}}'
sources:
- scripts/version.bat
- scripts/version.sh
generates:
- version.txt
build:
summary: Builds the application
deps: [version]
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
deps: [version]
cmds:
- task: "{{OS}}:package"

View File

@@ -5,12 +5,12 @@ version: '3'
# This information is used to generate the build assets.
info:
companyName: "Voidraft" # The name of the company
productName: "Voidraft" # The name of the application
companyName: "voidraft" # The name of the company
productName: "voidraft" # The name of the application
productIdentifier: "landaiqing" # The unique product identifier
description: "Voidraft" # The application description
copyright: "© 2025 Voidraft. All rights reserved." # Copyright text
comments: "Voidraft" # Comments
description: "voidraft" # The application description
copyright: "© 2025 voidraft. All rights reserved." # Copyright text
comments: "voidraft" # Comments
version: "0.0.1.0" # The application version
# Dev mode configuration

View File

@@ -4,15 +4,15 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleExecutable</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>
@@ -22,7 +22,7 @@
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Voidraft. All rights reserved.</string>
<string>© 2025 voidraft. All rights reserved.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>

View File

@@ -4,15 +4,15 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleExecutable</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleIdentifier</key>
<string>landaiqing</string>
<key>CFBundleVersion</key>
<string>0.0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>Voidraft</string>
<string>voidraft</string>
<key>CFBundleShortVersionString</key>
<string>0.0.1.0</string>
<key>CFBundleIconFile</key>
@@ -22,6 +22,6 @@
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 Voidraft. All rights reserved.</string>
<string>© 2025 voidraft. All rights reserved.</string>
</dict>
</plist>

View File

@@ -11,9 +11,12 @@ tasks:
- task: common:build:frontend
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
VERSION_FLAGS:
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:

View File

@@ -11,9 +11,12 @@ tasks:
- task: common:build:frontend
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s{{else}}{{end}}'
VERSION_FLAGS:
sh: 'grep "VERSION=" version.txt | cut -d"=" -f2 | xargs -I {} echo "-X voidraft/internal/version.Version={}"'
env:
GOOS: linux
CGO_ENABLED: 1

View File

@@ -3,26 +3,26 @@
#
# The lines below are called `modelines`. See `:help modeline`
name: "Voidraft"
name: "voidraft"
arch: ${GOARCH}
platform: "linux"
version: "0.0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "Voidraft"
vendor: "Voidraft"
homepage: "https://wails.io"
description: "voidraft"
vendor: "voidraft"
homepage: "https://voidraft.landaiqing.cn"
license: "MIT"
release: "1"
contents:
- src: "./bin/Voidraft"
dst: "/usr/local/bin/Voidraft"
- src: "./bin/voidraft"
dst: "/usr/local/bin/voidraft"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/Voidraft.png"
- src: "./build/linux/Voidraft.desktop"
dst: "/usr/share/applications/Voidraft.desktop"
dst: "/usr/share/icons/hicolor/128x128/apps/voidraft.png"
- src: "./build/linux/voidraft.desktop"
dst: "/usr/share/applications/voidraft.desktop"
depends:
- gtk3

View File

@@ -14,13 +14,16 @@ tasks:
- task: common:generate:icons
cmds:
- task: generate:syso
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- go build {{.BUILD_FLAGS}} -ldflags="{{.LDFLAGS}} {{.VERSION_FLAGS}}" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
LDFLAGS: '{{if eq .PRODUCTION "true"}}-w -s -H windowsgui{{else}}{{end}}'
VERSION_FLAGS:
sh: 'powershell -Command "(Get-Content version.txt) -replace ''VERSION='', ''-X voidraft/internal/version.Version=''"'
env:
GOOS: windows
CGO_ENABLED: 1

View File

@@ -5,11 +5,11 @@
"info": {
"0000": {
"ProductVersion": "0.0.1.0",
"CompanyName": "Voidraft",
"FileDescription": "Voidraft",
"LegalCopyright": "© 2025 Voidraft. All rights reserved.",
"ProductName": "Voidraft",
"Comments": "Voidraft"
"CompanyName": "voidraft",
"FileDescription": "voidraft",
"LegalCopyright": "© 2025 voidraft. All rights reserved.",
"ProductName": "voidraft",
"Comments": "voidraft"
}
}
}

View File

@@ -5,19 +5,19 @@
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "Voidraft"
!define INFO_PROJECTNAME "voidraft"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "Voidraft"
!define INFO_COMPANYNAME "voidraft"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "Voidraft"
!define INFO_PRODUCTNAME "voidraft"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.0.1.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2025 Voidraft. All rights reserved."
!define INFO_COPYRIGHT "© 2025 voidraft. All rights reserved."
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoidRaft - Changelog</title>
<title>voidraft - Changelog</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/changelog.css">
<link rel="icon" href="img/favicon.ico">
@@ -16,7 +16,7 @@
<!-- 主卡片 -->
<div class="card">
<div class="card-header">
<h1 class="card-title" data-en="VoidRaft Changelog" data-zh="VoidRaft 更新日志">VoidRaft Changelog</h1>
<h1 class="card-title" data-en="voidraft Changelog" data-zh="voidraft 更新日志">voidraft Changelog</h1>
<div class="card-controls">
<button id="theme-toggle" class="btn btn-secondary" title="切换主题">
<i class="fas fa-sun"></i> <span data-en="Theme" data-zh="主题">Theme</span>
@@ -59,7 +59,7 @@
<!-- 页脚 -->
<footer class="footer">
<p class="footer-text" data-en="© 2025 VoidRaft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 VoidRaft - 专为开发者打造的优雅文本片段记录工具">© 2023-2024 VoidRaft - An elegant text snippet recording tool designed for developers</p>
<p class="footer-text" data-en="© 2025 voidraft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 voidraft - 专为开发者打造的优雅文本片段记录工具">© 2023-2024 voidraft - An elegant text snippet recording tool designed for developers</p>
<div class="footer-links">
<a href="https://github.com/landaiqing/voidraft" target="_blank" class="footer-link">GitHub</a>
<a href="https://github.com/landaiqing/voidraft/issues" target="_blank" class="footer-link" data-en="Issues" data-zh="问题反馈">Issues</a>

View File

@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoidRaft - An elegant text snippet recording tool designed for developers.</title>
<meta name="description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta name="keywords" content="text editor, code snippets, developer tools, syntax highlighting, code formatting, multi-language, VoidRaft">
<meta name="author" content="VoidRaft Team">
<title>voidraft - An elegant text snippet recording tool designed for developers.</title>
<meta name="description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta name="keywords" content="text editor, code snippets, developer tools, syntax highlighting, code formatting, multi-language, voidraft">
<meta name="author" content="voidraft Team">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://landaiqing.github.io/voidraft/">
@@ -18,16 +18,16 @@
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://landaiqing.github.io/voidraft/">
<meta property="og:title" content="VoidRaft - An elegant text snippet recording tool designed for developers">
<meta property="og:description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="og:title" content="voidraft - An elegant text snippet recording tool designed for developers">
<meta property="og:description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="og:image" content="https://landaiqing.github.io/voidraft/img/screenshot-dark.png">
<meta property="og:site_name" content="VoidRaft">
<meta property="og:site_name" content="voidraft">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://landaiqing.github.io/voidraft/">
<meta property="twitter:title" content="VoidRaft - An elegant text snippet recording tool designed for developers">
<meta property="twitter:description" content="VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="twitter:title" content="voidraft - An elegant text snippet recording tool designed for developers">
<meta property="twitter:description" content="voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.">
<meta property="twitter:image" content="https://landaiqing.github.io/voidraft/img/screenshot-dark.png">
<link rel="stylesheet" href="./css/styles.css">
@@ -39,13 +39,13 @@
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "VoidRaft",
"name": "voidraft",
"description": "An elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.",
"url": "https://landaiqing.github.io/voidraft/",
"downloadUrl": "https://github.com/landaiqing/voidraft/releases",
"author": {
"@type": "Organization",
"name": "VoidRaft"
"name": "voidraft"
},
"operatingSystem": ["Windows", "macOS", "Linux"],
"applicationCategory": "DeveloperApplication",
@@ -66,7 +66,7 @@
<!-- 主卡片 -->
<div class="card">
<div class="card-header">
<h1 class="card-title">VoidRaft</h1>
<h1 class="card-title">voidraft</h1>
<div class="card-controls">
<button id="theme-toggle" class="btn btn-secondary" title="切换主题">
<i class="fas fa-sun"></i> <span data-en="Theme" data-zh="主题">Theme</span>
@@ -81,9 +81,9 @@
<!-- Logo和介绍 -->
<div class="logo-container">
<div class="logo-frame">
<img src="img/logo.png" alt="VoidRaft Logo" class="logo-image">
<img src="img/logo.png" alt="voidraft Logo" class="logo-image">
</div>
<h2 class="logo-text" data-en="VoidRaft" data-zh="VoidRaft">VoidRaft</h2>
<h2 class="logo-text" data-en="voidraft" data-zh="voidraft">voidraft</h2>
<p class="tagline" data-en="An elegant text snippet recording tool" data-zh="优雅的文本片段记录工具">An elegant text snippet recording tool</p>
</div>
@@ -195,15 +195,15 @@
<div class="block-language">text</div>
</div>
<pre class="code-block">
<span class="comment">// VoidRaft - An elegant text snippet recording tool</span>
<span class="comment">// voidraft - An elegant text snippet recording tool</span>
<span class="comment">// Multi-language support | Code formatting | Custom themes</span>
<span class="comment">// A modern text editor designed for developers</span></pre>
</div>
</div>
</div>
<div class="preview-window">
<img src="img/screenshot-dark.png" alt="VoidRaft 界面预览" class="preview-image dark-theme-img">
<img src="img/screenshot-light.png" alt="VoidRaft 界面预览" class="preview-image light-theme-img" style="display: none;">
<img src="img/screenshot-dark.png" alt="voidraft 界面预览" class="preview-image dark-theme-img">
<img src="img/screenshot-light.png" alt="voidraft 界面预览" class="preview-image light-theme-img" style="display: none;">
</div>
</div>
@@ -241,7 +241,7 @@
<!-- 页脚 -->
<footer class="footer">
<p class="footer-text" data-en="© 2025 VoidRaft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 VoidRaft - 专为开发者打造的优雅文本片段记录工具">© 2025 VoidRaft - An elegant text snippet recording tool designed for developers</p>
<p class="footer-text" data-en="© 2025 voidraft - An elegant text snippet recording tool designed for developers" data-zh="© 2025 voidraft - 专为开发者打造的优雅文本片段记录工具">© 2025 voidraft - An elegant text snippet recording tool designed for developers</p>
<div class="footer-links">
<a href="https://github.com/landaiqing/voidraft" target="_blank" class="footer-link">GitHub</a>
<a href="https://github.com/landaiqing/voidraft/issues" target="_blank" class="footer-link" data-en="Issues" data-zh="问题反馈">Issues</a>

View File

@@ -1,5 +1,5 @@
/**
* VoidRaft - Changelog Script
* voidraft - Changelog Script
* 从GitHub API获取发布信息支持Gitea备用源
*/

View File

@@ -1,5 +1,5 @@
/**
* VoidRaft - Website Script
* voidraft - Website Script
*/
/**
@@ -233,14 +233,14 @@ class SEOManager {
this.languageManager = languageManager;
this.metaTexts = {
en: {
description: 'VoidRaft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.',
title: 'VoidRaft - An elegant text snippet recording tool designed for developers.',
ogTitle: 'VoidRaft - An elegant text snippet recording tool designed for developers'
description: 'voidraft is an elegant text snippet recording tool designed for developers. Features multi-language code blocks, syntax highlighting, code formatting, custom themes, and more.',
title: 'voidraft - An elegant text snippet recording tool designed for developers.',
ogTitle: 'voidraft - An elegant text snippet recording tool designed for developers'
},
zh: {
description: 'VoidRaft 是专为开发者打造的优雅文本片段记录工具。支持多语言代码块、语法高亮、代码格式化、自定义主题等功能。',
title: 'VoidRaft - 专为开发者打造的优雅文本片段记录工具',
ogTitle: 'VoidRaft - 专为开发者打造的优雅文本片段记录工具'
description: 'voidraft 是专为开发者打造的优雅文本片段记录工具。支持多语言代码块、语法高亮、代码格式化、自定义主题等功能。',
title: 'voidraft - 专为开发者打造的优雅文本片段记录工具',
ogTitle: 'voidraft - 专为开发者打造的优雅文本片段记录工具'
}
};
this.init();
@@ -371,9 +371,9 @@ class UIEffects {
}
/**
* VoidRaft主应用类
* voidraft主应用类
*/
class VoidRaftApp {
class voidraftApp {
constructor() {
this.themeManager = null;
this.languageManager = null;
@@ -404,7 +404,7 @@ class VoidRaftApp {
* 显示控制台品牌信息
*/
showConsoleBranding() {
console.log('%c VoidRaft', 'color: #ff006e; font-size: 20px; font-family: "Space Mono", monospace;');
console.log('%c voidraft', 'color: #ff006e; font-size: 20px; font-family: "Space Mono", monospace;');
console.log('%c An elegant text snippet recording tool designed for developers.', 'color: #073B4C; font-family: "Space Mono", monospace;');
}
@@ -439,5 +439,5 @@ class VoidRaftApp {
// 当DOM加载完成时初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.voidRaftApp = new VoidRaftApp();
window.voidRaftApp = new voidraftApp();
});

View File

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

View File

@@ -1,51 +0,0 @@
// 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";
export class Config {
/**
* DBSource is the database URI to use.
* The string ":memory:" can be used to create an in-memory database.
* The sqlite driver can be configured through query parameters.
* For more details see https://pkg.go.dev/modernc.org/sqlite#Driver.Open
*/
"DBSource": string;
/** Creates a new Config instance. */
constructor($$source: Partial<Config> = {}) {
if (!("DBSource" in $$source)) {
this["DBSource"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new Config instance from a string or object.
*/
static createFrom($$source: any = {}): Config {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new Config($$parsedSource as Partial<Config>);
}
}
/**
* Row holds a single row in the result of a query.
* It is a key-value map where keys are column names.
*/
export type Row = { [_: string]: any };
/**
* Rows holds the result of a query
* as an array of key-value maps where keys are column names.
*/
export type Rows = Row[];
/**
* Stmt wraps a prepared sql statement pointer.
* It provides the same methods as the [sql.Stmt] type.
*/
export type Stmt = string;

View File

@@ -1,223 +0,0 @@
// 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 {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export {
ExecContext as Execute,
QueryContext as Query
};
import { Stmt } from "./stmt.js";
/**
* Prepare creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*
* Prepare supports early cancellation.
*/
export function Prepare(query: string): Promise<Stmt | null> & { cancel(): void } {
const promise = PrepareContext(query);
const wrapper: any = (promise.then(function (id) {
return id == null ? null : new Stmt(
ClosePrepared.bind(null, id),
ExecPrepared.bind(null, id),
QueryPrepared.bind(null, id));
}));
wrapper.cancel = promise.cancel;
return wrapper;
}
/**
* Close closes the current database connection if one is open, otherwise has no effect.
* Additionally, Close closes all open prepared statements associated to the connection.
*
* Even when a non-nil error is returned,
* the database service is left in a consistent state,
* ready for a call to [Service.Open].
*/
export function Close(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1888105376) as any;
return $resultPromise;
}
/**
* ClosePrepared closes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext].
* ClosePrepared is idempotent:
* it has no effect on prepared statements that are already closed.
*/
function ClosePrepared(stmt: $models.Stmt | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2526200629, stmt) as any;
return $resultPromise;
}
/**
* Configure changes the database service configuration.
* The connection state at call time is preserved.
* Consumers will need to call [Service.Open] manually after Configure
* in order to reconnect with the new configuration.
*
* See [NewWithConfig] for details on configuration.
*/
export function Configure(config: $models.Config | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1939578712, config) as any;
return $resultPromise;
}
/**
* ExecContext executes a query without returning any rows.
* It supports early cancellation.
*/
function ExecContext(query: string, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(674944556, query, args) as any;
return $resultPromise;
}
/**
* ExecPrepared executes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext]
* without returning any rows.
* It supports early cancellation.
*/
function ExecPrepared(stmt: $models.Stmt | null, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2086877656, stmt, args) as any;
return $resultPromise;
}
/**
* Execute executes a query without returning any rows.
*/
export function Execute(query: string, ...args: any[]): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3811930203, query, args) as any;
return $resultPromise;
}
/**
* Open validates the current configuration,
* closes the current connection if one is present,
* then opens and validates a new connection.
*
* Even when a non-nil error is returned,
* the database service is left in a consistent state,
* ready for a new call to Open.
*/
export function Open(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2012175612) as any;
return $resultPromise;
}
/**
* Prepare creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller should call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*/
export function Prepare(query: string): Promise<$models.Stmt | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1801965143, query) as any;
return $resultPromise;
}
/**
* PrepareContext creates a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently from the returned statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*
* PrepareContext supports early cancellation.
*/
function PrepareContext(query: string): Promise<$models.Stmt | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(570941694, query) as any;
return $resultPromise;
}
/**
* Query executes a query and returns a slice of key-value records,
* one per row, with column names as keys.
*/
export function Query(query: string, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(860757720, query, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* QueryContext executes a query and returns a slice of key-value records,
* one per row, with column names as keys.
* It supports early cancellation, returning the slice of results fetched so far.
*/
function QueryContext(query: string, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(4115542347, query, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* QueryPrepared executes a prepared statement
* obtained with [Service.Prepare] or [Service.PrepareContext]
* and returns a slice of key-value records, one per row, with column names as keys.
* It supports early cancellation, returning the slice of results fetched so far.
*/
function QueryPrepared(stmt: $models.Stmt | null, ...args: any[]): Promise<$models.Rows> & { cancel(): void } {
let $resultPromise = $Call.ByID(3885083725, stmt, args) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ServiceName returns the name of the plugin.
* You should use the go module format e.g. github.com/myuser/myplugin
*/
export function ServiceName(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(1637123084) as any;
return $resultPromise;
}
/**
* ServiceShutdown closes the database connection.
* It returns a non-nil error in case of failures.
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3650435925) as any;
return $resultPromise;
}
/**
* ServiceStartup opens the database connection.
* It returns a non-nil error in case of failures.
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1113159936, options) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
const $$createType1 = $Create.Array($$createType0);

View File

@@ -1,79 +0,0 @@
//@ts-check
//@ts-ignore: Unused imports
import * as $models from "./models.js";
const execSymbol = Symbol("exec"),
querySymbol = Symbol("query"),
closeSymbol = Symbol("close");
/**
* Stmt represents a prepared statement for later queries or executions.
* Multiple queries or executions may be run concurrently on the same statement.
*
* The caller must call the statement's Close method when it is no longer needed.
* Statements are closed automatically
* when the connection they are associated with is closed.
*/
export class Stmt {
/**
* Constructs a new prepared statement instance.
* @param {(...args: any[]) => Promise<void>} close
* @param {(...args: any[]) => Promise<void> & { cancel(): void }} exec
* @param {(...args: any[]) => Promise<$models.Rows> & { cancel(): void }} query
*/
constructor(close, exec, query) {
/**
* @member
* @private
* @type {typeof close}
*/
this[closeSymbol] = close;
/**
* @member
* @private
* @type {typeof exec}
*/
this[execSymbol] = exec;
/**
* @member
* @private
* @type {typeof query}
*/
this[querySymbol] = query;
}
/**
* Closes the prepared statement.
* It has no effect when the statement is already closed.
* @returns {Promise<void>}
*/
Close() {
return this[closeSymbol]();
}
/**
* Executes the prepared statement without returning any rows.
* It supports early cancellation.
*
* @param {any[]} args
* @returns {Promise<void> & { cancel(): void }}
*/
Exec(...args) {
return this[execSymbol](...args);
}
/**
* Executes the prepared statement
* and returns a slice of key-value records, one per row, with column names as keys.
* It supports early cancellation, returning the array of results fetched so far.
*
* @param {any[]} args
* @returns {Promise<$models.Rows> & { cancel(): void }}
*/
Query(...args) {
return this[querySymbol](...args);
}
}

View File

@@ -33,6 +33,11 @@ export class AppConfig {
*/
"updates": UpdatesConfig;
/**
* Git备份设置
*/
"backup": GitBackupConfig;
/**
* 配置元数据
*/
@@ -52,6 +57,9 @@ export class AppConfig {
if (!("updates" in $$source)) {
this["updates"] = (new UpdatesConfig());
}
if (!("backup" in $$source)) {
this["backup"] = (new GitBackupConfig());
}
if (!("metadata" in $$source)) {
this["metadata"] = (new ConfigMetadata());
}
@@ -68,6 +76,7 @@ export class AppConfig {
const $$createField2_0 = $$createType2;
const $$createField3_0 = $$createType3;
const $$createField4_0 = $$createType4;
const $$createField5_0 = $$createType5;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("general" in $$parsedSource) {
$$parsedSource["general"] = $$createField0_0($$parsedSource["general"]);
@@ -81,8 +90,11 @@ export class AppConfig {
if ("updates" in $$parsedSource) {
$$parsedSource["updates"] = $$createField3_0($$parsedSource["updates"]);
}
if ("backup" in $$parsedSource) {
$$parsedSource["backup"] = $$createField4_0($$parsedSource["backup"]);
}
if ("metadata" in $$parsedSource) {
$$parsedSource["metadata"] = $$createField4_0($$parsedSource["metadata"]);
$$parsedSource["metadata"] = $$createField5_0($$parsedSource["metadata"]);
}
return new AppConfig($$parsedSource as Partial<AppConfig>);
}
@@ -102,11 +114,6 @@ export class AppearanceConfig {
*/
"systemTheme": SystemThemeType;
/**
* 自定义主题配置
*/
"customTheme": CustomThemeConfig;
/** Creates a new AppearanceConfig instance. */
constructor($$source: Partial<AppearanceConfig> = {}) {
if (!("language" in $$source)) {
@@ -115,9 +122,6 @@ export class AppearanceConfig {
if (!("systemTheme" in $$source)) {
this["systemTheme"] = ("" as SystemThemeType);
}
if (!("customTheme" in $$source)) {
this["customTheme"] = (new CustomThemeConfig());
}
Object.assign(this, $$source);
}
@@ -126,15 +130,30 @@ export class AppearanceConfig {
* Creates a new AppearanceConfig instance from a string or object.
*/
static createFrom($$source: any = {}): AppearanceConfig {
const $$createField2_0 = $$createType5;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("customTheme" in $$parsedSource) {
$$parsedSource["customTheme"] = $$createField2_0($$parsedSource["customTheme"]);
}
return new AppearanceConfig($$parsedSource as Partial<AppearanceConfig>);
}
}
/**
* Git备份相关类型定义
*
* AuthMethod 定义Git认证方式
*/
export enum AuthMethod {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* 认证方式
*/
Token = "token",
SSHKey = "ssh_key",
UserPass = "user_pass",
};
/**
* ConfigMetadata 配置元数据
*/
@@ -170,49 +189,6 @@ export class ConfigMetadata {
}
}
/**
* CustomThemeConfig 自定义主题配置
*/
export class CustomThemeConfig {
/**
* 深色主题配置
*/
"darkTheme": ThemeColorConfig;
/**
* 浅色主题配置
*/
"lightTheme": ThemeColorConfig;
/** Creates a new CustomThemeConfig instance. */
constructor($$source: Partial<CustomThemeConfig> = {}) {
if (!("darkTheme" in $$source)) {
this["darkTheme"] = (new ThemeColorConfig());
}
if (!("lightTheme" in $$source)) {
this["lightTheme"] = (new ThemeColorConfig());
}
Object.assign(this, $$source);
}
/**
* Creates a new CustomThemeConfig instance from a string or object.
*/
static createFrom($$source: any = {}): CustomThemeConfig {
const $$createField0_0 = $$createType6;
const $$createField1_0 = $$createType6;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("darkTheme" in $$parsedSource) {
$$parsedSource["darkTheme"] = $$createField0_0($$parsedSource["darkTheme"]);
}
if ("lightTheme" in $$parsedSource) {
$$parsedSource["lightTheme"] = $$createField1_0($$parsedSource["lightTheme"]);
}
return new CustomThemeConfig($$parsedSource as Partial<CustomThemeConfig>);
}
}
/**
* Document represents a document in the system
*/
@@ -224,6 +200,11 @@ export class Document {
"updatedAt": time$0.Time;
"is_deleted": boolean;
/**
* 锁定标志,锁定的文档无法被删除
*/
"is_locked": boolean;
/** Creates a new Document instance. */
constructor($$source: Partial<Document> = {}) {
if (!("id" in $$source)) {
@@ -244,6 +225,9 @@ export class Document {
if (!("is_deleted" in $$source)) {
this["is_deleted"] = false;
}
if (!("is_locked" in $$source)) {
this["is_locked"] = false;
}
Object.assign(this, $$source);
}
@@ -389,7 +373,7 @@ export class Extension {
* Creates a new Extension instance from a string or object.
*/
static createFrom($$source: any = {}): Extension {
const $$createField3_0 = $$createType7;
const $$createField3_0 = $$createType6;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("config" in $$parsedSource) {
$$parsedSource["config"] = $$createField3_0($$parsedSource["config"]);
@@ -483,6 +467,12 @@ export class GeneralConfig {
*/
"startAtLogin": boolean;
/**
* 窗口吸附设置
* 是否启用窗口吸附功能(阈值现在是自适应的)
*/
"enableWindowSnap": boolean;
/**
* 全局热键设置
* 是否启用全局热键
@@ -508,6 +498,9 @@ export class GeneralConfig {
if (!("startAtLogin" in $$source)) {
this["startAtLogin"] = false;
}
if (!("enableWindowSnap" in $$source)) {
this["enableWindowSnap"] = false;
}
if (!("enableGlobalHotkey" in $$source)) {
this["enableGlobalHotkey"] = false;
}
@@ -522,15 +515,64 @@ export class GeneralConfig {
* Creates a new GeneralConfig instance from a string or object.
*/
static createFrom($$source: any = {}): GeneralConfig {
const $$createField5_0 = $$createType9;
const $$createField6_0 = $$createType8;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("globalHotkey" in $$parsedSource) {
$$parsedSource["globalHotkey"] = $$createField5_0($$parsedSource["globalHotkey"]);
$$parsedSource["globalHotkey"] = $$createField6_0($$parsedSource["globalHotkey"]);
}
return new GeneralConfig($$parsedSource as Partial<GeneralConfig>);
}
}
/**
* GitBackupConfig Git备份配置
*/
export class GitBackupConfig {
"enabled": boolean;
"repo_url": string;
"auth_method": AuthMethod;
"username"?: string;
"password"?: string;
"token"?: string;
"ssh_key_path"?: string;
"ssh_key_passphrase"?: string;
/**
* 分钟
*/
"backup_interval": number;
"auto_backup": boolean;
/** Creates a new GitBackupConfig instance. */
constructor($$source: Partial<GitBackupConfig> = {}) {
if (!("enabled" in $$source)) {
this["enabled"] = false;
}
if (!("repo_url" in $$source)) {
this["repo_url"] = "";
}
if (!("auth_method" in $$source)) {
this["auth_method"] = ("" as AuthMethod);
}
if (!("backup_interval" in $$source)) {
this["backup_interval"] = 0;
}
if (!("auto_backup" in $$source)) {
this["auto_backup"] = false;
}
Object.assign(this, $$source);
}
/**
* Creates a new GitBackupConfig instance from a string or object.
*/
static createFrom($$source: any = {}): GitBackupConfig {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new GitBackupConfig($$parsedSource as Partial<GitBackupConfig>);
}
}
/**
* GiteaConfig Gitea配置
*/
@@ -1083,6 +1125,58 @@ export enum TabType {
TabTypeTab = "tab",
};
/**
* Theme 主题数据库模型
*/
export class Theme {
"id": number;
"name": string;
"type": ThemeType;
"colors": ThemeColorConfig;
"isDefault": boolean;
"createdAt": time$0.Time;
"updatedAt": time$0.Time;
/** Creates a new Theme instance. */
constructor($$source: Partial<Theme> = {}) {
if (!("id" in $$source)) {
this["id"] = 0;
}
if (!("name" in $$source)) {
this["name"] = "";
}
if (!("type" in $$source)) {
this["type"] = ("" as ThemeType);
}
if (!("colors" in $$source)) {
this["colors"] = (new ThemeColorConfig());
}
if (!("isDefault" in $$source)) {
this["isDefault"] = false;
}
if (!("createdAt" in $$source)) {
this["createdAt"] = null;
}
if (!("updatedAt" in $$source)) {
this["updatedAt"] = null;
}
Object.assign(this, $$source);
}
/**
* Creates a new Theme instance from a string or object.
*/
static createFrom($$source: any = {}): Theme {
const $$createField3_0 = $$createType9;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("colors" in $$parsedSource) {
$$parsedSource["colors"] = $$createField3_0($$parsedSource["colors"]);
}
return new Theme($$parsedSource as Partial<Theme>);
}
}
/**
* ThemeColorConfig 主题颜色配置
*/
@@ -1291,6 +1385,19 @@ export class ThemeColorConfig {
}
}
/**
* ThemeType 主题类型枚举
*/
export enum ThemeType {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
ThemeTypeDark = "dark",
ThemeTypeLight = "light",
};
/**
* UpdateSourceType 更新源类型
*/
@@ -1407,16 +1514,16 @@ const $$createType0 = GeneralConfig.createFrom;
const $$createType1 = EditingConfig.createFrom;
const $$createType2 = AppearanceConfig.createFrom;
const $$createType3 = UpdatesConfig.createFrom;
const $$createType4 = ConfigMetadata.createFrom;
const $$createType5 = CustomThemeConfig.createFrom;
const $$createType6 = ThemeColorConfig.createFrom;
var $$createType7 = (function $$initCreateType7(...args): any {
if ($$createType7 === $$initCreateType7) {
$$createType7 = $$createType8;
const $$createType4 = GitBackupConfig.createFrom;
const $$createType5 = ConfigMetadata.createFrom;
var $$createType6 = (function $$initCreateType6(...args): any {
if ($$createType6 === $$initCreateType6) {
$$createType6 = $$createType7;
}
return $$createType7(...args);
return $$createType6(...args);
});
const $$createType8 = $Create.Map($Create.Any, $Create.Any);
const $$createType9 = HotkeyCombo.createFrom;
const $$createType7 = $Create.Map($Create.Any, $Create.Any);
const $$createType8 = HotkeyCombo.createFrom;
const $$createType9 = ThemeColorConfig.createFrom;
const $$createType10 = GithubConfig.createFrom;
const $$createType11 = GiteaConfig.createFrom;

View File

@@ -0,0 +1,71 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* BackupService 提供基于Git的备份功能
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* HandleConfigChange 处理备份配置变更
*/
export function HandleConfigChange(config: models$0.GitBackupConfig | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(395287784, config) as any;
return $resultPromise;
}
/**
* Initialize 初始化备份服务
*/
export function Initialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1052437974) as any;
return $resultPromise;
}
/**
* PushToRemote 推送本地更改到远程仓库
*/
export function PushToRemote(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(262644139) as any;
return $resultPromise;
}
/**
* Reinitialize 重新初始化备份服务,用于响应配置变更
*/
export function Reinitialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(301562543) as any;
return $resultPromise;
}
/**
* ServiceShutdown 服务关闭时的清理工作
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(422131801) as any;
return $resultPromise;
}
/**
* StartAutoBackup 启动自动备份定时器
*/
export function StartAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3035755449) as any;
return $resultPromise;
}
/**
* StopAutoBackup 停止自动备份
*/
export function StopAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2641894021) as any;
return $resultPromise;
}

View File

@@ -34,6 +34,22 @@ export function GetConfig(): Promise<models$0.AppConfig | null> & { cancel(): vo
return $typingPromise;
}
/**
* GetConfigDir 获取配置目录
*/
export function GetConfigDir(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2275626561) as any;
return $resultPromise;
}
/**
* GetSettingsPath 获取设置文件路径
*/
export function GetSettingsPath(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2175583370) as any;
return $resultPromise;
}
/**
* ResetConfig 强制重置所有配置为默认值
*/
@@ -58,6 +74,14 @@ export function Set(key: string, value: any): Promise<void> & { cancel(): void }
return $resultPromise;
}
/**
* SetBackupConfigChangeCallback 设置备份配置变更回调
*/
export function SetBackupConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3264871659, callback) as any;
return $resultPromise;
}
/**
* SetDataPathChangeCallback 设置数据路径配置变更回调
*/
@@ -74,6 +98,14 @@ export function SetHotkeyChangeCallback(callback: any): Promise<void> & { cancel
return $resultPromise;
}
/**
* SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
*/
export function SetWindowSnapConfigChangeCallback(callback: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2324961653, callback) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.AppConfig.createFrom;
const $$createType1 = $Create.Nullable($$createType0);

View File

@@ -22,6 +22,14 @@ export function OnDataPathChanged(): Promise<void> & { cancel(): void } {
return $resultPromise;
}
/**
* RegisterModel 注册模型与表的映射关系
*/
export function RegisterModel(tableName: string, model: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(175397515, tableName, model) as any;
return $resultPromise;
}
/**
* ServiceShutdown shuts down the service when the application closes
*/

View File

@@ -22,6 +22,14 @@ export function SelectDirectory(): Promise<string> & { cancel(): void } {
return $resultPromise;
}
/**
* SelectFile 打开文件选择对话框
*/
export function SelectFile(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(37302920) as any;
return $resultPromise;
}
/**
* SetWindow 设置绑定的窗口
*/

View File

@@ -81,6 +81,14 @@ export function ListDeletedDocumentsMeta(): Promise<(models$0.Document | null)[]
return $typingPromise;
}
/**
* LockDocument 锁定文档,防止删除
*/
export function LockDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1889494473, id) as any;
return $resultPromise;
}
/**
* RestoreDocument restores a deleted document
*/
@@ -97,6 +105,14 @@ export function ServiceStartup(options: application$0.ServiceOptions): Promise<v
return $resultPromise;
}
/**
* UnlockDocument 解锁文档
*/
export function UnlockDocument(id: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(222307930, id) as any;
return $resultPromise;
}
/**
* UpdateDocumentContent updates the content of a document
*/

View File

@@ -32,8 +32,8 @@ export function GetCurrentHotkey(): Promise<models$0.HotkeyCombo | null> & { can
/**
* Initialize 初始化热键服务
*/
export function Initialize(app: application$0.App | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3671360458, app) as any;
export function Initialize(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3671360458, app, mainWindow) as any;
return $resultPromise;
}

View File

@@ -1,6 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as BackupService from "./backupservice.js";
import * as ConfigService from "./configservice.js";
import * as DatabaseService from "./databaseservice.js";
import * as DialogService from "./dialogservice.js";
@@ -12,10 +13,13 @@ import * as MigrationService from "./migrationservice.js";
import * as SelfUpdateService from "./selfupdateservice.js";
import * as StartupService from "./startupservice.js";
import * as SystemService from "./systemservice.js";
import * as ThemeService from "./themeservice.js";
import * as TranslationService from "./translationservice.js";
import * as TrayService from "./trayservice.js";
import * as WindowService from "./windowservice.js";
import * as WindowSnapService from "./windowsnapservice.js";
export {
BackupService,
ConfigService,
DatabaseService,
DialogService,
@@ -27,9 +31,11 @@ export {
SelfUpdateService,
StartupService,
SystemService,
ThemeService,
TranslationService,
TrayService,
WindowService
WindowService,
WindowSnapService
};
export * from "./models.js";

View File

@@ -203,7 +203,7 @@ export class SelfUpdateResult {
}
/**
* WindowInfo 窗口信息
* WindowInfo 窗口信息(简化版)
*/
export class WindowInfo {
"Window": application$0.WebviewWindow | null;
@@ -238,6 +238,26 @@ export class WindowInfo {
}
}
/**
* WindowSnapService 窗口吸附服务
*/
export class WindowSnapService {
/** Creates a new WindowSnapService instance. */
constructor($$source: Partial<WindowSnapService> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new WindowSnapService instance from a string or object.
*/
static createFrom($$source: any = {}): WindowSnapService {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new WindowSnapService($$parsedSource as Partial<WindowSnapService>);
}
}
// Private type creation functions
const $$createType0 = application$0.WebviewWindow.createFrom;
const $$createType1 = $Create.Nullable($$createType0);

View File

@@ -0,0 +1,104 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* ThemeService 主题服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
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";
/**
* CreateTheme 创建新主题
*/
export function CreateTheme(theme: models$0.Theme | null): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(3274757686, theme) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetAllThemes 获取所有主题
*/
export function GetAllThemes(): Promise<(models$0.Theme | null)[]> & { cancel(): void } {
let $resultPromise = $Call.ByID(2425053076) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType2($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetDefaultThemes 获取默认主题
*/
export function GetDefaultThemes(): Promise<{ [_: string]: models$0.Theme | null }> & { cancel(): void } {
let $resultPromise = $Call.ByID(3801788118) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType3($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* GetThemeByType 根据类型获取默认主题
*/
export function GetThemeByType(themeType: models$0.ThemeType): Promise<models$0.Theme | null> & { cancel(): void } {
let $resultPromise = $Call.ByID(1680465265, themeType) as any;
let $typingPromise = $resultPromise.then(($result: any) => {
return $$createType1($result);
}) as any;
$typingPromise.cancel = $resultPromise.cancel.bind($resultPromise);
return $typingPromise;
}
/**
* ResetThemeColors 重置主题颜色为默认值
*/
export function ResetThemeColors(themeType: models$0.ThemeType): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(342461245, themeType) as any;
return $resultPromise;
}
/**
* ServiceShutdown 服务关闭
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1676749034) as any;
return $resultPromise;
}
/**
* ServiceStartup 服务启动时初始化
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2915959937, options) as any;
return $resultPromise;
}
/**
* UpdateThemeColors 更新主题颜色
*/
export function UpdateThemeColors(themeType: models$0.ThemeType, colors: models$0.ThemeColorConfig): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2750902529, themeType, colors) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = models$0.Theme.createFrom;
const $$createType1 = $Create.Nullable($$createType0);
const $$createType2 = $Create.Array($$createType1);
const $$createType3 = $Create.Map($Create.Any, $$createType1);

View File

@@ -2,7 +2,7 @@
// This file is automatically generated. DO NOT EDIT
/**
* WindowService 窗口管理服务
* WindowService 窗口管理服务(专注于窗口生命周期管理)
* @module
*/
@@ -46,6 +46,14 @@ export function OpenDocumentWindow(documentID: number): Promise<void> & { cancel
return $resultPromise;
}
/**
* ServiceShutdown 实现服务关闭接口
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(202192783) as any;
return $resultPromise;
}
/**
* SetAppReferences 设置应用和主窗口引用
*/
@@ -54,6 +62,14 @@ export function SetAppReferences(app: application$0.App | null, mainWindow: appl
return $resultPromise;
}
/**
* SetWindowSnapService 设置窗口吸附服务引用
*/
export function SetWindowSnapService(snapService: $models.WindowSnapService | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1105193745, snapService) as any;
return $resultPromise;
}
// Private type creation functions
const $$createType0 = $models.WindowInfo.createFrom;
const $$createType1 = $Create.Array($$createType0);

View File

@@ -0,0 +1,79 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* WindowSnapService 窗口吸附服务
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* Cleanup 清理资源
*/
export function Cleanup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2155505498) as any;
return $resultPromise;
}
/**
* GetCurrentThreshold 获取当前自适应阈值(用于调试或显示)
*/
export function GetCurrentThreshold(): Promise<number> & { cancel(): void } {
let $resultPromise = $Call.ByID(3176419026) as any;
return $resultPromise;
}
/**
* OnWindowSnapConfigChanged 处理窗口吸附配置变更
*/
export function OnWindowSnapConfigChanged(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3794787039, enabled) as any;
return $resultPromise;
}
/**
* RegisterWindow 注册需要吸附管理的窗口
*/
export function RegisterWindow(documentID: number, window: application$0.WebviewWindow | null, title: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1000222723, documentID, window, title) as any;
return $resultPromise;
}
/**
* ServiceShutdown 实现服务关闭接口
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1172710495) as any;
return $resultPromise;
}
/**
* SetAppReferences 设置应用和主窗口引用
*/
export function SetAppReferences(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1782093351, app, mainWindow) as any;
return $resultPromise;
}
/**
* SetSnapEnabled 设置是否启用窗口吸附
*/
export function SetSnapEnabled(enabled: boolean): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2280126835, enabled) as any;
return $resultPromise;
}
/**
* UnregisterWindow 取消注册窗口
*/
export function UnregisterWindow(documentID: number): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2844230768, documentID) as any;
return $resultPromise;
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,24 +24,24 @@
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-lezer": "^6.0.2",
"@codemirror/lang-liquid": "^6.2.3",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/lang-liquid": "^6.3.0",
"@codemirror/lang-markdown": "^6.3.4",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.9.0",
"@codemirror/lang-sql": "^6.9.1",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.2",
"@codemirror/language": "^6.11.3",
"@codemirror/language-data": "^6.5.1",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.0",
"@codemirror/view": "^6.38.1",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"codemirror": "^6.0.2",
@@ -52,31 +52,30 @@
"hsl-matcher": "^1.2.4",
"lezer": "^0.13.5",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"pinia-plugin-persistedstate": "^4.5.0",
"prettier": "^3.6.2",
"remarkable": "^2.0.1",
"sass": "^1.89.2",
"vue": "^3.5.17",
"vue-i18n": "^11.1.9",
"sass": "^1.90.0",
"vue": "^3.5.19",
"vue-i18n": "^11.1.11",
"vue-pick-colors": "^1.8.0",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.34.0",
"@lezer/generator": "^1.8.0",
"@types/lodash": "^4.17.20",
"@types/node": "^24.0.12",
"@types/node": "^24.3.0",
"@types/remarkable": "^2.0.8",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue": "^6.0.1",
"@wailsio/runtime": "latest",
"eslint": "^9.30.1",
"eslint-plugin-vue": "^10.3.0",
"eslint": "^9.34.0",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.3.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.3",
"typescript": "^5.9.2",
"typescript-eslint": "^8.40.0",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.1.3",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.1"
"vue-tsc": "^3.0.6"
}
}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useConfigStore } from '@/stores/configStore';
import { useSystemStore } from '@/stores/systemStore';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { useThemeStore } from '@/stores/themeStore';
import { useUpdateStore } from '@/stores/updateStore';
import {onMounted} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useSystemStore} from '@/stores/systemStore';
import {useKeybindingStore} from '@/stores/keybindingStore';
import {useThemeStore} from '@/stores/themeStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useBackupStore} from '@/stores/backupStore';
import WindowTitleBar from '@/components/titlebar/WindowTitleBar.vue';
const configStore = useConfigStore();
@@ -12,6 +13,7 @@ const systemStore = useSystemStore();
const keybindingStore = useKeybindingStore();
const themeStore = useThemeStore();
const updateStore = useUpdateStore();
const backupStore = useBackupStore();
// 应用启动时加载配置和初始化系统信息
onMounted(async () => {
@@ -26,6 +28,9 @@ onMounted(async () => {
await configStore.initializeLanguage();
themeStore.initializeTheme();
// 初始化备份服务
await backupStore.initialize();
// 启动时检查更新
await updateStore.checkOnStartup();
});
@@ -33,7 +38,7 @@ onMounted(async () => {
<template>
<div class="app-container">
<WindowTitleBar />
<WindowTitleBar/>
<div class="app-content">
<router-view/>
</div>

View File

@@ -2,16 +2,16 @@
<div class="linux-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft" />
<img src="/appicon.png" alt="voidraft"/>
</div>
<div class="titlebar-title">{{ titleText }}</div>
</div>
<div class="titlebar-controls" style="--wails-draggable:no-drag" @contextmenu.prevent>
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
class="titlebar-button minimize-button"
@click="minimizeWindow"
:title="t('titlebar.minimize')"
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M4 8h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -19,9 +19,9 @@
</button>
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
class="titlebar-button maximize-button"
@click="toggleMaximize"
:title="isMaximized ? t('titlebar.restore') : t('titlebar.maximize')"
>
<svg width="16" height="16" viewBox="0 0 16 16" v-if="!isMaximized">
<rect x="4" y="4" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2"/>
@@ -33,9 +33,9 @@
</button>
<button
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
class="titlebar-button close-button"
@click="closeWindow"
:title="t('titlebar.close')"
>
<svg width="16" height="16" viewBox="0 0 16 16">
<path d="M4 4l8 8m0-8L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -46,85 +46,56 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
import {useDocumentStore} from '@/stores/documentStore';
const { t } = useI18n();
const {t} = useI18n();
const isMaximized = ref(false);
const documentStore = useDocumentStore();
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
}
};
const toggleMaximize = async () => {
try {
const newState = !isMaximized.value;
isMaximized.value = newState;
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
setTimeout(async () => {
await checkMaximizedState();
}, 100);
} catch (error) {
isMaximized.value = !isMaximized.value;
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
}
};
// 计算标题文本
const titleText = computed(() => {
const currentDoc = documentStore.currentDocument;
return currentDoc ? `voidraft - ${currentDoc.title}` : 'voidraft';
});
const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
console.error(error);
}
};
const toggleMaximize = async () => {
try {
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
console.error(error);
}
};
const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
console.error(error);
}
};
const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
console.error(error);
}
};
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>

View File

@@ -53,7 +53,6 @@
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
const { t } = useI18n();
@@ -65,26 +64,16 @@ const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
console.error(error)
}
};
const toggleMaximize = async () => {
try {
const newState = !isMaximized.value;
isMaximized.value = newState;
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
setTimeout(async () => {
await checkMaximizedState();
}, 100);
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
isMaximized.value = !isMaximized.value;
console.error(error)
}
};
@@ -92,7 +81,7 @@ const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
console.error(error)
}
};
@@ -100,7 +89,7 @@ const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
console.error(error)
}
};
@@ -112,24 +101,6 @@ const titleText = computed(() => {
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="windows-titlebar" style="--wails-draggable:drag" @contextmenu.prevent>
<div class="windows-titlebar" style="--wails-draggable:drag">
<div class="titlebar-content" @dblclick="toggleMaximize" @contextmenu.prevent>
<div class="titlebar-icon">
<img src="/appicon.png" alt="voidraft"/>
@@ -36,11 +36,10 @@
</template>
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref} from 'vue';
import {computed, onMounted, ref} from 'vue';
import {useI18n} from 'vue-i18n';
import * as runtime from '@wailsio/runtime';
import { useWindowStore } from '@/stores/windowStore';
import { useDocumentStore } from '@/stores/documentStore';
import {useDocumentStore} from '@/stores/documentStore';
const {t} = useI18n();
const isMaximized = ref(false);
@@ -59,30 +58,16 @@ const minimizeWindow = async () => {
try {
await runtime.Window.Minimise();
} catch (error) {
// Error handling
console.error(error);
}
};
const toggleMaximize = async () => {
try {
// 立即更新UI状态提供即时反馈
const newState = !isMaximized.value;
isMaximized.value = newState;
// 然后执行实际操作
if (newState) {
await runtime.Window.Maximise();
} else {
await runtime.Window.UnMaximise();
}
// 操作完成后再次确认状态(防止操作失败时状态不一致)
setTimeout(async () => {
await checkMaximizedState();
}, 100);
await runtime.Window.ToggleMaximise();
await checkMaximizedState();
} catch (error) {
// 如果操作失败,恢复原状态
isMaximized.value = !isMaximized.value;
console.error(error);
}
};
@@ -90,7 +75,7 @@ const closeWindow = async () => {
try {
await runtime.Window.Close();
} catch (error) {
// Error handling
console.error(error);
}
};
@@ -98,31 +83,12 @@ const checkMaximizedState = async () => {
try {
isMaximized.value = await runtime.Window.IsMaximised();
} catch (error) {
// Error handling
console.error(error);
}
};
onMounted(async () => {
await checkMaximizedState();
runtime.Events.On('window:maximised', () => {
isMaximized.value = true;
});
runtime.Events.On('window:unmaximised', () => {
isMaximized.value = false;
});
runtime.Events.On('window:focus', async () => {
await checkMaximizedState();
});
});
onUnmounted(() => {
runtime.Events.Off('window:maximised');
runtime.Events.Off('window:unmaximised');
runtime.Events.Off('window:focus');
});
</script>

View File

@@ -107,6 +107,12 @@ const selectItem = async (item: any) => {
// 选择文档
const selectDoc = async (doc: Document) => {
try {
// 如果选择的就是当前文档,直接关闭菜单
if (documentStore.currentDocument?.id === doc.id) {
closeMenu();
return;
}
const hasOpen = await windowStore.isDocumentWindowOpen(doc.id);
if (hasOpen) {
// 设置错误状态并启动定时器
@@ -208,7 +214,7 @@ const openInNewWindow = async (doc: Document, event: Event) => {
}
};
// 处理删除 - 简化确认机制
// 处理删除
const handleDelete = async (doc: Document, event: Event) => {
event.stopPropagation();
@@ -236,9 +242,13 @@ const handleDelete = async (doc: Document, event: Event) => {
return;
}
await documentStore.deleteDocument(doc.id);
await documentStore.updateDocuments();
const deleteSuccess = await documentStore.deleteDocument(doc.id);
if (!deleteSuccess) {
return;
}
await documentStore.updateDocuments();
// 如果删除的是当前文档,切换到第一个文档
if (documentStore.currentDocument?.id === doc.id && documentStore.documentList.length > 0) {
const firstDoc = documentStore.documentList[0];

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import {useI18n} from 'vue-i18n';
import {onMounted, onUnmounted, ref, watch, computed} from 'vue';
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {useConfigStore} from '@/stores/configStore';
import {useEditorStore} from '@/stores/editorStore';
import {useUpdateStore} from '@/stores/updateStore';
import {useWindowStore} from '@/stores/windowStore';
import {useSystemStore} from '@/stores/systemStore';
import * as runtime from '@wailsio/runtime';
import {useRouter} from 'vue-router';
import BlockLanguageSelector from './BlockLanguageSelector.vue';
@@ -17,22 +18,32 @@ const editorStore = useEditorStore();
const configStore = useConfigStore();
const updateStore = useUpdateStore();
const windowStore = useWindowStore();
const systemStore = useSystemStore();
const {t} = useI18n();
const router = useRouter();
// 当前块是否支持格式化的响应式状态
const canFormatCurrentBlock = ref(false);
// 窗口置顶状态管理(仅当前窗口,不同步到配置文件)
const isCurrentWindowOnTop = ref(false);
const setWindowAlwaysOnTop = async (isTop: boolean) => {
await runtime.Window.SetAlwaysOnTop(isTop);
};
// 窗口置顶状态 - 合并配置和临时状态
const isCurrentWindowOnTop = computed(() => {
return configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
});
// 切换窗口置顶状态
const toggleAlwaysOnTop = async () => {
isCurrentWindowOnTop.value = !isCurrentWindowOnTop.value;
await runtime.Window.SetAlwaysOnTop(isCurrentWindowOnTop.value);
const currentlyOnTop = isCurrentWindowOnTop.value;
if (currentlyOnTop) {
// 如果当前是置顶状态,彻底关闭所有置顶
if (configStore.config.general.alwaysOnTop) {
await configStore.setAlwaysOnTop(false);
}
await systemStore.setWindowOnTop(false);
} else {
// 如果当前不是置顶状态,开启临时置顶
await systemStore.setWindowOnTop(true);
}
};
// 跳转到设置页面
@@ -62,8 +73,8 @@ const updateFormatButtonState = () => {
// 检查块和语言格式化支持
canFormatCurrentBlock.value = !!(
activeBlock &&
getLanguage(activeBlock.language.name as any)?.prettier
activeBlock &&
getLanguage(activeBlock.language.name as any)?.prettier
);
} catch (error) {
console.warn('Error checking format capability:', error);
@@ -80,7 +91,7 @@ const debouncedUpdateFormatButton = (() => {
timeout = window.setTimeout(() => {
updateFormatButtonState();
timeout = null;
}, 300);
}, 1000);
};
})();
@@ -89,9 +100,9 @@ const setupEditorListeners = (view: any) => {
if (!view?.dom) return [];
const events = [
{ type: 'click', handler: updateFormatButtonState },
{ type: 'keyup', handler: debouncedUpdateFormatButton },
{ type: 'focus', handler: updateFormatButtonState }
{type: 'click', handler: updateFormatButtonState},
{type: 'keyup', handler: debouncedUpdateFormatButton},
{type: 'focus', handler: updateFormatButtonState}
];
// 注册所有事件
@@ -99,7 +110,7 @@ const setupEditorListeners = (view: any) => {
// 返回清理函数数组
return events.map(event =>
() => view.dom.removeEventListener(event.type, event.handler)
() => view.dom.removeEventListener(event.type, event.handler)
);
};
@@ -107,22 +118,22 @@ const setupEditorListeners = (view: any) => {
let cleanupListeners: (() => void)[] = [];
watch(
() => editorStore.editorView,
(newView) => {
// 清理旧监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
() => editorStore.editorView,
(newView) => {
// 清理旧监听器
cleanupListeners.forEach(cleanup => cleanup());
cleanupListeners = [];
if (newView) {
// 初始更新状态
updateFormatButtonState();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
}
},
{ immediate: true }
if (newView) {
// 初始更新状态
updateFormatButtonState();
// 设置新监听器
cleanupListeners = setupEditorListeners(newView);
} else {
canFormatCurrentBlock.value = false;
}
},
{immediate: true}
);
// 组件生命周期
@@ -143,12 +154,28 @@ onUnmounted(() => {
// 组件加载后初始化置顶状态
watch(isLoaded, async (loaded) => {
if (loaded) {
// 初始化时从配置文件读取置顶状态
isCurrentWindowOnTop.value = configStore.config.general.alwaysOnTop;
await setWindowAlwaysOnTop(isCurrentWindowOnTop.value);
// 应用合并后的置顶状态
const shouldBeOnTop = configStore.config.general.alwaysOnTop || systemStore.isWindowOnTop;
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to apply window pin state:', error);
}
}
});
// 监听配置变化,同步窗口状态
watch(
() => isCurrentWindowOnTop.value,
async (shouldBeOnTop) => {
try {
await runtime.Window.SetAlwaysOnTop(shouldBeOnTop);
} catch (error) {
console.error('Failed to sync window pin state:', error);
}
}
);
const handleUpdateButtonClick = async () => {
if (updateStore.hasUpdate && !updateStore.isUpdating && !updateStore.updateSuccess) {
// 开始下载更新
@@ -230,20 +257,23 @@ const updateButtonTitle = computed(() => {
@click="handleUpdateButtonClick"
>
<!-- 检查更新中 -->
<svg v-if="updateStore.isChecking" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-if="updateStore.isChecking" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
<!-- 下载更新中 -->
<svg v-else-if="updateStore.isUpdating" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-else-if="updateStore.isUpdating" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="rotating">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
<path d="M12 2a10 10 0 1 0 10 10"></path>
</svg>
<!-- 更新成功等待重启 -->
<svg v-else-if="updateStore.updateSuccess" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
<svg v-else-if="updateStore.updateSuccess" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pulsing">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
@@ -252,7 +282,8 @@ const updateButtonTitle = computed(() => {
<!-- 有更新可用 -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="7.5,10.5 12,15 16.5,10.5"/>
<polyline points="12,15 12,3"/>
</svg>

View File

@@ -119,6 +119,7 @@ export default {
general: 'General',
editing: 'Editor',
appearance: 'Appearance',
backupPage: 'Backup',
keyBindings: 'Key Bindings',
updates: 'Updates',
reset: 'Reset',
@@ -133,6 +134,7 @@ export default {
showInSystemTray: 'Show in System Tray',
enableSystemTray: 'Enable System Tray',
alwaysOnTop: 'Always on Top',
enableWindowSnap: 'Enable Window Snapping',
startup: 'Startup Settings',
startAtLogin: 'Start at Login',
dataStorage: 'Data Storage',
@@ -242,6 +244,49 @@ export default {
restartNow: 'Restart Now',
hotkeyPreview: 'Preview:',
none: 'None',
backup: {
basicSettings: 'Basic Settings',
enableBackup: 'Enable Git Backup',
autoBackup: 'Auto Backup',
backupInterval: 'Backup Interval',
intervals: {
'5min': '5 minutes',
'10min': '10 minutes',
'15min': '15 minutes',
'30min': '30 minutes',
'1hour': '1 hour'
},
repositoryConfig: 'Repository Configuration',
repoUrl: 'Repository URL',
repoUrlPlaceholder: 'Enter Git repository URL',
authConfig: 'Authentication Configuration',
authMethod: 'Authentication Method',
authMethods: {
token: 'Access Token',
sshKey: 'SSH Key',
userPass: 'Username/Password'
},
username: 'Username',
usernamePlaceholder: 'Enter username',
password: 'Password',
passwordPlaceholder: 'Enter password',
token: 'Access Token',
tokenPlaceholder: 'Enter access token',
sshKeyPath: 'SSH Key Path',
sshKeyPathPlaceholder: 'Select SSH key file',
sshKeyPassphrase: 'SSH Key Passphrase',
sshKeyPassphrasePlaceholder: 'Enter SSH key passphrase',
backupOperations: 'Backup Operations',
pushToRemote: 'Push to Remote',
pushing: 'Pushing...',
actions: {
push: 'Push',
},
status: {
success: 'Success',
failed: 'Failed'
}
},
},
extensions: {
rainbowBrackets: {

View File

@@ -119,6 +119,7 @@ export default {
general: '常规',
editing: '编辑器',
appearance: '外观',
backupPage: '备份',
extensions: '扩展',
keyBindings: '快捷键',
updates: '更新',
@@ -134,6 +135,7 @@ export default {
showInSystemTray: '在系统托盘中显示',
enableSystemTray: '启用系统托盘',
alwaysOnTop: '窗口始终置顶',
enableWindowSnap: '启用窗口吸附',
startup: '启动设置',
startAtLogin: '开机自启动',
dataStorage: '数据存储',
@@ -243,6 +245,49 @@ export default {
},
hotkeyPreview: '预览:',
none: '无',
backup: {
basicSettings: '基本设置',
enableBackup: '启用备份',
autoBackup: '自动备份',
backupInterval: '备份间隔',
intervals: {
'5min': '5分钟',
'10min': '10分钟',
'15min': '15分钟',
'30min': '30分钟',
'1hour': '1小时'
},
repositoryConfig: '仓库配置',
repoUrl: '仓库地址',
repoUrlPlaceholder: '请输入Git仓库地址',
authConfig: '认证配置',
authMethod: '认证方式',
authMethods: {
token: '访问令牌',
sshKey: 'SSH密钥',
userPass: '用户名密码'
},
username: '用户名',
usernamePlaceholder: '请输入用户名',
password: '密码',
passwordPlaceholder: '请输入密码',
token: '访问令牌',
tokenPlaceholder: '请输入访问令牌',
sshKeyPath: 'SSH密钥路径',
sshKeyPathPlaceholder: '请选择SSH密钥文件',
sshKeyPassphrase: 'SSH密钥密码',
sshKeyPassphrasePlaceholder: '请输入SSH密钥密码',
backupOperations: '备份操作',
pushToRemote: '推送到远程',
pushing: '推送中...',
actions: {
push: '推送',
},
status: {
success: '成功',
failed: '失败'
}
},
},
extensions: {
rainbowBrackets: {

View File

@@ -7,6 +7,7 @@ import AppearancePage from '@/views/settings/pages/AppearancePage.vue';
import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue';
import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue';
import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue';
import BackupPage from '@/views/settings/pages/BackupPage.vue';
const routes: RouteRecordRaw[] = [
{
@@ -49,6 +50,11 @@ const routes: RouteRecordRaw[] = [
path: 'updates',
name: 'SettingsUpdates',
component: UpdatesPage
},
{
path: 'backup',
name: 'SettingsBackup',
component: BackupPage
}
]
}

View File

@@ -0,0 +1,123 @@
import {defineStore} from 'pinia'
import {computed, readonly, ref} from 'vue'
import type {GitBackupConfig} from '@/../bindings/voidraft/internal/models'
import {BackupService} from '@/../bindings/voidraft/internal/services'
import {useConfigStore} from '@/stores/configStore'
/**
* Minimalist Backup Store
*/
export const useBackupStore = defineStore('backup', () => {
// Core state
const config = ref<GitBackupConfig | null>(null)
const isPushing = ref(false)
const error = ref<string | null>(null)
const isInitialized = ref(false)
// Backup result states
const pushSuccess = ref(false)
const pushError = ref(false)
// Timers for auto-hiding status icons and error messages
let pushStatusTimer: number | null = null
let errorTimer: number | null = null
// 获取configStore
const configStore = useConfigStore()
// Computed properties
const isEnabled = computed(() => configStore.config.backup.enabled)
const isConfigured = computed(() => configStore.config.backup.repo_url)
// 清除状态显示
const clearPushStatus = () => {
if (pushStatusTimer !== null) {
window.clearTimeout(pushStatusTimer)
pushStatusTimer = null
}
pushSuccess.value = false
pushError.value = false
}
// 清除错误信息和错误图标
const clearError = () => {
if (errorTimer !== null) {
window.clearTimeout(errorTimer)
errorTimer = null
}
error.value = null
pushError.value = false
}
// 设置错误信息和错误图标并自动清除
const setErrorWithAutoHide = (errorMessage: string, hideAfter: number = 3000) => {
clearError()
clearPushStatus()
error.value = errorMessage
pushError.value = true
errorTimer = window.setTimeout(() => {
error.value = null
pushError.value = false
errorTimer = null
}, hideAfter)
}
// Push to remote repository
const pushToRemote = async () => {
if (isPushing.value || !isConfigured.value) return
isPushing.value = true
clearError() // 清除之前的错误信息
clearPushStatus()
try {
await BackupService.PushToRemote()
// 显示成功状态并设置3秒后自动消失
pushSuccess.value = true
pushStatusTimer = window.setTimeout(() => {
pushSuccess.value = false
pushStatusTimer = null
}, 3000)
} catch (err: any) {
setErrorWithAutoHide(err?.message || 'Backup operation failed')
} finally {
isPushing.value = false
}
}
// 初始化备份服务
const initialize = async () => {
if (!isEnabled.value) return
// 避免重复初始化
if (isInitialized.value) return
clearError() // 清除之前的错误信息
try {
await BackupService.Initialize()
isInitialized.value = true
} catch (err: any) {
setErrorWithAutoHide(err?.message || 'Failed to initialize backup service')
}
}
return {
// State
config: readonly(config),
isPushing: readonly(isPushing),
error: readonly(error),
isInitialized: readonly(isInitialized),
pushSuccess: readonly(pushSuccess),
pushError: readonly(pushError),
// Computed
isEnabled,
isConfigured,
// Methods
pushToRemote,
initialize,
clearError
}
})

View File

@@ -11,10 +11,11 @@ import {
TabType,
UpdatesConfig,
UpdateSourceType,
GitBackupConfig,
AuthMethod
} from '@/../bindings/voidraft/internal/models/models';
import {useI18n} from 'vue-i18n';
import {ConfigUtils} from '@/utils/configUtils';
import {WindowController} from '@/utils/windowController';
import * as runtime from '@wailsio/runtime';
// 国际化相关导入
export type SupportedLocaleType = 'zh-CN' | 'en-US';
@@ -48,6 +49,10 @@ type UpdatesConfigKeyMap = {
readonly [K in keyof UpdatesConfig]: string;
};
type BackupConfigKeyMap = {
readonly [K in keyof GitBackupConfig]: string;
};
type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
// 配置键映射
@@ -57,7 +62,8 @@ const GENERAL_CONFIG_KEY_MAP: GeneralConfigKeyMap = {
enableSystemTray: 'general.enableSystemTray',
startAtLogin: 'general.startAtLogin',
enableGlobalHotkey: 'general.enableGlobalHotkey',
globalHotkey: 'general.globalHotkey'
globalHotkey: 'general.globalHotkey',
enableWindowSnap: 'general.enableWindowSnap',
} as const;
const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
@@ -73,8 +79,7 @@ const EDITING_CONFIG_KEY_MAP: EditingConfigKeyMap = {
const APPEARANCE_CONFIG_KEY_MAP: AppearanceConfigKeyMap = {
language: 'appearance.language',
systemTheme: 'appearance.systemTheme',
customTheme: 'appearance.customTheme'
systemTheme: 'appearance.systemTheme'
} as const;
const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
@@ -88,6 +93,20 @@ const UPDATES_CONFIG_KEY_MAP: UpdatesConfigKeyMap = {
gitea: 'updates.gitea'
} as const;
const BACKUP_CONFIG_KEY_MAP: BackupConfigKeyMap = {
enabled: 'backup.enabled',
repo_url: 'backup.repo_url',
auth_method: 'backup.auth_method',
username: 'backup.username',
password: 'backup.password',
token: 'backup.token',
ssh_key_path: 'backup.ssh_key_path',
ssh_key_passphrase: 'backup.ssh_key_passphrase',
backup_interval: 'backup.backup_interval',
auto_backup: 'backup.auto_backup',
} as const;
// 配置限制
const CONFIG_LIMITS = {
fontSize: {min: 12, max: 28, default: 13},
@@ -158,7 +177,8 @@ const DEFAULT_CONFIG: AppConfig = {
alt: true,
win: false,
key: 'X'
}
},
enableWindowSnap: true,
},
editing: {
fontSize: CONFIG_LIMITS.fontSize.default,
@@ -170,80 +190,10 @@ const DEFAULT_CONFIG: AppConfig = {
tabType: CONFIG_LIMITS.tabType.default,
autoSaveDelay: 5000
},
appearance: {
language: LanguageType.LangZhCN,
systemTheme: SystemThemeType.SystemThemeAuto,
customTheme: {
darkTheme: {
// 基础色调
background: '#252B37',
backgroundSecondary: '#213644',
surface: '#474747',
foreground: '#9BB586',
foregroundSecondary: '#9c9c9c',
// 语法高亮
comment: '#6272a4',
keyword: '#ff79c6',
string: '#f1fa8c',
function: '#50fa7b',
number: '#bd93f9',
operator: '#ff79c6',
variable: '#8fbcbb',
type: '#8be9fd',
// 界面元素
cursor: '#fff',
selection: '#0865a9aa',
selectionBlur: '#225377aa',
activeLine: 'rgba(255,255,255,0.04)',
lineNumber: 'rgba(255,255,255, 0.15)',
activeLineNumber: 'rgba(255,255,255, 0.6)',
// 边框分割线
borderColor: '#1e222a',
borderLight: 'rgba(255,255,255, 0.1)',
// 搜索匹配
searchMatch: '#8fbcbb',
matchingBracket: 'rgba(255,255,255,0.1)'
},
lightTheme: {
// 基础色调
background: '#ffffff',
backgroundSecondary: '#f1faf1',
surface: '#f5f5f5',
foreground: '#444d56',
foregroundSecondary: '#6a737d',
// 语法高亮
comment: '#6a737d',
keyword: '#d73a49',
string: '#032f62',
function: '#005cc5',
number: '#005cc5',
operator: '#d73a49',
variable: '#24292e',
type: '#6f42c1',
// 界面元素
cursor: '#000',
selection: '#77baff8c',
selectionBlur: '#b2c2ca85',
activeLine: '#000000',
lineNumber: '#000000',
activeLineNumber: '#000000',
// 边框分割线
borderColor: '#dfdfdf',
borderLight: '#0000000C',
// 搜索匹配
searchMatch: '#005cc5',
matchingBracket: 'rgba(0,0,0,0.1)'
}
}
},
appearance: {
language: LanguageType.LangZhCN,
systemTheme: SystemThemeType.SystemThemeAuto
},
updates: {
version: "1.0.0",
autoUpdate: true,
@@ -261,6 +211,18 @@ const DEFAULT_CONFIG: AppConfig = {
repo: "voidraft",
}
},
backup: {
enabled: false,
repo_url: "",
auth_method: AuthMethod.UserPass,
username: "",
password: "",
token: "",
ssh_key_path: "",
ssh_key_passphrase: "",
backup_interval: 60,
auto_backup: true,
},
metadata: {
version: '1.0.0',
lastUpdated: new Date().toString(),
@@ -348,6 +310,21 @@ export const useConfigStore = defineStore('config', () => {
state.config.updates[key] = value;
};
const updateBackupConfig = async <K extends keyof GitBackupConfig>(key: K, value: GitBackupConfig[K]): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
const backendKey = BACKUP_CONFIG_KEY_MAP[key];
if (!backendKey) {
throw new Error(`No backend key mapping found for backup.${key.toString()}`);
}
await ConfigService.Set(backendKey, value);
state.config.backup[key] = value;
};
// 加载配置
const initConfig = async (): Promise<void> => {
if (state.isLoading) return;
@@ -362,14 +339,12 @@ export const useConfigStore = defineStore('config', () => {
if (appConfig.editing) Object.assign(state.config.editing, appConfig.editing);
if (appConfig.appearance) Object.assign(state.config.appearance, appConfig.appearance);
if (appConfig.updates) Object.assign(state.config.updates, appConfig.updates);
if (appConfig.backup) Object.assign(state.config.backup, appConfig.backup);
if (appConfig.metadata) Object.assign(state.config.metadata, appConfig.metadata);
}
state.configLoaded = true;
// 初始化热键监听器
const windowController = WindowController.getInstance();
await windowController.initializeHotkeyListener();
} finally {
state.isLoading = false;
}
@@ -434,50 +409,7 @@ export const useConfigStore = defineStore('config', () => {
await updateAppearanceConfig('systemTheme', systemTheme);
};
// 更新自定义主题方法
const updateCustomTheme = async (themeType: 'darkTheme' | 'lightTheme', colorKey: string, colorValue: string): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
try {
// 深拷贝当前配置
const customTheme = JSON.parse(JSON.stringify(state.config.appearance.customTheme));
// 更新对应主题的颜色值
customTheme[themeType][colorKey] = colorValue;
// 更新整个自定义主题配置到后端
await ConfigService.Set(APPEARANCE_CONFIG_KEY_MAP.customTheme, customTheme);
// 更新前端状态
state.config.appearance.customTheme = customTheme;
} catch (error) {
throw error;
}
};
// 设置整个自定义主题配置
const setCustomTheme = async (customTheme: any): Promise<void> => {
// 确保配置已加载
if (!state.configLoaded && !state.isLoading) {
await initConfig();
}
try {
// 更新整个自定义主题配置到后端
await ConfigService.Set(APPEARANCE_CONFIG_KEY_MAP.customTheme, customTheme);
// 更新前端状态
state.config.appearance.customTheme = customTheme;
// 确保Vue能检测到变化
state.config.appearance = { ...state.config.appearance };
} catch (error) {
throw error;
}
};
// 初始化语言设置
const initializeLanguage = async (): Promise<void> => {
@@ -542,8 +474,6 @@ export const useConfigStore = defineStore('config', () => {
// 主题相关方法
setSystemTheme,
updateCustomTheme,
setCustomTheme,
// 字体大小操作
...adjusters.fontSize,
@@ -593,7 +523,22 @@ export const useConfigStore = defineStore('config', () => {
await StartupService.SetEnabled(value);
},
// 窗口吸附配置相关方法
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
// 更新配置相关方法
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value)
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
// 备份配置相关方法
setEnableBackup: async (value: boolean) => {await updateBackupConfig('enabled', value);},
setAutoBackup: async (value: boolean) => {await updateBackupConfig('auto_backup', value);},
setRepoUrl: async (value: string) => await updateBackupConfig('repo_url', value),
setAuthMethod: async (value: AuthMethod) => await updateBackupConfig('auth_method', value),
setUsername: async (value: string) => await updateBackupConfig('username', value),
setPassword: async (value: string) => await updateBackupConfig('password', value),
setToken: async (value: string) => await updateBackupConfig('token', value),
setSshKeyPath: async (value: string) => await updateBackupConfig('ssh_key_path', value),
setSshKeyPassphrase: async (value: string) => await updateBackupConfig('ssh_key_passphrase', value),
setBackupInterval: async (value: number) => await updateBackupConfig('backup_interval', value),
};
});

View File

@@ -3,13 +3,15 @@ import {computed, ref} from 'vue';
import {DocumentService} from '@/../bindings/voidraft/internal/services';
import {OpenDocumentWindow} from '@/../bindings/voidraft/internal/services/windowservice';
import {Document} from '@/../bindings/voidraft/internal/models/models';
import {useSystemStore} from './systemStore';
const SCRATCH_DOCUMENT_ID = 1; // 默认草稿文档ID
export const useDocumentStore = defineStore('document', () => {
const DEFAULT_DOCUMENT_ID = ref<number>(1); // 默认草稿文档ID
// === 核心状态 ===
const documents = ref<Record<number, Document>>({});
const recentDocumentIds = ref<number[]>([SCRATCH_DOCUMENT_ID]);
const recentDocumentIds = ref<number[]>([DEFAULT_DOCUMENT_ID.value]);
const currentDocumentId = ref<number | null>(null);
const currentDocument = ref<Document | null>(null);
@@ -159,7 +161,7 @@ export const useDocumentStore = defineStore('document', () => {
const deleteDocument = async (docId: number): Promise<boolean> => {
try {
// 检查是否是默认文档使用ID判断
if (docId === SCRATCH_DOCUMENT_ID) {
if (docId === DEFAULT_DOCUMENT_ID.value) {
return false;
}
@@ -221,6 +223,7 @@ export const useDocumentStore = defineStore('document', () => {
};
return {
DEFAULT_DOCUMENT_ID,
// 状态
documents,
documentList,

View File

@@ -20,13 +20,14 @@ export const useSystemStore = defineStore('system', () => {
const environment = ref<SystemEnvironment | null>(null);
const isLoading = ref(false);
// 窗口置顶状态管理
const isWindowOnTop = ref<boolean>(false);
// 计算属性
const isWindows = computed(() => environment.value?.OS === 'windows');
const isMacOS = computed(() => environment.value?.OS === 'darwin');
const isLinux = computed(() => environment.value?.OS === 'linux');
// 获取标题栏高度
const titleBarHeight = computed(() => {
if (isWindows.value) return '32px';
@@ -49,10 +50,31 @@ export const useSystemStore = defineStore('system', () => {
}
};
// 设置窗口置顶状态
const setWindowOnTop = async (isPinned: boolean): Promise<void> => {
isWindowOnTop.value = isPinned;
try {
await runtime.Window.SetAlwaysOnTop(isPinned);
} catch (error) {
console.error('Failed to set window always on top:', error);
}
};
// 切换窗口置顶状态
const toggleWindowOnTop = async (): Promise<void> => {
await setWindowOnTop(!isWindowOnTop.value);
};
// 重置临时置顶状态不调用系统API
const resetWindowOnTop = (): void => {
isWindowOnTop.value = false;
};
return {
// 状态
environment,
isLoading,
isWindowOnTop,
// 计算属性
isWindows,
@@ -62,5 +84,14 @@ export const useSystemStore = defineStore('system', () => {
// 方法
initializeSystemInfo,
setWindowOnTop,
toggleWindowOnTop,
resetWindowOnTop,
};
}, {
persist: {
key: 'voidraft-system',
storage: localStorage,
pick: ['isWindowOnTop']
}
});

View File

@@ -1,6 +1,7 @@
import {defineStore} from 'pinia';
import {computed, reactive} from 'vue';
import {SystemThemeType} from '@/../bindings/voidraft/internal/models/models';
import {SystemThemeType, ThemeType, ThemeColorConfig} from '@/../bindings/voidraft/internal/models/models';
import {ThemeService} from '@/../bindings/voidraft/internal/services';
import {useConfigStore} from './configStore';
import {useEditorStore} from './editorStore';
import {defaultDarkColors} from '@/views/editor/theme/dark';
@@ -24,16 +25,21 @@ export const useThemeStore = defineStore('theme', () => {
configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto
);
// 初始化主题颜色 - 从配置加载
const initializeThemeColors = () => {
const customTheme = configStore.config?.appearance?.customTheme;
if (customTheme) {
if (customTheme.darkTheme) {
Object.assign(themeColors.darkTheme, customTheme.darkTheme);
// 初始化主题颜色 - 从数据库加载
const initializeThemeColors = async () => {
try {
const themes = await ThemeService.GetDefaultThemes();
if (themes.dark) {
Object.assign(themeColors.darkTheme, themes.dark.colors);
}
if (customTheme.lightTheme) {
Object.assign(themeColors.lightTheme, customTheme.lightTheme);
if (themes.light) {
Object.assign(themeColors.lightTheme, themes.light.colors);
}
} catch (error) {
console.warn('Failed to load themes from database, using defaults:', error);
// 如果数据库加载失败,使用默认主题
Object.assign(themeColors.darkTheme, defaultDarkColors);
Object.assign(themeColors.lightTheme, defaultLightColors);
}
};
@@ -46,10 +52,10 @@ export const useThemeStore = defineStore('theme', () => {
};
// 初始化主题
const initializeTheme = () => {
const initializeTheme = async () => {
const theme = configStore.config?.appearance?.systemTheme || SystemThemeType.SystemThemeAuto;
applyThemeToDOM(theme);
initializeThemeColors();
await initializeThemeColors();
};
// 设置主题
@@ -84,31 +90,35 @@ export const useThemeStore = defineStore('theme', () => {
return hasChanges;
};
// 保存主题颜色到配置
// 保存主题颜色到数据库
const saveThemeColors = async () => {
const customTheme = {
darkTheme: { ...themeColors.darkTheme },
lightTheme: { ...themeColors.lightTheme }
};
try {
const darkColors = ThemeColorConfig.createFrom(themeColors.darkTheme);
const lightColors = ThemeColorConfig.createFrom(themeColors.lightTheme);
await configStore.setCustomTheme(customTheme);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeDark, darkColors);
await ThemeService.UpdateThemeColors(ThemeType.ThemeTypeLight, lightColors);
} catch (error) {
console.error('Failed to save theme colors:', error);
throw error;
}
};
// 重置主题颜色
const resetThemeColors = async (themeType: 'darkTheme' | 'lightTheme') => {
try {
// 1. 更新内存中的颜色状态
const dbThemeType = themeType === 'darkTheme' ? ThemeType.ThemeTypeDark : ThemeType.ThemeTypeLight;
// 1. 调用后端重置服务
await ThemeService.ResetThemeColors(dbThemeType);
// 2. 更新内存中的颜色状态
if (themeType === 'darkTheme') {
Object.assign(themeColors.darkTheme, defaultDarkColors);
}
if (themeType === 'lightTheme') {
} else {
Object.assign(themeColors.lightTheme, defaultLightColors);
}
// 2. 保存到配置
await saveThemeColors();
// 3. 刷新编辑器主题
refreshEditorTheme();

View File

@@ -1,105 +0,0 @@
import * as wails from '@wailsio/runtime'
// 窗口控制工具类
export class WindowController {
private static instance: WindowController;
private currentWindow = wails.Window;
private isWindowVisible: boolean = true; // 跟踪窗口可见状态
private isInitialized: boolean = false; // 跟踪是否已初始化
private isToggling: boolean = false; // 防止重复切换
private lastToggleTime: number = 0; // 上次切换时间
private readonly TOGGLE_COOLDOWN = 500; // 切换冷却时间(毫秒)
static getInstance(): WindowController {
if (!WindowController.instance) {
WindowController.instance = new WindowController();
}
return WindowController.instance;
}
async toggleWindow(): Promise<void> {
const now = Date.now();
// 防抖检查
if (this.isToggling || (now - this.lastToggleTime) < this.TOGGLE_COOLDOWN) {
return;
}
this.isToggling = true;
this.lastToggleTime = now;
try {
// 如果还没初始化,先初始化状态
if (!this.isInitialized) {
await this.syncWindowState();
}
if (!this.isWindowVisible) {
// 窗口当前隐藏,显示它
await this.currentWindow.Show();
await this.currentWindow.UnMinimise(); // 修正API名称
await this.currentWindow.Focus();
this.isWindowVisible = true;
} else {
// 窗口当前可见,隐藏它
await this.currentWindow.Hide();
this.isWindowVisible = false;
}
} catch (error) {
console.error(error);
} finally {
// 延迟重置切换状态,确保操作完成
setTimeout(() => {
this.isToggling = false;
}, 100);
}
}
// 同步窗口状态
private async syncWindowState(): Promise<void> {
try {
// 检查窗口是否最小化
const isMinimised = await this.currentWindow.IsMinimised();
// 简化状态判断:只要不是最小化状态就认为是可见的
this.isWindowVisible = !isMinimised;
this.isInitialized = true;
} catch (error) {
// 如果检查失败,保持默认状态
this.isWindowVisible = true;
this.isInitialized = true;
}
}
// 当窗口被系统事件隐藏时调用(比如点击关闭/最小化按钮)
onWindowHidden(): void {
this.isWindowVisible = false;
}
// 当窗口被系统事件显示时调用(比如点击托盘图标)
onWindowShown(): void {
this.isWindowVisible = true;
}
async initializeHotkeyListener(): Promise<void> {
// 初始化时同步窗口状态
await this.syncWindowState();
// 监听后端发送的热键事件
wails.Events.On('hotkey:toggle-window', () => {
this.toggleWindow();
});
// 监听窗口显示/隐藏事件以同步状态
wails.Events.On('window:shown', () => {
this.onWindowShown();
});
wails.Events.On('window:hidden', () => {
this.onWindowHidden();
});
}
}

View File

@@ -13,6 +13,7 @@ const navItems = [
{ id: 'general', icon: '⚙️', route: '/settings/general' },
{ id: 'editing', icon: '✏️', route: '/settings/editing' },
{ id: 'appearance', icon: '🎨', route: '/settings/appearance' },
{ id: 'backupPage', icon: '🔗', route: '/settings/backup' },
{ id: 'extensions', icon: '🧩', route: '/settings/extensions' },
{ id: 'keyBindings', icon: '⌨️', route: '/settings/key-bindings' },
{ id: 'updates', icon: '🔄', route: '/settings/updates' }
@@ -194,8 +195,8 @@ const goBackToEditor = async () => {
.settings-content {
flex: 1;
height: 100%;
padding: 20px;
overflow-y: auto;
padding: 0 20px;
overflow-y: scroll;
background-color: var(--settings-bg);
}
}

View File

@@ -16,8 +16,8 @@ const themeStore = useThemeStore();
// 添加临时颜色状态
const tempColors = ref({
darkTheme: { ...configStore.config.appearance.customTheme?.darkTheme || defaultDarkColors },
lightTheme: { ...configStore.config.appearance.customTheme?.lightTheme || defaultLightColors }
darkTheme: { ...defaultDarkColors },
lightTheme: { ...defaultLightColors }
});
// 标记是否有未保存的更改
@@ -71,9 +71,9 @@ const currentThemeMode = computed(() => {
return isDark ? 'dark' : 'light';
});
// 监听配置变更,更新临时颜色
// 监听主题颜色变更,更新临时颜色
watch(
() => configStore.config.appearance.customTheme,
() => themeStore.themeColors,
(newValue) => {
if (!hasUnsavedChanges.value) {
tempColors.value = {

View File

@@ -0,0 +1,503 @@
<script setup lang="ts">
import {useConfigStore} from '@/stores/configStore';
import {useBackupStore} from '@/stores/backupStore';
import {useI18n} from 'vue-i18n';
import {computed, onMounted, onUnmounted} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import {AuthMethod} from '@/../bindings/voidraft/internal/models/models';
import {DialogService} from '@/../bindings/voidraft/internal/services';
const {t} = useI18n();
const configStore = useConfigStore();
const backupStore = useBackupStore();
// 确保配置已加载
onMounted(async () => {
if (!configStore.configLoaded) {
await configStore.initConfig();
}
});
onUnmounted(() => {
backupStore.clearError();
})
// 认证方式选项
const authMethodOptions = computed(() => [
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
{value: AuthMethod.UserPass, label: t('settings.backup.authMethods.userPass')}
]);
// 备份间隔选项(分钟)
const backupIntervalOptions = computed(() => [
{value: 5, label: t('settings.backup.intervals.5min')},
{value: 10, label: t('settings.backup.intervals.10min')},
{value: 15, label: t('settings.backup.intervals.15min')},
{value: 30, label: t('settings.backup.intervals.30min')},
{value: 60, label: t('settings.backup.intervals.1hour')}
]);
// 计算属性 - 启用备份
const enableBackup = computed({
get: () => configStore.config.backup.enabled,
set: (value: boolean) => configStore.setEnableBackup(value)
});
// 计算属性 - 自动备份
const autoBackup = computed({
get: () => configStore.config.backup.auto_backup,
set: (value: boolean) => configStore.setAutoBackup(value)
});
// 仓库URL
const repoUrl = computed({
get: () => configStore.config.backup.repo_url,
set: (value: string) => configStore.setRepoUrl(value)
});
// 认证方式
const authMethod = computed({
get: () => configStore.config.backup.auth_method,
set: (value: AuthMethod) => configStore.setAuthMethod(value)
});
// 备份间隔
const backupInterval = computed({
get: () => configStore.config.backup.backup_interval,
set: (value: number) => configStore.setBackupInterval(value)
});
// 用户名
const username = computed({
get: () => configStore.config.backup.username,
set: (value: string) => configStore.setUsername(value)
});
// 密码
const password = computed({
get: () => configStore.config.backup.password,
set: (value: string) => configStore.setPassword(value)
});
// 访问令牌
const token = computed({
get: () => configStore.config.backup.token,
set: (value: string) => configStore.setToken(value)
});
// SSH密钥路径
const sshKeyPath = computed({
get: () => configStore.config.backup.ssh_key_path,
set: (value: string) => configStore.setSshKeyPath(value)
});
// SSH密钥密码
const sshKeyPassphrase = computed({
get: () => configStore.config.backup.ssh_key_passphrase,
set: (value: string) => configStore.setSshKeyPassphrase(value)
});
// 处理输入变化
const handleRepoUrlChange = (event: Event) => {
const target = event.target as HTMLInputElement;
repoUrl.value = target.value;
};
const handleUsernameChange = (event: Event) => {
const target = event.target as HTMLInputElement;
username.value = target.value;
};
const handlePasswordChange = (event: Event) => {
const target = event.target as HTMLInputElement;
password.value = target.value;
};
const handleTokenChange = (event: Event) => {
const target = event.target as HTMLInputElement;
token.value = target.value;
};
const handleSshKeyPassphraseChange = (event: Event) => {
const target = event.target as HTMLInputElement;
sshKeyPassphrase.value = target.value;
};
const handleAuthMethodChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
authMethod.value = target.value as AuthMethod;
};
const handleBackupIntervalChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
backupInterval.value = parseInt(target.value);
};
// 推送到远程
const pushToRemote = async () => {
await backupStore.pushToRemote();
};
// 选择SSH密钥文件
const selectSshKeyFile = async () => {
// 使用DialogService选择文件
const selectedPath = await DialogService.SelectFile();
// 检查用户是否取消了选择或路径为空
if (!selectedPath.trim()) {
return;
}
// 更新SSH密钥路径
sshKeyPath.value = selectedPath.trim();
};
</script>
<template>
<div class="settings-page">
<!-- 基本设置 -->
<SettingSection :title="t('settings.backup.basicSettings')">
<SettingItem
:title="t('settings.backup.enableBackup')"
>
<ToggleSwitch v-model="enableBackup"/>
</SettingItem>
<SettingItem
:title="t('settings.backup.autoBackup')"
:class="{ 'disabled-setting': !enableBackup }"
>
<ToggleSwitch v-model="autoBackup" :disabled="!enableBackup"/>
</SettingItem>
<SettingItem
:title="t('settings.backup.backupInterval')"
:class="{ 'disabled-setting': !enableBackup || !autoBackup }"
>
<select
class="backup-interval-select"
:value="backupInterval"
@change="handleBackupIntervalChange"
:disabled="!enableBackup || !autoBackup"
>
<option
v-for="option in backupIntervalOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</SettingItem>
</SettingSection>
<!-- 仓库配置 -->
<SettingSection :title="t('settings.backup.repositoryConfig')">
<SettingItem
:title="t('settings.backup.repoUrl')"
>
<input
type="text"
class="repo-url-input"
:value="repoUrl"
@input="handleRepoUrlChange"
:placeholder="t('settings.backup.repoUrlPlaceholder')"
:disabled="!enableBackup"
/>
</SettingItem>
</SettingSection>
<!-- 认证配置 -->
<SettingSection :title="t('settings.backup.authConfig')">
<SettingItem
:title="t('settings.backup.authMethod')"
>
<select
class="auth-method-select"
:value="authMethod"
@change="handleAuthMethodChange"
:disabled="!enableBackup"
>
<option
v-for="option in authMethodOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</SettingItem>
<!-- 用户名密码认证 -->
<template v-if="authMethod === AuthMethod.UserPass">
<SettingItem :title="t('settings.backup.username')">
<input
type="text"
class="username-input"
:value="username"
@input="handleUsernameChange"
:placeholder="t('settings.backup.usernamePlaceholder')"
:disabled="!enableBackup"
/>
</SettingItem>
<SettingItem :title="t('settings.backup.password')">
<input
type="password"
class="password-input"
:value="password"
@input="handlePasswordChange"
:placeholder="t('settings.backup.passwordPlaceholder')"
:disabled="!enableBackup"
/>
</SettingItem>
</template>
<!-- 访问令牌认证 -->
<template v-if="authMethod === AuthMethod.Token">
<SettingItem
:title="t('settings.backup.token')"
>
<input
type="password"
class="token-input"
:value="token"
@input="handleTokenChange"
:placeholder="t('settings.backup.tokenPlaceholder')"
:disabled="!enableBackup"
/>
</SettingItem>
</template>
<!-- SSH密钥认证 -->
<template v-if="authMethod === AuthMethod.SSHKey">
<SettingItem
:title="t('settings.backup.sshKeyPath')"
>
<input
type="text"
class="ssh-key-path-input"
:value="sshKeyPath"
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
:disabled="!enableBackup"
readonly
@click="enableBackup && selectSshKeyFile()"
/>
</SettingItem>
<SettingItem
:title="t('settings.backup.sshKeyPassphrase')"
>
<input
type="password"
class="ssh-passphrase-input"
:value="sshKeyPassphrase"
@input="handleSshKeyPassphraseChange"
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
:disabled="!enableBackup"
/>
</SettingItem>
</template>
</SettingSection>
<!-- 备份操作 -->
<SettingSection :title="t('settings.backup.backupOperations')">
<SettingItem
:title="t('settings.backup.pushToRemote')"
>
<div class="backup-operation-container">
<div class="backup-status-icons">
<span v-if="backupStore.pushSuccess" class="success-icon"></span>
<span v-if="backupStore.pushError" class="error-icon"></span>
</div>
<button
class="push-button"
@click="() => pushToRemote()"
:disabled="!enableBackup || !repoUrl || backupStore.isPushing"
:class="{ 'backing-up': backupStore.isPushing }"
>
<span v-if="backupStore.isPushing" class="loading-spinner"></span>
{{ backupStore.isPushing ? t('settings.backup.pushing') : t('settings.backup.actions.push') }}
</button>
</div>
</SettingItem>
<div v-if="backupStore.error" class="error-message-row">
{{ backupStore.error }}
</div>
</SettingSection>
</div>
</template>
<style scoped lang="scss">
.settings-page {
max-width: 800px;
}
// 统一的输入控件样式
.repo-url-input,
.branch-input,
.username-input,
.password-input,
.token-input,
.ssh-key-path-input,
.ssh-passphrase-input,
.backup-interval-select,
.auth-method-select {
width: 50%;
min-width: 200px;
padding: 10px 12px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
font-size: 12px;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #4a9eff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--settings-hover);
}
&::placeholder {
color: var(--settings-text-secondary);
}
&[readonly]:not(:disabled) {
cursor: pointer;
&:hover {
border-color: var(--settings-hover);
background-color: var(--settings-hover);
}
}
}
// 选择框特有样式
.backup-interval-select,
.auth-method-select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 30px;
option {
background-color: var(--settings-input-bg);
color: var(--settings-text);
}
}
// 备份操作容器
.backup-operation-container {
display: flex;
align-items: center;
gap: 12px;
}
// 备份状态图标
.backup-status-icons {
width: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 8px;
}
// 成功和错误图标
.success-icon {
color: #4caf50;
font-size: 18px;
font-weight: bold;
}
.error-icon {
color: #f44336;
font-size: 18px;
font-weight: bold;
}
// 按钮样式
.push-button {
padding: 8px 16px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
&:hover:not(:disabled) {
background-color: var(--settings-hover);
border-color: var(--settings-border);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: var(--settings-text);
animation: spin 1s linear infinite;
}
&.backing-up {
background-color: #2196f3;
border-color: #2196f3;
color: white;
}
}
// 错误信息行样式
.error-message-row {
color: #f44336;
font-size: 11px;
line-height: 1.4;
word-wrap: break-word;
margin-top: 8px;
padding: 8px 16px;
background-color: rgba(244, 67, 54, 0.1);
border-left: 3px solid #f44336;
border-radius: 4px;
}
// 禁用状态
.disabled-setting {
opacity: 0.5;
pointer-events: none;
}
// 加载动画
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -159,6 +159,12 @@ const enableSystemTray = computed({
set: (value: boolean) => configStore.setEnableSystemTray(value)
});
// 计算属性 - 启用窗口吸附
const enableWindowSnap = computed({
get: () => configStore.config.general.enableWindowSnap,
set: (value: boolean) => configStore.setEnableWindowSnap(value)
});
// 计算属性 - 开机启动
const startAtLogin = computed({
get: () => configStore.config.general.startAtLogin,
@@ -334,6 +340,9 @@ onUnmounted(() => {
<SettingItem :title="t('settings.enableSystemTray')">
<ToggleSwitch v-model="enableSystemTray"/>
</SettingItem>
<SettingItem :title="t('settings.enableWindowSnap')">
<ToggleSwitch v-model="enableWindowSnap"/>
</SettingItem>
</SettingSection>
<SettingSection :title="t('settings.startup')">

View File

@@ -266,6 +266,7 @@ const currentVersion = computed(() => {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--settings-border, rgba(0,0,0,0.1));
background: transparent;
.notes-title {
font-size: 12px;
@@ -278,24 +279,76 @@ const currentVersion = computed(() => {
font-size: 12px;
color: var(--settings-text);
line-height: 1.4;
background: transparent;
/* Markdown内容样式 */
:deep(p) {
margin: 0 0 6px 0;
background: transparent;
}
:deep(ul), :deep(ol) {
margin: 6px 0;
padding-left: 16px;
background: transparent;
}
:deep(li) {
margin-bottom: 4px;
background: transparent;
}
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin: 10px 0 6px 0;
font-size: 13px;
background: transparent;
}
:deep(pre), :deep(code) {
background-color: var(--settings-code-bg, rgba(0,0,0,0.05));
border-radius: 3px;
padding: 2px 4px;
font-family: monospace;
}
:deep(pre) {
padding: 8px;
overflow-x: auto;
margin: 6px 0;
}
:deep(blockquote) {
border-left: 3px solid var(--settings-border, rgba(0,0,0,0.1));
margin: 6px 0;
padding-left: 10px;
color: var(--settings-text-secondary, #757575);
background: transparent;
}
:deep(a) {
color: var(--theme-primary, #2196f3);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(table) {
border-collapse: collapse;
width: 100%;
margin: 6px 0;
background: transparent;
}
:deep(th), :deep(td) {
border: 1px solid var(--settings-border, rgba(0,0,0,0.1));
padding: 4px 8px;
background: transparent;
}
:deep(th) {
background-color: var(--settings-table-header-bg, rgba(0,0,0,0.02));
}
}
}

27
go.mod
View File

@@ -3,29 +3,33 @@ module voidraft
go 1.24.4
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/creativeprojects/go-selfupdate v1.5.0
github.com/go-git/go-git/v5 v5.16.2
github.com/knadh/koanf/parsers/json v1.0.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/structs v1.0.0
github.com/knadh/koanf/v2 v2.2.2
github.com/robertkrimen/otto v0.5.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.11
golang.org/x/net v0.42.0
golang.org/x/sys v0.34.0
golang.org/x/text v0.27.0
github.com/stretchr/testify v1.10.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.25
golang.org/x/net v0.43.0
golang.org/x/sys v0.35.0
golang.org/x/text v0.28.0
modernc.org/sqlite v1.38.2
)
require (
code.gitea.io/sdk/gitea v0.21.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
@@ -35,9 +39,8 @@ require (
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-git/v5 v5.16.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
@@ -61,26 +64,26 @@ require (
github.com/pjbgf/sha1cd v0.4.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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/ulikunitz/xz v0.5.14 // indirect
github.com/wailsapp/go-webview2 v1.0.21 // indirect
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
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/libc v1.66.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
)

60
go.sum
View File

@@ -58,8 +58,8 @@ github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77
github.com/go-git/go-git/v5 v5.16.2/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-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@@ -158,14 +158,14 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg=
github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/wailsapp/go-webview2 v1.0.21 h1:k3dtoZU4KCoN/AEIbWiPln3P2661GtA2oEgA2Pb+maA=
github.com/wailsapp/go-webview2 v1.0.21/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.11 h1:MYZk2ci8fBd3loWanLzAYgAFcmq4qTRFyNggVqHMaHY=
github.com/wailsapp/wails/v3 v3.0.0-alpha.11/go.mod h1:4LCCW7s9e4PuSmu7l9OTvfWIGMO8TaSiftSeR5NpBIc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.25 h1:o05zUiPEvmrq2lqqCs4wqnrnAjGmhryYHRhjQmtkvk8=
github.com/wailsapp/wails/v3 v3.0.0-alpha.25/go.mod h1:UZpnhYuju4saspCJrIHAvC0H5XjtKnqd26FRxJLrQ0M=
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=
@@ -174,19 +174,19 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
@@ -203,21 +203,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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
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=
@@ -234,18 +234,18 @@ 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=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -254,8 +254,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

28
internal/models/backup.go Normal file
View File

@@ -0,0 +1,28 @@
package models
// Git备份相关类型定义
type (
// AuthMethod 定义Git认证方式
AuthMethod string
)
const (
// 认证方式
Token AuthMethod = "token"
SSHKey AuthMethod = "ssh_key"
UserPass AuthMethod = "user_pass"
)
// GitBackupConfig Git备份配置
type GitBackupConfig struct {
Enabled bool `json:"enabled"`
RepoURL string `json:"repo_url"`
AuthMethod AuthMethod `json:"auth_method"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
SSHKeyPath string `json:"ssh_key_path,omitempty"`
SSHKeyPass string `json:"ssh_key_passphrase,omitempty"`
BackupInterval int `json:"backup_interval"` // 分钟
AutoBackup bool `json:"auto_backup"`
}

View File

@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"time"
"voidraft/internal/version"
)
// TabType 定义了制表符类型
@@ -68,6 +69,9 @@ type GeneralConfig struct {
EnableSystemTray bool `json:"enableSystemTray"` // 是否启用系统托盘
StartAtLogin bool `json:"startAtLogin"` // 开机启动设置
// 窗口吸附设置
EnableWindowSnap bool `json:"enableWindowSnap"` // 是否启用窗口吸附功能(阈值现在是自适应的)
// 全局热键设置
EnableGlobalHotkey bool `json:"enableGlobalHotkey"` // 是否启用全局热键
GlobalHotkey HotkeyCombo `json:"globalHotkey"` // 全局热键组合
@@ -101,9 +105,8 @@ type EditingConfig struct {
// AppearanceConfig 外观设置配置
type AppearanceConfig struct {
Language LanguageType `json:"language"` // 界面语言
SystemTheme SystemThemeType `json:"systemTheme"` // 系统界面主题
CustomTheme CustomThemeConfig `json:"customTheme"` // 自定义主题配置
Language LanguageType `json:"language"` // 界面语言
SystemTheme SystemThemeType `json:"systemTheme"` // 系统界面主题
}
// UpdatesConfig 更新设置配置
@@ -124,6 +127,7 @@ type AppConfig struct {
Editing EditingConfig `json:"editing"` // 编辑设置
Appearance AppearanceConfig `json:"appearance"` // 外观设置
Updates UpdatesConfig `json:"updates"` // 更新设置
Backup GitBackupConfig `json:"backup"` // Git备份设置
Metadata ConfigMetadata `json:"metadata"` // 配置元数据
}
@@ -145,6 +149,7 @@ func NewDefaultAppConfig() *AppConfig {
DataPath: dataDir,
EnableSystemTray: true,
StartAtLogin: false,
EnableWindowSnap: true, // 默认启用窗口吸附
EnableGlobalHotkey: false,
GlobalHotkey: HotkeyCombo{
Ctrl: false,
@@ -170,13 +175,12 @@ func NewDefaultAppConfig() *AppConfig {
Appearance: AppearanceConfig{
Language: LangEnUS,
SystemTheme: SystemThemeAuto,
CustomTheme: *NewDefaultCustomThemeConfig(),
},
Updates: UpdatesConfig{
Version: "1.0.0",
Version: version.Version,
AutoUpdate: true,
PrimarySource: UpdateSourceGithub,
BackupSource: UpdateSourceGitea,
PrimarySource: UpdateSourceGitea,
BackupSource: UpdateSourceGithub,
BackupBeforeUpdate: true,
UpdateTimeout: 30,
Github: GithubConfig{
@@ -189,9 +193,20 @@ func NewDefaultAppConfig() *AppConfig {
Repo: "voidraft",
},
},
Backup: GitBackupConfig{
Enabled: false,
RepoURL: "",
AuthMethod: UserPass,
Username: "",
Password: "",
Token: "",
SSHKeyPath: "",
BackupInterval: 60,
AutoBackup: false,
},
Metadata: ConfigMetadata{
LastUpdated: time.Now().Format(time.RFC3339),
Version: "1.0.0",
Version: version.Version,
},
}
}

View File

@@ -11,10 +11,11 @@ type Document struct {
Content string `json:"content" db:"content"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
IsDeleted bool `json:"is_deleted"`
IsDeleted bool `json:"is_deleted" db:"is_deleted"`
IsLocked bool `json:"is_locked" db:"is_locked"` // 锁定标志,锁定的文档无法被删除
}
// NewDocument 创建新文档不需要传ID由数据库自增
// NewDocument 创建新文档
func NewDocument(title, content string) *Document {
now := time.Now()
return &Document{
@@ -23,6 +24,7 @@ func NewDocument(title, content string) *Document {
CreatedAt: now,
UpdatedAt: now,
IsDeleted: false,
IsLocked: false, // 默认不锁定
}
}

View File

@@ -4,10 +4,10 @@ import "time"
// Extension 单个扩展配置
type Extension struct {
ID ExtensionID `json:"id"` // 扩展唯一标识
Enabled bool `json:"enabled"` // 是否启用
IsDefault bool `json:"isDefault"` // 是否为默认扩展
Config ExtensionConfig `json:"config"` // 扩展配置项
ID ExtensionID `json:"id" db:"id"` // 扩展唯一标识
Enabled bool `json:"enabled" db:"enabled"` // 是否启用
IsDefault bool `json:"isDefault" db:"is_default"` // 是否为默认扩展
Config ExtensionConfig `json:"config" db:"config"` // 扩展配置项
}
// ExtensionID 扩展标识符

View File

@@ -4,11 +4,11 @@ import "time"
// KeyBinding 单个快捷键绑定
type KeyBinding struct {
Command KeyBindingCommand `json:"command"` // 快捷键动作
Extension ExtensionID `json:"extension"` // 所属扩展
Key string `json:"key"` // 快捷键组合(如 "Mod-f", "Ctrl-Shift-p"
Enabled bool `json:"enabled"` // 是否启用
IsDefault bool `json:"isDefault"` // 是否为默认快捷键
Command KeyBindingCommand `json:"command" db:"command"` // 快捷键动作
Extension ExtensionID `json:"extension" db:"extension"` // 所属扩展
Key string `json:"key" db:"key"` // 快捷键组合(如 "Mod-f", "Ctrl-Shift-p"
Enabled bool `json:"enabled" db:"enabled"` // 是否启用
IsDefault bool `json:"isDefault" db:"is_default"` // 是否为默认快捷键
}
// KeyBindingCommand 快捷键命令

View File

@@ -1,5 +1,20 @@
package models
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// ThemeType 主题类型枚举
type ThemeType string
const (
ThemeTypeDark ThemeType = "dark"
ThemeTypeLight ThemeType = "light"
)
// ThemeColorConfig 主题颜色配置
type ThemeColorConfig struct {
// 基础色调
@@ -36,15 +51,44 @@ type ThemeColorConfig struct {
MatchingBracket string `json:"matchingBracket"` // 匹配括号
}
// CustomThemeConfig 自定义主题配置
type CustomThemeConfig struct {
DarkTheme ThemeColorConfig `json:"darkTheme"` // 深色主题配置
LightTheme ThemeColorConfig `json:"lightTheme"` // 浅色主题配置
// Theme 主题数据库模型
type Theme struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Type ThemeType `db:"type" json:"type"`
Colors ThemeColorConfig `db:"colors" json:"colors"`
IsDefault bool `db:"is_default" json:"isDefault"`
CreatedAt time.Time `db:"created_at" json:"createdAt"`
UpdatedAt time.Time `db:"updated_at" json:"updatedAt"`
}
// Value 实现 driver.Valuer 接口,用于将 ThemeColorConfig 存储到数据库
func (tc ThemeColorConfig) Value() (driver.Value, error) {
return json.Marshal(tc)
}
// Scan 实现 sql.Scanner 接口,用于从数据库读取 ThemeColorConfig
func (tc *ThemeColorConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("cannot scan %T into ThemeColorConfig", value)
}
return json.Unmarshal(bytes, tc)
}
// NewDefaultDarkTheme 创建默认深色主题配置
func NewDefaultDarkTheme() ThemeColorConfig {
return ThemeColorConfig{
func NewDefaultDarkTheme() *ThemeColorConfig {
return &ThemeColorConfig{
// 基础色调
Background: "#252B37",
BackgroundSecondary: "#213644",
@@ -66,9 +110,9 @@ func NewDefaultDarkTheme() ThemeColorConfig {
Cursor: "#ffffff",
Selection: "#0865a9",
SelectionBlur: "#225377",
ActiveLine: "#ffffff",
LineNumber: "#ffffff",
ActiveLineNumber: "#ffffff",
ActiveLine: "#ffffff0a",
LineNumber: "#ffffff26",
ActiveLineNumber: "#ffffff99",
// 边框分割线
BorderColor: "#1e222a",
@@ -81,8 +125,8 @@ func NewDefaultDarkTheme() ThemeColorConfig {
}
// NewDefaultLightTheme 创建默认浅色主题配置
func NewDefaultLightTheme() ThemeColorConfig {
return ThemeColorConfig{
func NewDefaultLightTheme() *ThemeColorConfig {
return &ThemeColorConfig{
// 基础色调
Background: "#ffffff",
BackgroundSecondary: "#f1faf1",
@@ -104,24 +148,16 @@ func NewDefaultLightTheme() ThemeColorConfig {
Cursor: "#000000",
Selection: "#77baff",
SelectionBlur: "#b2c2ca",
ActiveLine: "#000000",
LineNumber: "#000000",
ActiveLineNumber: "#000000",
ActiveLine: "#0000000a",
LineNumber: "#00000040",
ActiveLineNumber: "#000000aa",
// 边框分割线
BorderColor: "#dfdfdf",
BorderLight: "#0000000d",
BorderLight: "#0000000c",
// 搜索匹配
SearchMatch: "#005cc5",
MatchingBracket: "#0000001a",
}
}
// NewDefaultCustomThemeConfig 创建默认自定义主题配置
func NewDefaultCustomThemeConfig() *CustomThemeConfig {
return &CustomThemeConfig{
DarkTheme: NewDefaultDarkTheme(),
LightTheme: NewDefaultLightTheme(),
MatchingBracket: "#00000019",
}
}

41
internal/models/window.go Normal file
View File

@@ -0,0 +1,41 @@
package models
import "time"
// SnapEdge 表示吸附的边缘类型
type SnapEdge int
const (
SnapEdgeNone SnapEdge = iota // 未吸附
SnapEdgeTop // 吸附到上边缘
SnapEdgeRight // 吸附到右边缘
SnapEdgeBottom // 吸附到下边缘
SnapEdgeLeft // 吸附到左边缘
SnapEdgeTopRight // 吸附到右上角
SnapEdgeBottomRight // 吸附到右下角
SnapEdgeBottomLeft // 吸附到左下角
SnapEdgeTopLeft // 吸附到左上角
)
// WindowPosition 窗口位置
type WindowPosition struct {
X int `json:"x"` // X坐标
Y int `json:"y"` // Y坐标
}
// SnapPosition 表示吸附的相对位置
type SnapPosition struct {
X int `json:"x"` // X轴相对偏移
Y int `json:"y"` // Y轴相对偏移
}
// WindowInfo 窗口信息
type WindowInfo struct {
DocumentID int64 `json:"documentID"` // 文档ID
Title string `json:"title"` // 窗口标题
IsSnapped bool `json:"isSnapped"` // 是否处于吸附状态
SnapOffset SnapPosition `json:"snapOffset"` // 与主窗口的相对位置偏移
SnapEdge SnapEdge `json:"snapEdge"` // 吸附的边缘类型
LastPos WindowPosition `json:"lastPos"` // 上一次记录的窗口位置
MoveTime time.Time `json:"moveTime"` // 上次移动时间,用于判断移动速度
}

View File

@@ -0,0 +1,388 @@
package services
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-git/go-git/v5"
gitConfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/wailsapp/wails/v3/pkg/services/log"
"voidraft/internal/models"
_ "modernc.org/sqlite"
)
const (
dbSerializeFile = "voidraft_data.bin"
)
// BackupService 提供基于Git的备份功能
type BackupService struct {
configService *ConfigService
dbService *DatabaseService
repository *git.Repository
logger *log.LogService
isInitialized bool
autoBackupTicker *time.Ticker
autoBackupStop chan bool
}
// NewBackupService 创建新的备份服务实例
func NewBackupService(configService *ConfigService, dbService *DatabaseService, logger *log.LogService) *BackupService {
return &BackupService{
configService: configService,
dbService: dbService,
logger: logger,
}
}
// Initialize 初始化备份服务
func (s *BackupService) Initialize() error {
config, repoPath, err := s.getConfigAndPath()
if err != nil {
return fmt.Errorf("getting backup config: %w", err)
}
if !config.Enabled {
return nil
}
// 初始化仓库
if err := s.initializeRepository(config, repoPath); err != nil {
return fmt.Errorf("initializing repository: %w", err)
}
// 启动自动备份
if config.AutoBackup && config.BackupInterval > 0 {
s.StartAutoBackup()
}
s.isInitialized = true
return nil
}
// getConfigAndPath 获取备份配置和仓库路径
func (s *BackupService) getConfigAndPath() (*models.GitBackupConfig, string, error) {
appConfig, err := s.configService.GetConfig()
if err != nil {
return nil, "", fmt.Errorf("getting app config: %w", err)
}
return &appConfig.Backup, appConfig.General.DataPath, nil
}
// initializeRepository 初始化或打开Git仓库并设置远程
func (s *BackupService) initializeRepository(config *models.GitBackupConfig, repoPath string) error {
// 检查本地仓库是否存在
_, err := os.Stat(filepath.Join(repoPath, ".git"))
if os.IsNotExist(err) {
// 仓库不存在,初始化新仓库
repo, err := git.PlainInit(repoPath, false)
if err != nil {
return fmt.Errorf("error initializing repository: %w", err)
}
s.repository = repo
} else if err != nil {
return fmt.Errorf("error checking repository path: %w", err)
} else {
// 仓库已存在,打开现有仓库
repo, err := git.PlainOpen(repoPath)
if err != nil {
return fmt.Errorf("error opening local repository: %w", err)
}
s.repository = repo
}
// 设置或更新远程仓库
remote, err := s.repository.Remote("origin")
if err != nil {
if errors.Is(err, git.ErrRemoteNotFound) {
// 远程不存在,添加远程
_, err = s.repository.CreateRemote(&gitConfig.RemoteConfig{
Name: "origin",
URLs: []string{config.RepoURL},
})
if err != nil {
return fmt.Errorf("error creating remote: %w", err)
}
} else {
return fmt.Errorf("error getting remote: %w", err)
}
} else {
// 检查远程URL是否一致如果不一致则更新
if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] != config.RepoURL {
if err := s.repository.DeleteRemote("origin"); err != nil {
return fmt.Errorf("error deleting remote: %w", err)
}
_, err = s.repository.CreateRemote(&gitConfig.RemoteConfig{
Name: "origin",
URLs: []string{config.RepoURL},
})
if err != nil {
return fmt.Errorf("error creating new remote: %w", err)
}
}
}
return nil
}
// getAuthMethod 根据配置获取认证方法
func (s *BackupService) getAuthMethod(config *models.GitBackupConfig) (transport.AuthMethod, error) {
switch config.AuthMethod {
case models.Token:
if config.Token == "" {
return nil, errors.New("token authentication requires a valid token")
}
return &http.BasicAuth{
Username: "git", // 使用token时用户名可以是任意值
Password: config.Token,
}, nil
case models.UserPass:
if config.Username == "" || config.Password == "" {
return nil, errors.New("username/password authentication requires both username and password")
}
return &http.BasicAuth{
Username: config.Username,
Password: config.Password,
}, nil
case models.SSHKey:
if config.SSHKeyPath == "" {
return nil, errors.New("SSH key authentication requires a valid SSH key path")
}
publicKeys, err := ssh.NewPublicKeysFromFile("git", config.SSHKeyPath, config.SSHKeyPass)
if err != nil {
return nil, fmt.Errorf("error creating SSH public keys: %w", err)
}
return publicKeys, nil
default:
return nil, fmt.Errorf("unsupported authentication method: %s", config.AuthMethod)
}
}
// serializeDatabase 序列化数据库到文件
func (s *BackupService) serializeDatabase(repoPath string) error {
if s.dbService == nil || s.dbService.db == nil {
return errors.New("database service not available")
}
// 获取数据库路径
dbPath, err := s.dbService.getDatabasePath()
if err != nil {
return fmt.Errorf("getting database path: %w", err)
}
// 关闭数据库连接以确保所有更改都写入磁盘
if err := s.dbService.ServiceShutdown(); err != nil {
s.logger.Error("Failed to close database connection", "error", err)
}
// 直接复制数据库文件到序列化文件
dbData, err := os.ReadFile(dbPath)
if err != nil {
return fmt.Errorf("reading database file: %w", err)
}
binFilePath := filepath.Join(repoPath, dbSerializeFile)
if err := os.WriteFile(binFilePath, dbData, 0644); err != nil {
return fmt.Errorf("writing serialized database to file: %w", err)
}
// 重新初始化数据库服务
if err := s.dbService.initDatabase(); err != nil {
return fmt.Errorf("reinitializing database: %w", err)
}
return nil
}
// PushToRemote 推送本地更改到远程仓库
func (s *BackupService) PushToRemote() error {
if !s.isInitialized {
return errors.New("backup service not initialized")
}
config, repoPath, err := s.getConfigAndPath()
if err != nil {
return fmt.Errorf("getting backup config: %w", err)
}
if !config.Enabled {
return errors.New("backup is disabled")
}
// 数据库序列化文件的路径
binFilePath := filepath.Join(repoPath, dbSerializeFile)
// 函数返回前都删除临时文件
defer func() {
if _, err := os.Stat(binFilePath); err == nil {
os.Remove(binFilePath)
}
}()
// 序列化数据库
if err := s.serializeDatabase(repoPath); err != nil {
return fmt.Errorf("serializing database: %w", err)
}
// 获取工作树
w, err := s.repository.Worktree()
if err != nil {
return fmt.Errorf("getting worktree: %w", err)
}
// 添加序列化的数据库文件
if _, err := w.Add(dbSerializeFile); err != nil {
return fmt.Errorf("adding serialized database file: %w", err)
}
// 检查是否有变化需要提交
status, err := w.Status()
if err != nil {
return fmt.Errorf("getting worktree status: %w", err)
}
// 如果没有变化,直接返回
if status.IsClean() {
return errors.New("no changes to backup")
}
// 创建提交
_, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{
Author: &object.Signature{
Name: "voidraft",
Email: "backup@voidraft.app",
When: time.Now(),
},
})
if err != nil {
if strings.Contains(err.Error(), "cannot create empty commit") {
return errors.New("no changes to backup")
}
return fmt.Errorf("creating commit: %w", err)
}
// 获取认证方法并推送到远程
auth, err := s.getAuthMethod(config)
if err != nil {
return fmt.Errorf("getting auth method: %w", err)
}
// 推送到远程仓库
if err := s.repository.Push(&git.PushOptions{
RemoteName: "origin",
Auth: auth,
}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
// 忽略一些常见的非错误情况
if strings.Contains(err.Error(), "clean working tree") ||
strings.Contains(err.Error(), "already up-to-date") ||
strings.Contains(err.Error(), " clean working tree") ||
strings.Contains(err.Error(), "reference not found") {
// 更新最后推送时间
return errors.New("no changes to backup")
}
return fmt.Errorf("push failed: %w", err)
}
return nil
}
// StartAutoBackup 启动自动备份定时器
func (s *BackupService) StartAutoBackup() error {
config, _, err := s.getConfigAndPath()
if err != nil {
return fmt.Errorf("getting backup config: %w", err)
}
if !config.AutoBackup || config.BackupInterval <= 0 {
return nil
}
s.StopAutoBackup()
// 将秒转换为分钟
s.autoBackupTicker = time.NewTicker(time.Duration(config.BackupInterval) * time.Minute)
s.autoBackupStop = make(chan bool)
go func() {
for {
select {
case <-s.autoBackupTicker.C:
// 执行推送操作
if err := s.PushToRemote(); err != nil {
s.logger.Error("Auto backup failed", "error", err)
}
case <-s.autoBackupStop:
return
}
}
}()
return nil
}
// StopAutoBackup 停止自动备份
func (s *BackupService) StopAutoBackup() {
if s.autoBackupTicker != nil {
s.autoBackupTicker.Stop()
s.autoBackupTicker = nil
}
if s.autoBackupStop != nil {
close(s.autoBackupStop)
s.autoBackupStop = nil
}
}
// Reinitialize 重新初始化备份服务,用于响应配置变更
func (s *BackupService) Reinitialize() error {
// 停止自动备份
s.StopAutoBackup()
// 重新设置标志
s.isInitialized = false
// 重新初始化
return s.Initialize()
}
// HandleConfigChange 处理备份配置变更
func (s *BackupService) HandleConfigChange(config *models.GitBackupConfig) error {
// 如果备份功能禁用,只需停止自动备份
if !config.Enabled {
s.StopAutoBackup()
s.isInitialized = false
return nil
}
// 如果服务已初始化,重新初始化以应用新配置
if s.isInitialized {
return s.Reinitialize()
}
// 如果服务未初始化但已启用,则初始化
if config.Enabled && !s.isInitialized {
return s.Initialize()
}
return nil
}
// ServiceShutdown 服务关闭时的清理工作
func (s *BackupService) ServiceShutdown() {
s.StopAutoBackup()
}

View File

@@ -1,318 +1,195 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"time"
"voidraft/internal/models"
"github.com/Masterminds/semver/v3"
jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/wailsapp/wails/v3/pkg/services/log"
"os"
"path/filepath"
"time"
)
const (
// CurrentAppConfigVersion 当前应用配置版本
CurrentAppConfigVersion = "1.2.0"
// BackupFilePattern 备份文件名模式
// BackupFilePattern backup file name pattern
BackupFilePattern = "%s.backup.%s.json"
// 资源限制常量
// MaxConfigFileSize maximum config file size
MaxConfigFileSize = 10 * 1024 * 1024 // 10MB
MaxRecursionDepth = 50 // 最大递归深度
)
// Migratable 可迁移的配置接口
type Migratable interface {
GetVersion() string // 获取当前版本
SetVersion(string) // 设置版本
SetLastUpdated(string) // 设置最后更新时间
GetDefaultConfig() any // 获取默认配置
// ConfigMigrator elegant configuration migrator with automatic field detection
type ConfigMigrator struct {
logger *log.LogService
configDir string
configName string
configPath string
}
// ConfigMigrationService 配置迁移服务
type ConfigMigrationService[T Migratable] struct {
logger *log.Service
configDir string
configName string
targetVersion string
configPath string
}
// MigrationResult 迁移结果
// MigrationResult migration operation result
type MigrationResult struct {
Migrated, ConfigUpdated bool
FromVersion, ToVersion string
BackupPath string
Migrated bool `json:"migrated"` // Whether migration was performed
MissingFields []string `json:"missingFields"` // Fields that were missing
BackupPath string `json:"backupPath"` // Path to backup file
Description string `json:"description"` // Description of migration
}
// NewConfigMigrationService 创建配置迁移服务
func NewConfigMigrationService[T Migratable](
logger *log.Service,
// NewConfigMigrator creates a new configuration migrator
func NewConfigMigrator(
logger *log.LogService,
configDir string,
configName, targetVersion, configPath string,
) *ConfigMigrationService[T] {
return &ConfigMigrationService[T]{
logger: orDefault(logger, log.New()),
configDir: configDir,
configName: configName,
targetVersion: targetVersion,
configPath: configPath,
configName, configPath string,
) *ConfigMigrator {
if logger == nil {
logger = log.New()
}
return &ConfigMigrator{
logger: logger,
configDir: configDir,
configName: configName,
configPath: configPath,
}
}
// MigrateConfig 迁移配置文件
func (cms *ConfigMigrationService[T]) MigrateConfig(existingConfig *koanf.Koanf) (*MigrationResult, error) {
currentVersion := orDefault(existingConfig.String("metadata.version"), "0.0.0")
result := &MigrationResult{
FromVersion: currentVersion,
ToVersion: cms.targetVersion,
// AutoMigrate automatically detects and migrates missing configuration fields
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
// Load default config into temporary koanf instance
defaultKoanf := koanf.New(".")
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
return nil, fmt.Errorf("failed to load default config: %w", err)
}
if needsMigration, err := cms.needsMigration(currentVersion); err != nil {
return result, fmt.Errorf("version comparison failed: %w", err)
} else if !needsMigration {
// Detect missing fields
missingFields := cm.detectMissingFields(currentConfig.All(), defaultKoanf.All())
// Create result object
result := &MigrationResult{
MissingFields: missingFields,
Migrated: len(missingFields) > 0,
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)),
}
// If no missing fields, return early
if !result.Migrated {
cm.logger.Info("No missing configuration fields detected")
return result, nil
}
// 资源检查和备份
if err := cms.checkResourceLimits(); err != nil {
return result, fmt.Errorf("resource limit check failed: %w", err)
// Only create backup if we actually need to migrate (has missing fields)
if len(missingFields) > 0 {
if backupPath, err := cm.createBackup(); err != nil {
cm.logger.Error("Failed to create backup", "error", err)
} else {
result.BackupPath = backupPath
}
}
if backupPath, err := cms.createBackupOptimized(); err != nil {
return result, fmt.Errorf("backup creation failed: %w", err)
} else {
result.BackupPath = backupPath
// Merge missing fields from default config
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
return result, fmt.Errorf("failed to merge default fields: %w", err)
}
// 自动恢复检查
cms.tryQuickRecovery(existingConfig)
// 执行迁移
if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil {
return result, fmt.Errorf("migration failed: %w", err)
} else {
result.Migrated = true
result.ConfigUpdated = configUpdated
// Save updated config
if err := cm.saveConfig(currentConfig); err != nil {
return result, fmt.Errorf("failed to save updated config: %w", err)
}
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields))
return result, nil
}
// needsMigration 检查是否需要迁移
func (cms *ConfigMigrationService[T]) needsMigration(current string) (bool, error) {
currentVer, err := semver.NewVersion(current)
if err != nil {
return true, nil
}
targetVer, err := semver.NewVersion(cms.targetVersion)
if err != nil {
return false, fmt.Errorf("invalid target version: %s", cms.targetVersion)
}
return currentVer.LessThan(targetVer), nil
// detectMissingFields detects missing configuration fields
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
var missingFields []string
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields)
return missingFields
}
// checkResourceLimits 检查资源限制
func (cms *ConfigMigrationService[T]) checkResourceLimits() error {
if info, err := os.Stat(cms.configPath); err == nil && info.Size() > MaxConfigFileSize {
return fmt.Errorf("config file size (%d bytes) exceeds limit (%d bytes)", info.Size(), MaxConfigFileSize)
// findMissingFieldsRecursive recursively finds missing fields
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
for key, defaultVal := range defaultMap {
fullKey := key
if prefix != "" {
fullKey = prefix + "." + key
}
currentVal, exists := currentMap[key]
if !exists {
// Field is completely missing
*missing = append(*missing, fullKey)
} else {
// Check nested structures
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing)
} else {
// Current value is not a map but default is, structure mismatch
*missing = append(*missing, fullKey)
}
}
}
}
}
// mergeDefaultFields merges default values for missing fields into current config
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
for _, field := range missingFields {
defaultValue := defaultConfig.Get(field)
if defaultValue != nil {
current.Set(field, defaultValue)
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue)
}
}
// Update last modified timestamp
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
return nil
}
// createBackupOptimized 优化的备份创建
func (cms *ConfigMigrationService[T]) createBackupOptimized() (string, error) {
if _, err := os.Stat(cms.configPath); os.IsNotExist(err) {
// createBackup creates a backup of the configuration file
func (cm *ConfigMigrator) createBackup() (string, error) {
if _, err := os.Stat(cm.configPath); os.IsNotExist(err) {
return "", nil
}
configDir := cms.configDir
timestamp := time.Now().Format("20060102150405")
newBackupPath := filepath.Join(configDir, fmt.Sprintf(BackupFilePattern, cms.configName, timestamp))
backupPath := filepath.Join(cm.configDir, fmt.Sprintf(BackupFilePattern, cm.configName, timestamp))
// 单次扫描:删除旧备份并创建新备份
pattern := filepath.Join(configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
if matches, err := filepath.Glob(pattern); err == nil {
for _, oldBackup := range matches {
if oldBackup != newBackupPath {
os.Remove(oldBackup) // 忽略删除错误,继续处理
}
}
}
return newBackupPath, copyFile(cms.configPath, newBackupPath)
}
// tryQuickRecovery 快速恢复检查
func (cms *ConfigMigrationService[T]) tryQuickRecovery(existingConfig *koanf.Koanf) {
var testConfig T
if existingConfig.Unmarshal("", &testConfig) != nil {
cms.logger.Info("Config appears corrupted, attempting quick recovery")
if backupPath := cms.findLatestBackupQuick(); backupPath != "" {
if data, err := os.ReadFile(backupPath); err == nil {
existingConfig.Delete("")
existingConfig.Load(&BytesProvider{data}, jsonparser.Parser())
cms.logger.Info("Quick recovery successful")
}
}
}
}
// findLatestBackupQuick 快速查找最新备份(优化排序)
func (cms *ConfigMigrationService[T]) findLatestBackupQuick() string {
pattern := filepath.Join(cms.configDir, fmt.Sprintf("%s.backup.*.json", cms.configName))
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return ""
}
sort.Strings(matches) // 字典序排序,时间戳格式确保正确性
return matches[len(matches)-1]
}
// performOptimizedMigration 优化的迁移执行
func (cms *ConfigMigrationService[T]) performOptimizedMigration(existingConfig *koanf.Koanf) (bool, error) {
// 直接从koanf实例获取配置避免额外序列化
var currentConfig T
if err := existingConfig.Unmarshal("", &currentConfig); err != nil {
return false, fmt.Errorf("unmarshal existing config failed: %w", err)
}
defaultConfig, ok := currentConfig.GetDefaultConfig().(T)
if !ok {
return false, fmt.Errorf("default config type mismatch")
}
return cms.mergeInPlace(existingConfig, currentConfig, defaultConfig)
}
// mergeInPlace 就地合并配置
func (cms *ConfigMigrationService[T]) mergeInPlace(existingConfig *koanf.Koanf, currentConfig, defaultConfig T) (bool, error) {
// 创建临时合并实例
mergeKoanf := koanf.New(".")
// 使用快速加载链
if err := chainLoad(mergeKoanf,
func() error { return mergeKoanf.Load(structs.Provider(defaultConfig, "json"), nil) },
func() error {
return mergeKoanf.Load(structs.Provider(currentConfig, "json"), nil,
koanf.WithMergeFunc(cms.fastMerge))
},
); err != nil {
return false, fmt.Errorf("config merge failed: %w", err)
}
// 更新元数据
mergeKoanf.Set("metadata.version", cms.targetVersion)
mergeKoanf.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
// 一次性序列化和原子写入
configBytes, err := mergeKoanf.Marshal(jsonparser.Parser())
data, err := os.ReadFile(cm.configPath)
if err != nil {
return false, fmt.Errorf("marshal config failed: %w", err)
return "", fmt.Errorf("failed to read config file: %w", err)
}
if err := os.WriteFile(backupPath, data, 0644); err != nil {
return "", fmt.Errorf("failed to create backup: %w", err)
}
cm.logger.Info("Configuration backup created", "path", backupPath)
return backupPath, nil
}
// saveConfig saves configuration to file
func (cm *ConfigMigrator) saveConfig(config *koanf.Koanf) error {
configBytes, err := config.Marshal(jsonparser.Parser())
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if len(configBytes) > MaxConfigFileSize {
return false, fmt.Errorf("merged config size exceeds limit")
return fmt.Errorf("config size (%d bytes) exceeds limit (%d bytes)", len(configBytes), MaxConfigFileSize)
}
// 原子写入
return true, cms.atomicWrite(existingConfig, configBytes)
}
// atomicWrite 原子写入操作
func (cms *ConfigMigrationService[T]) atomicWrite(existingConfig *koanf.Koanf, configBytes []byte) error {
tempPath := cms.configPath + ".tmp"
// Atomic write
tempPath := cm.configPath + ".tmp"
if err := os.WriteFile(tempPath, configBytes, 0644); err != nil {
return fmt.Errorf("write temp config failed: %w", err)
return fmt.Errorf("failed to write temp config: %w", err)
}
if err := os.Rename(tempPath, cms.configPath); err != nil {
if err := os.Rename(tempPath, cm.configPath); err != nil {
os.Remove(tempPath)
return fmt.Errorf("atomic rename failed: %w", err)
return fmt.Errorf("failed to rename temp config: %w", err)
}
// 重新加载到原实例
existingConfig.Delete("")
return existingConfig.Load(&BytesProvider{configBytes}, jsonparser.Parser())
}
// fastMerge 快速合并函数
func (cms *ConfigMigrationService[T]) fastMerge(src, dest map[string]interface{}) error {
return cms.fastMergeRecursive(src, dest, 0)
}
// fastMergeRecursive 快速递归合并
func (cms *ConfigMigrationService[T]) fastMergeRecursive(src, dest map[string]interface{}, depth int) error {
if depth > MaxRecursionDepth {
return fmt.Errorf("recursion depth exceeded")
}
for key, srcVal := range src {
if destVal, exists := dest[key]; exists {
// 优先检查map类型最常见情况
if srcMap, srcOK := srcVal.(map[string]interface{}); srcOK {
if destMap, destOK := destVal.(map[string]interface{}); destOK {
if err := cms.fastMergeRecursive(srcMap, destMap, depth+1); err != nil {
return err
}
continue
}
}
// 快速空值检查(避免反射)
if srcVal == nil || srcVal == "" || srcVal == 0 {
continue
}
}
dest[key] = srcVal
}
return nil
}
// BytesProvider 轻量字节提供器
type BytesProvider struct{ data []byte }
func (bp *BytesProvider) ReadBytes() ([]byte, error) { return bp.data, nil }
func (bp *BytesProvider) Read() (map[string]interface{}, error) {
var result map[string]interface{}
return result, json.Unmarshal(bp.data, &result)
}
// 工具函数
func orDefault[T any](value, defaultValue T) T {
var zero T
if reflect.DeepEqual(value, zero) {
return defaultValue
}
return value
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
func chainLoad(k *koanf.Koanf, loaders ...func() error) error {
for _, loader := range loaders {
if err := loader(); err != nil {
return err
}
}
return nil
}
// 工厂函数
func NewAppConfigMigrationService(logger *log.Service, configDir, settingsPath string) *ConfigMigrationService[*models.AppConfig] {
return NewConfigMigrationService[*models.AppConfig](
logger, configDir, "settings", CurrentAppConfigVersion, settingsPath)
}

View File

@@ -0,0 +1,167 @@
package services
import (
"encoding/json"
jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
"github.com/wailsapp/wails/v3/pkg/services/log"
"os"
"path/filepath"
"testing"
)
// TestConfig represents a simplified config structure for testing
type TestConfig struct {
App struct {
Name string `json:"name"`
Version string `json:"version"`
Theme string `json:"theme"`
} `json:"app"`
User struct {
Name string `json:"name"`
Email string `json:"email"`
Settings struct {
AutoSave bool `json:"autoSave"`
Language string `json:"language"`
NewSetting bool `json:"newSetting"` // This field will be missing in old config
NewSetting2 string `json:"newSetting2"` // This field will be missing in old config
} `json:"settings"`
} `json:"user"`
NewSection struct {
Enabled bool `json:"enabled"`
Value string `json:"value"`
} `json:"newSection"` // This entire section will be missing in old config
}
// createTestConfig creates a test configuration file
func createTestConfig(t *testing.T, tempDir string) string {
// Old config without some fields
oldConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "TestApp",
"version": "1.0.0",
"theme": "dark",
},
"user": map[string]interface{}{
"name": "Test User",
"email": "test@example.com",
"settings": map[string]interface{}{
"autoSave": true,
"language": "en",
// Missing newSetting and newSetting2
},
},
// Missing newSection
}
// Create config file
configPath := filepath.Join(tempDir, "config.json")
jsonData, err := json.MarshalIndent(oldConfig, "", " ")
if err != nil {
t.Fatalf("Failed to marshal test config: %v", err)
}
err = os.WriteFile(configPath, jsonData, 0644)
if err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
return configPath
}
// TestConfigMigrator_AutoMigrate tests the ConfigMigrator's AutoMigrate functionality
func TestConfigMigrator_AutoMigrate(t *testing.T) {
// Create temp directory for test
tempDir, err := os.MkdirTemp("", "config_migrator_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create test config file
configPath := createTestConfig(t, tempDir)
// Create logger
logger := log.New()
// Create config migrator
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
// Create koanf instance and load the config
k := koanf.New(".")
fileProvider := file.Provider(configPath)
jsonParser := jsonparser.Parser()
if err := k.Load(fileProvider, jsonParser); err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Create default config with all fields
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true // New field
defaultConfig.User.Settings.NewSetting2 = "value" // New field
defaultConfig.NewSection.Enabled = true // New section
defaultConfig.NewSection.Value = "new section" // New section
// Run auto migration
result, err := migrator.AutoMigrate(defaultConfig, k)
if err != nil {
t.Fatalf("Failed to auto migrate: %v", err)
}
// Assertions
assert.True(t, result.Migrated, "Migration should have been performed")
// 打印检测到的缺失字段,便于分析
t.Logf("Detected fields: %v", result.MissingFields)
// 验证检测到了正确数量的字段 - 实际检测到4个
assert.Equal(t, 4, len(result.MissingFields), "Should have detected 4 missing fields")
// 期望检测到的缺失字段
expectedFields := map[string]bool{
"user.settings.newSetting": true,
"user.settings.newSetting2": true,
"newSection.enabled": true,
"newSection.value": true,
}
// 验证所有预期的字段都被检测到了
for _, field := range result.MissingFields {
_, expected := expectedFields[field]
assert.True(t, expected, "Field %s was detected but not expected", field)
}
// 验证所有检测到的字段都在预期之内
for expectedField := range expectedFields {
found := false
for _, field := range result.MissingFields {
if field == expectedField {
found = true
break
}
}
assert.True(t, found, "Expected field %s was not detected", expectedField)
}
// Verify that the fields were actually added to the config
assert.True(t, k.Bool("user.settings.newSetting"), "newSetting should be added with correct value")
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "newSetting2 should be added with correct value")
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
// Check that backup was created
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
if err != nil {
t.Fatalf("Failed to list backup files: %v", err)
}
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created")
}

View File

@@ -22,6 +22,10 @@ const (
ConfigChangeTypeHotkey ConfigChangeType = "hotkey"
// ConfigChangeTypeDataPath 数据路径配置变更
ConfigChangeTypeDataPath ConfigChangeType = "datapath"
// ConfigChangeTypeBackup 备份配置变更
ConfigChangeTypeBackup ConfigChangeType = "backup"
// ConfigChangeTypeWindowSnap 窗口吸附配置变更
ConfigChangeTypeWindowSnap ConfigChangeType = "windowsnap"
)
// ConfigChangeCallback 配置变更回调函数类型
@@ -49,7 +53,7 @@ type ConfigListener struct {
type ConfigNotificationService struct {
listeners map[ConfigChangeType][]*ConfigListener // 支持多监听器的map
mu sync.RWMutex // 监听器map的读写锁
logger *log.Service // 日志服务
logger *log.LogService // 日志服务
koanf *koanf.Koanf // koanf实例
ctx context.Context
cancel context.CancelFunc
@@ -57,7 +61,7 @@ type ConfigNotificationService struct {
}
// NewConfigNotificationService 创建配置通知服务
func NewConfigNotificationService(k *koanf.Koanf, logger *log.Service) *ConfigNotificationService {
func NewConfigNotificationService(k *koanf.Koanf, logger *log.LogService) *ConfigNotificationService {
ctx, cancel := context.WithCancel(context.Background())
return &ConfigNotificationService{
listeners: make(map[ConfigChangeType][]*ConfigListener),
@@ -284,25 +288,6 @@ func deepCopyValue(src, dst reflect.Value) {
}
}
// deepCopyConfig 保留原有的JSON深拷贝方法作为备用
func deepCopyConfig(src *models.AppConfig) *models.AppConfig {
if src == nil {
return nil
}
jsonBytes, err := json.Marshal(src)
if err != nil {
return src
}
var dst models.AppConfig
if err := json.Unmarshal(jsonBytes, &dst); err != nil {
return src
}
return &dst
}
// debounceNotify 防抖通知
func (cns *ConfigNotificationService) debounceNotify(listener *ConfigListener, oldConfig, newConfig *models.AppConfig) {
listener.mu.Lock()
@@ -445,6 +430,52 @@ func CreateDataPathListener(name string, callback func() error) *ConfigListener
}
}
// CreateBackupConfigListener 创建备份配置监听器
func CreateBackupConfigListener(name string, callback func(config *models.GitBackupConfig) error) *ConfigListener {
return &ConfigListener{
Name: name,
ChangeType: ConfigChangeTypeBackup,
Callback: func(changeType ConfigChangeType, oldConfig, newConfig *models.AppConfig) error {
if newConfig == nil {
defaultConfig := models.NewDefaultAppConfig()
return callback(&defaultConfig.Backup)
}
return callback(&newConfig.Backup)
},
DebounceDelay: 200 * time.Millisecond,
GetConfigFunc: func(k *koanf.Koanf) *models.AppConfig {
var config models.AppConfig
if err := k.Unmarshal("", &config); err != nil {
return nil
}
return &config
},
}
}
// CreateWindowSnapConfigListener 创建窗口吸附配置监听器
func CreateWindowSnapConfigListener(name string, callback func(enabled bool) error) *ConfigListener {
return &ConfigListener{
Name: name,
ChangeType: ConfigChangeTypeWindowSnap,
Callback: func(changeType ConfigChangeType, oldConfig, newConfig *models.AppConfig) error {
if newConfig == nil {
defaultConfig := models.NewDefaultAppConfig()
return callback(defaultConfig.General.EnableWindowSnap)
}
return callback(newConfig.General.EnableWindowSnap)
},
DebounceDelay: 200 * time.Millisecond,
GetConfigFunc: func(k *koanf.Koanf) *models.AppConfig {
var config models.AppConfig
if err := k.Unmarshal("", &config); err != nil {
return nil
}
return &config
},
}
}
// ServiceShutdown 关闭服务
func (cns *ConfigNotificationService) ServiceShutdown() error {
cns.Cleanup()

View File

@@ -18,17 +18,18 @@ import (
// ConfigService 应用配置服务
type ConfigService struct {
koanf *koanf.Koanf // koanf 实例
logger *log.Service // 日志服务
configDir string // 配置目录
settingsPath string // 设置文件路径
mu sync.RWMutex // 读写锁
fileProvider *file.File // 文件提供器,用于监听
koanf *koanf.Koanf // koanf 实例
logger *log.LogService // 日志服务
configDir string // 配置目录
settingsPath string // 设置文件路径
mu sync.RWMutex // 读写锁
fileProvider *file.File // 文件提供器,用于监听
// 配置通知服务
notificationService *ConfigNotificationService
// 配置迁移服务
migrationService *ConfigMigrationService[*models.AppConfig]
// 配置迁移器
configMigrator *ConfigMigrator
}
// ConfigError 配置错误
@@ -55,7 +56,7 @@ func (e *ConfigError) Is(target error) bool {
}
// NewConfigService 创建新的配置服务实例
func NewConfigService(logger *log.Service) *ConfigService {
func NewConfigService(logger *log.LogService) *ConfigService {
// 获取用户主目录
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -67,16 +68,18 @@ func NewConfigService(logger *log.Service) *ConfigService {
settingsPath := filepath.Join(configDir, "settings.json")
cs := &ConfigService{
logger: logger,
configDir: configDir,
settingsPath: settingsPath,
koanf: koanf.New("."),
migrationService: NewAppConfigMigrationService(logger, configDir, settingsPath),
logger: logger,
configDir: configDir,
settingsPath: settingsPath,
koanf: koanf.New("."),
}
// 初始化配置通知服务
cs.notificationService = NewConfigNotificationService(cs.koanf, logger)
// 初始化配置迁移器
cs.configMigrator = NewConfigMigrator(logger, configDir, "settings", settingsPath)
cs.initConfig()
// 启动配置文件监听
@@ -106,23 +109,36 @@ func (cs *ConfigService) initConfig() error {
return cs.createDefaultConfig()
}
// 配置文件存在,先加载现有配置
// 检查并自动迁移配置
cs.checkConfigMigration()
// 配置文件存在,直接加载现有配置
cs.fileProvider = file.Provider(cs.settingsPath)
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
return &ConfigError{Operation: "load_config_file", Err: err}
}
// 检查并执行配置迁移
if cs.migrationService != nil {
result, err := cs.migrationService.MigrateConfig(cs.koanf)
if err != nil {
return &ConfigError{Operation: "migrate_config", Err: err}
}
return nil
}
if result.Migrated && result.ConfigUpdated {
// 迁移完成且配置已更新,重新创建文件提供器以监听新文件
cs.fileProvider = file.Provider(cs.settingsPath)
}
// checkConfigMigration 检查配置迁移
func (cs *ConfigService) checkConfigMigration() error {
if cs.configMigrator == nil {
return nil
}
defaultConfig := models.NewDefaultAppConfig()
result, err := cs.configMigrator.AutoMigrate(defaultConfig, cs.koanf)
if err != nil {
cs.logger.Error("Failed to check config migration", "error", err)
return err
}
if result != nil && result.Migrated {
cs.logger.Info("Config migration performed",
"fields", result.MissingFields,
"backup", result.BackupPath)
}
return nil
@@ -298,6 +314,26 @@ func (cs *ConfigService) SetDataPathChangeCallback(callback func() error) error
return cs.notificationService.RegisterListener(dataPathListener)
}
// SetBackupConfigChangeCallback 设置备份配置变更回调
func (cs *ConfigService) SetBackupConfigChangeCallback(callback func(config *models.GitBackupConfig) error) error {
cs.mu.Lock()
defer cs.mu.Unlock()
// 创建备份配置监听器并注册
backupListener := CreateBackupConfigListener("DefaultBackupConfigListener", callback)
return cs.notificationService.RegisterListener(backupListener)
}
// SetWindowSnapConfigChangeCallback 设置窗口吸附配置变更回调
func (cs *ConfigService) SetWindowSnapConfigChangeCallback(callback func(enabled bool) error) error {
cs.mu.Lock()
defer cs.mu.Unlock()
// 创建窗口吸附配置监听器并注册
windowSnapListener := CreateWindowSnapConfigListener("DefaultWindowSnapConfigListener", callback)
return cs.notificationService.RegisterListener(windowSnapListener)
}
// ServiceShutdown 关闭服务
func (cs *ConfigService) ServiceShutdown() error {
cs.stopWatching()
@@ -306,3 +342,13 @@ func (cs *ConfigService) ServiceShutdown() error {
}
return nil
}
// GetConfigDir 获取配置目录
func (cs *ConfigService) GetConfigDir() string {
return cs.configDir
}
// GetSettingsPath 获取设置文件路径
func (cs *ConfigService) GetSettingsPath() string {
return cs.settingsPath
}

View File

@@ -2,14 +2,18 @@ package services
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/sqlite"
_ "modernc.org/sqlite"
)
const (
@@ -31,7 +35,8 @@ CREATE TABLE IF NOT EXISTS documents (
content TEXT DEFAULT '∞∞∞text-a',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_deleted INTEGER DEFAULT 0
is_deleted INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0
)`
// Extensions table
@@ -58,28 +63,70 @@ CREATE TABLE IF NOT EXISTS key_bindings (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(command, extension)
)`
// Themes table
sqlCreateThemesTable = `
CREATE TABLE IF NOT EXISTS themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
colors TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(type, is_default)
)`
)
// ColumnInfo 存储列的信息
type ColumnInfo struct {
SQLType string
DefaultValue string
}
// TableModel 表示表与模型之间的映射关系
type TableModel struct {
TableName string
Model interface{}
}
// DatabaseService provides shared database functionality
type DatabaseService struct {
configService *ConfigService
logger *log.Service
SQLite *sqlite.Service
logger *log.LogService
db *sql.DB
mu sync.RWMutex
ctx context.Context
tableModels []TableModel // 注册的表模型
}
// NewDatabaseService creates a new database service
func NewDatabaseService(configService *ConfigService, logger *log.Service) *DatabaseService {
func NewDatabaseService(configService *ConfigService, logger *log.LogService) *DatabaseService {
if logger == nil {
logger = log.New()
}
return &DatabaseService{
ds := &DatabaseService{
configService: configService,
logger: logger,
SQLite: sqlite.New(),
}
// 注册所有模型
ds.registerAllModels()
return ds
}
// registerAllModels 注册所有数据模型,集中管理表-模型映射
func (ds *DatabaseService) registerAllModels() {
// 文档表
ds.RegisterModel("documents", &models.Document{})
// 扩展表
ds.RegisterModel("extensions", &models.Extension{})
// 快捷键表
ds.RegisterModel("key_bindings", &models.KeyBinding{})
// 主题表
ds.RegisterModel("themes", &models.Theme{})
}
// ServiceStartup initializes the service when the application starts
@@ -101,28 +148,19 @@ func (ds *DatabaseService) initDatabase() error {
return fmt.Errorf("failed to create database directory: %w", err)
}
// 检查数据库文件是否存在,如果不存在则创建
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
// 创建空文件
file, err := os.Create(dbPath)
if err != nil {
return fmt.Errorf("failed to create database file: %w", err)
}
file.Close()
}
// 配置SQLite服务
ds.SQLite.Configure(&sqlite.Config{
DBSource: dbPath,
})
// 打开数据库连接
if err := ds.SQLite.Open(); err != nil {
ds.db, err = sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// 测试连接
if err := ds.db.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
// 应用性能优化设置
if err := ds.SQLite.Execute(sqlOptimizationSettings); err != nil {
if _, err := ds.db.Exec(sqlOptimizationSettings); err != nil {
return fmt.Errorf("failed to apply optimization settings: %w", err)
}
@@ -135,6 +173,11 @@ func (ds *DatabaseService) initDatabase() error {
return fmt.Errorf("failed to create indexes: %w", err)
}
// 执行模型与表结构同步
if err := ds.syncAllModelTables(); err != nil {
return fmt.Errorf("failed to sync model tables: %w", err)
}
return nil
}
@@ -153,10 +196,11 @@ func (ds *DatabaseService) createTables() error {
sqlCreateDocumentsTable,
sqlCreateExtensionsTable,
sqlCreateKeyBindingsTable,
sqlCreateThemesTable,
}
for _, table := range tables {
if err := ds.SQLite.Execute(table); err != nil {
if _, err := ds.db.Exec(table); err != nil {
return err
}
}
@@ -176,26 +220,172 @@ func (ds *DatabaseService) createIndexes() error {
`CREATE INDEX IF NOT EXISTS idx_key_bindings_command ON key_bindings(command)`,
`CREATE INDEX IF NOT EXISTS idx_key_bindings_extension ON key_bindings(extension)`,
`CREATE INDEX IF NOT EXISTS idx_key_bindings_enabled ON key_bindings(enabled)`,
// Themes indexes
`CREATE INDEX IF NOT EXISTS idx_themes_type ON themes(type)`,
`CREATE INDEX IF NOT EXISTS idx_themes_is_default ON themes(is_default)`,
}
for _, index := range indexes {
if err := ds.SQLite.Execute(index); err != nil {
if _, err := ds.db.Exec(index); err != nil {
return err
}
}
return nil
}
// RegisterModel 注册模型与表的映射关系
func (ds *DatabaseService) RegisterModel(tableName string, model interface{}) {
ds.mu.Lock()
defer ds.mu.Unlock()
ds.tableModels = append(ds.tableModels, TableModel{
TableName: tableName,
Model: model,
})
}
// syncAllModelTables 同步所有注册的模型与表结构
func (ds *DatabaseService) syncAllModelTables() error {
for _, tm := range ds.tableModels {
if err := ds.syncModelTable(tm.TableName, tm.Model); err != nil {
return fmt.Errorf("failed to sync table %s: %w", tm.TableName, err)
}
}
return nil
}
// syncModelTable 同步模型与表结构
func (ds *DatabaseService) syncModelTable(tableName string, model interface{}) error {
// 获取表结构元数据
columns, err := ds.getTableColumns(tableName)
if err != nil {
return fmt.Errorf("failed to get table columns: %w", err)
}
// 使用反射从模型中提取字段信息
expectedColumns, err := ds.getModelColumns(model)
if err != nil {
return fmt.Errorf("failed to get model columns: %w", err)
}
// 检查缺失的列并添加
for colName, colInfo := range expectedColumns {
if _, exists := columns[colName]; !exists {
// 执行添加列的SQL
alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s DEFAULT %s",
tableName, colName, colInfo.SQLType, colInfo.DefaultValue)
if _, err := ds.db.Exec(alterSQL); err != nil {
return fmt.Errorf("failed to add column %s: %w", colName, err)
}
}
}
return nil
}
// getTableColumns 获取表的列信息
func (ds *DatabaseService) getTableColumns(table string) (map[string]string, error) {
query := fmt.Sprintf("PRAGMA table_info(%s)", table)
rows, err := ds.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]string)
for rows.Next() {
var cid int
var name, typeName string
var notNull, pk int
var dflt_value interface{}
if err := rows.Scan(&cid, &name, &typeName, &notNull, &dflt_value, &pk); err != nil {
return nil, err
}
columns[name] = typeName
}
if err := rows.Err(); err != nil {
return nil, err
}
return columns, nil
}
// getModelColumns 从模型结构体中提取数据库列信息
func (ds *DatabaseService) getModelColumns(model interface{}) (map[string]ColumnInfo, error) {
columns := make(map[string]ColumnInfo)
// 使用反射获取结构体的类型信息
t := reflect.TypeOf(model)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("model must be a struct or a pointer to struct")
}
// 遍历所有字段
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
// 只处理有db标签的字段
dbTag := field.Tag.Get("db")
if dbTag == "" {
// 如果没有db标签跳过该字段
continue
}
// 获取字段类型对应的SQL类型和默认值
sqlType, defaultVal := getSQLTypeAndDefault(field.Type)
columns[dbTag] = ColumnInfo{
SQLType: sqlType,
DefaultValue: defaultVal,
}
}
return columns, nil
}
// getSQLTypeAndDefault 根据Go类型获取对应的SQL类型和默认值
func getSQLTypeAndDefault(t reflect.Type) (string, string) {
switch t.Kind() {
case reflect.Bool:
return "INTEGER", "0"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "INTEGER", "0"
case reflect.Float32, reflect.Float64:
return "REAL", "0.0"
case reflect.String:
return "TEXT", "''"
default:
// 处理特殊类型
if t == reflect.TypeOf(time.Time{}) {
return "DATETIME", "CURRENT_TIMESTAMP"
}
return "TEXT", "NULL"
}
}
// ServiceShutdown shuts down the service when the application closes
func (ds *DatabaseService) ServiceShutdown() error {
return ds.SQLite.Close()
if ds.db != nil {
return ds.db.Close()
}
return nil
}
// OnDataPathChanged handles data path changes
func (ds *DatabaseService) OnDataPathChanged() error {
// 关闭当前连接
if err := ds.SQLite.Close(); err != nil {
return err
if ds.db != nil {
if err := ds.db.Close(); err != nil {
return err
}
}
// 用新路径重新初始化

View File

@@ -7,12 +7,12 @@ import (
// DialogService 对话框服务,处理文件选择等对话框操作
type DialogService struct {
logger *log.Service
logger *log.LogService
window *application.WebviewWindow // 绑定的窗口
}
// NewDialogService 创建新的对话框服务实例
func NewDialogService(logger *log.Service) *DialogService {
func NewDialogService(logger *log.LogService) *DialogService {
if logger == nil {
logger = log.New()
}
@@ -50,7 +50,6 @@ func (ds *DialogService) SelectDirectory() (string, error) {
// 对话框文本配置
Title: "Select Directory",
Message: "Select the folder where you want to store your app data",
ButtonText: "Select",
// 不设置过滤器,因为我们选择目录
@@ -69,3 +68,44 @@ func (ds *DialogService) SelectDirectory() (string, error) {
}
return path, nil
}
// SelectFile 打开文件选择对话框
func (ds *DialogService) SelectFile() (string, error) {
dialog := application.OpenFileDialog()
dialog.SetOptions(&application.OpenFileDialogOptions{
// 目录选择配置
CanChooseDirectories: false, // 允许选择目录
CanChooseFiles: true, // 不允许选择文件
CanCreateDirectories: true, // 允许创建新目录
AllowsMultipleSelection: false, // 单选模式
// 显示配置
ShowHiddenFiles: true, // 不显示隐藏文件
HideExtension: false, // 不隐藏扩展名
CanSelectHiddenExtension: false, // 不允许选择隐藏扩展名
TreatsFilePackagesAsDirectories: false, // 不将文件包当作目录处理
AllowsOtherFileTypes: false, // 不允许其他文件类型
// 系统配置
ResolvesAliases: true, // 解析别名/快捷方式
// 对话框文本配置
Title: "Select File",
ButtonText: "Select File",
// 不设置过滤器,因为我们选择目录
Filters: nil,
// 不指定默认目录,让系统决定
Directory: "",
// 绑定到主窗口
Window: ds.window,
})
path, err := dialog.PromptForSingleSelection()
if err != nil {
return "", err
}
return path, nil
}

View File

@@ -18,28 +18,28 @@ const (
// Document operations
sqlGetDocumentByID = `
SELECT id, title, content, created_at, updated_at, is_deleted
SELECT id, title, content, created_at, updated_at, is_deleted, is_locked
FROM documents
WHERE id = ?`
sqlInsertDocument = `
INSERT INTO documents (title, content, created_at, updated_at, is_deleted)
VALUES (?, ?, ?, ?, 0)`
INSERT INTO documents (title, content, created_at, updated_at, is_deleted, is_locked)
VALUES (?, ?, ?, ?, 0, 0)`
sqlUpdateDocumentContent = `
UPDATE documents
SET content = ?, updated_at = ?
WHERE id = ?`
WHERE id = ? AND is_deleted = 0`
sqlUpdateDocumentTitle = `
UPDATE documents
SET title = ?, updated_at = ?
WHERE id = ?`
WHERE id = ? AND is_deleted = 0`
sqlMarkDocumentAsDeleted = `
UPDATE documents
SET is_deleted = 1, updated_at = ?
WHERE id = ?`
WHERE id = ? AND is_locked = 0`
sqlRestoreDocument = `
UPDATE documents
@@ -47,13 +47,13 @@ SET is_deleted = 0, updated_at = ?
WHERE id = ?`
sqlListAllDocumentsMeta = `
SELECT id, title, created_at, updated_at
SELECT id, title, created_at, updated_at, is_locked
FROM documents
WHERE is_deleted = 0
ORDER BY updated_at DESC`
sqlListDeletedDocumentsMeta = `
SELECT id, title, created_at, updated_at
SELECT id, title, created_at, updated_at, is_locked
FROM documents
WHERE is_deleted = 1
ORDER BY updated_at DESC`
@@ -63,33 +63,46 @@ SELECT id FROM documents WHERE is_deleted = 0 ORDER BY id LIMIT 1`
sqlCountDocuments = `SELECT COUNT(*) FROM documents WHERE is_deleted = 0`
sqlSetDocumentLocked = `
UPDATE documents
SET is_locked = 1, updated_at = ?
WHERE id = ?`
sqlSetDocumentUnlocked = `
UPDATE documents
SET is_locked = 0, updated_at = ?
WHERE id = ?`
sqlDefaultDocumentID = 1 // 默认文档的ID
)
// DocumentService provides document management functionality
type DocumentService struct {
databaseService *DatabaseService
logger *log.Service
logger *log.LogService
mu sync.RWMutex
ctx context.Context
}
// NewDocumentService creates a new document service
func NewDocumentService(databaseService *DatabaseService, logger *log.Service) *DocumentService {
func NewDocumentService(databaseService *DatabaseService, logger *log.LogService) *DocumentService {
if logger == nil {
logger = log.New()
}
return &DocumentService{
ds := &DocumentService{
databaseService: databaseService,
logger: logger,
}
return ds
}
// ServiceStartup initializes the service when the application starts
func (ds *DocumentService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
ds.ctx = ctx
// Ensure default document exists
// 确保默认文档存在
if err := ds.ensureDefaultDocument(); err != nil {
return fmt.Errorf("failed to ensure default document: %w", err)
}
@@ -98,19 +111,15 @@ func (ds *DocumentService) ServiceStartup(ctx context.Context, options applicati
// ensureDefaultDocument ensures a default document exists
func (ds *DocumentService) ensureDefaultDocument() error {
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
// Check if any document exists
rows, err := ds.databaseService.SQLite.Query(sqlCountDocuments)
var count int64
err := ds.databaseService.db.QueryRow(sqlCountDocuments).Scan(&count)
if err != nil {
return err
}
if len(rows) == 0 {
return fmt.Errorf("failed to query document count")
}
count, ok := rows[0]["COUNT(*)"].(int64)
if !ok {
return fmt.Errorf("failed to convert count to int64")
return fmt.Errorf("failed to query document count: %w", err)
}
// If no documents exist, create default document
@@ -127,48 +136,42 @@ func (ds *DocumentService) GetDocumentByID(id int64) (*models.Document, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
rows, err := ds.databaseService.SQLite.Query(sqlGetDocumentByID, id)
if ds.databaseService == nil || ds.databaseService.db == nil {
return nil, errors.New("database service not available")
}
doc := &models.Document{}
var createdAt, updatedAt string
var isDeleted, isLocked int
err := ds.databaseService.db.QueryRow(sqlGetDocumentByID, id).Scan(
&doc.ID,
&doc.Title,
&doc.Content,
&createdAt,
&updatedAt,
&isDeleted,
&isLocked,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to get document by ID: %w", err)
}
if len(rows) == 0 {
return nil, nil
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
row := rows[0]
doc := &models.Document{}
// 从Row中提取数据
if idVal, ok := row["id"].(int64); ok {
doc.ID = idVal
}
if title, ok := row["title"].(string); ok {
doc.Title = title
}
if content, ok := row["content"].(string); ok {
doc.Content = content
}
if createdAt, ok := row["created_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", createdAt)
if err == nil {
doc.CreatedAt = t
}
}
if updatedAt, ok := row["updated_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", updatedAt)
if err == nil {
doc.UpdatedAt = t
}
}
if isDeletedInt, ok := row["is_deleted"].(int64); ok {
doc.IsDeleted = isDeletedInt == 1
}
// 转换布尔字段
doc.IsDeleted = isDeleted == 1
doc.IsLocked = isLocked == 1
return doc, nil
}
@@ -178,6 +181,10 @@ func (ds *DocumentService) CreateDocument(title string) (*models.Document, error
ds.mu.Lock()
defer ds.mu.Unlock()
if ds.databaseService == nil || ds.databaseService.db == nil {
return nil, errors.New("database service not available")
}
// Create document with default content
now := time.Now()
doc := &models.Document{
@@ -186,41 +193,102 @@ func (ds *DocumentService) CreateDocument(title string) (*models.Document, error
CreatedAt: now,
UpdatedAt: now,
IsDeleted: false,
IsLocked: false,
}
// 执行插入操作
if err := ds.databaseService.SQLite.Execute(sqlInsertDocument,
doc.Title, doc.Content, doc.CreatedAt, doc.UpdatedAt); err != nil {
result, err := ds.databaseService.db.Exec(sqlInsertDocument,
doc.Title, doc.Content, doc.CreatedAt.Format("2006-01-02 15:04:05"), doc.UpdatedAt.Format("2006-01-02 15:04:05"))
if err != nil {
return nil, fmt.Errorf("failed to create document: %w", err)
}
// 获取自增ID
lastIDRows, err := ds.databaseService.SQLite.Query("SELECT last_insert_rowid()")
lastID, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("failed to get last insert ID: %w", err)
}
if len(lastIDRows) == 0 {
return nil, fmt.Errorf("no rows returned for last insert ID query")
}
// 从结果中提取ID
lastID, ok := lastIDRows[0]["last_insert_rowid()"].(int64)
if !ok {
return nil, fmt.Errorf("failed to convert last insert ID to int64")
}
// 返回带ID的文档
doc.ID = lastID
return doc, nil
}
// LockDocument 锁定文档,防止删除
func (ds *DocumentService) LockDocument(id int64) error {
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
// 先检查文档是否存在且未删除(不加锁避免死锁)
doc, err := ds.GetDocumentByID(id)
if err != nil {
return fmt.Errorf("failed to get document: %w", err)
}
if doc == nil {
return fmt.Errorf("document not found: %d", id)
}
if doc.IsDeleted {
return fmt.Errorf("cannot lock deleted document: %d", id)
}
// 如果已经锁定,无需操作
if doc.IsLocked {
return nil
}
// 现在加锁执行锁定操作
ds.mu.Lock()
defer ds.mu.Unlock()
_, err = ds.databaseService.db.Exec(sqlSetDocumentLocked, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to lock document: %w", err)
}
return nil
}
// UnlockDocument 解锁文档
func (ds *DocumentService) UnlockDocument(id int64) error {
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
// 先检查文档是否存在(不加锁避免死锁)
doc, err := ds.GetDocumentByID(id)
if err != nil {
return fmt.Errorf("failed to get document: %w", err)
}
if doc == nil {
return fmt.Errorf("document not found: %d", id)
}
// 如果未锁定,无需操作
if !doc.IsLocked {
return nil
}
// 现在加锁执行解锁操作
ds.mu.Lock()
defer ds.mu.Unlock()
_, err = ds.databaseService.db.Exec(sqlSetDocumentUnlocked, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to unlock document: %w", err)
}
return nil
}
// UpdateDocumentContent updates the content of a document
func (ds *DocumentService) UpdateDocumentContent(id int64, content string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
err := ds.databaseService.SQLite.Execute(sqlUpdateDocumentContent, content, time.Now(), id)
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
_, err := ds.databaseService.db.Exec(sqlUpdateDocumentContent, content, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to update document content: %w", err)
}
@@ -232,7 +300,11 @@ func (ds *DocumentService) UpdateDocumentTitle(id int64, title string) error {
ds.mu.Lock()
defer ds.mu.Unlock()
err := ds.databaseService.SQLite.Execute(sqlUpdateDocumentTitle, title, time.Now(), id)
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
_, err := ds.databaseService.db.Exec(sqlUpdateDocumentTitle, title, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to update document title: %w", err)
}
@@ -241,15 +313,33 @@ func (ds *DocumentService) UpdateDocumentTitle(id int64, title string) error {
// DeleteDocument marks a document as deleted (default document with ID=1 cannot be deleted)
func (ds *DocumentService) DeleteDocument(id int64) error {
ds.mu.Lock()
defer ds.mu.Unlock()
if ds.databaseService == nil || ds.databaseService.db == nil {
ds.logger.Error("database service not available")
return errors.New("database service not available")
}
// 不允许删除默认文档
if id == sqlDefaultDocumentID {
return fmt.Errorf("cannot delete the default document")
}
err := ds.databaseService.SQLite.Execute(sqlMarkDocumentAsDeleted, time.Now(), id)
// 先检查文档是否存在和锁定状态(不加锁避免死锁)
doc, err := ds.GetDocumentByID(id)
if err != nil {
return fmt.Errorf("failed to get document: %w", err)
}
if doc == nil {
return fmt.Errorf("document not found: %d", id)
}
if doc.IsLocked {
return fmt.Errorf("cannot delete locked document: %d", id)
}
// 现在加锁执行删除操作
ds.mu.Lock()
defer ds.mu.Unlock()
_, err = ds.databaseService.db.Exec(sqlMarkDocumentAsDeleted, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to mark document as deleted: %w", err)
}
@@ -261,7 +351,11 @@ func (ds *DocumentService) RestoreDocument(id int64) error {
ds.mu.Lock()
defer ds.mu.Unlock()
err := ds.databaseService.SQLite.Execute(sqlRestoreDocument, time.Now(), id)
if ds.databaseService == nil || ds.databaseService.db == nil {
return errors.New("database service not available")
}
_, err := ds.databaseService.db.Exec(sqlRestoreDocument, time.Now().Format("2006-01-02 15:04:05"), id)
if err != nil {
return fmt.Errorf("failed to restore document: %w", err)
}
@@ -273,40 +367,50 @@ func (ds *DocumentService) ListAllDocumentsMeta() ([]*models.Document, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
rows, err := ds.databaseService.SQLite.Query(sqlListAllDocumentsMeta)
if ds.databaseService == nil || ds.databaseService.db == nil {
return nil, errors.New("database service not available")
}
rows, err := ds.databaseService.db.Query(sqlListAllDocumentsMeta)
if err != nil {
return nil, fmt.Errorf("failed to list document meta: %w", err)
}
defer rows.Close()
var documents []*models.Document
for _, row := range rows {
for rows.Next() {
doc := &models.Document{IsDeleted: false}
var createdAt, updatedAt string
var isLocked int
if id, ok := row["id"].(int64); ok {
doc.ID = id
err := rows.Scan(
&doc.ID,
&doc.Title,
&createdAt,
&updatedAt,
&isLocked,
)
if err != nil {
return nil, fmt.Errorf("failed to scan document row: %w", err)
}
if title, ok := row["title"].(string); ok {
doc.Title = title
}
if createdAt, ok := row["created_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", createdAt)
if err == nil {
doc.CreatedAt = t
}
}
if updatedAt, ok := row["updated_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", updatedAt)
if err == nil {
doc.UpdatedAt = t
}
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
doc.IsLocked = isLocked == 1
documents = append(documents, doc)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating document rows: %w", err)
}
return documents, nil
}
@@ -315,40 +419,50 @@ func (ds *DocumentService) ListDeletedDocumentsMeta() ([]*models.Document, error
ds.mu.RLock()
defer ds.mu.RUnlock()
rows, err := ds.databaseService.SQLite.Query(sqlListDeletedDocumentsMeta)
if ds.databaseService == nil || ds.databaseService.db == nil {
return nil, errors.New("database service not available")
}
rows, err := ds.databaseService.db.Query(sqlListDeletedDocumentsMeta)
if err != nil {
return nil, fmt.Errorf("failed to list deleted document meta: %w", err)
}
defer rows.Close()
var documents []*models.Document
for _, row := range rows {
for rows.Next() {
doc := &models.Document{IsDeleted: true}
var createdAt, updatedAt string
var isLocked int
if id, ok := row["id"].(int64); ok {
doc.ID = id
err := rows.Scan(
&doc.ID,
&doc.Title,
&createdAt,
&updatedAt,
&isLocked,
)
if err != nil {
return nil, fmt.Errorf("failed to scan document row: %w", err)
}
if title, ok := row["title"].(string); ok {
doc.Title = title
}
if createdAt, ok := row["created_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", createdAt)
if err == nil {
doc.CreatedAt = t
}
}
if updatedAt, ok := row["updated_at"].(string); ok {
t, err := time.Parse("2006-01-02 15:04:05", updatedAt)
if err == nil {
doc.UpdatedAt = t
}
// 转换时间字段
if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil {
doc.CreatedAt = t
}
if t, err := time.Parse("2006-01-02 15:04:05", updatedAt); err == nil {
doc.UpdatedAt = t
}
doc.IsLocked = isLocked == 1
documents = append(documents, doc)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating deleted document rows: %w", err)
}
return documents, nil
}
@@ -357,7 +471,12 @@ func (ds *DocumentService) GetFirstDocumentID() (int64, error) {
ds.mu.RLock()
defer ds.mu.RUnlock()
rows, err := ds.databaseService.SQLite.Query(sqlGetFirstDocumentID)
if ds.databaseService == nil || ds.databaseService.db == nil {
return 0, errors.New("database service not available")
}
var id int64
err := ds.databaseService.db.QueryRow(sqlGetFirstDocumentID).Scan(&id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil // No documents exist
@@ -365,14 +484,5 @@ func (ds *DocumentService) GetFirstDocumentID() (int64, error) {
return 0, fmt.Errorf("failed to get first document ID: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
id, ok := rows[0]["id"].(int64)
if !ok {
return 0, fmt.Errorf("failed to convert ID to int64")
}
return id, nil
}

View File

@@ -41,7 +41,7 @@ WHERE id = ?`
// ExtensionService 扩展管理服务
type ExtensionService struct {
databaseService *DatabaseService
logger *log.Service
logger *log.LogService
mu sync.RWMutex
ctx context.Context
@@ -73,7 +73,7 @@ func (e *ExtensionError) Is(target error) bool {
}
// NewExtensionService 创建扩展服务实例
func NewExtensionService(databaseService *DatabaseService, logger *log.Service) *ExtensionService {
func NewExtensionService(databaseService *DatabaseService, logger *log.LogService) *ExtensionService {
if logger == nil {
logger = log.New()
}
@@ -104,21 +104,17 @@ func (es *ExtensionService) initDatabase() error {
es.mu.Lock()
defer es.mu.Unlock()
if es.databaseService == nil || es.databaseService.db == nil {
return &ExtensionError{"check_db", "", errors.New("database service not available")}
}
// 检查是否已有扩展数据
rows, err := es.databaseService.SQLite.Query("SELECT COUNT(*) FROM extensions")
var count int64
err := es.databaseService.db.QueryRow("SELECT COUNT(*) FROM extensions").Scan(&count)
if err != nil {
return &ExtensionError{"check_extensions_count", "", err}
}
if len(rows) == 0 {
return &ExtensionError{"check_extensions_count", "", fmt.Errorf("no rows returned")}
}
count, ok := rows[0]["COUNT(*)"].(int64)
if !ok {
return &ExtensionError{"convert_count", "", fmt.Errorf("failed to convert count to int64")}
}
// 如果没有数据,插入默认配置
if count == 0 {
if err := es.insertDefaultExtensions(); err != nil {
@@ -133,16 +129,15 @@ func (es *ExtensionService) initDatabase() error {
// insertDefaultExtensions 插入默认扩展配置
func (es *ExtensionService) insertDefaultExtensions() error {
defaultSettings := models.NewDefaultExtensionSettings()
now := time.Now()
now := time.Now().Format("2006-01-02 15:04:05")
for _, ext := range defaultSettings.Extensions {
configJSON, err := json.Marshal(ext.Config)
if err != nil {
return &ExtensionError{"marshal_config", string(ext.ID), err}
}
err = es.databaseService.SQLite.Execute(sqlInsertExtension,
_, err = es.databaseService.db.Exec(sqlInsertExtension,
string(ext.ID),
ext.Enabled,
ext.IsDefault,
@@ -153,7 +148,6 @@ func (es *ExtensionService) insertDefaultExtensions() error {
if err != nil {
return &ExtensionError{"insert_extension", string(ext.ID), err}
}
}
return nil
@@ -179,38 +173,51 @@ func (es *ExtensionService) GetAllExtensions() ([]models.Extension, error) {
es.mu.RLock()
defer es.mu.RUnlock()
rows, err := es.databaseService.SQLite.Query(sqlGetAllExtensions)
if es.databaseService == nil || es.databaseService.db == nil {
return nil, &ExtensionError{"query_db", "", errors.New("database service not available")}
}
rows, err := es.databaseService.db.Query(sqlGetAllExtensions)
if err != nil {
return nil, &ExtensionError{"query_extensions", "", err}
}
defer rows.Close()
var extensions []models.Extension
for _, row := range rows {
for rows.Next() {
var ext models.Extension
var id string
var configJSON string
var enabled, isDefault int
if id, ok := row["id"].(string); ok {
ext.ID = models.ExtensionID(id)
err := rows.Scan(
&id,
&enabled,
&isDefault,
&configJSON,
)
if err != nil {
return nil, &ExtensionError{"scan_extension", "", err}
}
if enabled, ok := row["enabled"].(int64); ok {
ext.Enabled = enabled == 1
}
ext.ID = models.ExtensionID(id)
ext.Enabled = enabled == 1
ext.IsDefault = isDefault == 1
if isDefault, ok := row["is_default"].(int64); ok {
ext.IsDefault = isDefault == 1
}
if configJSON, ok := row["config"].(string); ok {
var config models.ExtensionConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, &ExtensionError{"unmarshal_config", string(ext.ID), err}
}
ext.Config = config
var config models.ExtensionConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, &ExtensionError{"unmarshal_config", id, err}
}
ext.Config = config
extensions = append(extensions, ext)
}
if err = rows.Err(); err != nil {
return nil, &ExtensionError{"iterate_extensions", "", err}
}
return extensions, nil
}
@@ -224,6 +231,10 @@ func (es *ExtensionService) UpdateExtensionState(id models.ExtensionID, enabled
es.mu.Lock()
defer es.mu.Unlock()
if es.databaseService == nil || es.databaseService.db == nil {
return &ExtensionError{"check_db", string(id), errors.New("database service not available")}
}
var configJSON []byte
var err error
@@ -234,24 +245,19 @@ func (es *ExtensionService) UpdateExtensionState(id models.ExtensionID, enabled
}
} else {
// 如果没有提供配置,保持原有配置
rows, err := es.databaseService.SQLite.Query("SELECT config FROM extensions WHERE id = ?", string(id))
var currentConfigJSON string
err := es.databaseService.db.QueryRow("SELECT config FROM extensions WHERE id = ?", string(id)).Scan(&currentConfigJSON)
if err != nil {
return &ExtensionError{"query_current_config", string(id), err}
}
if len(rows) == 0 {
return &ExtensionError{"query_current_config", string(id), fmt.Errorf("extension not found")}
}
currentConfigJSON, ok := rows[0]["config"].(string)
if !ok {
return &ExtensionError{"convert_config", string(id), fmt.Errorf("failed to get current config")}
}
configJSON = []byte(currentConfigJSON)
}
err = es.databaseService.SQLite.Execute(sqlUpdateExtension, enabled, string(configJSON), time.Now(), string(id))
_, err = es.databaseService.db.Exec(sqlUpdateExtension,
enabled,
string(configJSON),
time.Now().Format("2006-01-02 15:04:05"),
string(id))
if err != nil {
return &ExtensionError{"update_extension", string(id), err}
}
@@ -276,8 +282,12 @@ func (es *ExtensionService) ResetAllExtensionsToDefault() error {
es.mu.Lock()
defer es.mu.Unlock()
if es.databaseService == nil || es.databaseService.db == nil {
return &ExtensionError{"check_db", "", errors.New("database service not available")}
}
// 删除所有现有扩展
err := es.databaseService.SQLite.Execute(sqlDeleteAllExtensions)
_, err := es.databaseService.db.Exec(sqlDeleteAllExtensions)
if err != nil {
return &ExtensionError{"delete_all_extensions", "", err}
}

View File

@@ -24,17 +24,19 @@ import (
// HotkeyService Windows全局热键服务
type HotkeyService struct {
logger *log.Service
logger *log.LogService
configService *ConfigService
windowService *WindowService
app *application.App
mainWindow *application.WebviewWindow
mu sync.RWMutex
currentHotkey *models.HotkeyCombo
isRegistered atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
ctx context.Context
cancelFunc atomic.Value // 使用atomic.Value存储cancel函数避免竞态条件
wg sync.WaitGroup
}
// HotkeyError 热键错误
@@ -51,24 +53,48 @@ func (e *HotkeyError) Unwrap() error {
return e.Err
}
// setCancelFunc 原子地设置cancel函数
func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
hs.cancelFunc.Store(cancel)
}
// getCancelFunc 原子地获取cancel函数
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
if cancel := hs.cancelFunc.Load(); cancel != nil {
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
return cancelFunc
}
}
return nil
}
// clearCancelFunc 原子地清除cancel函数
func (hs *HotkeyService) clearCancelFunc() {
hs.cancelFunc.Store((context.CancelFunc)(nil))
}
// NewHotkeyService 创建热键服务实例
func NewHotkeyService(configService *ConfigService, logger *log.Service) *HotkeyService {
func NewHotkeyService(configService *ConfigService, windowService *WindowService, logger *log.LogService) *HotkeyService {
if logger == nil {
logger = log.New()
}
ctx, cancel := context.WithCancel(context.Background())
return &HotkeyService{
service := &HotkeyService{
logger: logger,
configService: configService,
windowService: windowService,
ctx: ctx,
cancel: cancel,
}
// 初始化时设置cancel函数
service.setCancelFunc(cancel)
return service
}
// Initialize 初始化热键服务
func (hs *HotkeyService) Initialize(app *application.App) error {
func (hs *HotkeyService) Initialize(app *application.App, mainWindow *application.WebviewWindow) error {
hs.app = app
hs.mainWindow = mainWindow
config, err := hs.configService.GetConfig()
if err != nil {
@@ -119,7 +145,7 @@ func (hs *HotkeyService) RegisterHotkey(hotkey *models.HotkeyCombo) error {
hs.currentHotkey = hotkey
hs.isRegistered.Store(true)
hs.cancel = cancel
hs.setCancelFunc(cancel)
return nil
}
@@ -137,13 +163,15 @@ func (hs *HotkeyService) unregisterInternal() error {
return nil
}
if hs.cancel != nil {
hs.cancel()
// 原子地获取并调用cancel函数
if cancel := hs.getCancelFunc(); cancel != nil {
cancel()
hs.wg.Wait()
}
hs.currentHotkey = nil
hs.isRegistered.Store(false)
hs.clearCancelFunc()
return nil
}
@@ -165,7 +193,7 @@ func (hs *HotkeyService) hotkeyListener(ctx context.Context, hotkey *models.Hotk
return
}
ticker := time.NewTicker(100 * time.Millisecond)
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
var wasPressed bool
@@ -199,11 +227,61 @@ func cBool(b bool) C.int {
return 0
}
// toggleWindow 切换窗口
// toggleWindow 切换窗口显示状态
func (hs *HotkeyService) toggleWindow() {
if hs.app != nil {
hs.app.Event.Emit("hotkey:toggle-window", nil)
if hs.mainWindow == nil {
hs.logger.Error("main window not set")
return
}
// 检查主窗口是否可见
if hs.isWindowVisible(hs.mainWindow) {
// 如果主窗口可见,隐藏所有窗口
hs.hideAllWindows()
} else {
// 如果主窗口不可见,显示所有窗口
hs.showAllWindows()
}
}
// isWindowVisible 检查窗口是否可见
func (hs *HotkeyService) isWindowVisible(window *application.WebviewWindow) bool {
return window.IsVisible()
}
// hideAllWindows 隐藏所有窗口
func (hs *HotkeyService) hideAllWindows() {
// 隐藏主窗口
hs.mainWindow.Hide()
// 隐藏所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Hide()
}
}
hs.logger.Debug("all windows hidden")
}
// showAllWindows 显示所有窗口
func (hs *HotkeyService) showAllWindows() {
// 显示主窗口
hs.mainWindow.Show()
hs.mainWindow.Restore()
hs.mainWindow.Focus()
// 显示所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Show()
windowInfo.Window.Restore()
}
}
hs.logger.Debug("all windows shown")
}
// keyToVirtualKeyCode 键名转虚拟键码
@@ -261,7 +339,10 @@ func (hs *HotkeyService) IsRegistered() bool {
// ServiceShutdown 关闭服务
func (hs *HotkeyService) ServiceShutdown() error {
hs.cancel()
// 原子地获取并调用cancel函数
if cancel := hs.getCancelFunc(); cancel != nil {
cancel()
}
hs.wg.Wait()
return nil
}

View File

@@ -66,6 +66,7 @@ int isHotkeyRegistered() {
import "C"
import (
"context"
"fmt"
"sync"
"sync/atomic"
@@ -80,12 +81,15 @@ var globalHotkeyService *HotkeyService
// HotkeyService macOS全局热键服务
type HotkeyService struct {
logger *log.Service
logger *log.LogService
configService *ConfigService
windowService *WindowService
app *application.App
mainWindow *application.WebviewWindow
mu sync.RWMutex
isRegistered atomic.Bool
currentHotkey *models.HotkeyCombo
cancelFunc atomic.Value // 使用atomic.Value存储cancel函数避免竞态条件
}
// HotkeyError 热键错误
@@ -104,8 +108,28 @@ func (e *HotkeyError) Unwrap() error {
return e.Err
}
// setCancelFunc 原子地设置cancel函数
func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
hs.cancelFunc.Store(cancel)
}
// getCancelFunc 原子地获取cancel函数
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
if cancel := hs.cancelFunc.Load(); cancel != nil {
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
return cancelFunc
}
}
return nil
}
// clearCancelFunc 原子地清除cancel函数
func (hs *HotkeyService) clearCancelFunc() {
hs.cancelFunc.Store((context.CancelFunc)(nil))
}
// NewHotkeyService 创建新的热键服务实例
func NewHotkeyService(configService *ConfigService, logger *log.Service) *HotkeyService {
func NewHotkeyService(configService *ConfigService, windowService *WindowService, logger *log.LogService) *HotkeyService {
if logger == nil {
logger = log.New()
}
@@ -113,6 +137,7 @@ func NewHotkeyService(configService *ConfigService, logger *log.Service) *Hotkey
service := &HotkeyService{
logger: logger,
configService: configService,
windowService: windowService,
}
// 设置全局实例
@@ -122,8 +147,9 @@ func NewHotkeyService(configService *ConfigService, logger *log.Service) *Hotkey
}
// Initialize 初始化热键服务
func (hs *HotkeyService) Initialize(app *application.App) error {
func (hs *HotkeyService) Initialize(app *application.App, mainWindow *application.WebviewWindow) error {
hs.app = app
hs.mainWindow = mainWindow
// 加载并应用当前配置
config, err := hs.configService.GetConfig()
@@ -285,9 +311,59 @@ func (hs *HotkeyService) IsRegistered() bool {
// ToggleWindow 切换窗口显示状态
func (hs *HotkeyService) ToggleWindow() {
if hs.app != nil {
hs.app.EmitEvent("hotkey:toggle-window", nil)
if hs.mainWindow == nil {
hs.logger.Error("main window not set")
return
}
// 检查主窗口是否可见
if hs.isWindowVisible(hs.mainWindow) {
// 如果主窗口可见,隐藏所有窗口
hs.hideAllWindows()
} else {
// 如果主窗口不可见,显示所有窗口
hs.showAllWindows()
}
}
// isWindowVisible 检查窗口是否可见
func (hs *HotkeyService) isWindowVisible(window *application.WebviewWindow) bool {
return window.IsVisible()
}
// hideAllWindows 隐藏所有窗口
func (hs *HotkeyService) hideAllWindows() {
// 隐藏主窗口
hs.mainWindow.Hide()
// 隐藏所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Hide()
}
}
hs.logger.Debug("all windows hidden")
}
// showAllWindows 显示所有窗口
func (hs *HotkeyService) showAllWindows() {
// 显示主窗口
hs.mainWindow.Show()
hs.mainWindow.Restore()
hs.mainWindow.Focus()
// 显示所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Show()
windowInfo.Window.Restore()
}
}
hs.logger.Debug("all windows shown")
}
// ServiceShutdown 关闭热键服务

View File

@@ -141,17 +141,19 @@ import (
// HotkeyService Linux全局热键服务
type HotkeyService struct {
logger *log.Service
logger *log.LogService
configService *ConfigService
windowService *WindowService
app *application.App
mainWindow *application.WebviewWindow
mu sync.RWMutex
currentHotkey *models.HotkeyCombo
isRegistered atomic.Bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
ctx context.Context
cancelFunc atomic.Value // 使用atomic.Value存储cancel函数避免竞态条件
wg sync.WaitGroup
}
// HotkeyError 热键错误
@@ -169,24 +171,48 @@ func (e *HotkeyError) Unwrap() error {
return e.Err
}
// setCancelFunc 原子地设置cancel函数
func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
hs.cancelFunc.Store(cancel)
}
// getCancelFunc 原子地获取cancel函数
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
if cancel := hs.cancelFunc.Load(); cancel != nil {
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
return cancelFunc
}
}
return nil
}
// clearCancelFunc 原子地清除cancel函数
func (hs *HotkeyService) clearCancelFunc() {
hs.cancelFunc.Store((context.CancelFunc)(nil))
}
// NewHotkeyService 创建热键服务实例
func NewHotkeyService(configService *ConfigService, logger *log.Service) *HotkeyService {
func NewHotkeyService(configService *ConfigService, windowService *WindowService, logger *log.LogService) *HotkeyService {
if logger == nil {
logger = log.New()
}
ctx, cancel := context.WithCancel(context.Background())
return &HotkeyService{
service := &HotkeyService{
logger: logger,
configService: configService,
windowService: windowService,
ctx: ctx,
cancel: cancel,
}
// 初始化时设置cancel函数
service.setCancelFunc(cancel)
return service
}
// Initialize 初始化热键服务
func (hs *HotkeyService) Initialize(app *application.App) error {
func (hs *HotkeyService) Initialize(app *application.App, mainWindow *application.WebviewWindow) error {
hs.app = app
hs.mainWindow = mainWindow
if int(C.initX11Display()) == 0 {
return &HotkeyError{"init_x11", fmt.Errorf("failed to initialize X11 display")}
@@ -253,7 +279,7 @@ func (hs *HotkeyService) RegisterHotkey(hotkey *models.HotkeyCombo) error {
hs.currentHotkey = hotkey
hs.isRegistered.Store(true)
hs.cancel = cancel
hs.setCancelFunc(cancel)
return nil
}
@@ -271,8 +297,9 @@ func (hs *HotkeyService) unregisterInternal() error {
return nil
}
if hs.cancel != nil {
hs.cancel()
// 原子地获取并调用cancel函数
if cancel := hs.getCancelFunc(); cancel != nil {
cancel()
hs.wg.Wait()
}
@@ -283,6 +310,7 @@ func (hs *HotkeyService) unregisterInternal() error {
hs.currentHotkey = nil
hs.isRegistered.Store(false)
hs.clearCancelFunc()
return nil
}
@@ -298,7 +326,8 @@ func (hs *HotkeyService) UpdateHotkey(enable bool, hotkey *models.HotkeyCombo) e
func (hs *HotkeyService) hotkeyListener(ctx context.Context, ready chan<- error) {
defer hs.wg.Done()
ticker := time.NewTicker(100 * time.Millisecond)
// 优化轮询频率从100ms改为50ms提高响应性
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
ready <- nil // 标记准备就绪
@@ -315,11 +344,61 @@ func (hs *HotkeyService) hotkeyListener(ctx context.Context, ready chan<- error)
}
}
// toggleWindow 切换窗口
// toggleWindow 切换窗口显示状态
func (hs *HotkeyService) toggleWindow() {
if hs.app != nil {
hs.app.EmitEvent("hotkey:toggle-window", nil)
if hs.mainWindow == nil {
hs.logger.Error("main window not set")
return
}
// 检查主窗口是否可见
if hs.isWindowVisible(hs.mainWindow) {
// 如果主窗口可见,隐藏所有窗口
hs.hideAllWindows()
} else {
// 如果主窗口不可见,显示所有窗口
hs.showAllWindows()
}
}
// isWindowVisible 检查窗口是否可见
func (hs *HotkeyService) isWindowVisible(window *application.WebviewWindow) bool {
return window.IsVisible()
}
// hideAllWindows 隐藏所有窗口
func (hs *HotkeyService) hideAllWindows() {
// 隐藏主窗口
hs.mainWindow.Hide()
// 隐藏所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Hide()
}
}
hs.logger.Debug("all windows hidden")
}
// showAllWindows 显示所有窗口
func (hs *HotkeyService) showAllWindows() {
// 显示主窗口
hs.mainWindow.Show()
hs.mainWindow.Restore()
hs.mainWindow.Focus()
// 显示所有子窗口
if hs.windowService != nil {
openWindows := hs.windowService.GetOpenWindows()
for _, windowInfo := range openWindows {
windowInfo.Window.Show()
windowInfo.Window.Restore()
}
}
hs.logger.Debug("all windows shown")
}
// keyToX11KeyCode 键名转X11键码
@@ -386,7 +465,10 @@ func (hs *HotkeyService) IsRegistered() bool {
// ServiceShutdown 关闭服务
func (hs *HotkeyService) ServiceShutdown() error {
hs.cancel()
// 原子地获取并调用cancel函数
if cancel := hs.getCancelFunc(); cancel != nil {
cancel()
}
hs.wg.Wait()
C.closeX11Display()
return nil

View File

@@ -51,7 +51,7 @@ const (
// KeyBindingService 快捷键管理服务
type KeyBindingService struct {
databaseService *DatabaseService
logger *log.Service
logger *log.LogService
mu sync.RWMutex
ctx context.Context
@@ -83,7 +83,7 @@ func (e *KeyBindingError) Is(target error) bool {
}
// NewKeyBindingService 创建快捷键服务实例
func NewKeyBindingService(databaseService *DatabaseService, logger *log.Service) *KeyBindingService {
func NewKeyBindingService(databaseService *DatabaseService, logger *log.LogService) *KeyBindingService {
if logger == nil {
logger = log.New()
}
@@ -105,21 +105,17 @@ func (kbs *KeyBindingService) initDatabase() error {
kbs.mu.Lock()
defer kbs.mu.Unlock()
if kbs.databaseService == nil || kbs.databaseService.db == nil {
return &KeyBindingError{"check_db", "", errors.New("database service not available")}
}
// 检查是否已有快捷键数据
rows, err := kbs.databaseService.SQLite.Query("SELECT COUNT(*) FROM key_bindings")
var count int64
err := kbs.databaseService.db.QueryRow("SELECT COUNT(*) FROM key_bindings").Scan(&count)
if err != nil {
return &KeyBindingError{"check_keybindings_count", "", err}
}
if len(rows) == 0 {
return &KeyBindingError{"check_keybindings_count", "", fmt.Errorf("no rows returned")}
}
count, ok := rows[0]["COUNT(*)"].(int64)
if !ok {
return &KeyBindingError{"convert_count", "", fmt.Errorf("failed to convert count to int64")}
}
// 如果没有数据,插入默认配置
if count == 0 {
if err := kbs.insertDefaultKeyBindings(); err != nil {
@@ -134,11 +130,10 @@ func (kbs *KeyBindingService) initDatabase() error {
// insertDefaultKeyBindings 插入默认快捷键配置
func (kbs *KeyBindingService) insertDefaultKeyBindings() error {
defaultConfig := models.NewDefaultKeyBindingConfig()
now := time.Now()
now := time.Now().Format("2006-01-02 15:04:05")
for _, kb := range defaultConfig.KeyBindings {
err := kbs.databaseService.SQLite.Execute(sqlInsertKeyBinding,
_, err := kbs.databaseService.db.Exec(sqlInsertKeyBinding,
string(kb.Command), // 转换为字符串存储
string(kb.Extension), // 转换为字符串存储
kb.Key,
@@ -150,7 +145,6 @@ func (kbs *KeyBindingService) insertDefaultKeyBindings() error {
if err != nil {
return &KeyBindingError{"insert_keybinding", string(kb.Command), err}
}
}
return nil
@@ -161,38 +155,46 @@ func (kbs *KeyBindingService) GetAllKeyBindings() ([]models.KeyBinding, error) {
kbs.mu.RLock()
defer kbs.mu.RUnlock()
rows, err := kbs.databaseService.SQLite.Query(sqlGetAllKeyBindings)
if kbs.databaseService == nil || kbs.databaseService.db == nil {
return nil, &KeyBindingError{"query_db", "", errors.New("database service not available")}
}
rows, err := kbs.databaseService.db.Query(sqlGetAllKeyBindings)
if err != nil {
return nil, &KeyBindingError{"query_keybindings", "", err}
}
defer rows.Close()
var keyBindings []models.KeyBinding
for _, row := range rows {
for rows.Next() {
var kb models.KeyBinding
var command, extension string
var enabled, isDefault int
if command, ok := row["command"].(string); ok {
kb.Command = models.KeyBindingCommand(command)
err := rows.Scan(
&command,
&extension,
&kb.Key,
&enabled,
&isDefault,
)
if err != nil {
return nil, &KeyBindingError{"scan_keybinding", "", err}
}
if extension, ok := row["extension"].(string); ok {
kb.Extension = models.ExtensionID(extension)
}
if key, ok := row["key"].(string); ok {
kb.Key = key
}
if enabled, ok := row["enabled"].(int64); ok {
kb.Enabled = enabled == 1
}
if isDefault, ok := row["is_default"].(int64); ok {
kb.IsDefault = isDefault == 1
}
kb.Command = models.KeyBindingCommand(command)
kb.Extension = models.ExtensionID(extension)
kb.Enabled = enabled == 1
kb.IsDefault = isDefault == 1
keyBindings = append(keyBindings, kb)
}
if err = rows.Err(); err != nil {
return nil, &KeyBindingError{"iterate_keybindings", "", err}
}
return keyBindings, nil
}

View File

@@ -33,22 +33,24 @@ type MigrationProgress struct {
// MigrationService 迁移服务
type MigrationService struct {
logger *log.Service
mu sync.RWMutex
progress atomic.Value // stores MigrationProgress
logger *log.LogService
dbService *DatabaseService
mu sync.RWMutex
progress atomic.Value // stores MigrationProgress
ctx context.Context
cancel context.CancelFunc
}
// NewMigrationService 创建迁移服务
func NewMigrationService(logger *log.Service) *MigrationService {
func NewMigrationService(dbService *DatabaseService, logger *log.LogService) *MigrationService {
if logger == nil {
logger = log.New()
}
ms := &MigrationService{
logger: logger,
logger: logger,
dbService: dbService,
}
// 初始化进度
@@ -104,6 +106,17 @@ func (ms *MigrationService) MigrateDirectory(srcPath, dstPath string) error {
return ms.failWithError(err)
}
// 迁移前断开数据库连接
ms.updateProgress(MigrationProgress{
Status: MigrationStatusMigrating,
Progress: 10,
})
if ms.dbService != nil {
if err := ms.dbService.ServiceShutdown(); err != nil {
ms.logger.Error("Failed to close database connection", "error", err)
}
}
// 执行原子迁移
if err := ms.atomicMove(ctx, srcPath, dstPath); err != nil {
return ms.failWithError(err)
@@ -177,10 +190,17 @@ func (ms *MigrationService) atomicMove(ctx context.Context, srcPath, dstPath str
})
if err := os.Rename(srcPath, dstPath); err == nil {
// 重命名成功更新进度到90%
ms.updateProgress(MigrationProgress{
Status: MigrationStatusMigrating,
Progress: 90,
})
ms.logger.Info("Directory migration completed using direct rename", "src", srcPath, "dst", dstPath)
return nil
}
// 重命名失败,使用压缩迁移
ms.logger.Info("Direct rename failed, using compress migration", "src", srcPath, "dst", dstPath)
ms.updateProgress(MigrationProgress{
Status: MigrationStatusMigrating,
Progress: 30,
@@ -249,12 +269,18 @@ func (ms *MigrationService) compressMove(ctx context.Context, srcPath, dstPath s
default:
}
// 验证迁移是否成功
if err := ms.verifyMigration(dstPath); err != nil {
// 迁移验证失败,清理目标目录
os.RemoveAll(dstPath)
return fmt.Errorf("migration verification failed: %v", err)
}
// 删除源目录
ms.updateProgress(MigrationProgress{
Status: MigrationStatusMigrating,
Progress: 90,
})
os.RemoveAll(srcPath)
return nil
}
@@ -404,6 +430,29 @@ func (ms *MigrationService) isSubDirectory(parent, target string) bool {
return len(target) > len(parent) && strings.HasPrefix(target, parent)
}
// verifyMigration 验证迁移是否成功
func (ms *MigrationService) verifyMigration(dstPath string) error {
// 检查目标目录是否存在
dstStat, err := os.Stat(dstPath)
if err != nil {
return fmt.Errorf("target directory does not exist: %v", err)
}
if !dstStat.IsDir() {
return fmt.Errorf("target path is not a directory")
}
// 简单验证:检查目标目录是否非空
isEmpty, err := ms.isDirectoryEmpty(dstPath)
if err != nil {
return fmt.Errorf("failed to check target directory: %v", err)
}
if isEmpty {
return fmt.Errorf("target directory is empty after migration")
}
return nil
}
// CancelMigration 取消迁移
func (ms *MigrationService) CancelMigration() error {
ms.mu.Lock()

View File

@@ -26,7 +26,7 @@ type SelfUpdateResult struct {
// SelfUpdateService 自我更新服务
type SelfUpdateService struct {
logger *log.Service
logger *log.LogService
configService *ConfigService
config *models.AppConfig
@@ -35,7 +35,7 @@ type SelfUpdateService struct {
}
// NewSelfUpdateService 创建自我更新服务实例
func NewSelfUpdateService(configService *ConfigService, logger *log.Service) (*SelfUpdateService, error) {
func NewSelfUpdateService(configService *ConfigService, logger *log.LogService) (*SelfUpdateService, error) {
// 获取配置
appConfig, err := configService.GetConfig()
if err != nil {

View File

@@ -5,16 +5,15 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/sqlite"
)
// ServiceManager 服务管理器,负责协调各个服务
type ServiceManager struct {
configService *ConfigService
databaseService *DatabaseService
sqliteService *sqlite.Service
documentService *DocumentService
windowService *WindowService
windowSnapService *WindowSnapService
migrationService *MigrationService
systemService *SystemService
hotkeyService *HotkeyService
@@ -25,7 +24,9 @@ type ServiceManager struct {
startupService *StartupService
selfUpdateService *SelfUpdateService
translationService *TranslationService
logger *log.Service
themeService *ThemeService
BackupService *BackupService
logger *log.LogService
}
// NewServiceManager 创建新的服务管理器实例
@@ -36,14 +37,11 @@ func NewServiceManager() *ServiceManager {
// 初始化配置服务
configService := NewConfigService(logger)
// 初始化SQLite服务
sqliteService := sqlite.New()
// 初始化数据库服务
databaseService := NewDatabaseService(configService, logger)
// 初始化迁移服务
migrationService := NewMigrationService(logger)
migrationService := NewMigrationService(databaseService, logger)
// 初始化文档服务
documentService := NewDocumentService(databaseService, logger)
@@ -51,11 +49,17 @@ func NewServiceManager() *ServiceManager {
// 初始化窗口服务
windowService := NewWindowService(logger, documentService)
// 初始化窗口吸附服务
windowSnapService := NewWindowSnapService(logger, configService)
// 将吸附服务与窗口服务关联
windowService.SetWindowSnapService(windowSnapService)
// 初始化系统服务
systemService := NewSystemService(logger)
// 初始化热键服务
hotkeyService := NewHotkeyService(configService, logger)
hotkeyService := NewHotkeyService(configService, windowService, logger)
// 初始化对话服务
dialogService := NewDialogService(logger)
@@ -81,6 +85,12 @@ func NewServiceManager() *ServiceManager {
// 初始化翻译服务
translationService := NewTranslationService(logger)
// 初始化主题服务
themeService := NewThemeService(databaseService, logger)
// 初始化备份服务
backupService := NewBackupService(configService, databaseService, logger)
// 使用新的配置通知系统设置热键配置变更监听
err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
return hotkeyService.UpdateHotkey(enable, hotkey)
@@ -97,12 +107,28 @@ func NewServiceManager() *ServiceManager {
panic(err)
}
// 设置备份配置变更监听,处理备份配置变更
err = configService.SetBackupConfigChangeCallback(func(config *models.GitBackupConfig) error {
return backupService.HandleConfigChange(config)
})
if err != nil {
panic(err)
}
// 设置窗口吸附配置变更回调
err = configService.SetWindowSnapConfigChangeCallback(func(enabled bool) error {
return windowSnapService.OnWindowSnapConfigChanged(enabled)
})
if err != nil {
panic(err)
}
return &ServiceManager{
configService: configService,
databaseService: databaseService,
sqliteService: sqliteService,
documentService: documentService,
windowService: windowService,
windowSnapService: windowSnapService,
migrationService: migrationService,
systemService: systemService,
hotkeyService: hotkeyService,
@@ -113,6 +139,8 @@ func NewServiceManager() *ServiceManager {
startupService: startupService,
selfUpdateService: selfUpdateService,
translationService: translationService,
themeService: themeService,
BackupService: backupService,
logger: logger,
}
}
@@ -121,10 +149,10 @@ func NewServiceManager() *ServiceManager {
func (sm *ServiceManager) GetServices() []application.Service {
services := []application.Service{
application.NewService(sm.configService),
application.NewService(sm.sqliteService),
application.NewService(sm.databaseService),
application.NewService(sm.documentService),
application.NewService(sm.windowService),
application.NewService(sm.windowSnapService),
application.NewService(sm.keyBindingService),
application.NewService(sm.extensionService),
application.NewService(sm.migrationService),
@@ -135,6 +163,8 @@ func (sm *ServiceManager) GetServices() []application.Service {
application.NewService(sm.startupService),
application.NewService(sm.selfUpdateService),
application.NewService(sm.translationService),
application.NewService(sm.themeService),
application.NewService(sm.BackupService),
}
return services
}
@@ -150,7 +180,7 @@ func (sm *ServiceManager) GetDialogService() *DialogService {
}
// GetLogger 获取日志服务实例
func (sm *ServiceManager) GetLogger() *log.Service {
func (sm *ServiceManager) GetLogger() *log.LogService {
return sm.logger
}
@@ -194,11 +224,6 @@ func (sm *ServiceManager) GetDatabaseService() *DatabaseService {
return sm.databaseService
}
// GetSQLiteService 获取SQLite服务实例
func (sm *ServiceManager) GetSQLiteService() *sqlite.Service {
return sm.sqliteService
}
// GetWindowService 获取窗口服务实例
func (sm *ServiceManager) GetWindowService() *WindowService {
return sm.windowService
@@ -208,3 +233,13 @@ func (sm *ServiceManager) GetWindowService() *WindowService {
func (sm *ServiceManager) GetDocumentService() *DocumentService {
return sm.documentService
}
// GetThemeService 获取主题服务实例
func (sm *ServiceManager) GetThemeService() *ThemeService {
return sm.themeService
}
// GetWindowSnapService 获取窗口吸附服务实例
func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService {
return sm.windowSnapService
}

View File

@@ -15,14 +15,14 @@ import (
// DarwinStartupImpl macOS 平台开机启动实现
type DarwinStartupImpl struct {
logger *log.Service
logger *log.LogService
disabled bool
appPath string
appName string
}
// newStartupImplementation 创建平台特定的开机启动实现
func newStartupImplementation(logger *log.Service) StartupImplementation {
func newStartupImplementation(logger *log.LogService) StartupImplementation {
return &DarwinStartupImpl{
logger: logger,
}

View File

@@ -13,7 +13,7 @@ import (
// LinuxStartupImpl Linux 平台开机启动实现
type LinuxStartupImpl struct {
logger *log.Service
logger *log.LogService
autostartDir string
execPath string
appName string
@@ -37,7 +37,7 @@ X-GNOME-Autostart-enabled=true
`
// newStartupImplementation 创建平台特定的开机启动实现
func newStartupImplementation(logger *log.Service) StartupImplementation {
func newStartupImplementation(logger *log.LogService) StartupImplementation {
return &LinuxStartupImpl{
logger: logger,
}

View File

@@ -7,7 +7,7 @@ import (
// StartupService 开机启动服务
type StartupService struct {
configService *ConfigService
logger *log.Service
logger *log.LogService
impl StartupImplementation
initError error
}
@@ -19,7 +19,7 @@ type StartupImplementation interface {
}
// NewStartupService 创建开机启动服务实例
func NewStartupService(configService *ConfigService, logger *log.Service) *StartupService {
func NewStartupService(configService *ConfigService, logger *log.LogService) *StartupService {
service := &StartupService{
configService: configService,
logger: logger,

View File

@@ -15,7 +15,7 @@ import (
// WindowsStartupImpl Windows 平台开机启动实现
type WindowsStartupImpl struct {
logger *log.Service
logger *log.LogService
registryKey string
execPath string
workingDir string
@@ -23,7 +23,7 @@ type WindowsStartupImpl struct {
}
// newStartupImplementation 创建平台特定的开机启动实现
func newStartupImplementation(logger *log.Service) StartupImplementation {
func newStartupImplementation(logger *log.LogService) StartupImplementation {
return &WindowsStartupImpl{
logger: logger,
}

View File

@@ -15,7 +15,7 @@ import (
type StoreOption struct {
FilePath string
AutoSave bool
Logger *log.Service
Logger *log.LogService
}
// Store 泛型存储服务
@@ -25,7 +25,7 @@ type Store[T any] struct {
dataMap sync.Map // thread-safe map
unsaved atomic.Bool
initOnce sync.Once
logger *log.Service
logger *log.LogService
}
// NewStore 存储服务

View File

@@ -9,7 +9,7 @@ import (
// SystemService 系统监控服务
type SystemService struct {
logger *log.Service
logger *log.LogService
}
// MemoryStats 内存统计信息
@@ -29,7 +29,7 @@ type MemoryStats struct {
}
// NewSystemService 创建新的系统服务实例
func NewSystemService(logger *log.Service) *SystemService {
func NewSystemService(logger *log.LogService) *SystemService {
return &SystemService{
logger: logger,
}

View File

@@ -0,0 +1,275 @@
package services
import (
"context"
"database/sql"
"fmt"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// ThemeService 主题服务
type ThemeService struct {
databaseService *DatabaseService
logger *log.LogService
ctx context.Context
}
// NewThemeService 创建新的主题服务
func NewThemeService(databaseService *DatabaseService, logger *log.LogService) *ThemeService {
if logger == nil {
logger = log.New()
}
return &ThemeService{
databaseService: databaseService,
logger: logger,
}
}
// ServiceStartup 服务启动时初始化
func (ts *ThemeService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
ts.ctx = ctx
// 初始化默认主题
return ts.initializeDefaultThemes()
}
// getDB 获取数据库连接
func (ts *ThemeService) getDB() *sql.DB {
return ts.databaseService.db
}
// initializeDefaultThemes 初始化默认主题
func (ts *ThemeService) initializeDefaultThemes() error {
db := ts.getDB()
if db == nil {
return fmt.Errorf("database not available")
}
// 检查是否已存在默认主题
var count int
err := db.QueryRow("SELECT COUNT(*) FROM themes WHERE is_default = 1").Scan(&count)
if err != nil {
return fmt.Errorf("failed to check existing themes: %w", err)
}
if count > 0 {
return nil // 默认主题已存在
}
// 创建默认深色主题
darkTheme := &models.Theme{
Name: "Default Dark",
Type: models.ThemeTypeDark,
Colors: *models.NewDefaultDarkTheme(),
IsDefault: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 创建默认浅色主题
lightTheme := &models.Theme{
Name: "Default Light",
Type: models.ThemeTypeLight,
Colors: *models.NewDefaultLightTheme(),
IsDefault: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 插入默认主题
if _, err := ts.CreateTheme(darkTheme); err != nil {
return fmt.Errorf("failed to create default dark theme: %w", err)
}
if _, err := ts.CreateTheme(lightTheme); err != nil {
return fmt.Errorf("failed to create default light theme: %w", err)
}
return nil
}
// GetDefaultThemes 获取默认主题
func (ts *ThemeService) GetDefaultThemes() (map[models.ThemeType]*models.Theme, error) {
query := `
SELECT id, name, type, colors, is_default, created_at, updated_at
FROM themes
WHERE is_default = 1
ORDER BY type
`
db := ts.getDB()
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query default themes: %w", err)
}
defer rows.Close()
themes := make(map[models.ThemeType]*models.Theme)
for rows.Next() {
theme := &models.Theme{}
err := rows.Scan(
&theme.ID,
&theme.Name,
&theme.Type,
&theme.Colors,
&theme.IsDefault,
&theme.CreatedAt,
&theme.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan theme: %w", err)
}
themes[theme.Type] = theme
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate themes: %w", err)
}
return themes, nil
}
// GetThemeByType 根据类型获取默认主题
func (ts *ThemeService) GetThemeByType(themeType models.ThemeType) (*models.Theme, error) {
query := `
SELECT id, name, type, colors, is_default, created_at, updated_at
FROM themes
WHERE type = ? AND is_default = 1
LIMIT 1
`
theme := &models.Theme{}
db := ts.getDB()
err := db.QueryRow(query, themeType).Scan(
&theme.ID,
&theme.Name,
&theme.Type,
&theme.Colors,
&theme.IsDefault,
&theme.CreatedAt,
&theme.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no default theme found for type: %s", themeType)
}
return nil, fmt.Errorf("failed to get theme by type: %w", err)
}
return theme, nil
}
// UpdateThemeColors 更新主题颜色
func (ts *ThemeService) UpdateThemeColors(themeType models.ThemeType, colors models.ThemeColorConfig) error {
query := `
UPDATE themes
SET colors = ?, updated_at = ?
WHERE type = ? AND is_default = 1
`
db := ts.getDB()
_, err := db.Exec(query, colors, time.Now(), themeType)
if err != nil {
return fmt.Errorf("failed to update theme colors: %w", err)
}
return nil
}
// ResetThemeColors 重置主题颜色为默认值
func (ts *ThemeService) ResetThemeColors(themeType models.ThemeType) error {
var defaultColors models.ThemeColorConfig
switch themeType {
case models.ThemeTypeDark:
defaultColors = *models.NewDefaultDarkTheme()
case models.ThemeTypeLight:
defaultColors = *models.NewDefaultLightTheme()
default:
return fmt.Errorf("unknown theme type: %s", themeType)
}
return ts.UpdateThemeColors(themeType, defaultColors)
}
// CreateTheme 创建新主题
func (ts *ThemeService) CreateTheme(theme *models.Theme) (*models.Theme, error) {
query := `
INSERT INTO themes (name, type, colors, is_default, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`
db := ts.getDB()
result, err := db.Exec(
query,
theme.Name,
theme.Type,
theme.Colors,
theme.IsDefault,
theme.CreatedAt,
theme.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to create theme: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("failed to get theme ID: %w", err)
}
theme.ID = int(id)
return theme, nil
}
// GetAllThemes 获取所有主题
func (ts *ThemeService) GetAllThemes() ([]*models.Theme, error) {
query := `
SELECT id, name, type, colors, is_default, created_at, updated_at
FROM themes
ORDER BY is_default DESC, created_at ASC
`
db := ts.getDB()
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query themes: %w", err)
}
defer rows.Close()
var themes []*models.Theme
for rows.Next() {
theme := &models.Theme{}
err := rows.Scan(
&theme.ID,
&theme.Name,
&theme.Type,
&theme.Colors,
&theme.IsDefault,
&theme.CreatedAt,
&theme.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan theme: %w", err)
}
themes = append(themes, theme)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to iterate themes: %w", err)
}
return themes, nil
}
// ServiceShutdown 服务关闭
func (ts *ThemeService) ServiceShutdown() error {
return nil
}

View File

@@ -10,7 +10,7 @@ import (
// TranslationService 翻译服务
type TranslationService struct {
logger *log.Service
logger *log.LogService
factory *translator.TranslatorFactory
defaultTimeout time.Duration
translators map[translator.TranslatorType]translator.Translator
@@ -18,7 +18,7 @@ type TranslationService struct {
}
// NewTranslationService 创建翻译服务实例
func NewTranslationService(logger *log.Service) *TranslationService {
func NewTranslationService(logger *log.LogService) *TranslationService {
service := &TranslationService{
logger: logger,
factory: translator.NewTranslatorFactory(),

View File

@@ -7,14 +7,14 @@ import (
// TrayService 系统托盘服务
type TrayService struct {
logger *log.Service
logger *log.LogService
configService *ConfigService
app *application.App
mainWindow *application.WebviewWindow
}
// NewTrayService 创建新的系统托盘服务实例
func NewTrayService(logger *log.Service, configService *ConfigService) *TrayService {
func NewTrayService(logger *log.LogService, configService *ConfigService) *TrayService {
return &TrayService{
logger: logger,
configService: configService,
@@ -42,7 +42,6 @@ func (ts *TrayService) HandleWindowClose() {
if ts.ShouldMinimizeToTray() {
// 隐藏到托盘
ts.mainWindow.Hide()
ts.app.Event.Emit("window:hidden", nil)
} else {
// 直接退出应用
ts.app.Quit()
@@ -54,7 +53,6 @@ func (ts *TrayService) HandleWindowMinimize() {
if ts.ShouldMinimizeToTray() {
// 隐藏到托盘
ts.mainWindow.Hide()
ts.app.Event.Emit("window:hidden", nil)
}
}
@@ -64,9 +62,6 @@ func (ts *TrayService) ShowWindow() {
ts.mainWindow.Show()
ts.mainWindow.Restore()
ts.mainWindow.Focus()
if ts.app != nil {
ts.app.Event.Emit("window:shown", nil)
}
}
}

View File

@@ -9,25 +9,28 @@ import (
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// WindowInfo 窗口信息
// WindowInfo 窗口信息(简化版)
type WindowInfo struct {
Window *application.WebviewWindow
DocumentID int64
Title string
}
// WindowService 窗口管理服务
// WindowService 窗口管理服务(专注于窗口生命周期管理)
type WindowService struct {
logger *log.Service
logger *log.LogService
documentService *DocumentService
app *application.App
mainWindow *application.WebviewWindow
windows map[int64]*WindowInfo // documentID -> WindowInfo
mu sync.RWMutex
// 吸附服务引用
windowSnapService *WindowSnapService
}
// NewWindowService 创建新的窗口服务实例
func NewWindowService(logger *log.Service, documentService *DocumentService) *WindowService {
func NewWindowService(logger *log.LogService, documentService *DocumentService) *WindowService {
if logger == nil {
logger = log.New()
}
@@ -39,10 +42,20 @@ func NewWindowService(logger *log.Service, documentService *DocumentService) *Wi
}
}
// SetWindowSnapService 设置窗口吸附服务引用
func (ws *WindowService) SetWindowSnapService(snapService *WindowSnapService) {
ws.windowSnapService = snapService
}
// SetAppReferences 设置应用和主窗口引用
func (ws *WindowService) SetAppReferences(app *application.App, mainWindow *application.WebviewWindow) {
ws.app = app
ws.mainWindow = mainWindow
// 如果吸附服务已设置,也为它设置引用
if ws.windowSnapService != nil {
ws.windowSnapService.SetAppReferences(app, mainWindow)
}
}
// OpenDocumentWindow 为指定文档ID打开新窗口
@@ -101,9 +114,14 @@ func (ws *WindowService) OpenDocumentWindow(documentID int64) error {
}
ws.windows[documentID] = windowInfo
// 注册窗口关闭事件
// 注册窗口事件
ws.registerWindowEvents(newWindow, documentID)
// 向吸附服务注册新窗口
if ws.windowSnapService != nil {
ws.windowSnapService.RegisterWindow(documentID, newWindow, doc.Title)
}
return nil
}
@@ -119,12 +137,17 @@ func (ws *WindowService) registerWindowEvents(window *application.WebviewWindow,
func (ws *WindowService) onWindowClosing(documentID int64) {
ws.mu.Lock()
defer ws.mu.Unlock()
windowInfo, exists := ws.windows[documentID]
if exists {
windowInfo.Window.Close()
delete(ws.windows, documentID)
}
// 从吸附服务中取消注册
if ws.windowSnapService != nil {
ws.windowSnapService.UnregisterWindow(documentID)
}
}
}
// GetOpenWindows 获取所有打开的窗口信息
@@ -147,3 +170,18 @@ func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool {
_, exists := ws.windows[documentID]
return exists
}
// ServiceShutdown 实现服务关闭接口
func (ws *WindowService) ServiceShutdown() error {
// 关闭所有窗口
ws.mu.Lock()
defer ws.mu.Unlock()
for documentID := range ws.windows {
if ws.windowSnapService != nil {
ws.windowSnapService.UnregisterWindow(documentID)
}
}
return nil
}

View File

@@ -0,0 +1,487 @@
package services
import (
"math"
"sync"
"time"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
// WindowSnapService 窗口吸附服务
type WindowSnapService struct {
logger *log.LogService
configService *ConfigService
app *application.App
mainWindow *application.WebviewWindow
mu sync.RWMutex
// 吸附配置
snapEnabled bool // 是否启用窗口吸附功能
// 自适应阈值参数
baseThresholdRatio float64 // 基础阈值比例
minThreshold int // 最小阈值(像素)
maxThreshold int // 最大阈值(像素)
// 位置缓存
lastMainWindowPos models.WindowPosition // 缓存主窗口位置
lastMainWindowSize [2]int // 缓存主窗口尺寸 [width, height]
// 管理的窗口
managedWindows map[int64]*models.WindowInfo // documentID -> WindowInfo
windowRefs map[int64]*application.WebviewWindow // documentID -> Window引用
}
// NewWindowSnapService 创建新的窗口吸附服务实例
func NewWindowSnapService(logger *log.LogService, configService *ConfigService) *WindowSnapService {
if logger == nil {
logger = log.New()
}
// 从配置获取窗口吸附设置
config, err := configService.GetConfig()
snapEnabled := true // 默认启用
if err == nil {
snapEnabled = config.General.EnableWindowSnap
}
return &WindowSnapService{
logger: logger,
configService: configService,
snapEnabled: snapEnabled,
baseThresholdRatio: 0.025, // 2.5%的主窗口宽度作为基础阈值
minThreshold: 8, // 最小8像素小屏幕保底
maxThreshold: 40, // 最大40像素大屏幕上限
managedWindows: make(map[int64]*models.WindowInfo),
windowRefs: make(map[int64]*application.WebviewWindow),
}
}
// SetAppReferences 设置应用和主窗口引用
func (wss *WindowSnapService) SetAppReferences(app *application.App, mainWindow *application.WebviewWindow) {
wss.app = app
wss.mainWindow = mainWindow
// 初始化主窗口位置缓存
wss.updateMainWindowCache()
// 设置主窗口移动事件监听
if mainWindow != nil {
wss.setupMainWindowEvents()
}
}
// RegisterWindow 注册需要吸附管理的窗口
func (wss *WindowSnapService) RegisterWindow(documentID int64, window *application.WebviewWindow, title string) {
wss.mu.Lock()
defer wss.mu.Unlock()
// 获取初始位置
x, y := window.Position()
windowInfo := &models.WindowInfo{
DocumentID: documentID,
Title: title,
IsSnapped: false,
SnapOffset: models.SnapPosition{X: 0, Y: 0},
SnapEdge: models.SnapEdgeNone,
LastPos: models.WindowPosition{X: x, Y: y},
MoveTime: time.Now(),
}
wss.managedWindows[documentID] = windowInfo
wss.windowRefs[documentID] = window
// 为窗口设置移动事件监听
wss.setupWindowEvents(window, windowInfo)
}
// UnregisterWindow 取消注册窗口
func (wss *WindowSnapService) UnregisterWindow(documentID int64) {
wss.mu.Lock()
defer wss.mu.Unlock()
delete(wss.managedWindows, documentID)
delete(wss.windowRefs, documentID)
}
// SetSnapEnabled 设置是否启用窗口吸附
func (wss *WindowSnapService) SetSnapEnabled(enabled bool) {
wss.mu.Lock()
defer wss.mu.Unlock()
if wss.snapEnabled == enabled {
return
}
wss.snapEnabled = enabled
// 如果禁用吸附,解除所有吸附窗口
if !enabled {
for _, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
}
}
}
}
// calculateAdaptiveThreshold 计算自适应吸附阈值
func (wss *WindowSnapService) calculateAdaptiveThreshold() int {
// 基于主窗口宽度计算阈值
mainWidth := wss.lastMainWindowSize[0]
if mainWidth == 0 {
return wss.minThreshold // 默认最小值
}
// 计算基础阈值主窗口宽度的2.5%
adaptiveThreshold := int(float64(mainWidth) * wss.baseThresholdRatio)
// 限制在最小和最大值之间
if adaptiveThreshold < wss.minThreshold {
return wss.minThreshold
}
if adaptiveThreshold > wss.maxThreshold {
return wss.maxThreshold
}
return adaptiveThreshold
}
// GetCurrentThreshold 获取当前自适应阈值(用于调试或显示)
func (wss *WindowSnapService) GetCurrentThreshold() int {
wss.mu.RLock()
defer wss.mu.RUnlock()
return wss.calculateAdaptiveThreshold()
}
// OnWindowSnapConfigChanged 处理窗口吸附配置变更
func (wss *WindowSnapService) OnWindowSnapConfigChanged(enabled bool) error {
wss.SetSnapEnabled(enabled)
// 阈值现在是自适应的,无需手动设置
return nil
}
// setupMainWindowEvents 设置主窗口事件监听
func (wss *WindowSnapService) setupMainWindowEvents() {
// 监听主窗口移动事件
wss.mainWindow.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) {
wss.onMainWindowMoved()
})
}
// setupWindowEvents 为子窗口设置事件监听
func (wss *WindowSnapService) setupWindowEvents(window *application.WebviewWindow, windowInfo *models.WindowInfo) {
// 监听子窗口移动事件
window.RegisterHook(events.Common.WindowDidMove, func(event *application.WindowEvent) {
wss.onChildWindowMoved(window, windowInfo)
})
}
// updateMainWindowCache 更新主窗口缓存
func (wss *WindowSnapService) updateMainWindowCache() {
if wss.mainWindow == nil {
return
}
x, y := wss.mainWindow.Position()
w, h := wss.mainWindow.Size()
wss.lastMainWindowPos = models.WindowPosition{X: x, Y: y}
wss.lastMainWindowSize = [2]int{w, h}
}
// onMainWindowMoved 主窗口移动事件处理
func (wss *WindowSnapService) onMainWindowMoved() {
if !wss.snapEnabled {
return
}
wss.mu.Lock()
defer wss.mu.Unlock()
// 更新主窗口缓存
wss.updateMainWindowCache()
// 只更新已吸附窗口的位置,无需重新检测所有窗口
for _, windowInfo := range wss.managedWindows {
if windowInfo.IsSnapped {
wss.updateSnappedWindowPosition(windowInfo)
}
}
}
// onChildWindowMoved 子窗口移动事件处理
func (wss *WindowSnapService) onChildWindowMoved(window *application.WebviewWindow, windowInfo *models.WindowInfo) {
if !wss.snapEnabled {
return
}
wss.mu.Lock()
defer wss.mu.Unlock()
// 获取当前位置
x, y := window.Position()
currentPos := models.WindowPosition{X: x, Y: y}
// 检查是否真的移动了(避免无效触发)
if currentPos.X == windowInfo.LastPos.X && currentPos.Y == windowInfo.LastPos.Y {
return
}
// 保存上次移动时间用于防抖检测
lastMoveTime := windowInfo.MoveTime
windowInfo.MoveTime = time.Now()
if windowInfo.IsSnapped {
// 已吸附窗口:检查是否被用户拖拽解除吸附
wss.handleSnappedWindow(window, windowInfo, currentPos)
// 对于已吸附窗口,总是更新为当前位置
windowInfo.LastPos = currentPos
} else {
// 未吸附窗口:检查是否应该吸附
isSnapped := wss.handleUnsnappedWindow(window, windowInfo, currentPos, lastMoveTime)
if !isSnapped {
// 如果没有吸附,更新为当前位置
windowInfo.LastPos = currentPos
}
// 如果成功吸附位置已在handleUnsnappedWindow中更新
}
}
// updateSnappedWindowPosition 更新已吸附窗口的位置
func (wss *WindowSnapService) updateSnappedWindowPosition(windowInfo *models.WindowInfo) {
// 计算新的目标位置(基于主窗口新位置)
expectedX := wss.lastMainWindowPos.X + windowInfo.SnapOffset.X
expectedY := wss.lastMainWindowPos.Y + windowInfo.SnapOffset.Y
// 查找对应的window对象并移动
if window, exists := wss.windowRefs[windowInfo.DocumentID]; exists {
window.SetPosition(expectedX, expectedY)
}
windowInfo.LastPos = models.WindowPosition{X: expectedX, Y: expectedY}
}
// handleSnappedWindow 处理已吸附窗口的移动
func (wss *WindowSnapService) handleSnappedWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition) {
// 计算预期位置
expectedX := wss.lastMainWindowPos.X + windowInfo.SnapOffset.X
expectedY := wss.lastMainWindowPos.Y + windowInfo.SnapOffset.Y
// 计算实际位置与预期位置的距离
distanceX := math.Abs(float64(currentPos.X - expectedX))
distanceY := math.Abs(float64(currentPos.Y - expectedY))
maxDistance := math.Max(distanceX, distanceY)
// 用户拖拽检测:距离超过阈值且移动很快
userDragThreshold := float64(wss.calculateAdaptiveThreshold())
isUserDrag := maxDistance > userDragThreshold && time.Since(windowInfo.MoveTime) < 50*time.Millisecond
if isUserDrag {
// 用户主动拖拽,解除吸附
windowInfo.IsSnapped = false
windowInfo.SnapEdge = models.SnapEdgeNone
}
}
// handleUnsnappedWindow 处理未吸附窗口的移动,返回是否成功吸附
func (wss *WindowSnapService) handleUnsnappedWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition, lastMoveTime time.Time) bool {
// 检查是否应该吸附
should, snapEdge := wss.shouldSnapToMainWindow(window, windowInfo, currentPos, lastMoveTime)
if should {
// 设置吸附状态
windowInfo.IsSnapped = true
windowInfo.SnapEdge = snapEdge
// 执行吸附移动
targetPos := wss.calculateSnapPosition(snapEdge, currentPos, window)
window.SetPosition(targetPos.X, targetPos.Y)
// 计算并保存偏移量
windowInfo.SnapOffset.X = targetPos.X - wss.lastMainWindowPos.X
windowInfo.SnapOffset.Y = targetPos.Y - wss.lastMainWindowPos.Y
// 更新位置为吸附后的位置
windowInfo.LastPos = targetPos
return true
}
return false
}
// getWindowPosition 获取窗口的位置
func (wss *WindowSnapService) getWindowPosition(window *application.WebviewWindow) (models.WindowPosition, bool) {
x, y := window.Position()
return models.WindowPosition{X: x, Y: y}, true
}
// shouldSnapToMainWindow 优化版吸附检测
func (wss *WindowSnapService) shouldSnapToMainWindow(window *application.WebviewWindow, windowInfo *models.WindowInfo, currentPos models.WindowPosition, lastMoveTime time.Time) (bool, models.SnapEdge) {
// 防抖:移动太快时不检测,
timeSinceLastMove := time.Since(lastMoveTime)
if timeSinceLastMove < 30*time.Millisecond && timeSinceLastMove > 0 {
return false, models.SnapEdgeNone
}
// 使用缓存的主窗口位置和尺寸
if wss.lastMainWindowSize[0] == 0 || wss.lastMainWindowSize[1] == 0 {
// 主窗口缓存未初始化,立即更新
wss.updateMainWindowCache()
}
mainPos := wss.lastMainWindowPos
mainWidth := wss.lastMainWindowSize[0]
mainHeight := wss.lastMainWindowSize[1]
// 获取子窗口尺寸
windowWidth, windowHeight := window.Size()
// 自适应阈值计算
threshold := float64(wss.calculateAdaptiveThreshold())
cornerThreshold := threshold * 1.5
// 计算边界
mainLeft, mainTop := mainPos.X, mainPos.Y
mainRight, mainBottom := mainPos.X+mainWidth, mainPos.Y+mainHeight
windowLeft, windowTop := currentPos.X, currentPos.Y
windowRight, windowBottom := currentPos.X+windowWidth, currentPos.Y+windowHeight
// 简化的距离计算结构
type snapCheck struct {
edge models.SnapEdge
distance float64
priority int // 1=角落, 2=边缘
}
var bestSnap *snapCheck
// 检查角落吸附优先级1
cornerChecks := []struct {
edge models.SnapEdge
dx int
dy int
}{
{models.SnapEdgeTopRight, mainRight - windowLeft, mainTop - windowBottom},
{models.SnapEdgeBottomRight, mainRight - windowLeft, mainBottom - windowTop},
{models.SnapEdgeBottomLeft, mainLeft - windowRight, mainBottom - windowTop},
{models.SnapEdgeTopLeft, mainLeft - windowRight, mainTop - windowBottom},
}
for _, check := range cornerChecks {
dist := math.Sqrt(float64(check.dx*check.dx + check.dy*check.dy))
if dist <= cornerThreshold {
if bestSnap == nil || dist < bestSnap.distance {
bestSnap = &snapCheck{check.edge, dist, 1}
}
}
}
// 如果没有角落吸附检查边缘吸附优先级2
if bestSnap == nil {
edgeChecks := []struct {
edge models.SnapEdge
distance float64
}{
{models.SnapEdgeRight, math.Abs(float64(mainRight - windowLeft))},
{models.SnapEdgeLeft, math.Abs(float64(mainLeft - windowRight))},
{models.SnapEdgeBottom, math.Abs(float64(mainBottom - windowTop))},
{models.SnapEdgeTop, math.Abs(float64(mainTop - windowBottom))},
}
for _, check := range edgeChecks {
if check.distance <= threshold {
if bestSnap == nil || check.distance < bestSnap.distance {
bestSnap = &snapCheck{check.edge, check.distance, 2}
}
}
}
}
if bestSnap == nil {
return false, models.SnapEdgeNone
}
return true, bestSnap.edge
}
// calculateSnapPosition 计算吸附目标位置
func (wss *WindowSnapService) calculateSnapPosition(snapEdge models.SnapEdge, currentPos models.WindowPosition, window *application.WebviewWindow) models.WindowPosition {
// 使用缓存的主窗口信息
mainPos := wss.lastMainWindowPos
mainWidth := wss.lastMainWindowSize[0]
mainHeight := wss.lastMainWindowSize[1]
// 获取子窗口尺寸
windowWidth, windowHeight := window.Size()
switch snapEdge {
case models.SnapEdgeRight:
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: currentPos.Y, // 保持当前Y位置
}
case models.SnapEdgeLeft:
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: currentPos.Y,
}
case models.SnapEdgeBottom:
return models.WindowPosition{
X: currentPos.X,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeTop:
return models.WindowPosition{
X: currentPos.X,
Y: mainPos.Y - windowHeight,
}
case models.SnapEdgeTopRight:
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: mainPos.Y - windowHeight,
}
case models.SnapEdgeBottomRight:
return models.WindowPosition{
X: mainPos.X + mainWidth,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeBottomLeft:
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: mainPos.Y + mainHeight,
}
case models.SnapEdgeTopLeft:
return models.WindowPosition{
X: mainPos.X - windowWidth,
Y: mainPos.Y - windowHeight,
}
}
return currentPos
}
// Cleanup 清理资源
func (wss *WindowSnapService) Cleanup() {
wss.mu.Lock()
defer wss.mu.Unlock()
// 清空管理的窗口
wss.managedWindows = make(map[int64]*models.WindowInfo)
wss.windowRefs = make(map[int64]*application.WebviewWindow)
}
// ServiceShutdown 实现服务关闭接口
func (wss *WindowSnapService) ServiceShutdown() error {
wss.Cleanup()
return nil
}

View File

@@ -12,13 +12,13 @@ func SetupSystemTray(app *application.App, mainWindow *application.WebviewWindow
// 创建系统托盘
systray := app.SystemTray.New()
// 设置标签
systray.SetLabel("voidraft")
systray.Label()
// 设置图标
iconBytes, _ := assets.ReadFile("appicon.png")
systray.SetIcon(iconBytes)
// 设置标签
systray.SetLabel("VoidRaft")
// 创建托盘菜单
menu := app.NewMenu()

View File

@@ -0,0 +1,4 @@
package version
// Version 版本注入 Ldflags
var Version = "0.0.0"

11
main.go
View File

@@ -25,6 +25,7 @@ var assets embed.FS
// logs any error that might occur.
func main() {
serviceManager := services.NewServiceManager()
var window *application.WebviewWindow
var encryptionKey = [32]byte{
0x1e, 0x1f, 0x1c, 0x1d, 0x1a, 0x1b, 0x18, 0x19,
@@ -51,6 +52,13 @@ func main() {
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.voidraft",
EncryptionKey: encryptionKey,
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
if window != nil {
window.Show()
window.Restore()
window.Focus()
}
},
AdditionalData: map[string]string{
"launchtime": time.Now().Local().String(),
},
@@ -82,6 +90,7 @@ func main() {
URL: "/",
})
mainWindow.Center()
window = mainWindow
// 获取托盘服务并设置应用引用
trayService := serviceManager.GetTrayService()
@@ -96,7 +105,7 @@ func main() {
// 初始化热键服务
hotkeyService := serviceManager.GetHotkeyService()
err := hotkeyService.Initialize(app)
err := hotkeyService.Initialize(app, mainWindow)
if err != nil {
panic(err)
}

73
scripts/version.bat Normal file
View File

@@ -0,0 +1,73 @@
@echo off
setlocal enabledelayedexpansion
REM Simplified version management script - Windows version
REM Auto-increment patch version from git tags or use custom version
REM Configuration section - Set custom version here if needed
set "CUSTOM_VERSION="
REM Example: set "CUSTOM_VERSION=2.0.0"
set "VERSION_FILE=version.txt"
REM Check if custom version is set
if not "%CUSTOM_VERSION%"=="" (
echo [INFO] Using custom version: %CUSTOM_VERSION%
set "VERSION=%CUSTOM_VERSION%"
goto :save_version
)
REM Check if git is available
git --version >nul 2>&1
if errorlevel 1 (
echo [ERROR] Git is not installed or not in PATH
exit /b 1
)
REM Check if in git repository
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 (
echo [ERROR] Not in a git repository
exit /b 1
)
REM Get latest git tag
git describe --abbrev=0 --tags > temp_tag.txt 2>nul
if errorlevel 1 (
echo [ERROR] No git tags found in repository
if exist temp_tag.txt del temp_tag.txt
exit /b 1
)
set /p LATEST_TAG=<temp_tag.txt
del temp_tag.txt
if not defined LATEST_TAG (
echo [ERROR] Failed to read git tag
exit /b 1
)
echo [INFO] Latest git tag: %LATEST_TAG%
REM Remove v prefix
set "CLEAN_VERSION=%LATEST_TAG:v=%"
REM Split version number and increment patch
for /f "tokens=1,2,3 delims=." %%a in ("%CLEAN_VERSION%") do (
set "MAJOR=%%a"
set "MINOR=%%b"
set /a "PATCH=%%c+1"
)
set "VERSION=%MAJOR%.%MINOR%.%PATCH%"
echo [INFO] Auto-incremented patch version: %VERSION%
:save_version
REM Output version information
echo [SUCCESS] Version resolved: %VERSION%
echo VERSION=%VERSION%
REM Save to file
echo VERSION=%VERSION% > %VERSION_FILE%
echo [INFO] Version information saved to %VERSION_FILE%

50
scripts/version.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# 配置区域 - 如需自定义版本,请在此处设置
CUSTOM_VERSION=""
# 示例: CUSTOM_VERSION="2.0.0"
VERSION_FILE="version.txt"
# 检查是否设置了自定义版本
if [ -n "$CUSTOM_VERSION" ]; then
echo "[INFO] Using custom version: $CUSTOM_VERSION"
VERSION="$CUSTOM_VERSION"
else
# 检查git是否可用
if ! command -v git &> /dev/null; then
echo "[ERROR] Git is not installed or not in PATH"
exit 1
elif ! git rev-parse --git-dir &> /dev/null; then
echo "[ERROR] Not in a git repository"
exit 1
else
# 获取最新的git标签
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)
if [ -z "$LATEST_TAG" ]; then
echo "[ERROR] No git tags found in repository"
exit 1
else
echo "[INFO] Latest git tag: $LATEST_TAG"
# 移除v前缀
CLEAN_VERSION=${LATEST_TAG#v}
# 分割版本号并递增patch版本
IFS='.' read -r MAJOR MINOR PATCH <<< "$CLEAN_VERSION"
PATCH=$((PATCH + 1))
VERSION="$MAJOR.$MINOR.$PATCH"
echo "[INFO] Auto-incremented patch version: $VERSION"
fi
fi
fi
# 输出版本信息
echo "VERSION=$VERSION"
# 保存到文件供其他脚本使用
echo "VERSION=$VERSION" > "$VERSION_FILE"
echo "[INFO] Version information saved to $VERSION_FILE"

1
version.txt Normal file
View File

@@ -0,0 +1 @@
VERSION=1.3.5