Compare commits
15 Commits
v1.3.2
...
d24a522b32
| Author | SHA1 | Date | |
|---|---|---|---|
| d24a522b32 | |||
| 41afb834ae | |||
| b745329e26 | |||
| 1fb4f64cb3 | |||
| 1f8e8981ce | |||
| a257d30dba | |||
| 97ee3b0667 | |||
| 8e2bafba5f | |||
| 6149bc133d | |||
| 5f22ee3b1f | |||
| fa72ff8061 | |||
| 65f24860e6 | |||
|
|
4881233211 | ||
| bc01fdf362 | |||
| 709998ff9c |
14
README.md
14
README.md
@@ -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
|
||||
@@ -129,11 +129,11 @@ Voidraft/
|
||||
|
||||
> 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.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/yourusername/Voidraft)
|
||||
[](https://github.com/yourusername/Voidraft)
|
||||
[](https://github.com/yourusername/voidraft)
|
||||
[](https://github.com/yourusername/voidraft)
|
||||
|
||||
*Made with ❤️ by landaiqing*
|
||||
14
README_ZH.md
14
README_ZH.md
@@ -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/ # 编辑器核心视图
|
||||
@@ -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 和贡献代码。
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/yourusername/Voidraft)
|
||||
[](https://github.com/yourusername/Voidraft)
|
||||
[](https://github.com/yourusername/voidraft)
|
||||
[](https://github.com/yourusername/voidraft)
|
||||
|
||||
*Made with ❤️ by landaiqing*
|
||||
|
||||
12
Taskfile.yml
12
Taskfile.yml
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* VoidRaft - Changelog Script
|
||||
* voidraft - Changelog Script
|
||||
* 从GitHub API获取发布信息,支持Gitea备用源
|
||||
*/
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Service represents the notifications service
|
||||
* @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 "../../application/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* RemoveBadge removes the badge label from the application icon.
|
||||
*/
|
||||
export function RemoveBadge(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2374916939) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceName returns the name of the service.
|
||||
*/
|
||||
export function ServiceName(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2428202016) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceShutdown is called when the service is unloaded.
|
||||
*/
|
||||
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3893755233) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup is called when the service is loaded.
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4078800764, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetBadge sets the badge label on the application icon.
|
||||
*/
|
||||
export function SetBadge(label: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(784276339, label) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SetCustomBadge(label: string, options: $models.Options): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3058653106, label, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as BadgeService from "./badgeservice.js";
|
||||
export {
|
||||
BadgeService
|
||||
};
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as color$0 from "../../../../../../../image/color/models.js";
|
||||
|
||||
export class Options {
|
||||
"TextColour": color$0.RGBA;
|
||||
"BackgroundColour": color$0.RGBA;
|
||||
"FontName": string;
|
||||
"FontSize": number;
|
||||
"SmallFontSize": number;
|
||||
|
||||
/** Creates a new Options instance. */
|
||||
constructor($$source: Partial<Options> = {}) {
|
||||
if (!("TextColour" in $$source)) {
|
||||
this["TextColour"] = (new color$0.RGBA());
|
||||
}
|
||||
if (!("BackgroundColour" in $$source)) {
|
||||
this["BackgroundColour"] = (new color$0.RGBA());
|
||||
}
|
||||
if (!("FontName" in $$source)) {
|
||||
this["FontName"] = "";
|
||||
}
|
||||
if (!("FontSize" in $$source)) {
|
||||
this["FontSize"] = 0;
|
||||
}
|
||||
if (!("SmallFontSize" in $$source)) {
|
||||
this["SmallFontSize"] = 0;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Options instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): Options {
|
||||
const $$createField0_0 = $$createType0;
|
||||
const $$createField1_0 = $$createType0;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("TextColour" in $$parsedSource) {
|
||||
$$parsedSource["TextColour"] = $$createField0_0($$parsedSource["TextColour"]);
|
||||
}
|
||||
if ("BackgroundColour" in $$parsedSource) {
|
||||
$$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
|
||||
}
|
||||
return new Options($$parsedSource as Partial<Options>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = color$0.RGBA.createFrom;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as NotificationService from "./notificationservice.js";
|
||||
export {
|
||||
NotificationService
|
||||
};
|
||||
|
||||
export * from "./models.js";
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* NotificationAction represents an action button for a notification.
|
||||
*/
|
||||
export class NotificationAction {
|
||||
"id"?: string;
|
||||
"title"?: string;
|
||||
|
||||
/**
|
||||
* (macOS-specific)
|
||||
*/
|
||||
"destructive"?: boolean;
|
||||
|
||||
/** Creates a new NotificationAction instance. */
|
||||
constructor($$source: Partial<NotificationAction> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationAction instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationAction {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new NotificationAction($$parsedSource as Partial<NotificationAction>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationCategory groups actions for notifications.
|
||||
*/
|
||||
export class NotificationCategory {
|
||||
"id"?: string;
|
||||
"actions"?: NotificationAction[];
|
||||
"hasReplyField"?: boolean;
|
||||
"replyPlaceholder"?: string;
|
||||
"replyButtonTitle"?: string;
|
||||
|
||||
/** Creates a new NotificationCategory instance. */
|
||||
constructor($$source: Partial<NotificationCategory> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationCategory instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationCategory {
|
||||
const $$createField1_0 = $$createType1;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("actions" in $$parsedSource) {
|
||||
$$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]);
|
||||
}
|
||||
return new NotificationCategory($$parsedSource as Partial<NotificationCategory>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationOptions contains configuration for a notification
|
||||
*/
|
||||
export class NotificationOptions {
|
||||
"id": string;
|
||||
"title": string;
|
||||
|
||||
/**
|
||||
* (macOS and Linux only)
|
||||
*/
|
||||
"subtitle"?: string;
|
||||
"body"?: string;
|
||||
"categoryId"?: string;
|
||||
"data"?: { [_: string]: any };
|
||||
|
||||
/** Creates a new NotificationOptions instance. */
|
||||
constructor($$source: Partial<NotificationOptions> = {}) {
|
||||
if (!("id" in $$source)) {
|
||||
this["id"] = "";
|
||||
}
|
||||
if (!("title" in $$source)) {
|
||||
this["title"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new NotificationOptions instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): NotificationOptions {
|
||||
const $$createField5_0 = $$createType2;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("data" in $$parsedSource) {
|
||||
$$parsedSource["data"] = $$createField5_0($$parsedSource["data"]);
|
||||
}
|
||||
return new NotificationOptions($$parsedSource as Partial<NotificationOptions>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = NotificationAction.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
|
||||
@@ -0,0 +1,110 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* Service represents the notifications service
|
||||
* @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 "../../application/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
export function CheckNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2216952893) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* OnNotificationResponse registers a callback function that will be called when
|
||||
* a notification response is received from the user.
|
||||
*/
|
||||
export function OnNotificationResponse(callback: any): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1642697808, callback) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RegisterNotificationCategory(category: $models.NotificationCategory): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2917562919, category) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveAllDeliveredNotifications(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3956282340) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveAllPendingNotifications(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(108821341) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveDeliveredNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(975691940, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3966653866, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemoveNotificationCategory(categoryID: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2032615554, categoryID) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function RemovePendingNotification(identifier: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3729049703, identifier) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public methods that delegate to the implementation.
|
||||
*/
|
||||
export function RequestNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3933442950) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SendNotification(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3968228732, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
export function SendNotificationWithActions(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1886542847, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceName returns the name of the service.
|
||||
*/
|
||||
export function ServiceName(): Promise<string> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2704532675) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceShutdown is called when the service is unloaded.
|
||||
*/
|
||||
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2550195434) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup is called when the service is loaded.
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4047820929, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
4
frontend/bindings/image/color/index.ts
Normal file
4
frontend/bindings/image/color/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export * from "./models.js";
|
||||
46
frontend/bindings/image/color/models.ts
Normal file
46
frontend/bindings/image/color/models.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* RGBA represents a traditional 32-bit alpha-premultiplied color, having 8
|
||||
* bits for each of red, green, blue and alpha.
|
||||
*
|
||||
* An alpha-premultiplied color component C has been scaled by alpha (A), so
|
||||
* has valid values 0 <= C <= A.
|
||||
*/
|
||||
export class RGBA {
|
||||
"R": number;
|
||||
"G": number;
|
||||
"B": number;
|
||||
"A": number;
|
||||
|
||||
/** Creates a new RGBA instance. */
|
||||
constructor($$source: Partial<RGBA> = {}) {
|
||||
if (!("R" in $$source)) {
|
||||
this["R"] = 0;
|
||||
}
|
||||
if (!("G" in $$source)) {
|
||||
this["G"] = 0;
|
||||
}
|
||||
if (!("B" in $$source)) {
|
||||
this["B"] = 0;
|
||||
}
|
||||
if (!("A" in $$source)) {
|
||||
this["A"] = 0;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RGBA instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): RGBA {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new RGBA($$parsedSource as Partial<RGBA>);
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,12 @@ export class GeneralConfig {
|
||||
*/
|
||||
"startAtLogin": boolean;
|
||||
|
||||
/**
|
||||
* 窗口吸附设置
|
||||
* 是否启用窗口吸附功能(阈值现在是自适应的)
|
||||
*/
|
||||
"enableWindowSnap": boolean;
|
||||
|
||||
/**
|
||||
* 全局热键设置
|
||||
* 是否启用全局热键
|
||||
@@ -492,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;
|
||||
}
|
||||
@@ -506,10 +515,10 @@ export class GeneralConfig {
|
||||
* Creates a new GeneralConfig instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): GeneralConfig {
|
||||
const $$createField5_0 = $$createType8;
|
||||
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>);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,30 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* MigrateConfig 执行配置迁移
|
||||
*/
|
||||
export function MigrateConfig(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(434292783) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ResetConfig 强制重置所有配置为默认值
|
||||
*/
|
||||
@@ -82,6 +106,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);
|
||||
|
||||
@@ -13,10 +13,12 @@ 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 TestService from "./testservice.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,
|
||||
@@ -30,10 +32,12 @@ export {
|
||||
SelfUpdateService,
|
||||
StartupService,
|
||||
SystemService,
|
||||
TestService,
|
||||
ThemeService,
|
||||
TranslationService,
|
||||
TrayService,
|
||||
WindowService
|
||||
WindowService,
|
||||
WindowSnapService
|
||||
};
|
||||
|
||||
export * from "./models.js";
|
||||
|
||||
@@ -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);
|
||||
|
||||
55
frontend/bindings/voidraft/internal/services/testservice.ts
Normal file
55
frontend/bindings/voidraft/internal/services/testservice.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* TestService 测试服务 - 仅在开发环境使用
|
||||
* @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";
|
||||
|
||||
/**
|
||||
* ClearAll 清除所有测试状态
|
||||
*/
|
||||
export function ClearAll(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(2179720854) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceStartup 服务启动时调用
|
||||
*/
|
||||
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(617408198, options) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestBadge 测试Badge功能
|
||||
*/
|
||||
export function TestBadge(text: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(4242952145, text) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestNotification 测试通知功能
|
||||
*/
|
||||
export function TestNotification(title: string, subtitle: string, body: string): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(1697553289, title, subtitle, body) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* TestUpdateNotification 测试更新通知
|
||||
*/
|
||||
export function TestUpdateNotification(): Promise<void> & { cancel(): void } {
|
||||
let $resultPromise = $Call.ByID(3091730060) as any;
|
||||
return $resultPromise;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
2356
frontend/package-lock.json
generated
2356
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,13 @@
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/autocomplete": "^6.18.7",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-html": "^6.4.10",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
@@ -33,7 +33,6 @@
|
||||
"@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.3",
|
||||
"@codemirror/language-data": "^6.5.1",
|
||||
@@ -41,7 +40,7 @@
|
||||
"@codemirror/lint": "^6.8.5",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@lezer/lr": "^1.4.2",
|
||||
"codemirror": "^6.0.2",
|
||||
@@ -50,32 +49,38 @@
|
||||
"colors-named-hex": "^1.0.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"hsl-matcher": "^1.2.4",
|
||||
"jsox": "^1.2.123",
|
||||
"lezer": "^0.13.5",
|
||||
"linguist-languages": "^9.0.0",
|
||||
"node-sql-parser": "^5.3.12",
|
||||
"php-parser": "^3.2.5",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"prettier": "^3.6.2",
|
||||
"remarkable": "^2.0.1",
|
||||
"sass": "^1.90.0",
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"sass": "^1.92.1",
|
||||
"sql-formatter": "^15.6.9",
|
||||
"vue": "^3.5.21",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-pick-colors": "^1.8.0",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/remarkable": "^2.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@wailsio/runtime": "latest",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"globals": "^16.4.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"vite": "^7.1.2",
|
||||
"vite": "^7.1.5",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.5"
|
||||
"vue-tsc": "^3.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/go.wasm
Normal file
BIN
frontend/public/go.wasm
Normal file
Binary file not shown.
561
frontend/public/wasm_exec.js
Normal file
561
frontend/public/wasm_exec.js
Normal file
@@ -0,0 +1,561 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到设置页面
|
||||
@@ -59,11 +70,11 @@ const updateFormatButtonState = () => {
|
||||
// 获取活动块和语言信息
|
||||
const state = view.state;
|
||||
const activeBlock = getActiveNoteBlock(state as any);
|
||||
|
||||
|
||||
// 检查块和语言格式化支持
|
||||
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);
|
||||
@@ -74,32 +85,32 @@ const updateFormatButtonState = () => {
|
||||
// 创建带300ms防抖的更新函数
|
||||
const debouncedUpdateFormatButton = (() => {
|
||||
let timeout: number | null = null;
|
||||
|
||||
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = window.setTimeout(() => {
|
||||
updateFormatButtonState();
|
||||
timeout = null;
|
||||
}, 300);
|
||||
}, 1000);
|
||||
};
|
||||
})();
|
||||
|
||||
// 编辑器事件管理
|
||||
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}
|
||||
];
|
||||
|
||||
|
||||
// 注册所有事件
|
||||
events.forEach(event => view.dom.addEventListener(event.type, event.handler));
|
||||
|
||||
|
||||
// 返回清理函数数组
|
||||
return events.map(event =>
|
||||
() => view.dom.removeEventListener(event.type, event.handler)
|
||||
return events.map(event =>
|
||||
() => 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 = [];
|
||||
|
||||
if (newView) {
|
||||
// 初始更新状态
|
||||
updateFormatButtonState();
|
||||
// 设置新监听器
|
||||
cleanupListeners = setupEditorListeners(newView);
|
||||
} else {
|
||||
canFormatCurrentBlock.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
() => editorStore.editorView,
|
||||
(newView) => {
|
||||
// 清理旧监听器
|
||||
cleanupListeners.forEach(cleanup => cleanup());
|
||||
cleanupListeners = [];
|
||||
|
||||
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,29 +257,33 @@ 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>
|
||||
</svg>
|
||||
|
||||
|
||||
<!-- 有更新可用 -->
|
||||
<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>
|
||||
@@ -338,39 +369,39 @@ const updateButtonTitle = computed(() => {
|
||||
&.available {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
animation: pulse 2s infinite;
|
||||
|
||||
|
||||
svg {
|
||||
stroke: #4caf50;
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(76, 175, 80, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 检查更新中状态 */
|
||||
&.checking {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
|
||||
|
||||
svg {
|
||||
stroke: #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 更新下载中状态 */
|
||||
&.updating {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
|
||||
|
||||
svg {
|
||||
stroke: #2196f3;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 更新成功状态 */
|
||||
&.success {
|
||||
background-color: rgba(156, 39, 176, 0.1);
|
||||
|
||||
|
||||
svg {
|
||||
stroke: #9c27b0;
|
||||
}
|
||||
@@ -380,7 +411,7 @@ const updateButtonTitle = computed(() => {
|
||||
.rotating {
|
||||
animation: rotate 1.5s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
/* 脉冲动画 */
|
||||
.pulsing {
|
||||
animation: pulse-strong 1.2s ease-in-out infinite;
|
||||
@@ -394,7 +425,7 @@ const updateButtonTitle = computed(() => {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse-strong {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
@@ -405,7 +436,7 @@ const updateButtonTitle = computed(() => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
|
||||
@@ -134,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',
|
||||
|
||||
@@ -135,6 +135,7 @@ export default {
|
||||
showInSystemTray: '在系统托盘中显示',
|
||||
enableSystemTray: '启用系统托盘',
|
||||
alwaysOnTop: '窗口始终置顶',
|
||||
enableWindowSnap: '启用窗口吸附',
|
||||
startup: '启动设置',
|
||||
startAtLogin: '开机自启动',
|
||||
dataStorage: '数据存储',
|
||||
|
||||
@@ -8,6 +8,56 @@ 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';
|
||||
// 测试页面
|
||||
import TestPage from '@/views/settings/pages/TestPage.vue';
|
||||
|
||||
// 基础设置子路由
|
||||
const settingsChildren: RouteRecordRaw[] = [
|
||||
{
|
||||
path: 'general',
|
||||
name: 'SettingsGeneral',
|
||||
component: GeneralPage
|
||||
},
|
||||
{
|
||||
path: 'editing',
|
||||
name: 'SettingsEditing',
|
||||
component: EditingPage
|
||||
},
|
||||
{
|
||||
path: 'appearance',
|
||||
name: 'SettingsAppearance',
|
||||
component: AppearancePage
|
||||
},
|
||||
{
|
||||
path: 'extensions',
|
||||
name: 'SettingsExtensions',
|
||||
component: ExtensionsPage
|
||||
},
|
||||
{
|
||||
path: 'key-bindings',
|
||||
name: 'SettingsKeyBindings',
|
||||
component: KeyBindingsPage
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
name: 'SettingsUpdates',
|
||||
component: UpdatesPage
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'SettingsBackup',
|
||||
component: BackupPage
|
||||
}
|
||||
];
|
||||
|
||||
// 仅在开发环境添加测试页面路由
|
||||
if (import.meta.env.DEV) {
|
||||
settingsChildren.push({
|
||||
path: 'test',
|
||||
name: 'SettingsTest',
|
||||
component: TestPage
|
||||
});
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -20,43 +70,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'Settings',
|
||||
redirect: '/settings/general',
|
||||
component: Settings,
|
||||
children: [
|
||||
{
|
||||
path: 'general',
|
||||
name: 'SettingsGeneral',
|
||||
component: GeneralPage
|
||||
},
|
||||
{
|
||||
path: 'editing',
|
||||
name: 'SettingsEditing',
|
||||
component: EditingPage
|
||||
},
|
||||
{
|
||||
path: 'appearance',
|
||||
name: 'SettingsAppearance',
|
||||
component: AppearancePage
|
||||
},
|
||||
{
|
||||
path: 'extensions',
|
||||
name: 'SettingsExtensions',
|
||||
component: ExtensionsPage
|
||||
},
|
||||
{
|
||||
path: 'key-bindings',
|
||||
name: 'SettingsKeyBindings',
|
||||
component: KeyBindingsPage
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
name: 'SettingsUpdates',
|
||||
component: UpdatesPage
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'SettingsBackup',
|
||||
component: BackupPage
|
||||
}
|
||||
]
|
||||
children: settingsChildren
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -62,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 = {
|
||||
@@ -176,7 +177,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
alt: true,
|
||||
win: false,
|
||||
key: 'X'
|
||||
}
|
||||
},
|
||||
enableWindowSnap: true,
|
||||
},
|
||||
editing: {
|
||||
fontSize: CONFIG_LIMITS.fontSize.default,
|
||||
@@ -520,6 +522,9 @@ export const useConfigStore = defineStore('config', () => {
|
||||
// 再调用系统设置API
|
||||
await StartupService.SetEnabled(value);
|
||||
},
|
||||
|
||||
// 窗口吸附配置相关方法
|
||||
setEnableWindowSnap: async (value: boolean) => await updateGeneralConfig('enableWindowSnap', value),
|
||||
|
||||
// 更新配置相关方法
|
||||
setAutoUpdate: async (value: boolean) => await updateUpdatesConfig('autoUpdate', value),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,14 +19,15 @@ 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']
|
||||
}
|
||||
});
|
||||
32
frontend/src/utils/prettier/plugins/go/build.bat
Normal file
32
frontend/src/utils/prettier/plugins/go/build.bat
Normal file
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
rem Build script for Go Prettier Plugin WASM
|
||||
rem This script compiles the Go code to WebAssembly
|
||||
|
||||
echo 🔨 Building Go Prettier Plugin WASM...
|
||||
|
||||
rem Set WASM build environment
|
||||
set GOOS=js
|
||||
set GOARCH=wasm
|
||||
|
||||
rem Build the WASM file
|
||||
echo Compiling main.go to go.wasm...
|
||||
go build -o go.wasm main.go
|
||||
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo ✅ Build successful!
|
||||
|
||||
rem Show file size (Windows version)
|
||||
for %%A in (go.wasm) do echo 📊 WASM file size: %%~zA bytes
|
||||
|
||||
rem Copy to public directory for browser access
|
||||
if exist "..\..\..\..\..\public" (
|
||||
copy go.wasm ..\..\..\..\..\public\go.wasm > nul
|
||||
echo 📋 Copied to public directory
|
||||
)
|
||||
|
||||
echo 🎉 Go Prettier Plugin WASM is ready!
|
||||
) else (
|
||||
echo ❌ Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
30
frontend/src/utils/prettier/plugins/go/build.sh
Normal file
30
frontend/src/utils/prettier/plugins/go/build.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for Go Prettier Plugin WASM
|
||||
# This script compiles the Go code to WebAssembly
|
||||
|
||||
echo "🔨 Building Go Prettier Plugin WASM..."
|
||||
|
||||
# Set WASM build environment
|
||||
export GOOS=js
|
||||
export GOARCH=wasm
|
||||
|
||||
# Build the WASM file
|
||||
echo "Compiling main.go to go.wasm..."
|
||||
go build -o go.wasm main.go
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Build successful!"
|
||||
echo "📊 WASM file size: $(du -h go.wasm | cut -f1)"
|
||||
|
||||
# Copy to public directory for browser access
|
||||
if [ -d "../../../../../public" ]; then
|
||||
cp go.wasm ../../../../../public/go.wasm
|
||||
echo "📋 Copied to public directory"
|
||||
fi
|
||||
|
||||
echo "🎉 Go Prettier Plugin WASM is ready!"
|
||||
else
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
10
frontend/src/utils/prettier/plugins/go/go.d.ts
vendored
Normal file
10
frontend/src/utils/prettier/plugins/go/go.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Parser, Plugin } from "prettier";
|
||||
|
||||
export declare const languages: Plugin["languages"];
|
||||
export declare const parsers: {
|
||||
go: Parser;
|
||||
};
|
||||
export declare const printers: Plugin["printers"];
|
||||
|
||||
declare const plugin: Plugin;
|
||||
export default plugin;
|
||||
116
frontend/src/utils/prettier/plugins/go/go.mjs
Normal file
116
frontend/src/utils/prettier/plugins/go/go.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Go Prettier Plugin for Vite + Vue3 Environment
|
||||
* WebAssembly-based Go code formatter for Prettier
|
||||
*/
|
||||
|
||||
let initializePromise = null;
|
||||
|
||||
// Load WASM file from public directory
|
||||
const loadWasm = async () => {
|
||||
try {
|
||||
const response = await fetch('/go.wasm');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load WASM file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.arrayBuffer();
|
||||
} catch (error) {
|
||||
console.error('WASM loading failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize Go runtime
|
||||
const initGoRuntime = async () => {
|
||||
if (globalThis.Go) return;
|
||||
|
||||
// Auto-load wasm_exec.js if not available
|
||||
try {
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = '/wasm_exec.js';
|
||||
document.head.appendChild(script);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error('Failed to load wasm_exec.js'));
|
||||
setTimeout(() => reject(new Error('wasm_exec.js loading timeout')), 5000);
|
||||
});
|
||||
if (!globalThis.Go) {
|
||||
throw new Error('Go WASM runtime is not available after loading wasm_exec.js');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load wasm_exec.js:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (initializePromise) return initializePromise;
|
||||
|
||||
initializePromise = (async () => {
|
||||
await initGoRuntime();
|
||||
|
||||
const go = new globalThis.Go();
|
||||
const wasmBuffer = await loadWasm();
|
||||
const {instance} = await WebAssembly.instantiate(wasmBuffer, go.importObject);
|
||||
|
||||
// Run Go program
|
||||
go.run(instance).catch(err => {
|
||||
console.error('Go WASM program exit error:', err);
|
||||
});
|
||||
|
||||
// Wait for initialization to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Check if formatGo function is available
|
||||
if (typeof globalThis.formatGo !== 'function') {
|
||||
throw new Error('Go WASM module not properly initialized - formatGo function not available');
|
||||
}
|
||||
})();
|
||||
|
||||
return initializePromise;
|
||||
};
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
name: "Go",
|
||||
parsers: ["go"],
|
||||
extensions: [".go"],
|
||||
vscodeLanguageIds: ["go"],
|
||||
},
|
||||
];
|
||||
|
||||
export const parsers = {
|
||||
go: {
|
||||
parse: (text) => text,
|
||||
astFormat: "go-format",
|
||||
locStart: (node) => 0,
|
||||
locEnd: (node) => node.length,
|
||||
},
|
||||
};
|
||||
|
||||
export const printers = {
|
||||
"go-format": {
|
||||
print: async (path) => {
|
||||
await initialize();
|
||||
const text = path.getValue();
|
||||
|
||||
if (typeof globalThis.formatGo !== 'function') {
|
||||
throw new Error('Go WASM module not properly initialized - formatGo function missing');
|
||||
}
|
||||
|
||||
try {
|
||||
return globalThis.formatGo(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Go formatting failed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Default export for Prettier plugin compatibility
|
||||
export default {
|
||||
languages,
|
||||
parsers,
|
||||
printers
|
||||
};
|
||||
255
frontend/src/utils/prettier/plugins/go/main.go
Normal file
255
frontend/src/utils/prettier/plugins/go/main.go
Normal file
@@ -0,0 +1,255 @@
|
||||
//go:build js && wasm
|
||||
|
||||
// Package main implements a WebAssembly module that provides Go code formatting
|
||||
// functionality for the Prettier plugin. This package exposes the formatGo function
|
||||
// to JavaScript, enabling web-based Go code formatting with better error tolerance.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strings"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
// formatGoCode attempts to format Go source code with better error tolerance
|
||||
func formatGoCode(src string) (string, error) {
|
||||
// Trim input but preserve leading/trailing newlines structure
|
||||
trimmed := strings.TrimSpace(src)
|
||||
if trimmed == "" {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// First try the standard format.Source for complete, valid code
|
||||
if formatted, err := format.Source([]byte(src)); err == nil {
|
||||
return string(formatted), nil
|
||||
}
|
||||
|
||||
// Create a new file set for parsing
|
||||
fset := token.NewFileSet()
|
||||
|
||||
// Strategy 1: Try as complete Go file
|
||||
if parsed, err := parser.ParseFile(fset, "", src, parser.ParseComments); err == nil {
|
||||
return formatASTNode(fset, parsed)
|
||||
}
|
||||
|
||||
// Strategy 2: Try wrapping as package-level declarations
|
||||
packageWrapped := fmt.Sprintf("package main\n\n%s", trimmed)
|
||||
if parsed, err := parser.ParseFile(fset, "", packageWrapped, parser.ParseComments); err == nil {
|
||||
if formatted, err := formatASTNode(fset, parsed); err == nil {
|
||||
return extractPackageContent(formatted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try wrapping in main function
|
||||
funcWrapped := fmt.Sprintf("package main\n\nfunc main() {\n%s\n}", indentLines(trimmed, "\t"))
|
||||
if parsed, err := parser.ParseFile(fset, "", funcWrapped, parser.ParseComments); err == nil {
|
||||
if formatted, err := formatASTNode(fset, parsed); err == nil {
|
||||
return extractFunctionBody(formatted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Try wrapping in anonymous function
|
||||
anonWrapped := fmt.Sprintf("package main\n\nvar _ = func() {\n%s\n}", indentLines(trimmed, "\t"))
|
||||
if parsed, err := parser.ParseFile(fset, "", anonWrapped, parser.ParseComments); err == nil {
|
||||
if formatted, err := formatASTNode(fset, parsed); err == nil {
|
||||
return extractFunctionBody(formatted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: Try line-by-line formatting for complex cases
|
||||
return formatLineByLine(trimmed, fset)
|
||||
}
|
||||
|
||||
// formatASTNode formats an AST node using the standard formatter
|
||||
func formatASTNode(fset *token.FileSet, node interface{}) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := format.Node(&buf, fset, node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// extractPackageContent extracts content after package declaration
|
||||
func extractPackageContent(formatted string) string {
|
||||
lines := strings.Split(formatted, "\n")
|
||||
var contentLines []string
|
||||
skipNext := false
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "package ") {
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
if skipNext && strings.TrimSpace(line) == "" {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if !skipNext {
|
||||
contentLines = append(contentLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(contentLines, "\n")
|
||||
}
|
||||
|
||||
// extractFunctionBody extracts content from within a function body
|
||||
func extractFunctionBody(formatted string) string {
|
||||
lines := strings.Split(formatted, "\n")
|
||||
var bodyLines []string
|
||||
inFunction := false
|
||||
braceCount := 0
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "func ") && strings.Contains(line, "{") {
|
||||
inFunction = true
|
||||
braceCount = 1
|
||||
continue
|
||||
}
|
||||
|
||||
if inFunction {
|
||||
// Count braces to know when function ends
|
||||
braceCount += strings.Count(line, "{")
|
||||
braceCount -= strings.Count(line, "}")
|
||||
|
||||
if braceCount == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
// Remove one level of indentation and add the line
|
||||
if strings.HasPrefix(line, "\t") {
|
||||
bodyLines = append(bodyLines, line[1:])
|
||||
} else {
|
||||
bodyLines = append(bodyLines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty lines from start and end
|
||||
for len(bodyLines) > 0 && strings.TrimSpace(bodyLines[0]) == "" {
|
||||
bodyLines = bodyLines[1:]
|
||||
}
|
||||
for len(bodyLines) > 0 && strings.TrimSpace(bodyLines[len(bodyLines)-1]) == "" {
|
||||
bodyLines = bodyLines[:len(bodyLines)-1]
|
||||
}
|
||||
|
||||
return strings.Join(bodyLines, "\n")
|
||||
}
|
||||
|
||||
// indentLines adds indentation to each non-empty line
|
||||
func indentLines(text, indent string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var indentedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
indentedLines = append(indentedLines, "")
|
||||
} else {
|
||||
indentedLines = append(indentedLines, indent+line)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(indentedLines, "\n")
|
||||
}
|
||||
|
||||
// formatLineByLine attempts to format each statement individually
|
||||
func formatLineByLine(src string, fset *token.FileSet) (string, error) {
|
||||
lines := strings.Split(src, "\n")
|
||||
var formattedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmedLine := strings.TrimSpace(line)
|
||||
if trimmedLine == "" {
|
||||
formattedLines = append(formattedLines, "")
|
||||
continue
|
||||
}
|
||||
|
||||
// Try different wrapping strategies for individual lines
|
||||
attempts := []string{
|
||||
fmt.Sprintf("package main\n\nfunc main() {\n\t%s\n}", trimmedLine),
|
||||
fmt.Sprintf("package main\n\n%s", trimmedLine),
|
||||
fmt.Sprintf("package main\n\nvar _ = %s", trimmedLine),
|
||||
}
|
||||
|
||||
formatted := trimmedLine // fallback
|
||||
for _, attempt := range attempts {
|
||||
if parsed, err := parser.ParseFile(fset, "", attempt, parser.ParseComments); err == nil {
|
||||
if result, err := formatASTNode(fset, parsed); err == nil {
|
||||
if extracted := extractSingleStatement(result, trimmedLine); extracted != "" {
|
||||
formatted = extracted
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formattedLines = append(formattedLines, formatted)
|
||||
}
|
||||
|
||||
return strings.Join(formattedLines, "\n"), nil
|
||||
}
|
||||
|
||||
// extractSingleStatement tries to extract a single formatted statement
|
||||
func extractSingleStatement(formatted, original string) string {
|
||||
lines := strings.Split(formatted, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "package ") &&
|
||||
!strings.HasPrefix(trimmed, "func ") && !strings.HasPrefix(trimmed, "var _ =") &&
|
||||
trimmed != "{" && trimmed != "}" {
|
||||
// Remove leading tabs/spaces to normalize indentation
|
||||
return strings.TrimLeft(line, " \t")
|
||||
}
|
||||
}
|
||||
|
||||
return original
|
||||
}
|
||||
|
||||
// formatGo is a JavaScript-callable function that formats Go source code.
|
||||
// It attempts multiple strategies to format code, including handling incomplete
|
||||
// or syntactically invalid code fragments.
|
||||
//
|
||||
// Parameters:
|
||||
// - this: The JavaScript 'this' context (unused)
|
||||
// - i: JavaScript arguments array where i[0] should contain the Go source code as a string
|
||||
//
|
||||
// Returns:
|
||||
// - js.Value: The formatted Go source code as a JavaScript string value
|
||||
// - If formatting fails completely, returns the original code unchanged
|
||||
// - If no arguments are provided, returns js.Null() and logs an error
|
||||
func formatGo(this js.Value, i []js.Value) interface{} {
|
||||
if len(i) == 0 {
|
||||
js.Global().Get("console").Call("error", "formatGo: missing code argument")
|
||||
return js.Null()
|
||||
}
|
||||
|
||||
code := i[0].String()
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return js.ValueOf(code)
|
||||
}
|
||||
|
||||
formatted, err := formatGoCode(code)
|
||||
if err != nil {
|
||||
js.Global().Get("console").Call("warn", "Go formatting had issues:", err.Error())
|
||||
return js.ValueOf(code) // Return original code if all attempts fail
|
||||
}
|
||||
|
||||
return js.ValueOf(formatted)
|
||||
}
|
||||
|
||||
// main initializes the WebAssembly module and exposes the formatGo function
|
||||
// to the JavaScript global scope.
|
||||
func main() {
|
||||
// Create a channel to keep the Go program running
|
||||
c := make(chan struct{}, 0)
|
||||
|
||||
// Expose the formatGo function to the JavaScript global scope
|
||||
js.Global().Set("formatGo", js.FuncOf(formatGo))
|
||||
|
||||
// Block forever
|
||||
<-c
|
||||
}
|
||||
4
frontend/src/utils/prettier/plugins/php/index.d.ts
vendored
Normal file
4
frontend/src/utils/prettier/plugins/php/index.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Plugin } from "prettier";
|
||||
|
||||
declare const plugin: Plugin;
|
||||
export default plugin;
|
||||
19
frontend/src/utils/prettier/plugins/php/index.mjs
Normal file
19
frontend/src/utils/prettier/plugins/php/index.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
languages,
|
||||
printers,
|
||||
parsers,
|
||||
options,
|
||||
defaultOptions
|
||||
} from './src/index.mjs';
|
||||
|
||||
import { languages, printers, parsers, options, defaultOptions } from './src/index.mjs';
|
||||
|
||||
const phpPlugin = {
|
||||
languages,
|
||||
printers,
|
||||
parsers,
|
||||
options,
|
||||
defaultOptions
|
||||
};
|
||||
|
||||
export default phpPlugin;
|
||||
111
frontend/src/utils/prettier/plugins/php/src/clean.mjs
Normal file
111
frontend/src/utils/prettier/plugins/php/src/clean.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { printNumber, normalizeMagicMethodName } from "./util.mjs";
|
||||
|
||||
const ignoredProperties = new Set([
|
||||
"loc",
|
||||
"range",
|
||||
"raw",
|
||||
"comments",
|
||||
"leadingComments",
|
||||
"trailingComments",
|
||||
"parenthesizedExpression",
|
||||
"parent",
|
||||
"prev",
|
||||
"start",
|
||||
"end",
|
||||
"tokens",
|
||||
"errors",
|
||||
"extra",
|
||||
]);
|
||||
|
||||
/**
|
||||
* This function takes the existing ast node and a copy, by reference
|
||||
* We use it for testing, so that we can compare pre-post versions of the AST,
|
||||
* excluding things we don't care about (like node location, case that will be
|
||||
* changed by the printer, etc.)
|
||||
*/
|
||||
function clean(node, newObj) {
|
||||
if (node.kind === "string") {
|
||||
// TODO if options are available in this method, replace with
|
||||
// newObj.isDoubleQuote = !useSingleQuote(node, options);
|
||||
delete newObj.isDoubleQuote;
|
||||
}
|
||||
|
||||
if (["array", "list"].includes(node.kind)) {
|
||||
// TODO if options are available in this method, assign instead of delete
|
||||
delete newObj.shortForm;
|
||||
}
|
||||
|
||||
if (node.kind === "inline") {
|
||||
if (node.value.includes("___PSEUDO_INLINE_PLACEHOLDER___")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
newObj.value = newObj.value.replace(/\n/g, "");
|
||||
}
|
||||
|
||||
// continue ((2)); -> continue 2;
|
||||
// continue 1; -> continue;
|
||||
if ((node.kind === "continue" || node.kind === "break") && node.level) {
|
||||
const { level } = newObj;
|
||||
|
||||
if (level.kind === "number") {
|
||||
newObj.level = level.value === "1" ? null : level;
|
||||
}
|
||||
}
|
||||
|
||||
// if () {{ }} -> if () {}
|
||||
if (node.kind === "block") {
|
||||
if (node.children.length === 1 && node.children[0].kind === "block") {
|
||||
while (newObj.children[0].kind === "block") {
|
||||
newObj.children = newObj.children[0].children;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize numbers
|
||||
if (node.kind === "number") {
|
||||
newObj.value = printNumber(node.value);
|
||||
}
|
||||
|
||||
const statements = ["foreach", "for", "if", "while", "do"];
|
||||
|
||||
if (statements.includes(node.kind)) {
|
||||
if (node.body && node.body.kind !== "block") {
|
||||
newObj.body = {
|
||||
kind: "block",
|
||||
children: [newObj.body],
|
||||
};
|
||||
} else {
|
||||
newObj.body = newObj.body ? newObj.body : null;
|
||||
}
|
||||
|
||||
if (node.alternate && node.alternate.kind !== "block") {
|
||||
newObj.alternate = {
|
||||
kind: "block",
|
||||
children: [newObj.alternate],
|
||||
};
|
||||
} else {
|
||||
newObj.alternate = newObj.alternate ? newObj.alternate : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (node.kind === "usegroup" && typeof node.name === "string") {
|
||||
newObj.name = newObj.name.replace(/^\\/, "");
|
||||
}
|
||||
|
||||
if (node.kind === "useitem") {
|
||||
newObj.name = newObj.name.replace(/^\\/, "");
|
||||
}
|
||||
|
||||
if (node.kind === "method" && node.name.kind === "identifier") {
|
||||
newObj.name.name = normalizeMagicMethodName(newObj.name.name);
|
||||
}
|
||||
|
||||
if (node.kind === "noop") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
clean.ignoredProperties = ignoredProperties;
|
||||
|
||||
export default clean;
|
||||
1044
frontend/src/utils/prettier/plugins/php/src/comments.mjs
Normal file
1044
frontend/src/utils/prettier/plugins/php/src/comments.mjs
Normal file
File diff suppressed because it is too large
Load Diff
173
frontend/src/utils/prettier/plugins/php/src/index.mjs
Normal file
173
frontend/src/utils/prettier/plugins/php/src/index.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
import { doc } from "prettier";
|
||||
import LINGUIST_LANGUAGES_PHP from "linguist-languages/data/PHP";
|
||||
import LINGUIST_LANGUAGES_HTML_PHP from "linguist-languages/data/HTML_2b_PHP";
|
||||
import parse from "./parser.mjs";
|
||||
import print from "./printer.mjs";
|
||||
import clean from "./clean.mjs";
|
||||
import options from "./options.mjs";
|
||||
import {
|
||||
handleOwnLineComment,
|
||||
handleEndOfLineComment,
|
||||
handleRemainingComment,
|
||||
getCommentChildNodes,
|
||||
canAttachComment,
|
||||
isBlockComment,
|
||||
} from "./comments.mjs";
|
||||
import { hasPragma, insertPragma } from "./pragma.mjs";
|
||||
import { locStart, locEnd } from "./loc.mjs";
|
||||
|
||||
const { join, hardline } = doc.builders;
|
||||
|
||||
function createLanguage(linguistData, { extend, override }) {
|
||||
const language = {};
|
||||
|
||||
for (const key in linguistData) {
|
||||
const newKey = key === "languageId" ? "linguistLanguageId" : key;
|
||||
language[newKey] = linguistData[key];
|
||||
}
|
||||
|
||||
if (extend) {
|
||||
for (const key in extend) {
|
||||
language[key] = (language[key] || []).concat(extend[key]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in override) {
|
||||
language[key] = override[key];
|
||||
}
|
||||
|
||||
return language;
|
||||
}
|
||||
|
||||
const languages = [
|
||||
createLanguage(LINGUIST_LANGUAGES_PHP, {
|
||||
override: {
|
||||
parsers: ["php"],
|
||||
vscodeLanguageIds: ["php"],
|
||||
},
|
||||
}),
|
||||
createLanguage(LINGUIST_LANGUAGES_HTML_PHP, {
|
||||
override: {
|
||||
parsers: ["php"],
|
||||
vscodeLanguageIds: ["php"],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const parsers = {
|
||||
php: {
|
||||
parse,
|
||||
astFormat: "php",
|
||||
locStart,
|
||||
locEnd,
|
||||
hasPragma,
|
||||
},
|
||||
};
|
||||
|
||||
const ignoredKeys = new Set([
|
||||
"kind",
|
||||
"loc",
|
||||
"errors",
|
||||
"extra",
|
||||
"comments",
|
||||
"leadingComments",
|
||||
"enclosingNode",
|
||||
"precedingNode",
|
||||
"followingNode",
|
||||
]);
|
||||
function getVisitorKeys(node, nonTraversableKeys) {
|
||||
return Object.keys(node).filter(
|
||||
(key) => !nonTraversableKeys.has(key) && !ignoredKeys.has(key)
|
||||
);
|
||||
}
|
||||
|
||||
const printers = {
|
||||
php: {
|
||||
print,
|
||||
getVisitorKeys,
|
||||
insertPragma,
|
||||
massageAstNode: clean,
|
||||
getCommentChildNodes,
|
||||
canAttachComment,
|
||||
isBlockComment,
|
||||
handleComments: {
|
||||
ownLine: handleOwnLineComment,
|
||||
endOfLine: handleEndOfLineComment,
|
||||
remaining: handleRemainingComment,
|
||||
},
|
||||
willPrintOwnComments(path) {
|
||||
const { node } = path;
|
||||
|
||||
return node && node.kind === "noop";
|
||||
},
|
||||
printComment(path) {
|
||||
const comment = path.node;
|
||||
|
||||
switch (comment.kind) {
|
||||
case "commentblock": {
|
||||
// for now, don't touch single line block comments
|
||||
if (!comment.value.includes("\n")) {
|
||||
return comment.value;
|
||||
}
|
||||
|
||||
const lines = comment.value.split("\n");
|
||||
// if this is a block comment, handle indentation
|
||||
if (
|
||||
lines
|
||||
.slice(1, lines.length - 1)
|
||||
.every((line) => line.trim()[0] === "*")
|
||||
) {
|
||||
return join(
|
||||
hardline,
|
||||
lines.map(
|
||||
(line, index) =>
|
||||
(index > 0 ? " " : "") +
|
||||
(index < lines.length - 1 ? line.trim() : line.trimLeft())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// otherwise we can't be sure about indentation, so just print as is
|
||||
return comment.value;
|
||||
}
|
||||
case "commentline": {
|
||||
return comment.value.trimRight();
|
||||
}
|
||||
/* c8 ignore next 2 */
|
||||
default:
|
||||
throw new Error(`Not a comment: ${JSON.stringify(comment)}`);
|
||||
}
|
||||
},
|
||||
hasPrettierIgnore(path) {
|
||||
const isSimpleIgnore = (comment) =>
|
||||
comment.value.includes("prettier-ignore") &&
|
||||
!comment.value.includes("prettier-ignore-start") &&
|
||||
!comment.value.includes("prettier-ignore-end");
|
||||
|
||||
const { node, parent: parentNode } = path;
|
||||
|
||||
return (
|
||||
(node &&
|
||||
node.kind !== "classconstant" &&
|
||||
node.comments &&
|
||||
node.comments.length > 0 &&
|
||||
node.comments.some(isSimpleIgnore)) ||
|
||||
// For proper formatting, the classconstant ignore formatting should
|
||||
// run on the "constant" child
|
||||
(node &&
|
||||
node.kind === "constant" &&
|
||||
parentNode &&
|
||||
parentNode.kind === "classconstant" &&
|
||||
parentNode.comments &&
|
||||
parentNode.comments.length > 0 &&
|
||||
parentNode.comments.some(isSimpleIgnore))
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
tabWidth: 4,
|
||||
};
|
||||
|
||||
export { languages, printers, parsers, options, defaultOptions };
|
||||
4
frontend/src/utils/prettier/plugins/php/src/loc.mjs
Normal file
4
frontend/src/utils/prettier/plugins/php/src/loc.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
const loc = (prop) => (node) => node.loc?.[prop]?.offset;
|
||||
|
||||
export const locStart = loc("start");
|
||||
export const locEnd = loc("end");
|
||||
250
frontend/src/utils/prettier/plugins/php/src/needs-parens.mjs
Normal file
250
frontend/src/utils/prettier/plugins/php/src/needs-parens.mjs
Normal file
@@ -0,0 +1,250 @@
|
||||
import { getPrecedence, shouldFlatten, isBitwiseOperator } from "./util.mjs";
|
||||
|
||||
function needsParens(path, options) {
|
||||
const { parent } = path;
|
||||
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { key, node } = path;
|
||||
|
||||
if (
|
||||
[
|
||||
// No need parens for top level children of this nodes
|
||||
"program",
|
||||
"expressionstatement",
|
||||
"namespace",
|
||||
"declare",
|
||||
"block",
|
||||
|
||||
// No need parens
|
||||
"include",
|
||||
"print",
|
||||
"return",
|
||||
"echo",
|
||||
].includes(parent.kind)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (node.kind) {
|
||||
case "pre":
|
||||
case "post":
|
||||
if (parent.kind === "unary") {
|
||||
return (
|
||||
node.kind === "pre" &&
|
||||
((node.type === "+" && parent.type === "+") ||
|
||||
(node.type === "-" && parent.type === "-"))
|
||||
);
|
||||
}
|
||||
// else fallthrough
|
||||
case "unary":
|
||||
switch (parent.kind) {
|
||||
case "unary":
|
||||
return (
|
||||
node.type === parent.type &&
|
||||
(node.type === "+" || node.type === "-")
|
||||
);
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
case "call":
|
||||
return key === "what";
|
||||
case "bin":
|
||||
return parent.type === "**" && key === "left";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case "bin": {
|
||||
switch (parent.kind) {
|
||||
case "assign":
|
||||
case "retif":
|
||||
return ["and", "xor", "or"].includes(node.type);
|
||||
case "silent":
|
||||
case "cast":
|
||||
// TODO: bug https://github.com/glayzzle/php-parser/issues/172
|
||||
return node.parenthesizedExpression;
|
||||
case "pre":
|
||||
case "post":
|
||||
case "unary":
|
||||
return true;
|
||||
case "call":
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
return key === "what";
|
||||
case "bin": {
|
||||
const po = parent.type;
|
||||
const pp = getPrecedence(po);
|
||||
const no = node.type;
|
||||
const np = getPrecedence(no);
|
||||
|
||||
if (pp > np) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (po === "||" && no === "&&") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pp === np && key === "right") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pp === np && !shouldFlatten(po, no)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pp < np && no === "%") {
|
||||
return po === "+" || po === "-";
|
||||
}
|
||||
|
||||
// Add parenthesis when working with bitwise operators
|
||||
// It's not stricly needed but helps with code understanding
|
||||
if (isBitwiseOperator(po)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup": {
|
||||
switch (parent.kind) {
|
||||
case "call":
|
||||
return key === "what" && node.parenthesizedExpression;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "clone":
|
||||
case "new": {
|
||||
const requiresParens =
|
||||
node.kind === "clone" ||
|
||||
(node.kind === "new" && options.phpVersion < 8.4);
|
||||
switch (parent.kind) {
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
case "call":
|
||||
return key === "what" && requiresParens;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case "yield": {
|
||||
switch (parent.kind) {
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
case "call":
|
||||
return key === "what";
|
||||
|
||||
case "retif":
|
||||
return key === "test";
|
||||
|
||||
default:
|
||||
return !!(node.key || node.value);
|
||||
}
|
||||
}
|
||||
case "assign": {
|
||||
if (
|
||||
parent.kind === "for" &&
|
||||
(parent.init.includes(node) || parent.increment.includes(node))
|
||||
) {
|
||||
return false;
|
||||
} else if (parent.kind === "assign") {
|
||||
return false;
|
||||
} else if (parent.kind === "static") {
|
||||
return false;
|
||||
} else if (
|
||||
["if", "do", "while", "foreach", "switch"].includes(parent.kind)
|
||||
) {
|
||||
return false;
|
||||
} else if (parent.kind === "silent") {
|
||||
return false;
|
||||
} else if (parent.kind === "call") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case "retif":
|
||||
switch (parent.kind) {
|
||||
case "cast":
|
||||
return true;
|
||||
case "unary":
|
||||
case "bin":
|
||||
case "retif":
|
||||
if (key === "test" && !parent.trueExpr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
case "call":
|
||||
return key === "what";
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case "closure":
|
||||
switch (parent.kind) {
|
||||
case "call":
|
||||
return key === "what";
|
||||
|
||||
// https://github.com/prettier/plugin-php/issues/1675
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case "silence":
|
||||
case "cast":
|
||||
// TODO: bug https://github.com/glayzzle/php-parser/issues/172
|
||||
return node.parenthesizedExpression;
|
||||
// else fallthrough
|
||||
case "string":
|
||||
case "array":
|
||||
switch (parent.kind) {
|
||||
case "propertylookup":
|
||||
case "nullsafepropertylookup":
|
||||
case "staticlookup":
|
||||
case "offsetlookup":
|
||||
case "call":
|
||||
if (
|
||||
["string", "array"].includes(node.kind) &&
|
||||
parent.kind === "offsetlookup"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return key === "what";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
case "print":
|
||||
case "include":
|
||||
return parent.kind === "bin";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default needsParens;
|
||||
69
frontend/src/utils/prettier/plugins/php/src/options.mjs
Normal file
69
frontend/src/utils/prettier/plugins/php/src/options.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
const CATEGORY_PHP = "PHP";
|
||||
|
||||
// prettier-ignore
|
||||
const SUPPORTED_PHP_VERSIONS = [
|
||||
5.0, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6,
|
||||
7.0, 7.1, 7.2, 7.3, 7.4,
|
||||
8.0, 8.1, 8.2, 8.3, 8.4,
|
||||
];
|
||||
|
||||
export const LATEST_SUPPORTED_PHP_VERSION = Math.max(...SUPPORTED_PHP_VERSIONS);
|
||||
|
||||
/**
|
||||
* Resolve the PHP version to a number based on the provided options.
|
||||
*/
|
||||
export function resolvePhpVersion(options) {
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.phpVersion === "auto" || options.phpVersion === "composer") {
|
||||
options.phpVersion = LATEST_SUPPORTED_PHP_VERSION;
|
||||
} else {
|
||||
options.phpVersion = parseFloat(options.phpVersion);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
phpVersion: {
|
||||
since: "0.13.0",
|
||||
category: CATEGORY_PHP,
|
||||
type: "choice",
|
||||
default: "auto",
|
||||
description: "Minimum target PHP version.",
|
||||
choices: [
|
||||
...SUPPORTED_PHP_VERSIONS.map((v) => ({ value: v.toFixed(1) })),
|
||||
{
|
||||
value: "auto",
|
||||
description: `Use latest PHP Version (${LATEST_SUPPORTED_PHP_VERSION})`,
|
||||
},
|
||||
],
|
||||
},
|
||||
trailingCommaPHP: {
|
||||
since: "0.0.0",
|
||||
category: CATEGORY_PHP,
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Print trailing commas wherever possible when multi-line.",
|
||||
},
|
||||
braceStyle: {
|
||||
since: "0.10.0",
|
||||
category: CATEGORY_PHP,
|
||||
type: "choice",
|
||||
default: "per-cs",
|
||||
description:
|
||||
"Print one space or newline for code blocks (classes and functions).",
|
||||
choices: [
|
||||
{ value: "psr-2", description: "(deprecated) Use per-cs" },
|
||||
{ value: "per-cs", description: "Use the PER Coding Style brace style." },
|
||||
{ value: "1tbs", description: "Use 1tbs brace style." },
|
||||
],
|
||||
},
|
||||
singleQuote: {
|
||||
since: "0.0.0",
|
||||
category: CATEGORY_PHP,
|
||||
type: "boolean",
|
||||
default: false,
|
||||
description: "Use single quotes instead of double quotes.",
|
||||
},
|
||||
};
|
||||
67
frontend/src/utils/prettier/plugins/php/src/parser.mjs
Normal file
67
frontend/src/utils/prettier/plugins/php/src/parser.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import engine from "php-parser";
|
||||
import { LATEST_SUPPORTED_PHP_VERSION } from "./options.mjs";
|
||||
import { resolvePhpVersion } from "./options.mjs";
|
||||
|
||||
function parse(text, opts) {
|
||||
const inMarkdown = opts && opts.parentParser === "markdown";
|
||||
|
||||
if (!text && inMarkdown) {
|
||||
return "";
|
||||
}
|
||||
resolvePhpVersion(opts);
|
||||
|
||||
// Todo https://github.com/glayzzle/php-parser/issues/170
|
||||
text = text.replace(/\?>\n<\?/g, "?>\n___PSEUDO_INLINE_PLACEHOLDER___<?");
|
||||
|
||||
// initialize a new parser instance
|
||||
const parser = new engine({
|
||||
parser: {
|
||||
extractDoc: true,
|
||||
version: `${LATEST_SUPPORTED_PHP_VERSION}`,
|
||||
},
|
||||
ast: {
|
||||
withPositions: true,
|
||||
withSource: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasOpenPHPTag = text.indexOf("<?php") !== -1;
|
||||
const parseAsEval = inMarkdown && !hasOpenPHPTag;
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parseAsEval ? parser.parseEval(text) : parser.parseCode(text);
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError && "lineNumber" in err) {
|
||||
err.loc = {
|
||||
start: {
|
||||
line: err.lineNumber,
|
||||
column: err.columnNumber,
|
||||
},
|
||||
};
|
||||
|
||||
delete err.lineNumber;
|
||||
delete err.columnNumber;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
ast.extra = {
|
||||
parseAsEval,
|
||||
};
|
||||
|
||||
// https://github.com/glayzzle/php-parser/issues/155
|
||||
// currently inline comments include the line break at the end, we need to
|
||||
// strip those out and update the end location for each comment manually
|
||||
ast.comments.forEach((comment) => {
|
||||
if (comment.value[comment.value.length - 1] === "\n") {
|
||||
comment.value = comment.value.slice(0, -1);
|
||||
comment.loc.end.offset = comment.loc.end.offset - 1;
|
||||
}
|
||||
});
|
||||
|
||||
return ast;
|
||||
}
|
||||
|
||||
export default parse;
|
||||
92
frontend/src/utils/prettier/plugins/php/src/pragma.mjs
Normal file
92
frontend/src/utils/prettier/plugins/php/src/pragma.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
import { memoize } from "./util.mjs";
|
||||
import parse from "./parser.mjs";
|
||||
|
||||
const reHasPragma = /@prettier|@format/;
|
||||
|
||||
const getPageLevelDocBlock = memoize((text) => {
|
||||
const parsed = parse(text);
|
||||
|
||||
const [firstChild] = parsed.children;
|
||||
const [firstDocBlock] = parsed.comments.filter(
|
||||
(el) => el.kind === "commentblock"
|
||||
);
|
||||
|
||||
if (
|
||||
firstChild &&
|
||||
firstDocBlock &&
|
||||
firstDocBlock.loc.start.line < firstChild.loc.start.line
|
||||
) {
|
||||
return firstDocBlock;
|
||||
}
|
||||
});
|
||||
|
||||
function hasPragma(text) {
|
||||
// fast path optimization - check if the pragma shows up in the file at all
|
||||
if (!reHasPragma.test(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageLevelDocBlock = getPageLevelDocBlock(text);
|
||||
|
||||
if (pageLevelDocBlock) {
|
||||
const { value } = pageLevelDocBlock;
|
||||
|
||||
return reHasPragma.test(value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function injectPragma(docblock) {
|
||||
let lines = docblock.split("\n");
|
||||
|
||||
if (lines.length === 1) {
|
||||
// normalize to multiline for simplicity
|
||||
const [, line] = /\/*\*\*(.*)\*\//.exec(lines[0]);
|
||||
|
||||
lines = ["/**", ` * ${line.trim()}`, " */"];
|
||||
}
|
||||
|
||||
// find the first @pragma
|
||||
// if there happens to be one on the opening line, just put it on the next line.
|
||||
const pragmaIndex = lines.findIndex((line) => /@\S/.test(line)) || 1;
|
||||
|
||||
// not found => index == -1, which conveniently will splice 1 from the end.
|
||||
lines.splice(pragmaIndex, 0, " * @format");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function insertPragma(text) {
|
||||
const pageLevelDocBlock = getPageLevelDocBlock(text);
|
||||
|
||||
if (pageLevelDocBlock) {
|
||||
const {
|
||||
start: { offset: startOffset },
|
||||
end: { offset: endOffset },
|
||||
} = pageLevelDocBlock.loc;
|
||||
const before = text.substring(0, startOffset);
|
||||
const after = text.substring(endOffset);
|
||||
|
||||
return `${before}${injectPragma(pageLevelDocBlock.value, text)}${after}`;
|
||||
}
|
||||
|
||||
const openTag = "<?php";
|
||||
|
||||
if (!text.startsWith(openTag)) {
|
||||
// bail out
|
||||
return text;
|
||||
}
|
||||
|
||||
const splitAt = openTag.length;
|
||||
const phpTag = text.substring(0, splitAt);
|
||||
const after = text.substring(splitAt);
|
||||
|
||||
return `${phpTag}
|
||||
/**
|
||||
* @format
|
||||
*/
|
||||
${after}`;
|
||||
}
|
||||
|
||||
export { hasPragma, insertPragma };
|
||||
2899
frontend/src/utils/prettier/plugins/php/src/printer.mjs
Normal file
2899
frontend/src/utils/prettier/plugins/php/src/printer.mjs
Normal file
File diff suppressed because it is too large
Load Diff
743
frontend/src/utils/prettier/plugins/php/src/util.mjs
Normal file
743
frontend/src/utils/prettier/plugins/php/src/util.mjs
Normal file
@@ -0,0 +1,743 @@
|
||||
import { util as prettierUtil } from "prettier";
|
||||
import { locStart } from "./loc.mjs";
|
||||
|
||||
const { hasNewline, skipEverythingButNewLine, skipNewline } = prettierUtil;
|
||||
|
||||
function printNumber(rawNumber) {
|
||||
return (
|
||||
rawNumber
|
||||
.toLowerCase()
|
||||
// Remove unnecessary plus and zeroes from scientific notation.
|
||||
.replace(/^([+-]?[\d.]+e)(?:\+|(-))?0*(\d)/, "$1$2$3")
|
||||
// Remove unnecessary scientific notation (1e0).
|
||||
.replace(/^([+-]?[\d.]+)e[+-]?0+$/, "$1")
|
||||
// Make sure numbers always start with a digit.
|
||||
.replace(/^([+-])?\./, "$10.")
|
||||
// Remove extraneous trailing decimal zeroes.
|
||||
.replace(/(\.\d+?)0+(?=e|$)/, "$1")
|
||||
// Remove unnecessary .e notation
|
||||
.replace(/\.(?=e)/, "")
|
||||
);
|
||||
}
|
||||
|
||||
// http://php.net/manual/en/language.operators.precedence.php
|
||||
const PRECEDENCE = new Map(
|
||||
[
|
||||
["or"],
|
||||
["xor"],
|
||||
["and"],
|
||||
[
|
||||
"=",
|
||||
"+=",
|
||||
"-=",
|
||||
"*=",
|
||||
"**=",
|
||||
"/=",
|
||||
".=",
|
||||
"%=",
|
||||
"&=",
|
||||
"|=",
|
||||
"^=",
|
||||
"<<=",
|
||||
">>=",
|
||||
],
|
||||
["??"],
|
||||
["||"],
|
||||
["&&"],
|
||||
["|"],
|
||||
["^"],
|
||||
["&"],
|
||||
["==", "===", "!=", "!==", "<>", "<=>"],
|
||||
["<", ">", "<=", ">="],
|
||||
[">>", "<<"],
|
||||
["+", "-", "."],
|
||||
["*", "/", "%"],
|
||||
["!"],
|
||||
["instanceof"],
|
||||
["++", "--", "~"],
|
||||
["**"],
|
||||
].flatMap((operators, index) =>
|
||||
operators.map((operator) => [operator, index])
|
||||
)
|
||||
);
|
||||
function getPrecedence(operator) {
|
||||
return PRECEDENCE.get(operator);
|
||||
}
|
||||
|
||||
const equalityOperators = ["==", "!=", "===", "!==", "<>", "<=>"];
|
||||
const multiplicativeOperators = ["*", "/", "%"];
|
||||
const bitshiftOperators = [">>", "<<"];
|
||||
|
||||
function isBitwiseOperator(operator) {
|
||||
return (
|
||||
!!bitshiftOperators[operator] ||
|
||||
operator === "|" ||
|
||||
operator === "^" ||
|
||||
operator === "&"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldFlatten(parentOp, nodeOp) {
|
||||
if (getPrecedence(nodeOp) !== getPrecedence(parentOp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ** is right-associative
|
||||
// x ** y ** z --> x ** (y ** z)
|
||||
if (parentOp === "**") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// x == y == z --> (x == y) == z
|
||||
if (
|
||||
equalityOperators.includes(parentOp) &&
|
||||
equalityOperators.includes(nodeOp)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// x * y % z --> (x * y) % z
|
||||
if (
|
||||
(nodeOp === "%" && multiplicativeOperators.includes(parentOp)) ||
|
||||
(parentOp === "%" && multiplicativeOperators.includes(nodeOp))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// x * y / z --> (x * y) / z
|
||||
// x / y * z --> (x / y) * z
|
||||
if (
|
||||
nodeOp !== parentOp &&
|
||||
multiplicativeOperators.includes(nodeOp) &&
|
||||
multiplicativeOperators.includes(parentOp)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// x << y << z --> (x << y) << z
|
||||
if (
|
||||
bitshiftOperators.includes(parentOp) &&
|
||||
bitshiftOperators.includes(nodeOp)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function nodeHasStatement(node) {
|
||||
return [
|
||||
"block",
|
||||
"program",
|
||||
"namespace",
|
||||
"class",
|
||||
"enum",
|
||||
"interface",
|
||||
"trait",
|
||||
"traituse",
|
||||
"declare",
|
||||
].includes(node.kind);
|
||||
}
|
||||
|
||||
function getBodyFirstChild({ body }) {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
if (body.kind === "block") {
|
||||
body = body.children;
|
||||
}
|
||||
return body[0];
|
||||
}
|
||||
|
||||
function getNodeListProperty(node) {
|
||||
const body = node.children || node.body || node.adaptations;
|
||||
return Array.isArray(body) ? body : null;
|
||||
}
|
||||
|
||||
function getLast(arr) {
|
||||
if (arr.length > 0) {
|
||||
return arr[arr.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPenultimate(arr) {
|
||||
if (arr.length > 1) {
|
||||
return arr[arr.length - 2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFirstChildrenInlineNode(path) {
|
||||
const { node } = path;
|
||||
|
||||
if (node.kind === "program") {
|
||||
const children = getNodeListProperty(node);
|
||||
|
||||
if (!children || children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return children[0].kind === "inline";
|
||||
}
|
||||
|
||||
if (node.kind === "switch") {
|
||||
if (!node.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = getNodeListProperty(node.body);
|
||||
|
||||
if (children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [firstCase] = children;
|
||||
|
||||
if (!firstCase.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstCaseChildren = getNodeListProperty(firstCase.body);
|
||||
|
||||
if (firstCaseChildren.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return firstCaseChildren[0].kind === "inline";
|
||||
}
|
||||
|
||||
const firstChild = getBodyFirstChild(node);
|
||||
|
||||
if (!firstChild) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return firstChild.kind === "inline";
|
||||
}
|
||||
|
||||
function isDocNode(node) {
|
||||
return (
|
||||
node.kind === "nowdoc" ||
|
||||
(node.kind === "encapsed" && node.type === "heredoc")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heredoc/Nowdoc nodes need a trailing linebreak if they
|
||||
* appear as function arguments or array elements
|
||||
*/
|
||||
function docShouldHaveTrailingNewline(path, recurse = 0) {
|
||||
const node = path.getNode(recurse);
|
||||
const parent = path.getNode(recurse + 1);
|
||||
const parentParent = path.getNode(recurse + 2);
|
||||
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(parentParent &&
|
||||
["call", "new", "echo"].includes(parentParent.kind) &&
|
||||
!["call", "array"].includes(parent.kind)) ||
|
||||
parent.kind === "parameter"
|
||||
) {
|
||||
const lastIndex = parentParent.arguments.length - 1;
|
||||
const index = parentParent.arguments.indexOf(parent);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (parentParent && parentParent.kind === "for") {
|
||||
const initIndex = parentParent.init.indexOf(parent);
|
||||
|
||||
if (initIndex !== -1) {
|
||||
return initIndex !== parentParent.init.length - 1;
|
||||
}
|
||||
|
||||
const testIndex = parentParent.test.indexOf(parent);
|
||||
|
||||
if (testIndex !== -1) {
|
||||
return testIndex !== parentParent.test.length - 1;
|
||||
}
|
||||
|
||||
const incrementIndex = parentParent.increment.indexOf(parent);
|
||||
|
||||
if (incrementIndex !== -1) {
|
||||
return incrementIndex !== parentParent.increment.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (parent.kind === "bin") {
|
||||
return (
|
||||
parent.left === node || docShouldHaveTrailingNewline(path, recurse + 1)
|
||||
);
|
||||
}
|
||||
|
||||
if (parent.kind === "case" && parent.test === node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parent.kind === "staticvariable") {
|
||||
const lastIndex = parentParent.variables.length - 1;
|
||||
const index = parentParent.variables.indexOf(parent);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (parent.kind === "entry") {
|
||||
if (parent.key === node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastIndex = parentParent.items.length - 1;
|
||||
const index = parentParent.items.indexOf(parent);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (["call", "new"].includes(parent.kind)) {
|
||||
const lastIndex = parent.arguments.length - 1;
|
||||
const index = parent.arguments.indexOf(node);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (parent.kind === "echo") {
|
||||
const lastIndex = parent.expressions.length - 1;
|
||||
const index = parent.expressions.indexOf(node);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (parent.kind === "array") {
|
||||
const lastIndex = parent.items.length - 1;
|
||||
const index = parent.items.indexOf(node);
|
||||
|
||||
return index !== lastIndex;
|
||||
}
|
||||
|
||||
if (parent.kind === "retif") {
|
||||
return docShouldHaveTrailingNewline(path, recurse + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function lineShouldEndWithSemicolon(path) {
|
||||
const { node, parent: parentNode } = path;
|
||||
if (!parentNode) {
|
||||
return false;
|
||||
}
|
||||
// for single line control structures written in a shortform (ie without a block),
|
||||
// we need to make sure the single body node gets a semicolon
|
||||
if (
|
||||
["for", "foreach", "while", "do", "if", "switch"].includes(
|
||||
parentNode.kind
|
||||
) &&
|
||||
node.kind !== "block" &&
|
||||
node.kind !== "if" &&
|
||||
(parentNode.body === node || parentNode.alternate === node)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!nodeHasStatement(parentNode)) {
|
||||
return false;
|
||||
}
|
||||
if (node.kind === "echo" && node.shortForm) {
|
||||
return false;
|
||||
}
|
||||
if (node.kind === "traituse") {
|
||||
return !node.adaptations;
|
||||
}
|
||||
if (node.kind === "method" && node.isAbstract) {
|
||||
return true;
|
||||
}
|
||||
if (node.kind === "method") {
|
||||
const { parent } = path;
|
||||
if (parent && parent.kind === "interface") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return [
|
||||
"expressionstatement",
|
||||
"do",
|
||||
"usegroup",
|
||||
"classconstant",
|
||||
"propertystatement",
|
||||
"traitprecedence",
|
||||
"traitalias",
|
||||
"goto",
|
||||
"constantstatement",
|
||||
"enumcase",
|
||||
"global",
|
||||
"static",
|
||||
"echo",
|
||||
"unset",
|
||||
"return",
|
||||
"break",
|
||||
"continue",
|
||||
"throw",
|
||||
].includes(node.kind);
|
||||
}
|
||||
|
||||
function fileShouldEndWithHardline(path) {
|
||||
const { node } = path;
|
||||
const isProgramNode = node.kind === "program";
|
||||
const lastNode = node.children && getLast(node.children);
|
||||
|
||||
if (!isProgramNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastNode && ["halt", "inline"].includes(lastNode.kind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
lastNode &&
|
||||
(lastNode.kind === "declare" || lastNode.kind === "namespace")
|
||||
) {
|
||||
const lastNestedNode =
|
||||
lastNode.children.length > 0 && getLast(lastNode.children);
|
||||
|
||||
if (lastNestedNode && ["halt", "inline"].includes(lastNestedNode.kind)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function maybeStripLeadingSlashFromUse(name) {
|
||||
const nameWithoutLeadingSlash = name.replace(/^\\/, "");
|
||||
if (nameWithoutLeadingSlash.indexOf("\\") !== -1) {
|
||||
return nameWithoutLeadingSlash;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function hasDanglingComments(node) {
|
||||
return (
|
||||
node.comments &&
|
||||
node.comments.some((comment) => !comment.leading && !comment.trailing)
|
||||
);
|
||||
}
|
||||
|
||||
function isLookupNode(node) {
|
||||
return (
|
||||
node.kind === "propertylookup" ||
|
||||
node.kind === "nullsafepropertylookup" ||
|
||||
node.kind === "staticlookup" ||
|
||||
node.kind === "offsetlookup"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldPrintHardLineAfterStartInControlStructure(path) {
|
||||
const { node } = path;
|
||||
|
||||
if (["try", "catch"].includes(node.kind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isFirstChildrenInlineNode(path);
|
||||
}
|
||||
|
||||
function shouldPrintHardLineBeforeEndInControlStructure(path) {
|
||||
const { node } = path;
|
||||
|
||||
if (["try", "catch"].includes(node.kind)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.kind === "switch") {
|
||||
const children = getNodeListProperty(node.body);
|
||||
|
||||
if (children.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastCase = getLast(children);
|
||||
|
||||
if (!lastCase.body) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const childrenInCase = getNodeListProperty(lastCase.body);
|
||||
|
||||
if (childrenInCase.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return childrenInCase[0].kind !== "inline";
|
||||
}
|
||||
|
||||
return !isFirstChildrenInlineNode(path);
|
||||
}
|
||||
|
||||
function getAlignment(text) {
|
||||
const lines = text.split("\n");
|
||||
const lastLine = lines.pop();
|
||||
|
||||
return lastLine.length - lastLine.trimLeft().length + 1;
|
||||
}
|
||||
|
||||
function isProgramLikeNode(node) {
|
||||
return ["program", "declare", "namespace"].includes(node.kind);
|
||||
}
|
||||
|
||||
function isReferenceLikeNode(node) {
|
||||
return [
|
||||
"name",
|
||||
"parentreference",
|
||||
"selfreference",
|
||||
"staticreference",
|
||||
].includes(node.kind);
|
||||
}
|
||||
|
||||
// Return `logical` value for `bin` node containing `||` or `&&` type otherwise return kind of node.
|
||||
// Require for grouping logical and binary nodes in right way.
|
||||
function getNodeKindIncludingLogical(node) {
|
||||
if (node.kind === "bin" && ["||", "&&"].includes(node.type)) {
|
||||
return "logical";
|
||||
}
|
||||
|
||||
return node.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string can safely be converted from double to single quotes and vice-versa, i.e.
|
||||
*
|
||||
* - no embedded variables ("foo $bar")
|
||||
* - no linebreaks
|
||||
* - no special characters like \n, \t, ...
|
||||
* - no octal/hex/unicode characters
|
||||
*
|
||||
* See https://php.net/manual/en/language.types.string.php#language.types.string.syntax.double
|
||||
*/
|
||||
function useDoubleQuote(node, options) {
|
||||
if (node.isDoubleQuote === options.singleQuote) {
|
||||
// We have a double quote and the user passed singleQuote:true, or the other way around.
|
||||
const rawValue = node.raw.slice(node.raw[0] === "b" ? 2 : 1, -1);
|
||||
const isComplex = rawValue.match(
|
||||
/\\([$nrtfve]|[xX][0-9a-fA-F]{1,2}|[0-7]{1,3}|u{([0-9a-fA-F]+)})|\r?\n|'|"|\$/
|
||||
);
|
||||
return node.isDoubleQuote ? isComplex : !isComplex;
|
||||
}
|
||||
return node.isDoubleQuote;
|
||||
}
|
||||
|
||||
function hasEmptyBody(path, name = "body") {
|
||||
const { node } = path;
|
||||
|
||||
return (
|
||||
node[name] &&
|
||||
node[name].children &&
|
||||
node[name].children.length === 0 &&
|
||||
(!node[name].comments || node[name].comments.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
function isNextLineEmptyAfterNamespace(text, node) {
|
||||
let idx = locStart(node);
|
||||
idx = skipEverythingButNewLine(text, idx);
|
||||
idx = skipNewline(text, idx);
|
||||
return hasNewline(text, idx);
|
||||
}
|
||||
|
||||
function shouldPrintHardlineBeforeTrailingComma(lastElem) {
|
||||
if (
|
||||
lastElem.kind === "nowdoc" ||
|
||||
(lastElem.kind === "encapsed" && lastElem.type === "heredoc")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
lastElem.kind === "entry" &&
|
||||
(lastElem.value.kind === "nowdoc" ||
|
||||
(lastElem.value.kind === "encapsed" && lastElem.value.type === "heredoc"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAncestorCounter(path, typeOrTypes) {
|
||||
const types = [].concat(typeOrTypes);
|
||||
let counter = -1;
|
||||
let ancestorNode;
|
||||
while ((ancestorNode = path.getParentNode(++counter))) {
|
||||
if (types.indexOf(ancestorNode.kind) !== -1) {
|
||||
return counter;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getAncestorNode(path, typeOrTypes) {
|
||||
const counter = getAncestorCounter(path, typeOrTypes);
|
||||
return counter === -1 ? null : path.getParentNode(counter);
|
||||
}
|
||||
|
||||
const magicMethods = [
|
||||
"__construct",
|
||||
"__destruct",
|
||||
"__call",
|
||||
"__callStatic",
|
||||
"__get",
|
||||
"__set",
|
||||
"__isset",
|
||||
"__unset",
|
||||
"__sleep",
|
||||
"__wakeup",
|
||||
"__toString",
|
||||
"__invoke",
|
||||
"__set_state",
|
||||
"__clone",
|
||||
"__debugInfo",
|
||||
];
|
||||
const magicMethodsMap = new Map(
|
||||
magicMethods.map((name) => [name.toLowerCase(), name])
|
||||
);
|
||||
|
||||
function normalizeMagicMethodName(name) {
|
||||
const loweredName = name.toLowerCase();
|
||||
|
||||
if (magicMethodsMap.has(loweredName)) {
|
||||
return magicMethodsMap.get(loweredName);
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} kindsArray
|
||||
* @returns {(node: Node | Comment) => Boolean}
|
||||
*/
|
||||
function createTypeCheckFunction(kindsArray) {
|
||||
const kinds = new Set(kindsArray);
|
||||
return (node) => kinds.has(node?.kind);
|
||||
}
|
||||
|
||||
const isSingleWordType = createTypeCheckFunction([
|
||||
"variadicplaceholder",
|
||||
"namedargument",
|
||||
"nullkeyword",
|
||||
"identifier",
|
||||
"parameter",
|
||||
"variable",
|
||||
"variadic",
|
||||
"boolean",
|
||||
"literal",
|
||||
"number",
|
||||
"string",
|
||||
"clone",
|
||||
"cast",
|
||||
]);
|
||||
|
||||
const isArrayExpression = createTypeCheckFunction(["array"]);
|
||||
const isCallLikeExpression = createTypeCheckFunction([
|
||||
"nullsafepropertylookup",
|
||||
"propertylookup",
|
||||
"staticlookup",
|
||||
"offsetlookup",
|
||||
"call",
|
||||
"new",
|
||||
]);
|
||||
const isArrowFuncExpression = createTypeCheckFunction(["arrowfunc"]);
|
||||
|
||||
function getChainParts(node, prev = []) {
|
||||
const parts = prev;
|
||||
if (isCallLikeExpression(node)) {
|
||||
parts.push(node);
|
||||
}
|
||||
|
||||
if (!node.what) {
|
||||
return parts;
|
||||
}
|
||||
|
||||
return getChainParts(node.what, parts);
|
||||
}
|
||||
|
||||
function isSimpleCallArgument(node, depth = 2) {
|
||||
if (depth <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChildSimple = (child) => isSimpleCallArgument(child, depth - 1);
|
||||
|
||||
if (isSingleWordType(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isArrayExpression(node)) {
|
||||
return node.items.every((x) => x === null || isChildSimple(x));
|
||||
}
|
||||
|
||||
if (isCallLikeExpression(node)) {
|
||||
const parts = getChainParts(node);
|
||||
parts.unshift();
|
||||
|
||||
return (
|
||||
parts.length <= depth &&
|
||||
parts.every((node) =>
|
||||
isLookupNode(node)
|
||||
? isChildSimple(node.offset)
|
||||
: node.arguments.every(isChildSimple)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isArrowFuncExpression(node)) {
|
||||
return (
|
||||
node.arguments.length <= depth && node.arguments.every(isChildSimple)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function memoize(fn) {
|
||||
const cache = new Map();
|
||||
return (key) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, fn(key));
|
||||
}
|
||||
return cache.get(key);
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
printNumber,
|
||||
getPrecedence,
|
||||
isBitwiseOperator,
|
||||
shouldFlatten,
|
||||
nodeHasStatement,
|
||||
getLast,
|
||||
getPenultimate,
|
||||
getBodyFirstChild,
|
||||
lineShouldEndWithSemicolon,
|
||||
fileShouldEndWithHardline,
|
||||
maybeStripLeadingSlashFromUse,
|
||||
hasDanglingComments,
|
||||
docShouldHaveTrailingNewline,
|
||||
isLookupNode,
|
||||
isFirstChildrenInlineNode,
|
||||
shouldPrintHardLineAfterStartInControlStructure,
|
||||
shouldPrintHardLineBeforeEndInControlStructure,
|
||||
getAlignment,
|
||||
isProgramLikeNode,
|
||||
isReferenceLikeNode,
|
||||
getNodeKindIncludingLogical,
|
||||
useDoubleQuote,
|
||||
hasEmptyBody,
|
||||
isNextLineEmptyAfterNamespace,
|
||||
shouldPrintHardlineBeforeTrailingComma,
|
||||
isDocNode,
|
||||
getAncestorNode,
|
||||
normalizeMagicMethodName,
|
||||
isSimpleCallArgument,
|
||||
memoize,
|
||||
};
|
||||
1180
frontend/src/utils/prettier/plugins/sql/detect.mjs
Normal file
1180
frontend/src/utils/prettier/plugins/sql/detect.mjs
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/src/utils/prettier/plugins/sql/languages.js
Normal file
30
frontend/src/utils/prettier/plugins/sql/languages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// SQL语言定义
|
||||
export const languages = [
|
||||
{
|
||||
name: "SQL",
|
||||
parsers: ["sql"],
|
||||
extensions: [
|
||||
".sql",
|
||||
".ddl",
|
||||
".dml",
|
||||
".hql",
|
||||
".psql",
|
||||
".plsql",
|
||||
".mysql",
|
||||
".mssql",
|
||||
".pgsql",
|
||||
".sqlite",
|
||||
".bigquery",
|
||||
".snowflake",
|
||||
".redshift",
|
||||
".db2",
|
||||
".n1ql",
|
||||
".cql"
|
||||
],
|
||||
filenames: [
|
||||
"*.sql",
|
||||
"*.ddl",
|
||||
"*.dml"
|
||||
]
|
||||
}
|
||||
];
|
||||
40
frontend/src/utils/prettier/plugins/sql/sql.d.ts
vendored
Normal file
40
frontend/src/utils/prettier/plugins/sql/sql.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AST, Option } from 'node-sql-parser';
|
||||
import { Options, ParserOptions, Plugin } from 'prettier';
|
||||
import {
|
||||
FormatOptions,
|
||||
FormatOptionsWithLanguage,
|
||||
} from 'sql-formatter';
|
||||
|
||||
export type SqlBaseOptions = Option &
|
||||
Partial<
|
||||
| (FormatOptions & { dialect: string })
|
||||
| (FormatOptionsWithLanguage & { dialect?: never })
|
||||
> & {
|
||||
formatter?: 'sql-formatter' | 'node-sql-parser' | 'sql-cst';
|
||||
params?: string;
|
||||
paramTypes?: string;
|
||||
autoDetectDialect?: boolean;
|
||||
};
|
||||
|
||||
export type SqlOptions = ParserOptions<AST> & SqlBaseOptions;
|
||||
export type SqlFormatOptions = Options & SqlBaseOptions;
|
||||
export declare const languages: Plugin["languages"];
|
||||
|
||||
export declare const parsers: {
|
||||
sql: {
|
||||
parse(text: string, options?: SqlOptions): AST | string;
|
||||
astFormat: 'sql';
|
||||
locStart(): number;
|
||||
locEnd(): number;
|
||||
};
|
||||
};
|
||||
|
||||
export declare const printers: Plugin["printers"];
|
||||
|
||||
export declare const options: Plugin["options"];
|
||||
|
||||
export declare function detectDialect(sql: string): string;
|
||||
|
||||
declare const SqlPlugin: Plugin<AST | string>;
|
||||
|
||||
export default SqlPlugin;
|
||||
459
frontend/src/utils/prettier/plugins/sql/sql.mjs
Normal file
459
frontend/src/utils/prettier/plugins/sql/sql.mjs
Normal file
@@ -0,0 +1,459 @@
|
||||
import { JSOX } from 'jsox';
|
||||
import nodeSqlParser from 'node-sql-parser';
|
||||
import {
|
||||
format,
|
||||
formatDialect,
|
||||
} from 'sql-formatter';
|
||||
import { detectDialect } from './detect.mjs';
|
||||
import { languages } from './languages.js';
|
||||
|
||||
const parser = new nodeSqlParser.Parser();
|
||||
|
||||
const SQL_FORMATTER = 'sql-formatter';
|
||||
const NODE_SQL_PARSER = 'node-sql-parser';
|
||||
|
||||
// Parsers
|
||||
export const parsers = {
|
||||
sql: {
|
||||
parse(text, options = {}) {
|
||||
const { formatter, type, database } = options;
|
||||
|
||||
return formatter === SQL_FORMATTER
|
||||
? text
|
||||
: parser.astify(text, { type, database });
|
||||
},
|
||||
astFormat: 'sql',
|
||||
locStart: () => -1,
|
||||
locEnd: () => -1,
|
||||
},
|
||||
};
|
||||
|
||||
// Printers
|
||||
export const printers = {
|
||||
sql: {
|
||||
print(path, options = {}) {
|
||||
const value = path.node;
|
||||
const {
|
||||
formatter = SQL_FORMATTER,
|
||||
type,
|
||||
database,
|
||||
dialect,
|
||||
language,
|
||||
params,
|
||||
paramTypes,
|
||||
autoDetectDialect = true,
|
||||
...formatOptions
|
||||
} = options;
|
||||
|
||||
let formatted;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// 准备sql-formatter选项
|
||||
const sqlFormatterOptions = {
|
||||
...formatOptions,
|
||||
params:
|
||||
params == null
|
||||
? undefined
|
||||
: JSOX.parse(params),
|
||||
paramTypes:
|
||||
paramTypes == null
|
||||
? undefined
|
||||
: JSOX.parse(paramTypes),
|
||||
};
|
||||
|
||||
let finalLanguage = language;
|
||||
let finalDialect = dialect;
|
||||
|
||||
if (autoDetectDialect && !language && !dialect) {
|
||||
const detectedDialect = detectDialect(value);
|
||||
finalLanguage = detectedDialect;
|
||||
}
|
||||
|
||||
// 使用适当的格式化方法
|
||||
if (finalDialect != null) {
|
||||
// 使用formatDialect方法
|
||||
formatted = formatDialect(value, {
|
||||
...sqlFormatterOptions,
|
||||
dialect: JSOX.parse(finalDialect),
|
||||
});
|
||||
} else {
|
||||
// 使用format方法
|
||||
formatted = format(value, {
|
||||
...sqlFormatterOptions,
|
||||
language: finalLanguage,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 使用node-sql-parser进行格式化
|
||||
formatted = parser.sqlify(value, { type, database });
|
||||
}
|
||||
|
||||
return formatted + '\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 插件选项
|
||||
export const options = {
|
||||
formatter: {
|
||||
category: 'Config',
|
||||
type: 'choice',
|
||||
default: SQL_FORMATTER,
|
||||
description: 'Choose which formatter to be used',
|
||||
choices: [
|
||||
{
|
||||
value: SQL_FORMATTER,
|
||||
description: 'use `sql-formatter` as formatter',
|
||||
},
|
||||
{
|
||||
value: NODE_SQL_PARSER,
|
||||
description: 'use `node-sql-parser` as formatter',
|
||||
},
|
||||
],
|
||||
},
|
||||
autoDetectDialect: {
|
||||
category: 'Config',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Automatically detect SQL dialect if language/dialect is not specified',
|
||||
},
|
||||
dialect: {
|
||||
category: 'Config',
|
||||
type: 'string',
|
||||
description: 'SQL dialect for `sql-formatter` formatDialect()',
|
||||
},
|
||||
language: {
|
||||
category: 'Config',
|
||||
type: 'choice',
|
||||
default: 'sql',
|
||||
description: 'SQL dialect for `sql-formatter` format()',
|
||||
choices: [
|
||||
{
|
||||
value: 'sql',
|
||||
description: 'Standard SQL: https://en.wikipedia.org/wiki/SQL:2011',
|
||||
},
|
||||
{
|
||||
value: 'bigquery',
|
||||
description:
|
||||
'Google Standard SQL (Bigquery): https://cloud.google.com/bigquery',
|
||||
},
|
||||
{
|
||||
value: 'db2',
|
||||
description: 'IBM DB2: https://www.ibm.com/products/db2',
|
||||
},
|
||||
{
|
||||
value: 'db2i',
|
||||
description:
|
||||
'IBM DB2i (experimental): https://www.ibm.com/docs/en/i/7.5?topic=overview-db2-i',
|
||||
},
|
||||
{
|
||||
value: 'hive',
|
||||
description: 'Apache Hive: https://hive.apache.org',
|
||||
},
|
||||
{
|
||||
value: 'mariadb',
|
||||
description: 'MariaDB: https://mariadb.com',
|
||||
},
|
||||
{
|
||||
value: 'mysql',
|
||||
description: 'MySQL: https://www.mysql.com',
|
||||
},
|
||||
{
|
||||
value: 'n1ql',
|
||||
description:
|
||||
'Couchbase N1QL: https://www.couchbase.com/products/n1ql',
|
||||
},
|
||||
{
|
||||
value: 'plsql',
|
||||
description:
|
||||
'Oracle PL/SQL: https://www.oracle.com/database/technologies/appdev/plsql.html',
|
||||
},
|
||||
{
|
||||
value: 'postgresql',
|
||||
description: 'PostgreSQL: https://www.postgresql.org',
|
||||
},
|
||||
{
|
||||
value: 'redshift',
|
||||
description:
|
||||
'Amazon Redshift: https://docs.aws.amazon.com/redshift/latest/dg/cm_chap_SQLCommandRef.html',
|
||||
},
|
||||
{
|
||||
value: 'singlestoredb',
|
||||
description:
|
||||
'SingleStoreDB: https://docs.singlestore.com/db/v7.8/en/introduction/singlestore-documentation.html',
|
||||
},
|
||||
{
|
||||
value: 'snowflake',
|
||||
description: 'Snowflake: https://docs.snowflake.com',
|
||||
},
|
||||
{
|
||||
value: 'spark',
|
||||
description: 'Spark: https://spark.apache.org',
|
||||
},
|
||||
{
|
||||
value: 'sqlite',
|
||||
description: 'SQLite: https://www.sqlite.org',
|
||||
},
|
||||
{
|
||||
value: 'transactsql',
|
||||
description:
|
||||
'SQL Server Transact-SQL: https://docs.microsoft.com/en-us/sql/sql-server/',
|
||||
},
|
||||
{
|
||||
value: 'tsql',
|
||||
description:
|
||||
'SQL Server Transact-SQL: https://docs.microsoft.com/en-us/sql/sql-server/',
|
||||
},
|
||||
{
|
||||
value: 'trino',
|
||||
description: 'Trino: https://trino.io',
|
||||
},
|
||||
],
|
||||
},
|
||||
keywordCase: {
|
||||
category: 'Output',
|
||||
type: 'choice',
|
||||
default: 'preserve',
|
||||
description:
|
||||
'Converts reserved keywords to upper- or lowercase for `sql-formatter`',
|
||||
choices: [
|
||||
{
|
||||
value: 'preserve',
|
||||
description: 'preserves the original case of reserved keywords',
|
||||
},
|
||||
{
|
||||
value: 'upper',
|
||||
description: 'converts reserved keywords to uppercase',
|
||||
},
|
||||
{
|
||||
value: 'lower',
|
||||
description: 'converts reserved keywords to lowercase',
|
||||
},
|
||||
],
|
||||
},
|
||||
dataTypeCase: {
|
||||
category: 'Output',
|
||||
type: 'choice',
|
||||
default: 'preserve',
|
||||
description:
|
||||
'Converts data types to upper- or lowercase for `sql-formatter`',
|
||||
choices: [
|
||||
{
|
||||
value: 'preserve',
|
||||
description: 'preserves the original case of data types',
|
||||
},
|
||||
{
|
||||
value: 'upper',
|
||||
description: 'converts data types to uppercase',
|
||||
},
|
||||
{
|
||||
value: 'lower',
|
||||
description: 'converts data types to lowercase',
|
||||
},
|
||||
],
|
||||
},
|
||||
functionCase: {
|
||||
category: 'Output',
|
||||
type: 'choice',
|
||||
default: 'preserve',
|
||||
description:
|
||||
'Converts functions to upper- or lowercase for `sql-formatter`',
|
||||
choices: [
|
||||
{
|
||||
value: 'preserve',
|
||||
description: 'preserves the original case of functions',
|
||||
},
|
||||
{
|
||||
value: 'upper',
|
||||
description: 'converts functions to uppercase',
|
||||
},
|
||||
{
|
||||
value: 'lower',
|
||||
description: 'converts functions to lowercase',
|
||||
},
|
||||
],
|
||||
},
|
||||
identifierCase: {
|
||||
category: 'Output',
|
||||
type: 'choice',
|
||||
default: 'preserve',
|
||||
description:
|
||||
'Converts identifiers to upper- or lowercase for `sql-formatter`. Only unquoted identifiers are converted. (experimental)',
|
||||
choices: [
|
||||
{
|
||||
value: 'preserve',
|
||||
description: 'preserves the original case of identifiers',
|
||||
},
|
||||
{
|
||||
value: 'upper',
|
||||
description: 'converts identifiers to uppercase',
|
||||
},
|
||||
{
|
||||
value: 'lower',
|
||||
description: 'converts identifiers to lowercase',
|
||||
},
|
||||
],
|
||||
},
|
||||
uppercase: {
|
||||
category: 'Output',
|
||||
type: 'boolean',
|
||||
deprecated: '0.7.0',
|
||||
description: 'Use `keywordCase` option instead',
|
||||
},
|
||||
indentStyle: {
|
||||
category: 'Format',
|
||||
type: 'choice',
|
||||
default: 'standard',
|
||||
description: `Switches between different indentation styles for \`sql-formatter\`.
|
||||
|
||||
Caveats of using \`"tabularLeft"\` and \`"tabularRight"\`:
|
||||
|
||||
- \`tabWidth\` option is ignored. Indentation will always be 10 spaces, regardless of what is specified by \`tabWidth\``,
|
||||
choices: [
|
||||
{
|
||||
value: 'standard',
|
||||
description:
|
||||
'indents code by the amount specified by `tabWidth` option',
|
||||
},
|
||||
{
|
||||
value: 'tabularLeft',
|
||||
description:
|
||||
'indents in tabular style with 10 spaces, aligning keywords to left',
|
||||
},
|
||||
{
|
||||
value: 'tabularRight',
|
||||
description:
|
||||
'indents in tabular style with 10 spaces, aligning keywords to right',
|
||||
},
|
||||
],
|
||||
},
|
||||
logicalOperatorNewline: {
|
||||
category: 'Format',
|
||||
type: 'choice',
|
||||
default: 'before',
|
||||
description:
|
||||
'Decides newline placement before or after logical operators (AND, OR, XOR)',
|
||||
choices: [
|
||||
{
|
||||
value: 'before',
|
||||
description: 'adds newline before the operator',
|
||||
},
|
||||
{
|
||||
value: 'after',
|
||||
description: 'adds newline after the operator',
|
||||
},
|
||||
],
|
||||
},
|
||||
expressionWidth: {
|
||||
category: 'Format',
|
||||
type: 'int',
|
||||
default: 50,
|
||||
description:
|
||||
'Determines maximum length of parenthesized expressions for `sql-formatter`',
|
||||
},
|
||||
linesBetweenQueries: {
|
||||
category: 'Format',
|
||||
type: 'int',
|
||||
default: 1,
|
||||
description:
|
||||
'Decides how many empty lines to leave between SQL statements for `sql-formatter`',
|
||||
},
|
||||
denseOperators: {
|
||||
category: 'Format',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Decides whitespace around operators for `sql-formatter`. Does not apply to logical operators (AND, OR, XOR).',
|
||||
},
|
||||
newlineBeforeSemicolon: {
|
||||
category: 'Format',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to place query separator (`;`) on a separate line for `sql-formatter`',
|
||||
},
|
||||
params: {
|
||||
category: 'Format',
|
||||
type: 'string',
|
||||
description:
|
||||
'Specifies `JSOX` **stringified** parameter values to fill in for placeholders inside SQL for `sql-formatter`. This option is designed to be used through API (though nothing really prevents usage from command line).',
|
||||
},
|
||||
paramTypes: {
|
||||
category: 'Config',
|
||||
type: 'string',
|
||||
description:
|
||||
'Specifies `JSOX` **stringified** parameter types to support when parsing SQL prepared statements for `sql-formatter`.',
|
||||
},
|
||||
type: {
|
||||
category: 'Config',
|
||||
type: 'choice',
|
||||
default: 'table',
|
||||
description: 'Check the SQL with Authority List for `node-sql-parser`',
|
||||
choices: [
|
||||
{
|
||||
value: 'table',
|
||||
description: '`table` mode',
|
||||
},
|
||||
{
|
||||
value: 'column',
|
||||
description: '`column` mode',
|
||||
},
|
||||
],
|
||||
},
|
||||
database: {
|
||||
category: 'Config',
|
||||
type: 'choice',
|
||||
default: 'mysql',
|
||||
description: 'SQL dialect for `node-sql-parser`',
|
||||
choices: [
|
||||
{
|
||||
value: 'bigquery',
|
||||
description: 'BigQuery: https://cloud.google.com/bigquery',
|
||||
},
|
||||
{
|
||||
value: 'db2',
|
||||
description: 'IBM DB2: https://www.ibm.com/analytics/db2',
|
||||
},
|
||||
{
|
||||
value: 'hive',
|
||||
description: 'Hive: https://hive.apache.org',
|
||||
},
|
||||
{
|
||||
value: 'mariadb',
|
||||
description: 'MariaDB: https://mariadb.com',
|
||||
},
|
||||
{
|
||||
value: 'mysql',
|
||||
description: 'MySQL: https://www.mysql.com',
|
||||
},
|
||||
{
|
||||
value: 'postgresql',
|
||||
description: 'PostgreSQL: https://www.postgresql.org',
|
||||
},
|
||||
{
|
||||
value: 'transactsql',
|
||||
description:
|
||||
'TransactSQL: https://docs.microsoft.com/en-us/sql/t-sql',
|
||||
},
|
||||
{
|
||||
value: 'flinksql',
|
||||
description:
|
||||
'FlinkSQL: https://ci.apache.org/projects/flink/flink-docs-stable',
|
||||
},
|
||||
{
|
||||
value: 'snowflake',
|
||||
description: 'Snowflake (alpha): https://docs.snowflake.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const SqlPlugin = {
|
||||
languages,
|
||||
parsers,
|
||||
printers,
|
||||
options,
|
||||
};
|
||||
|
||||
export { languages };
|
||||
export default SqlPlugin;
|
||||
@@ -2,35 +2,33 @@
|
||||
* 语言映射和解析器配置
|
||||
*/
|
||||
|
||||
import { jsonLanguage } from "@codemirror/lang-json";
|
||||
import { pythonLanguage } from "@codemirror/lang-python";
|
||||
import { javascriptLanguage, typescriptLanguage } from "@codemirror/lang-javascript";
|
||||
import { htmlLanguage } from "@codemirror/lang-html";
|
||||
import { StandardSQL } from "@codemirror/lang-sql";
|
||||
import { markdownLanguage } from "@codemirror/lang-markdown";
|
||||
import { javaLanguage } from "@codemirror/lang-java";
|
||||
import { phpLanguage } from "@codemirror/lang-php";
|
||||
import { cssLanguage } from "@codemirror/lang-css";
|
||||
import { cppLanguage } from "@codemirror/lang-cpp";
|
||||
import { xmlLanguage } from "@codemirror/lang-xml";
|
||||
import { rustLanguage } from "@codemirror/lang-rust";
|
||||
import { yamlLanguage } from "@codemirror/lang-yaml";
|
||||
import {jsonLanguage} from "@codemirror/lang-json";
|
||||
import {pythonLanguage} from "@codemirror/lang-python";
|
||||
import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
|
||||
import {htmlLanguage} from "@codemirror/lang-html";
|
||||
import {StandardSQL} from "@codemirror/lang-sql";
|
||||
import {markdownLanguage} from "@codemirror/lang-markdown";
|
||||
import {javaLanguage} from "@codemirror/lang-java";
|
||||
import {phpLanguage} from "@codemirror/lang-php";
|
||||
import {cssLanguage} from "@codemirror/lang-css";
|
||||
import {cppLanguage} from "@codemirror/lang-cpp";
|
||||
import {xmlLanguage} from "@codemirror/lang-xml";
|
||||
import {rustLanguage} from "@codemirror/lang-rust";
|
||||
import {yamlLanguage} from "@codemirror/lang-yaml";
|
||||
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { go } from "@codemirror/legacy-modes/mode/go";
|
||||
import { csharp } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
|
||||
import { erlang } from "@codemirror/legacy-modes/mode/erlang";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { kotlin } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { groovy } from "@codemirror/legacy-modes/mode/groovy";
|
||||
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
|
||||
import { scala } from "@codemirror/legacy-modes/mode/clike";
|
||||
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||
import { elixir } from "codemirror-lang-elixir";
|
||||
import { SupportedLanguage } from '../types';
|
||||
import {StreamLanguage} from "@codemirror/language";
|
||||
import {ruby} from "@codemirror/legacy-modes/mode/ruby";
|
||||
import {shell} from "@codemirror/legacy-modes/mode/shell";
|
||||
import {go} from "@codemirror/legacy-modes/mode/go";
|
||||
import {csharp, kotlin, scala} from "@codemirror/legacy-modes/mode/clike";
|
||||
import {clojure} from "@codemirror/legacy-modes/mode/clojure";
|
||||
import {erlang} from "@codemirror/legacy-modes/mode/erlang";
|
||||
import {swift} from "@codemirror/legacy-modes/mode/swift";
|
||||
import {groovy} from "@codemirror/legacy-modes/mode/groovy";
|
||||
import {powerShell} from "@codemirror/legacy-modes/mode/powershell";
|
||||
import {toml} from "@codemirror/legacy-modes/mode/toml";
|
||||
import {elixir} from "codemirror-lang-elixir";
|
||||
import {SupportedLanguage} from '../types';
|
||||
|
||||
import typescriptPlugin from "prettier/plugins/typescript"
|
||||
import babelPrettierPlugin from "prettier/plugins/babel"
|
||||
@@ -38,96 +36,108 @@ import htmlPrettierPlugin from "prettier/plugins/html"
|
||||
import cssPrettierPlugin from "prettier/plugins/postcss"
|
||||
import markdownPrettierPlugin from "prettier/plugins/markdown"
|
||||
import yamlPrettierPlugin from "prettier/plugins/yaml"
|
||||
import goPrettierPlugin from "@/utils/prettier/plugins/go/go"
|
||||
import sqlPrettierPlugin from "@/utils/prettier/plugins/sql/sql"
|
||||
import phpPrettierPlugin from "@/utils/prettier/plugins/php"
|
||||
import * as prettierPluginEstree from "prettier/plugins/estree";
|
||||
|
||||
/**
|
||||
* 语言信息类
|
||||
*/
|
||||
export class LanguageInfo {
|
||||
constructor(
|
||||
public token: SupportedLanguage,
|
||||
public name: string,
|
||||
public parser: any,
|
||||
public prettier?: {
|
||||
parser: string;
|
||||
plugins: any[];
|
||||
constructor(
|
||||
public token: SupportedLanguage,
|
||||
public name: string,
|
||||
public parser: any,
|
||||
public prettier?: {
|
||||
parser: string;
|
||||
plugins: any[];
|
||||
}) {
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持的语言列表(与 Worker 中的 LANGUAGES 对应)
|
||||
*/
|
||||
export const LANGUAGES: LanguageInfo[] = [
|
||||
new LanguageInfo("text", "Plain Text", null),
|
||||
new LanguageInfo("json", "JSON", jsonLanguage.parser, {
|
||||
parser: "json",
|
||||
plugins: [babelPrettierPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("py", "Python", pythonLanguage.parser),
|
||||
new LanguageInfo("html", "HTML", htmlLanguage.parser, {
|
||||
parser: "html",
|
||||
plugins: [htmlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("sql", "SQL", StandardSQL.language.parser),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("java", "Java", javaLanguage.parser),
|
||||
new LanguageInfo("php", "PHP", phpLanguage.configure({top:"Program"}).parser),
|
||||
new LanguageInfo("css", "CSS", cssLanguage.parser, {
|
||||
parser: "css",
|
||||
plugins: [cssPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("xml", "XML", xmlLanguage.parser),
|
||||
new LanguageInfo("cpp", "C++", cppLanguage.parser),
|
||||
new LanguageInfo("rs", "Rust", rustLanguage.parser),
|
||||
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser),
|
||||
new LanguageInfo("rb", "Ruby", StreamLanguage.define(ruby).parser),
|
||||
new LanguageInfo("sh", "Shell", StreamLanguage.define(shell).parser),
|
||||
new LanguageInfo("yaml", "YAML", yamlLanguage.parser, {
|
||||
parser: "yaml",
|
||||
plugins: [yamlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("toml", "TOML", StreamLanguage.define(toml).parser),
|
||||
new LanguageInfo("go", "Go", StreamLanguage.define(go).parser),
|
||||
new LanguageInfo("clj", "Clojure", StreamLanguage.define(clojure).parser),
|
||||
new LanguageInfo("ex", "Elixir", elixir().language.parser),
|
||||
new LanguageInfo("erl", "Erlang", StreamLanguage.define(erlang).parser),
|
||||
new LanguageInfo("js", "JavaScript", javascriptLanguage.parser, {
|
||||
parser: "babel",
|
||||
plugins: [babelPrettierPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("ts", "TypeScript", typescriptLanguage.parser, {
|
||||
parser: "typescript",
|
||||
plugins: [typescriptPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("swift", "Swift", StreamLanguage.define(swift).parser),
|
||||
new LanguageInfo("kt", "Kotlin", StreamLanguage.define(kotlin).parser),
|
||||
new LanguageInfo("groovy", "Groovy", StreamLanguage.define(groovy).parser),
|
||||
new LanguageInfo("ps1", "PowerShell", StreamLanguage.define(powerShell).parser),
|
||||
new LanguageInfo("dart", "Dart", null), // 暂无解析器
|
||||
new LanguageInfo("scala", "Scala", StreamLanguage.define(scala).parser),
|
||||
new LanguageInfo("text", "Plain Text", null),
|
||||
new LanguageInfo("json", "JSON", jsonLanguage.parser, {
|
||||
parser: "json",
|
||||
plugins: [babelPrettierPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("py", "Python", pythonLanguage.parser),
|
||||
new LanguageInfo("html", "HTML", htmlLanguage.parser, {
|
||||
parser: "html",
|
||||
plugins: [htmlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("sql", "SQL", StandardSQL.language.parser, {
|
||||
parser: "sql",
|
||||
plugins: [sqlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("md", "Markdown", markdownLanguage.parser, {
|
||||
parser: "markdown",
|
||||
plugins: [markdownPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("java", "Java", javaLanguage.parser),
|
||||
new LanguageInfo("php", "PHP", phpLanguage.configure({top: "Program"}).parser, {
|
||||
parser: "php",
|
||||
plugins: [phpPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("css", "CSS", cssLanguage.parser, {
|
||||
parser: "css",
|
||||
plugins: [cssPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("xml", "XML", xmlLanguage.parser),
|
||||
new LanguageInfo("cpp", "C++", cppLanguage.parser),
|
||||
new LanguageInfo("rs", "Rust", rustLanguage.parser),
|
||||
new LanguageInfo("cs", "C#", StreamLanguage.define(csharp).parser),
|
||||
new LanguageInfo("rb", "Ruby", StreamLanguage.define(ruby).parser),
|
||||
new LanguageInfo("sh", "Shell", StreamLanguage.define(shell).parser),
|
||||
new LanguageInfo("yaml", "YAML", yamlLanguage.parser, {
|
||||
parser: "yaml",
|
||||
plugins: [yamlPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("toml", "TOML", StreamLanguage.define(toml).parser),
|
||||
new LanguageInfo("go", "Go", StreamLanguage.define(go).parser, {
|
||||
parser: "go",
|
||||
plugins: [goPrettierPlugin]
|
||||
}),
|
||||
new LanguageInfo("clj", "Clojure", StreamLanguage.define(clojure).parser),
|
||||
new LanguageInfo("ex", "Elixir", elixir().language.parser),
|
||||
new LanguageInfo("erl", "Erlang", StreamLanguage.define(erlang).parser),
|
||||
new LanguageInfo("js", "JavaScript", javascriptLanguage.parser, {
|
||||
parser: "babel",
|
||||
plugins: [babelPrettierPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("ts", "TypeScript", typescriptLanguage.parser, {
|
||||
parser: "typescript",
|
||||
plugins: [typescriptPlugin, prettierPluginEstree]
|
||||
}),
|
||||
new LanguageInfo("swift", "Swift", StreamLanguage.define(swift).parser),
|
||||
new LanguageInfo("kt", "Kotlin", StreamLanguage.define(kotlin).parser),
|
||||
new LanguageInfo("groovy", "Groovy", StreamLanguage.define(groovy).parser),
|
||||
new LanguageInfo("ps1", "PowerShell", StreamLanguage.define(powerShell).parser),
|
||||
new LanguageInfo("dart", "Dart", null), // 暂无解析器
|
||||
new LanguageInfo("scala", "Scala", StreamLanguage.define(scala).parser),
|
||||
];
|
||||
|
||||
/**
|
||||
* 语言映射表
|
||||
*/
|
||||
export const languageMapping = Object.fromEntries(
|
||||
LANGUAGES.map(l => [l.token, l.parser])
|
||||
LANGUAGES.map(l => [l.token, l.parser])
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据 token 获取语言信息
|
||||
*/
|
||||
export function getLanguage(token: SupportedLanguage): LanguageInfo | undefined {
|
||||
return LANGUAGES.find(lang => lang.token === token);
|
||||
return LANGUAGES.find(lang => lang.token === token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有语言的 token 列表
|
||||
*/
|
||||
export function getLanguageTokens(): SupportedLanguage[] {
|
||||
return LANGUAGES.map(lang => lang.token);
|
||||
return LANGUAGES.map(lang => lang.token);
|
||||
}
|
||||
@@ -19,6 +19,11 @@ const navItems = [
|
||||
{ id: 'updates', icon: '🔄', route: '/settings/updates' }
|
||||
];
|
||||
|
||||
// 仅在开发环境添加测试页面导航
|
||||
if (import.meta.env.DEV) {
|
||||
navItems.push({ id: 'test', icon: '🧪', route: '/settings/test' });
|
||||
}
|
||||
|
||||
const activeNavItem = ref(route.path.split('/').pop() || 'general');
|
||||
|
||||
// 处理导航点击
|
||||
@@ -56,7 +61,7 @@ const goBackToEditor = async () => {
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-text">{{ t(`settings.${item.id}`) }}</span>
|
||||
<span class="nav-text">{{ item.id === 'test' ? 'Test' : t(`settings.${item.id}`) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
|
||||
@@ -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')">
|
||||
|
||||
274
frontend/src/views/settings/pages/TestPage.vue
Normal file
274
frontend/src/views/settings/pages/TestPage.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<SettingSection title="Development Test Page">
|
||||
<div class="dev-description">
|
||||
This page is only available in development environment for testing notification and badge services.
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- Badge测试区域 -->
|
||||
<SettingSection title="Badge Service Test">
|
||||
<SettingItem title="Badge Text">
|
||||
<input
|
||||
v-model="badgeText"
|
||||
type="text"
|
||||
placeholder="Enter badge text (empty to remove)"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Actions">
|
||||
<div class="button-group">
|
||||
<button @click="testBadge" class="test-button primary">
|
||||
Set Badge
|
||||
</button>
|
||||
<button @click="clearBadge" class="test-button">
|
||||
Clear Badge
|
||||
</button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div v-if="badgeStatus" class="test-status" :class="badgeStatus.type">
|
||||
{{ badgeStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- 通知测试区域 -->
|
||||
<SettingSection title="Notification Service Test">
|
||||
<SettingItem title="Title">
|
||||
<input
|
||||
v-model="notificationTitle"
|
||||
type="text"
|
||||
placeholder="Notification title"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Subtitle">
|
||||
<input
|
||||
v-model="notificationSubtitle"
|
||||
type="text"
|
||||
placeholder="Notification subtitle"
|
||||
class="select-input"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem title="Body">
|
||||
<textarea
|
||||
v-model="notificationBody"
|
||||
placeholder="Notification body text"
|
||||
class="select-input textarea-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</SettingItem>
|
||||
<SettingItem title="Actions">
|
||||
<div class="button-group">
|
||||
<button @click="testNotification" class="test-button primary">
|
||||
Send Test Notification
|
||||
</button>
|
||||
<button @click="testUpdateNotification" class="test-button">
|
||||
Test Update Notification
|
||||
</button>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div v-if="notificationStatus" class="test-status" :class="notificationStatus.type">
|
||||
{{ notificationStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
<!-- 清除所有测试状态 -->
|
||||
<SettingSection title="Cleanup">
|
||||
<SettingItem title="Clear All">
|
||||
<button @click="clearAll" class="test-button danger">
|
||||
Clear All Test States
|
||||
</button>
|
||||
</SettingItem>
|
||||
<div v-if="clearStatus" class="test-status" :class="clearStatus.type">
|
||||
{{ clearStatus.message }}
|
||||
</div>
|
||||
</SettingSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import * as TestService from '@/../bindings/voidraft/internal/services/testservice'
|
||||
import SettingSection from '../components/SettingSection.vue'
|
||||
import SettingItem from '../components/SettingItem.vue'
|
||||
|
||||
// Badge测试状态
|
||||
const badgeText = ref('')
|
||||
const badgeStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 通知测试状态
|
||||
const notificationTitle = ref('')
|
||||
const notificationSubtitle = ref('')
|
||||
const notificationBody = ref('')
|
||||
const notificationStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 清除状态
|
||||
const clearStatus = ref<{ type: string; message: string } | null>(null)
|
||||
|
||||
// 显示状态消息的辅助函数
|
||||
const showStatus = (statusRef: any, type: 'success' | 'error', message: string) => {
|
||||
statusRef.value = { type, message }
|
||||
setTimeout(() => {
|
||||
statusRef.value = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 测试Badge功能
|
||||
const testBadge = async () => {
|
||||
try {
|
||||
await TestService.TestBadge(badgeText.value)
|
||||
showStatus(badgeStatus, 'success', `Badge ${badgeText.value ? 'set to: ' + badgeText.value : 'cleared'} successfully`)
|
||||
} catch (error: any) {
|
||||
showStatus(badgeStatus, 'error', `Failed to set badge: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除Badge
|
||||
const clearBadge = async () => {
|
||||
try {
|
||||
await TestService.TestBadge('')
|
||||
badgeText.value = ''
|
||||
showStatus(badgeStatus, 'success', 'Badge cleared successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(badgeStatus, 'error', `Failed to clear badge: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试通知功能
|
||||
const testNotification = async () => {
|
||||
try {
|
||||
await TestService.TestNotification(
|
||||
notificationTitle.value,
|
||||
notificationSubtitle.value,
|
||||
notificationBody.value
|
||||
)
|
||||
showStatus(notificationStatus, 'success', 'Notification sent successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(notificationStatus, 'error', `Failed to send notification: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试更新通知
|
||||
const testUpdateNotification = async () => {
|
||||
try {
|
||||
await TestService.TestUpdateNotification()
|
||||
showStatus(notificationStatus, 'success', 'Update notification sent successfully (badge + notification)')
|
||||
} catch (error: any) {
|
||||
showStatus(notificationStatus, 'error', `Failed to send update notification: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有测试状态
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
await TestService.ClearAll()
|
||||
// 清空表单
|
||||
badgeText.value = ''
|
||||
notificationTitle.value = ''
|
||||
notificationSubtitle.value = ''
|
||||
notificationBody.value = ''
|
||||
showStatus(clearStatus, 'success', 'All test states cleared successfully')
|
||||
} catch (error: any) {
|
||||
showStatus(clearStatus, 'error', `Failed to clear test states: ${error.message || error}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
padding: 20px 0 20px 0;
|
||||
}
|
||||
|
||||
.dev-description {
|
||||
color: var(--settings-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--settings-input-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-input-bg);
|
||||
color: var(--settings-text);
|
||||
font-size: 12px;
|
||||
width: 180px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
&.textarea-input {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--settings-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--settings-card-bg);
|
||||
color: var(--settings-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--settings-hover);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: #4a9eff;
|
||||
color: white;
|
||||
border-color: #4a9eff;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a8eef;
|
||||
border-color: #3a8eef;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--text-danger);
|
||||
color: white;
|
||||
border-color: var(--text-danger);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.test-status {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
|
||||
&.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@ import {defineConfig, loadEnv} from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import * as path from 'path';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
export default defineConfig(({mode}: { mode: string }): object => {
|
||||
const env: Record<string, string> = loadEnv(mode, process.cwd());
|
||||
@@ -15,6 +16,7 @@ export default defineConfig(({mode}: { mode: string }): object => {
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
nodePolyfills(),
|
||||
Components({
|
||||
dts: true,
|
||||
dirs: ['src/components'],
|
||||
|
||||
9
go.mod
9
go.mod
@@ -3,7 +3,6 @@ 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
|
||||
@@ -11,6 +10,7 @@ require (
|
||||
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/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
|
||||
@@ -21,13 +21,16 @@ require (
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // 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
|
||||
@@ -62,18 +65,20 @@ 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.41.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/image v0.24.0 // 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
|
||||
|
||||
8
go.sum
8
go.sum
@@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
|
||||
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
@@ -158,8 +160,8 @@ 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=
|
||||
@@ -178,6 +180,8 @@ 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/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
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=
|
||||
|
||||
@@ -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"` // 全局热键组合
|
||||
@@ -145,6 +149,7 @@ func NewDefaultAppConfig() *AppConfig {
|
||||
DataPath: dataDir,
|
||||
EnableSystemTray: true,
|
||||
StartAtLogin: false,
|
||||
EnableWindowSnap: true, // 默认启用窗口吸附
|
||||
EnableGlobalHotkey: false,
|
||||
GlobalHotkey: HotkeyCombo{
|
||||
Ctrl: false,
|
||||
@@ -172,7 +177,7 @@ func NewDefaultAppConfig() *AppConfig {
|
||||
SystemTheme: SystemThemeAuto,
|
||||
},
|
||||
Updates: UpdatesConfig{
|
||||
Version: "1.3.0",
|
||||
Version: version.Version,
|
||||
AutoUpdate: true,
|
||||
PrimarySource: UpdateSourceGitea,
|
||||
BackupSource: UpdateSourceGithub,
|
||||
@@ -201,7 +206,7 @@ func NewDefaultAppConfig() *AppConfig {
|
||||
},
|
||||
Metadata: ConfigMetadata{
|
||||
LastUpdated: time.Now().Format(time.RFC3339),
|
||||
Version: "1.2.0",
|
||||
Version: version.Version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
41
internal/models/window.go
Normal file
41
internal/models/window.go
Normal 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"` // 上次移动时间,用于判断移动速度
|
||||
}
|
||||
@@ -263,7 +263,7 @@ func (s *BackupService) PushToRemote() error {
|
||||
// 创建提交
|
||||
_, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: "VoidRaft",
|
||||
Name: "voidraft",
|
||||
Email: "backup@voidraft.app",
|
||||
When: time.Now(),
|
||||
},
|
||||
|
||||
@@ -1,318 +1,200 @@
|
||||
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.3.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.LogService
|
||||
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](
|
||||
// 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) {
|
||||
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("Found %d missing fields", len(missingFields)),
|
||||
}
|
||||
|
||||
// No migration needed
|
||||
if !result.Migrated {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 资源检查和备份
|
||||
if err := cms.checkResourceLimits(); err != nil {
|
||||
return result, fmt.Errorf("resource limit check failed: %w", err)
|
||||
}
|
||||
|
||||
if backupPath, err := cms.createBackupOptimized(); err != nil {
|
||||
// Create backup before migration
|
||||
backupPath, err := cm.createBackup()
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("backup creation failed: %w", err)
|
||||
} else {
|
||||
result.BackupPath = backupPath
|
||||
}
|
||||
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)
|
||||
// Save updated config
|
||||
if err := cm.saveConfig(currentConfig); err != nil {
|
||||
return result, fmt.Errorf("failed to save updated config: %w", err)
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
if configUpdated, err := cms.performOptimizedMigration(existingConfig); err != nil {
|
||||
return result, fmt.Errorf("migration failed: %w", err)
|
||||
} else {
|
||||
result.Migrated = true
|
||||
result.ConfigUpdated = configUpdated
|
||||
// Clean up backup on success
|
||||
if backupPath != "" {
|
||||
if err := os.Remove(backupPath); err != nil {
|
||||
cm.logger.Error("Failed to remove backup", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 missing []string
|
||||
cm.findMissing("", defaultConfig, current, &missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
// 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)
|
||||
// findMissing recursively finds missing fields
|
||||
func (cm *ConfigMigrator) findMissing(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 completely missing - add it
|
||||
*missing = append(*missing, fullKey)
|
||||
} else if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
|
||||
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
|
||||
// Both are maps, recurse into them
|
||||
cm.findMissing(fullKey, defaultNestedMap, currentNestedMap, missing)
|
||||
}
|
||||
// Type mismatch: user has different type, don't recurse
|
||||
}
|
||||
// For non-map default values, field exists, preserve user's value
|
||||
}
|
||||
}
|
||||
|
||||
// mergeDefaultFields merges default values for missing fields into current config
|
||||
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
|
||||
actuallyMerged := 0
|
||||
|
||||
for _, field := range missingFields {
|
||||
if defaultConfig.Exists(field) {
|
||||
if defaultValue := defaultConfig.Get(field); defaultValue != nil {
|
||||
// Always set the field, even if it causes type conflicts
|
||||
// This allows configuration structure evolution during upgrades
|
||||
current.Set(field, defaultValue)
|
||||
actuallyMerged++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamp if we actually merged fields
|
||||
if actuallyMerged > 0 {
|
||||
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("", ¤tConfig); 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)
|
||||
}
|
||||
|
||||
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.LogService, configDir, settingsPath string) *ConfigMigrationService[*models.AppConfig] {
|
||||
return NewConfigMigrationService[*models.AppConfig](
|
||||
logger, configDir, "settings", CurrentAppConfigVersion, settingsPath)
|
||||
}
|
||||
|
||||
1035
internal/services/config_migrator_test.go
Normal file
1035
internal/services/config_migrator_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ const (
|
||||
ConfigChangeTypeDataPath ConfigChangeType = "datapath"
|
||||
// ConfigChangeTypeBackup 备份配置变更
|
||||
ConfigChangeTypeBackup ConfigChangeType = "backup"
|
||||
// ConfigChangeTypeWindowSnap 窗口吸附配置变更
|
||||
ConfigChangeTypeWindowSnap ConfigChangeType = "windowsnap"
|
||||
)
|
||||
|
||||
// ConfigChangeCallback 配置变更回调函数类型
|
||||
@@ -286,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()
|
||||
@@ -470,6 +453,29 @@ func CreateBackupConfigListener(name string, callback func(config *models.GitBac
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -27,8 +27,9 @@ type ConfigService struct {
|
||||
|
||||
// 配置通知服务
|
||||
notificationService *ConfigNotificationService
|
||||
// 配置迁移服务
|
||||
migrationService *ConfigMigrationService[*models.AppConfig]
|
||||
|
||||
// 配置迁移器
|
||||
configMigrator *ConfigMigrator
|
||||
}
|
||||
|
||||
// ConfigError 配置错误
|
||||
@@ -67,16 +68,18 @@ func NewConfigService(logger *log.LogService) *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,33 @@ func (cs *ConfigService) initConfig() error {
|
||||
return cs.createDefaultConfig()
|
||||
}
|
||||
|
||||
// 配置文件存在,先加载现有配置
|
||||
// 配置文件存在,直接加载现有配置
|
||||
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)
|
||||
}
|
||||
// MigrateConfig 执行配置迁移
|
||||
func (cs *ConfigService) MigrateConfig() 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
|
||||
@@ -308,6 +321,16 @@ func (cs *ConfigService) SetBackupConfigChangeCallback(callback func(config *mod
|
||||
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()
|
||||
@@ -316,3 +339,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
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package services
|
||||
#cgo CFLAGS: -I../lib
|
||||
#cgo LDFLAGS: -luser32
|
||||
#include "../lib/hotkey_windows.c"
|
||||
#include "hotkey_windows.h"
|
||||
#include "../lib/hotkey_windows.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -61,14 +61,16 @@ func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
|
||||
// getCancelFunc 原子地获取cancel函数
|
||||
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
|
||||
if cancel := hs.cancelFunc.Load(); cancel != nil {
|
||||
return cancel.(context.CancelFunc)
|
||||
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
|
||||
return cancelFunc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearCancelFunc 原子地清除cancel函数
|
||||
func (hs *HotkeyService) clearCancelFunc() {
|
||||
hs.cancelFunc.Store((*context.CancelFunc)(nil))
|
||||
hs.cancelFunc.Store((context.CancelFunc)(nil))
|
||||
}
|
||||
|
||||
// NewHotkeyService 创建热键服务实例
|
||||
|
||||
@@ -66,6 +66,7 @@ int isHotkeyRegistered() {
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -115,14 +116,16 @@ func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
|
||||
// getCancelFunc 原子地获取cancel函数
|
||||
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
|
||||
if cancel := hs.cancelFunc.Load(); cancel != nil {
|
||||
return cancel.(context.CancelFunc)
|
||||
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
|
||||
return cancelFunc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearCancelFunc 原子地清除cancel函数
|
||||
func (hs *HotkeyService) clearCancelFunc() {
|
||||
hs.cancelFunc.Store((*context.CancelFunc)(nil))
|
||||
hs.cancelFunc.Store((context.CancelFunc)(nil))
|
||||
}
|
||||
|
||||
// NewHotkeyService 创建新的热键服务实例
|
||||
|
||||
@@ -179,14 +179,16 @@ func (hs *HotkeyService) setCancelFunc(cancel context.CancelFunc) {
|
||||
// getCancelFunc 原子地获取cancel函数
|
||||
func (hs *HotkeyService) getCancelFunc() context.CancelFunc {
|
||||
if cancel := hs.cancelFunc.Load(); cancel != nil {
|
||||
return cancel.(context.CancelFunc)
|
||||
if cancelFunc, ok := cancel.(context.CancelFunc); ok {
|
||||
return cancelFunc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearCancelFunc 原子地清除cancel函数
|
||||
func (hs *HotkeyService) clearCancelFunc() {
|
||||
hs.cancelFunc.Store((*context.CancelFunc)(nil))
|
||||
hs.cancelFunc.Store((context.CancelFunc)(nil))
|
||||
}
|
||||
|
||||
// NewHotkeyService 创建热键服务实例
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/creativeprojects/go-selfupdate"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
@@ -26,16 +28,18 @@ type SelfUpdateResult struct {
|
||||
|
||||
// SelfUpdateService 自我更新服务
|
||||
type SelfUpdateService struct {
|
||||
logger *log.LogService
|
||||
configService *ConfigService
|
||||
config *models.AppConfig
|
||||
logger *log.LogService
|
||||
configService *ConfigService
|
||||
badgeService *badge.BadgeService // 直接使用Wails原生badge服务
|
||||
notificationService *notifications.NotificationService // 通知服务
|
||||
config *models.AppConfig
|
||||
|
||||
// 状态管理
|
||||
isUpdating bool
|
||||
}
|
||||
|
||||
// NewSelfUpdateService 创建自我更新服务实例
|
||||
func NewSelfUpdateService(configService *ConfigService, logger *log.LogService) (*SelfUpdateService, error) {
|
||||
func NewSelfUpdateService(configService *ConfigService, badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) (*SelfUpdateService, error) {
|
||||
// 获取配置
|
||||
appConfig, err := configService.GetConfig()
|
||||
if err != nil {
|
||||
@@ -43,10 +47,12 @@ func NewSelfUpdateService(configService *ConfigService, logger *log.LogService)
|
||||
}
|
||||
|
||||
service := &SelfUpdateService{
|
||||
logger: logger,
|
||||
configService: configService,
|
||||
config: appConfig,
|
||||
isUpdating: false,
|
||||
logger: logger,
|
||||
configService: configService,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
config: appConfig,
|
||||
isUpdating: false,
|
||||
}
|
||||
|
||||
return service, nil
|
||||
@@ -63,6 +69,7 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
|
||||
// 首先尝试主要更新源
|
||||
primaryResult, err := s.checkSourceForUpdates(ctx, s.config.Updates.PrimarySource)
|
||||
if err == nil && primaryResult != nil {
|
||||
s.handleUpdateBadge(primaryResult)
|
||||
return primaryResult, nil
|
||||
}
|
||||
|
||||
@@ -71,9 +78,12 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
|
||||
if backupErr != nil {
|
||||
// 如果备用源也失败,返回主要源的错误信息
|
||||
result.Error = fmt.Sprintf("Primary source error: %v; Backup source error: %v", err, backupErr)
|
||||
// 确保在检查失败时也调用handleUpdateBadge来清除可能存在的badge
|
||||
s.handleUpdateBadge(result)
|
||||
return result, errors.New(result.Error)
|
||||
}
|
||||
|
||||
s.handleUpdateBadge(backupResult)
|
||||
return backupResult, nil
|
||||
}
|
||||
|
||||
@@ -251,7 +261,6 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
|
||||
|
||||
// 检查是否有可用更新
|
||||
if !primaryRelease.GreaterThan(s.config.Updates.Version) {
|
||||
s.logger.Info("Current version is up to date, no need to apply update")
|
||||
result.LatestVersion = primaryRelease.Version()
|
||||
return result, nil
|
||||
}
|
||||
@@ -310,6 +319,18 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
|
||||
s.logger.Error("Failed to update config version", "error", err)
|
||||
}
|
||||
|
||||
// 执行配置迁移
|
||||
if err := s.configService.MigrateConfig(); err != nil {
|
||||
s.logger.Error("Failed to migrate config after update", "error", err)
|
||||
}
|
||||
|
||||
// 更新成功,移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge after successful update", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -365,7 +386,6 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
|
||||
}
|
||||
|
||||
// 尝试下载并应用更新,不设置超时
|
||||
s.logger.Info("Downloading update...", "source", sourceType)
|
||||
err = updater.UpdateTo(ctx, release, exe)
|
||||
|
||||
if err != nil {
|
||||
@@ -392,6 +412,13 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
|
||||
s.logger.Error("Failed to update config version", "error", err)
|
||||
}
|
||||
|
||||
// 更新成功,移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge after successful update", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -462,3 +489,63 @@ func (s *SelfUpdateService) cleanupBackup(backupPath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUpdateBadge 处理更新通知badge和通知
|
||||
func (s *SelfUpdateService) handleUpdateBadge(result *SelfUpdateResult) {
|
||||
if result != nil && result.HasUpdate {
|
||||
// 有更新时显示更新badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.SetBadge("●"); err != nil {
|
||||
s.logger.Error("failed to set update badge", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送简单通知
|
||||
s.sendUpdateNotification(result)
|
||||
} else {
|
||||
// 没有更新或出错时移除badge
|
||||
if s.badgeService != nil {
|
||||
if err := s.badgeService.RemoveBadge(); err != nil {
|
||||
s.logger.Error("failed to remove update badge", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendUpdateNotification 发送更新通知
|
||||
func (s *SelfUpdateService) sendUpdateNotification(result *SelfUpdateResult) {
|
||||
if s.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查通知授权(macOS需要)
|
||||
authorized, err := s.notificationService.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to check notification authorization", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
authorized, err = s.notificationService.RequestNotificationAuthorization()
|
||||
if err != nil || !authorized {
|
||||
s.logger.Error("Failed to get notification authorization", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 构建简单通知内容
|
||||
title := "Voidraft Update Available"
|
||||
body := fmt.Sprintf("New version %s available (current: %s)", result.LatestVersion, result.CurrentVersion)
|
||||
|
||||
// 发送简单通知
|
||||
err = s.notificationService.SendNotification(notifications.NotificationOptions{
|
||||
ID: "update_available",
|
||||
Title: title,
|
||||
Subtitle: "New version available",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to send notification", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,34 @@ import (
|
||||
"voidraft/internal/models"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
// ServiceManager 服务管理器,负责协调各个服务
|
||||
type ServiceManager struct {
|
||||
configService *ConfigService
|
||||
databaseService *DatabaseService
|
||||
documentService *DocumentService
|
||||
windowService *WindowService
|
||||
migrationService *MigrationService
|
||||
systemService *SystemService
|
||||
hotkeyService *HotkeyService
|
||||
dialogService *DialogService
|
||||
trayService *TrayService
|
||||
keyBindingService *KeyBindingService
|
||||
extensionService *ExtensionService
|
||||
startupService *StartupService
|
||||
selfUpdateService *SelfUpdateService
|
||||
translationService *TranslationService
|
||||
themeService *ThemeService
|
||||
BackupService *BackupService
|
||||
logger *log.LogService
|
||||
configService *ConfigService
|
||||
databaseService *DatabaseService
|
||||
documentService *DocumentService
|
||||
windowService *WindowService
|
||||
windowSnapService *WindowSnapService
|
||||
migrationService *MigrationService
|
||||
systemService *SystemService
|
||||
hotkeyService *HotkeyService
|
||||
dialogService *DialogService
|
||||
trayService *TrayService
|
||||
keyBindingService *KeyBindingService
|
||||
extensionService *ExtensionService
|
||||
startupService *StartupService
|
||||
selfUpdateService *SelfUpdateService
|
||||
translationService *TranslationService
|
||||
themeService *ThemeService
|
||||
badgeService *badge.BadgeService
|
||||
notificationService *notifications.NotificationService
|
||||
testService *TestService // 测试服务(仅开发环境)
|
||||
BackupService *BackupService
|
||||
logger *log.LogService
|
||||
}
|
||||
|
||||
// NewServiceManager 创建新的服务管理器实例
|
||||
@@ -33,6 +39,12 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化日志服务
|
||||
logger := log.New()
|
||||
|
||||
// 初始化badge服务
|
||||
badgeService := badge.New()
|
||||
|
||||
// 初始化通知服务
|
||||
notificationService := notifications.New()
|
||||
|
||||
// 初始化配置服务
|
||||
configService := NewConfigService(logger)
|
||||
|
||||
@@ -48,6 +60,12 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化窗口服务
|
||||
windowService := NewWindowService(logger, documentService)
|
||||
|
||||
// 初始化窗口吸附服务
|
||||
windowSnapService := NewWindowSnapService(logger, configService)
|
||||
|
||||
// 将吸附服务与窗口服务关联
|
||||
windowService.SetWindowSnapService(windowSnapService)
|
||||
|
||||
// 初始化系统服务
|
||||
systemService := NewSystemService(logger)
|
||||
|
||||
@@ -70,7 +88,7 @@ func NewServiceManager() *ServiceManager {
|
||||
startupService := NewStartupService(configService, logger)
|
||||
|
||||
// 初始化自我更新服务
|
||||
selfUpdateService, err := NewSelfUpdateService(configService, logger)
|
||||
selfUpdateService, err := NewSelfUpdateService(configService, badgeService, notificationService, logger)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -84,6 +102,9 @@ func NewServiceManager() *ServiceManager {
|
||||
// 初始化备份服务
|
||||
backupService := NewBackupService(configService, databaseService, logger)
|
||||
|
||||
// 初始化测试服务(开发环境使用)
|
||||
testService := NewTestService(badgeService, notificationService, logger)
|
||||
|
||||
// 使用新的配置通知系统设置热键配置变更监听
|
||||
err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
|
||||
return hotkeyService.UpdateHotkey(enable, hotkey)
|
||||
@@ -108,24 +129,36 @@ func NewServiceManager() *ServiceManager {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 设置窗口吸附配置变更回调
|
||||
err = configService.SetWindowSnapConfigChangeCallback(func(enabled bool) error {
|
||||
return windowSnapService.OnWindowSnapConfigChanged(enabled)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &ServiceManager{
|
||||
configService: configService,
|
||||
databaseService: databaseService,
|
||||
documentService: documentService,
|
||||
windowService: windowService,
|
||||
migrationService: migrationService,
|
||||
systemService: systemService,
|
||||
hotkeyService: hotkeyService,
|
||||
dialogService: dialogService,
|
||||
trayService: trayService,
|
||||
keyBindingService: keyBindingService,
|
||||
extensionService: extensionService,
|
||||
startupService: startupService,
|
||||
selfUpdateService: selfUpdateService,
|
||||
translationService: translationService,
|
||||
themeService: themeService,
|
||||
BackupService: backupService,
|
||||
logger: logger,
|
||||
configService: configService,
|
||||
databaseService: databaseService,
|
||||
documentService: documentService,
|
||||
windowService: windowService,
|
||||
windowSnapService: windowSnapService,
|
||||
migrationService: migrationService,
|
||||
systemService: systemService,
|
||||
hotkeyService: hotkeyService,
|
||||
dialogService: dialogService,
|
||||
trayService: trayService,
|
||||
keyBindingService: keyBindingService,
|
||||
extensionService: extensionService,
|
||||
startupService: startupService,
|
||||
selfUpdateService: selfUpdateService,
|
||||
translationService: translationService,
|
||||
themeService: themeService,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
testService: testService,
|
||||
BackupService: backupService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +169,7 @@ func (sm *ServiceManager) GetServices() []application.Service {
|
||||
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),
|
||||
@@ -147,6 +181,9 @@ func (sm *ServiceManager) GetServices() []application.Service {
|
||||
application.NewService(sm.selfUpdateService),
|
||||
application.NewService(sm.translationService),
|
||||
application.NewService(sm.themeService),
|
||||
application.NewService(sm.badgeService),
|
||||
application.NewService(sm.notificationService),
|
||||
application.NewService(sm.testService), // 注册测试服务
|
||||
application.NewService(sm.BackupService),
|
||||
}
|
||||
return services
|
||||
@@ -221,3 +258,18 @@ func (sm *ServiceManager) GetDocumentService() *DocumentService {
|
||||
func (sm *ServiceManager) GetThemeService() *ThemeService {
|
||||
return sm.themeService
|
||||
}
|
||||
|
||||
// GetWindowSnapService 获取窗口吸附服务实例
|
||||
func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService {
|
||||
return sm.windowSnapService
|
||||
}
|
||||
|
||||
// GetBadgeService 获取badge服务实例
|
||||
func (sm *ServiceManager) GetBadgeService() *badge.BadgeService {
|
||||
return sm.badgeService
|
||||
}
|
||||
|
||||
// GetNotificationService 获取通知服务实例
|
||||
func (sm *ServiceManager) GetNotificationService() *notifications.NotificationService {
|
||||
return sm.notificationService
|
||||
}
|
||||
|
||||
139
internal/services/test_service.go
Normal file
139
internal/services/test_service.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/badge"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
)
|
||||
|
||||
// TestService 测试服务 - 仅在开发环境使用
|
||||
type TestService struct {
|
||||
logger *log.LogService
|
||||
badgeService *badge.BadgeService
|
||||
notificationService *notifications.NotificationService
|
||||
}
|
||||
|
||||
// NewTestService 创建测试服务实例
|
||||
func NewTestService(badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) *TestService {
|
||||
if logger == nil {
|
||||
logger = log.New()
|
||||
}
|
||||
|
||||
return &TestService{
|
||||
logger: logger,
|
||||
badgeService: badgeService,
|
||||
notificationService: notificationService,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup 服务启动时调用
|
||||
func (ts *TestService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBadge 测试Badge功能
|
||||
func (ts *TestService) TestBadge(text string) error {
|
||||
if ts.badgeService == nil {
|
||||
return fmt.Errorf("badge service not available")
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
// 如果文本为空,则移除badge
|
||||
err := ts.badgeService.RemoveBadge()
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to remove badge", "error", err)
|
||||
return err
|
||||
}
|
||||
ts.logger.Info("Badge removed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置badge
|
||||
err := ts.badgeService.SetBadge(text)
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to set badge", "text", text, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ts.logger.Info("Badge set successfully", "text", text)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestNotification 测试通知功能
|
||||
func (ts *TestService) TestNotification(title, subtitle, body string) error {
|
||||
if ts.notificationService == nil {
|
||||
return fmt.Errorf("notification service not available")
|
||||
}
|
||||
|
||||
// 检查通知授权(macOS需要)
|
||||
authorized, err := ts.notificationService.CheckNotificationAuthorization()
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to check notification authorization", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
authorized, err = ts.notificationService.RequestNotificationAuthorization()
|
||||
if err != nil || !authorized {
|
||||
ts.logger.Error("Failed to get notification authorization", "error", err)
|
||||
return fmt.Errorf("notification authorization denied")
|
||||
}
|
||||
}
|
||||
|
||||
// 使用默认值如果参数为空
|
||||
if title == "" {
|
||||
title = "Test Notification"
|
||||
}
|
||||
if subtitle == "" {
|
||||
subtitle = "Testing notification system"
|
||||
}
|
||||
if body == "" {
|
||||
body = "This is a test notification to verify the system is working correctly."
|
||||
}
|
||||
|
||||
// 发送测试通知
|
||||
err = ts.notificationService.SendNotification(notifications.NotificationOptions{
|
||||
ID: "test_notification",
|
||||
Title: title,
|
||||
Subtitle: subtitle,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ts.logger.Error("Failed to send test notification", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ts.logger.Info("Test notification sent successfully", "title", title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestUpdateNotification 测试更新通知
|
||||
func (ts *TestService) TestUpdateNotification() error {
|
||||
// 设置badge
|
||||
if err := ts.TestBadge("●"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送更新通知
|
||||
return ts.TestNotification(
|
||||
"Voidraft Update Available",
|
||||
"New version available",
|
||||
"New version 1.2.3 available (current: 1.2.0)",
|
||||
)
|
||||
}
|
||||
|
||||
// ClearAll 清除所有测试状态
|
||||
func (ts *TestService) ClearAll() error {
|
||||
// 移除badge
|
||||
if err := ts.TestBadge(""); err != nil {
|
||||
ts.logger.Error("Failed to clear badge during cleanup", "error", err)
|
||||
}
|
||||
|
||||
ts.logger.Info("Test states cleared successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -9,14 +9,14 @@ 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.LogService
|
||||
documentService *DocumentService
|
||||
@@ -24,6 +24,9 @@ type WindowService struct {
|
||||
mainWindow *application.WebviewWindow
|
||||
windows map[int64]*WindowInfo // documentID -> WindowInfo
|
||||
mu sync.RWMutex
|
||||
|
||||
// 吸附服务引用
|
||||
windowSnapService *WindowSnapService
|
||||
}
|
||||
|
||||
// NewWindowService 创建新的窗口服务实例
|
||||
@@ -39,10 +42,20 @@ func NewWindowService(logger *log.LogService, documentService *DocumentService)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
487
internal/services/window_snap_service.go
Normal file
487
internal/services/window_snap_service.go
Normal 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
|
||||
}
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
func SetupSystemTray(app *application.App, mainWindow *application.WebviewWindow, assets embed.FS, trayService *services.TrayService) {
|
||||
// 创建系统托盘
|
||||
systray := app.SystemTray.New()
|
||||
|
||||
// 设置提示
|
||||
systray.SetTooltip("voidraft")
|
||||
// 设置标签
|
||||
systray.SetLabel("voidraft")
|
||||
// 设置图标
|
||||
iconBytes, _ := assets.ReadFile("appicon.png")
|
||||
systray.SetIcon(iconBytes)
|
||||
|
||||
// 设置标签
|
||||
systray.SetLabel("VoidRaft")
|
||||
|
||||
// 创建托盘菜单
|
||||
menu := app.NewMenu()
|
||||
|
||||
|
||||
4
internal/version/version.go
Normal file
4
internal/version/version.go
Normal file
@@ -0,0 +1,4 @@
|
||||
package version
|
||||
|
||||
// Version 版本注入 Ldflags
|
||||
var Version = "0.0.0"
|
||||
86
scripts/version.bat
Normal file
86
scripts/version.bat
Normal file
@@ -0,0 +1,86 @@
|
||||
@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 Sync remote tags
|
||||
echo [INFO] Syncing remote tags...
|
||||
echo [INFO] Deleting local tags...
|
||||
for /f "delims=" %%i in ('git tag -l') do git tag -d %%i >nul 2>&1
|
||||
|
||||
echo [INFO] Fetching remote tags...
|
||||
git fetch origin --prune >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [WARNING] Failed to fetch from remote, using local tags
|
||||
) else (
|
||||
echo [INFO] Remote tags synced successfully
|
||||
)
|
||||
|
||||
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%
|
||||
62
scripts/version.sh
Normal file
62
scripts/version.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/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
|
||||
# 同步远程标签
|
||||
echo "[INFO] Syncing remote tags..."
|
||||
echo "[INFO] Deleting local tags..."
|
||||
git tag -l | xargs git tag -d &> /dev/null
|
||||
|
||||
echo "[INFO] Fetching remote tags..."
|
||||
if git fetch origin --prune &> /dev/null; then
|
||||
echo "[INFO] Remote tags synced successfully"
|
||||
else
|
||||
echo "[WARNING] Failed to fetch from remote, using local tags"
|
||||
fi
|
||||
|
||||
# 获取最新的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
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
VERSION=1.3.7
|
||||
Reference in New Issue
Block a user