Compare commits
449 Commits
release/0.
...
v0.8.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
836b8258f7 | ||
|
|
caf450d4f0 | ||
|
|
e11706f9a9 | ||
|
|
e30c2d8c62 | ||
|
|
c19fcdd183 | ||
|
|
75749d2465 | ||
|
|
0ea807c3cb | ||
|
|
0fb9857378 | ||
|
|
c067ad8a62 | ||
|
|
8ca2c23d18 | ||
|
|
bf060f0082 | ||
|
|
93bb164004 | ||
|
|
d4d3ac1f34 | ||
|
|
d774aa741e | ||
|
|
82af1e365e | ||
|
|
ece61d097f | ||
|
|
b272c2e39c | ||
|
|
23d95f2771 | ||
|
|
78880cfce5 | ||
|
|
89b65cfb2d | ||
|
|
5424f22677 | ||
|
|
92dd0cc650 | ||
|
|
1fe1e51791 | ||
|
|
62b7ea9aa9 | ||
|
|
375b472143 | ||
|
|
c6ca44aa44 | ||
|
|
d864a83527 | ||
|
|
ea7c05f28f | ||
|
|
f9b685f5e7 | ||
|
|
2015676ece | ||
|
|
813e9cde2c | ||
|
|
cd55228e1e | ||
|
|
074f695e15 | ||
|
|
f64c4a4b98 | ||
|
|
7c7ed0ca81 | ||
|
|
26e980917f | ||
|
|
76e74a02fd | ||
|
|
c0b1b0873e | ||
|
|
419480800c | ||
|
|
7916538a19 | ||
|
|
fda0733f11 | ||
|
|
62a045b39d | ||
|
|
5c3535c85d | ||
|
|
d7b60891c5 | ||
|
|
3c8692b133 | ||
|
|
446707698f | ||
|
|
49e566e01e | ||
|
|
37e196d122 | ||
|
|
af2922a937 | ||
|
|
8c4014c931 | ||
|
|
60d172254d | ||
|
|
2597d087ad | ||
|
|
b75fd37b05 | ||
|
|
0f474e4918 | ||
|
|
118da179f6 | ||
|
|
b2819db495 | ||
|
|
db74e01447 | ||
|
|
049ec7bdf1 | ||
|
|
21e0921b3b | ||
|
|
19d962d9ba | ||
|
|
89e35b087a | ||
|
|
258f5bdd64 | ||
|
|
02eec5aee3 | ||
|
|
63ffa12958 | ||
|
|
cb5f6a668f | ||
|
|
ae0eb03c21 | ||
|
|
54c06ed113 | ||
|
|
ea9ccb3648 | ||
|
|
c4f24a6245 | ||
|
|
453bed439d | ||
|
|
2428fe6cc5 | ||
|
|
94ad5ae2f6 | ||
|
|
82c62fd55c | ||
|
|
3e9335d6ad | ||
|
|
b6cfeccfe4 | ||
|
|
0fb986868b | ||
|
|
e404fa3ac0 | ||
|
|
7913811549 | ||
|
|
af49d15563 | ||
|
|
6e58240c38 | ||
|
|
3aea725f7b | ||
|
|
fd093b3325 | ||
|
|
0136b87311 | ||
|
|
aeecffb517 | ||
|
|
5f5b28ced3 | ||
|
|
15fba1d89f | ||
|
|
f08bcb5e10 | ||
|
|
7017db418b | ||
|
|
6cd2835066 | ||
|
|
07a4fe1fc9 | ||
|
|
1ee8f7f980 | ||
|
|
d0a6a97f71 | ||
|
|
e24942083a | ||
|
|
e7857bb2e4 | ||
|
|
8f52cbc319 | ||
|
|
ca8be18531 | ||
|
|
54f18cdd2f | ||
|
|
0ca8b1960c | ||
|
|
3f4b247faa | ||
|
|
a86f5c6078 | ||
|
|
38a18b9b8e | ||
|
|
f2d622b53f | ||
|
|
fa87c2ee9f | ||
|
|
43b33ca42a | ||
|
|
fa9d21be8b | ||
|
|
d3dee295d6 | ||
|
|
35427d08a8 | ||
|
|
193d68398b | ||
|
|
d14ac65db9 | ||
|
|
c90dcce859 | ||
|
|
dfa6836580 | ||
|
|
8f4bd9a3a6 | ||
|
|
0080cb33ac | ||
|
|
2400823ca4 | ||
|
|
695fad283c | ||
|
|
6de7807f01 | ||
|
|
3de7e1bb40 | ||
|
|
ac4b520295 | ||
|
|
823fd54ef0 | ||
|
|
5413089db0 | ||
|
|
4df1dac2c2 | ||
|
|
5d91deaade | ||
|
|
b781a1197e | ||
|
|
176b100274 | ||
|
|
d87e314880 | ||
|
|
9a693ec4b1 | ||
|
|
1bf8271562 | ||
|
|
8a5209c084 | ||
|
|
89c25e3019 | ||
|
|
c8e7495f50 | ||
|
|
1b4e47d709 | ||
|
|
6548f939a2 | ||
|
|
1458b57c0d | ||
|
|
1c1adb8331 | ||
|
|
6f65f9e7cc | ||
|
|
5b8e7b538c | ||
|
|
a0bb19b8bb | ||
|
|
3fa4f8e900 | ||
|
|
8a1c1842ee | ||
|
|
7886cdc613 | ||
|
|
454b6e0236 | ||
|
|
c1e07dddaa | ||
|
|
1b3137d947 | ||
|
|
72423e80a8 | ||
|
|
5fd12d5dd8 | ||
|
|
c9ddd8919e | ||
|
|
b22a0894b8 | ||
|
|
e17e21b105 | ||
|
|
57172f1296 | ||
|
|
2d5f246486 | ||
|
|
0077c6dba7 | ||
|
|
1423ac0902 | ||
|
|
6958299ec2 | ||
|
|
6329345e73 | ||
|
|
ef0845a9c5 | ||
|
|
509962a773 | ||
|
|
b9e890379e | ||
|
|
933e601816 | ||
|
|
b23e768973 | ||
|
|
4bee3cefa6 | ||
|
|
1ae2b74279 | ||
|
|
00473e7ac0 | ||
|
|
7bdce19d01 | ||
|
|
431f740476 | ||
|
|
6e2e3afc85 | ||
|
|
4d90ed776f | ||
|
|
7c8e8d8dd3 | ||
|
|
0b33b3a683 | ||
|
|
ef5e344c4f | ||
|
|
034e968888 | ||
|
|
5c80f6e9a2 | ||
|
|
cf979f436c | ||
|
|
328c9dbb28 | ||
|
|
9870959ca6 | ||
|
|
1ac9103735 | ||
|
|
69be54c1cf | ||
|
|
689467ec91 | ||
|
|
be09808d1f | ||
|
|
18f40753dd | ||
|
|
b23c1ba932 | ||
|
|
e1fb5b12d4 | ||
|
|
06bfbc4a84 | ||
|
|
8d186fc066 | ||
|
|
0a9aec5929 | ||
|
|
155ba0f1f7 | ||
|
|
52592ffd6c | ||
|
|
92aedde121 | ||
|
|
b2df94a3ba | ||
|
|
19572a3cc9 | ||
|
|
8d7e7a6ddf | ||
|
|
af9f490172 | ||
|
|
099443fddc | ||
|
|
947277bdec | ||
|
|
b1bd4bb5ec | ||
|
|
d210b3f39c | ||
|
|
901b436107 | ||
|
|
097826508a | ||
|
|
cdfbdd5dd5 | ||
|
|
d31bb978f2 | ||
|
|
d067e2c00a | ||
|
|
3beab030f4 | ||
|
|
566339f09f | ||
|
|
dec9b8317c | ||
|
|
c77d338ab1 | ||
|
|
bc3f76dd73 | ||
|
|
4fcd7ff61f | ||
|
|
f930ffe153 | ||
|
|
26c22379ae | ||
|
|
4c9bff5e8c | ||
|
|
8808a5aa77 | ||
|
|
6fb6674803 | ||
|
|
c1cf19ef67 | ||
|
|
de868a2763 | ||
|
|
5fc33102a9 | ||
|
|
f74ad02b9d | ||
|
|
dca1e8a251 | ||
|
|
22fab86010 | ||
|
|
da895ea502 | ||
|
|
b94168957e | ||
|
|
c792a30932 | ||
|
|
dd01d1c3f9 | ||
|
|
23c895e839 | ||
|
|
7a5960e737 | ||
|
|
5c6110a57e | ||
|
|
17843ffd71 | ||
|
|
7296ab0425 | ||
|
|
47664023ee | ||
|
|
33918c6837 | ||
|
|
66a6052f88 | ||
|
|
31b5b838a2 | ||
|
|
575a3d88b9 | ||
|
|
bef811ce13 | ||
|
|
dee5620fc9 | ||
|
|
b1174861d4 | ||
|
|
a1f296ee08 | ||
|
|
3e34795ef5 | ||
|
|
f73d8dcb97 | ||
|
|
82ad393a05 | ||
|
|
257406120d | ||
|
|
9ba3216fa8 | ||
|
|
fca3f0cffb | ||
|
|
5712b39cf9 | ||
|
|
07dc1436af | ||
|
|
9e52397732 | ||
|
|
20ff4f3687 | ||
|
|
73ce3039ce | ||
|
|
c27a038fc5 | ||
|
|
c3c4efc6ec | ||
|
|
61296bc855 | ||
|
|
78da0076cf | ||
|
|
279b38fec3 | ||
|
|
0e348fcca4 | ||
|
|
7d79fae84d | ||
|
|
f642e0325d | ||
|
|
43a408f1c9 | ||
|
|
b97b5cf6e2 | ||
|
|
6c269a99cf | ||
|
|
24841f2dd9 | ||
|
|
c2e60c1e52 | ||
|
|
7a87d43bdc | ||
|
|
5fa1c90674 | ||
|
|
c79225ef27 | ||
|
|
0bd8fdea1f | ||
|
|
18dbd043ae | ||
|
|
a7535dec2b | ||
|
|
dbd4b7f763 | ||
|
|
68eacbbcf7 | ||
|
|
cecefce84d | ||
|
|
2406c1ff71 | ||
|
|
a4a0ac25cf | ||
|
|
fbb23f0b9a | ||
|
|
016e2bc70e | ||
|
|
070c2328e0 | ||
|
|
c117da42e4 | ||
|
|
950856d86f | ||
|
|
93a3240862 | ||
|
|
1116a3736b | ||
|
|
19af57e46b | ||
|
|
401c33c505 | ||
|
|
8c968c1a87 | ||
|
|
4fee58999d | ||
|
|
bd0752df96 | ||
|
|
47a15a6674 | ||
|
|
2a64bad76e | ||
|
|
7ee8698165 | ||
|
|
9ae35e5fec | ||
|
|
b618927454 | ||
|
|
e8ad34d2f0 | ||
|
|
007fdc9a5d | ||
|
|
61b62fe5af | ||
|
|
d574e6b396 | ||
|
|
f798b183e6 | ||
|
|
e8ef4b824a | ||
|
|
d87f8bf36a | ||
|
|
50b4169148 | ||
|
|
4a63a175e4 | ||
|
|
2508b7935a | ||
|
|
22697baa37 | ||
|
|
2fc587fd31 | ||
|
|
2a3bd5bd97 | ||
|
|
ac68729734 | ||
|
|
19c10f2369 | ||
|
|
8af8dd1ae0 | ||
|
|
258fccd56f | ||
|
|
93dc696e90 | ||
|
|
00d70d2934 | ||
|
|
2f39767211 | ||
|
|
5beb4c3ce4 | ||
|
|
8280f342a8 | ||
|
|
e5baf9ead6 | ||
|
|
06c4222aff | ||
|
|
f57f9913d5 | ||
|
|
579891a53a | ||
|
|
061f78b897 | ||
|
|
6671915cf7 | ||
|
|
0cfcfa77d3 | ||
|
|
9e6a56a77b | ||
|
|
714c6fc553 | ||
|
|
d7e15f5eb0 | ||
|
|
b388b08226 | ||
|
|
3bf42a169c | ||
|
|
29ee21e9d7 | ||
|
|
5eafd2aa3c | ||
|
|
b0f077ffbb | ||
|
|
0fe3060cf1 | ||
|
|
c2eaf27c20 | ||
|
|
275d490ca7 | ||
|
|
0707d60f0b | ||
|
|
9b5e785d67 | ||
|
|
69d9f41085 | ||
|
|
ffb4d16950 | ||
|
|
68d4081fcf | ||
|
|
92eb4266da | ||
|
|
b220dd051f | ||
|
|
e466e87fcc | ||
|
|
0428a30071 | ||
|
|
faaf7169da | ||
|
|
04a61a50dc | ||
|
|
749e543e37 | ||
|
|
d94335dc62 | ||
|
|
e135956eba | ||
|
|
66b14766a3 | ||
|
|
923e789211 | ||
|
|
52eacdd9df | ||
|
|
a9bae6bb3f | ||
|
|
b0d8ccbf23 | ||
|
|
4b18075314 | ||
|
|
784d7d60e2 | ||
|
|
ece1119c31 | ||
|
|
b9ffcd2959 | ||
|
|
d900bda756 | ||
|
|
8ac435dc6d | ||
|
|
99942e37d6 | ||
|
|
d2a535e6ff | ||
|
|
61c2e524b4 | ||
|
|
b434247838 | ||
|
|
b3f69ac401 | ||
|
|
93006d8423 | ||
|
|
a7fdab5bb2 | ||
|
|
bedd1eceb5 | ||
|
|
dac9e1f571 | ||
|
|
aed87665f4 | ||
|
|
04bbab3d7e | ||
|
|
4798d3e8ec | ||
|
|
c940e930bc | ||
|
|
8280306f82 | ||
|
|
59e12b0867 | ||
|
|
ded531a3c2 | ||
|
|
3a471bc1aa | ||
|
|
ea4f88a20d | ||
|
|
8e857b9aee | ||
|
|
f55a332ead | ||
|
|
893a0f70fb | ||
|
|
fdb05fb8d3 | ||
|
|
730d4bb1ab | ||
|
|
fee703e879 | ||
|
|
4f2f7003c8 | ||
|
|
97540206b3 | ||
|
|
793e078676 | ||
|
|
7453600ee5 | ||
|
|
6c53ef4eff | ||
|
|
60eb696859 | ||
|
|
e456925c23 | ||
|
|
038ecc8b70 | ||
|
|
3bfcd15dc0 | ||
|
|
6abdb8684f | ||
|
|
080ae0986a | ||
|
|
bfb61c8449 | ||
|
|
23d7511f55 | ||
|
|
2d73bcc6de | ||
|
|
a24f4a2bc1 | ||
|
|
bf51003c66 | ||
|
|
2df9dce78b | ||
|
|
3ed45fab41 | ||
|
|
7c9cf95698 | ||
|
|
af2c358822 | ||
|
|
1226e544ec | ||
|
|
faa7ed1ae2 | ||
|
|
22cdeb677b | ||
|
|
b210d078d0 | ||
|
|
ce30de20b6 | ||
|
|
8dde4c3c6d | ||
|
|
bdb60a656a | ||
|
|
8a56367d02 | ||
|
|
aebe9bab54 | ||
|
|
9ab31a7614 | ||
|
|
f6556f25d5 | ||
|
|
16a8a763f4 | ||
|
|
4b1cd1b727 | ||
|
|
37ccaf7743 | ||
|
|
06a984f39d | ||
|
|
6d1c034052 | ||
|
|
672d05d124 | ||
|
|
322d0a7cb8 | ||
|
|
5725e78931 | ||
|
|
69b6072e37 | ||
|
|
1a9d417c0a | ||
|
|
8a552c4cb3 | ||
|
|
9da9a36cf3 | ||
|
|
d08ab62f92 | ||
|
|
1cb112bcdf | ||
|
|
2fb4851d1f | ||
|
|
32c3ba017e | ||
|
|
d09b968cc0 | ||
|
|
90d84da849 | ||
|
|
d2c4160c6d | ||
|
|
2b4beae9df | ||
|
|
7dab6f2e33 | ||
|
|
1aab48783d | ||
|
|
5089e6b294 | ||
|
|
3be08cb6cd | ||
|
|
6c4833dcd1 | ||
|
|
ce17629a8d | ||
|
|
5e15c4dd2d | ||
|
|
c04d82ee30 | ||
|
|
7962aea008 | ||
|
|
5493b62bb9 | ||
|
|
86c39ac36f | ||
|
|
6bf05c9ed7 | ||
|
|
71989af586 | ||
|
|
09ae3d74c4 | ||
|
|
839fdd7d66 | ||
|
|
52d193b2e8 | ||
|
|
d9e52c734a | ||
|
|
3ce85617da | ||
|
|
0ba984b277 | ||
|
|
f282da3bcb | ||
|
|
eba689754c | ||
|
|
d13c153f5e |
18
.containerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.ace-tool
|
||||
.playwright-mcp
|
||||
.superpowers
|
||||
.worktrees
|
||||
bin
|
||||
build
|
||||
dist
|
||||
frontend/dist
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
tools/__pycache__
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.exe
|
||||
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
.idea
|
||||
.ace-tool
|
||||
.playwright-mcp
|
||||
.superpowers
|
||||
.worktrees
|
||||
bin
|
||||
build
|
||||
dist
|
||||
frontend/dist
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
tools/__pycache__
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.exe
|
||||
508
.github/workflows/dev-build.yml
vendored
@@ -25,6 +25,18 @@ jobs:
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Test release asset contracts
|
||||
run: |
|
||||
bash tools/detect-changed-driver-agents.test.sh
|
||||
bash tools/generate-driver-agent-revisions.test.sh
|
||||
python3 tools/generate-driver-release-manifest.test.py
|
||||
python3 tools/generate-update-latest-manifest.test.py
|
||||
python3 tools/r2-release-retention.test.py
|
||||
python3 tools/package-driver-release-assets.test.py
|
||||
python3 tools/legal-release-assets.test.py
|
||||
python3 tools/windows-release-artifacts.test.py
|
||||
python3 tools/generate-release-notes.test.py
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -36,7 +48,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
|
||||
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
|
||||
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
@@ -51,6 +63,9 @@ jobs:
|
||||
node frontend/scripts/wails-frontend-install.mjs
|
||||
npm --prefix frontend run build
|
||||
|
||||
- name: Test frontend virtual scrolling
|
||||
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
|
||||
|
||||
- name: Pack frontend dist
|
||||
shell: bash
|
||||
run: tar -cf frontend-dist.tar -C frontend/dist .
|
||||
@@ -73,6 +88,7 @@ jobs:
|
||||
drivers_windows_amd64: ${{ steps.detect.outputs.drivers_windows_amd64 }}
|
||||
drivers_windows_arm64: ${{ steps.detect.outputs.drivers_windows_arm64 }}
|
||||
drivers_linux_amd64: ${{ steps.detect.outputs.drivers_linux_amd64 }}
|
||||
drivers_linux_arm64: ${{ steps.detect.outputs.drivers_linux_arm64 }}
|
||||
has_changes: ${{ steps.detect.outputs.has_changes }}
|
||||
release_source: ${{ steps.detect.outputs.release_source }}
|
||||
compare_base: ${{ steps.detect.outputs.compare_base }}
|
||||
@@ -167,6 +183,7 @@ jobs:
|
||||
windows/amd64) echo "windows_amd64" ;;
|
||||
windows/arm64) echo "windows_arm64" ;;
|
||||
linux/amd64) echo "linux_amd64" ;;
|
||||
linux/arm64) echo "linux_arm64" ;;
|
||||
*)
|
||||
echo "unknown platform: $1" >&2
|
||||
return 1
|
||||
@@ -204,7 +221,7 @@ jobs:
|
||||
PLATFORM_DIFF_FAILED=false
|
||||
if [[ "$BASE_REF" != "all" && "$FORCE_GLOBAL_DRIVER_BUILDS" != "true" ]]; then
|
||||
REVISION_DRIVERS=""
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
DIFF_ARGS=(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")
|
||||
if [[ -n "$DRIVERS" ]]; then
|
||||
@@ -228,12 +245,12 @@ jobs:
|
||||
fi
|
||||
if [[ "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" || "$PLATFORM_DIFF_FAILED" == "true" ]]; then
|
||||
PLATFORM_OUTPUTS=()
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=${DRIVERS}")
|
||||
done
|
||||
elif [[ ${#PLATFORM_OUTPUTS[@]} -eq 0 ]]; then
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_KEY="$(platform_output_key "$PLATFORM")"
|
||||
PLATFORM_OUTPUTS+=("drivers_${PLATFORM_KEY}=")
|
||||
done
|
||||
@@ -312,6 +329,15 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-22.04-arm
|
||||
platform: linux/arm64
|
||||
os_name: Linux
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-linux-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
@@ -380,6 +406,24 @@ jobs:
|
||||
}
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install WiX Toolset (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$wixVersion = "6.0.2"
|
||||
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
|
||||
dotnet tool install wix --tool-path $wixTools --version $wixVersion
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$env:PATH = "$wixTools;$env:PATH"
|
||||
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
wix --version
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
@@ -397,6 +441,12 @@ jobs:
|
||||
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
|
||||
echo "⚠️ Linux arm64 暂不生成 AppImage,仅产出 tar.gz"
|
||||
touch /tmp/skip-appimage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
|
||||
@@ -499,6 +549,11 @@ jobs:
|
||||
& "$env:CC" --version
|
||||
& "$env:CXX" --version
|
||||
|
||||
- name: Test Windows Online Updater (Windows AMD64)
|
||||
if: ${{ matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
|
||||
|
||||
# ---- 生成 dev 版本号 ----
|
||||
- name: Generate Dev Version
|
||||
id: version
|
||||
@@ -554,6 +609,9 @@ jobs:
|
||||
linux/amd64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_amd64 }}'
|
||||
;;
|
||||
linux/arm64)
|
||||
DRIVERS='${{ needs.driver_agents.outputs.drivers_linux_arm64 }}'
|
||||
;;
|
||||
*)
|
||||
echo "unsupported platform: ${{ matrix.platform }}" >&2
|
||||
exit 1
|
||||
@@ -588,7 +646,45 @@ jobs:
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE"
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
normalize_driver_token() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
"") return 1 ;;
|
||||
doris|diros) echo "doris" ;;
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elastic|elasticsearch) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
|
||||
echo "$value"
|
||||
;;
|
||||
*)
|
||||
echo "❌ 不支持的 driver-agent:${1:-}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_driver_name() {
|
||||
case "$1" in
|
||||
doris) echo "diros" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -a RAW_DRIVERS=()
|
||||
declare -a DRIVERS=()
|
||||
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
|
||||
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
|
||||
DRIVERS+=("$DRIVER")
|
||||
done
|
||||
if [ ${#DRIVERS[@]} -eq 0 ]; then
|
||||
echo "🧭 没有需要构建的 driver-agent"
|
||||
exit 0
|
||||
fi
|
||||
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
|
||||
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
@@ -644,10 +740,7 @@ jobs:
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
@@ -742,7 +835,12 @@ jobs:
|
||||
bash ./tools/verify-driver-agent-revisions.sh \
|
||||
--assets-dir drivers \
|
||||
--platform "$TARGET_PLATFORM" \
|
||||
--drivers "$CHANGED_DRIVER_AGENTS"
|
||||
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
|
||||
|
||||
mkdir -p driver-provenance
|
||||
python3 ./tools/generate-driver-release-manifest.py \
|
||||
--assets-dir drivers \
|
||||
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
@@ -758,6 +856,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
mkdir -p "$APP_NAME/Contents/Resources"
|
||||
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
|
||||
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
|
||||
|
||||
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||
if [ -z "$APP_BIN" ]; then
|
||||
@@ -801,14 +902,16 @@ jobs:
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
# Windows Packaging
|
||||
- name: Package Windows EXE
|
||||
- name: Package Windows Portable EXE and MSI
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-Location build/bin
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||
$archName = "${{ matrix.arch_name }}"
|
||||
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
|
||||
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
|
||||
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
@@ -851,8 +954,65 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
|
||||
|
||||
$runNumber = [int64]$env:GITHUB_RUN_NUMBER
|
||||
if ($runNumber -le 0 -or $runNumber -gt 65535) {
|
||||
throw "GitHub run number 无法映射为 MSI 版本号: $runNumber"
|
||||
}
|
||||
$productVersion = "255.0.$runNumber"
|
||||
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
|
||||
$componentGuid = if ($archName -eq "Arm64") {
|
||||
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
|
||||
} else {
|
||||
"0BCEE70B-9CF2-449C-9ADE-190188493234"
|
||||
}
|
||||
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
|
||||
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
|
||||
} else {
|
||||
"114030DD-DB5F-460A-9CAC-997680E9F938"
|
||||
}
|
||||
|
||||
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
|
||||
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
|
||||
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
|
||||
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
|
||||
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
|
||||
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
|
||||
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
|
||||
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
|
||||
|
||||
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
|
||||
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
|
||||
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
|
||||
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
|
||||
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
|
||||
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
|
||||
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
|
||||
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
|
||||
wix build $wxsPath `
|
||||
-arch $wixArch `
|
||||
-ext "WixToolset.UI.wixext/6.0.2" `
|
||||
-d "ProductName=GoNavi" `
|
||||
-d "ProductVersion=$productVersion" `
|
||||
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
|
||||
-d "ComponentGuid=$componentGuid" `
|
||||
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
|
||||
-d "InstallFolderName=GoNavi" `
|
||||
-d "RegistryKeyName=GoNavi" `
|
||||
-d "SourceExe=$sourceExe" `
|
||||
-d "InstallMarker=$installMarkerPath" `
|
||||
-d "IconPath=$iconPath" `
|
||||
-d "LicenseRtf=$licenseRtf" `
|
||||
-d "LicenseFile=$licenseFile" `
|
||||
-d "NoticeFile=$noticeFile" `
|
||||
-pdbtype none `
|
||||
-intermediatefolder $wixIntermediateDir `
|
||||
-out "..\\..\\$msiName"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
|
||||
# Linux Packaging
|
||||
- name: Package Linux
|
||||
@@ -883,7 +1043,7 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "📦 正在打包 $TAR_NAME..."
|
||||
tar -czvf "$TAR_NAME" "$TARGET"
|
||||
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
|
||||
mv "$TAR_NAME" ../../
|
||||
|
||||
if [ -f /tmp/skip-appimage ]; then
|
||||
@@ -894,9 +1054,12 @@ jobs:
|
||||
echo "📦 正在生成 AppImage..."
|
||||
mkdir -p AppDir/usr/bin
|
||||
mkdir -p AppDir/usr/share/applications
|
||||
mkdir -p AppDir/usr/share/doc/gonavi
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
|
||||
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
|
||||
|
||||
printf '%s\n' \
|
||||
'[Desktop Entry]' \
|
||||
@@ -943,9 +1106,11 @@ jobs:
|
||||
path: |
|
||||
GoNavi-*.dmg
|
||||
GoNavi-*.exe
|
||||
GoNavi-*.msi
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
drivers/**
|
||||
driver-provenance/**
|
||||
retention-days: 7
|
||||
|
||||
# 汇总所有产物并发布为 Pre-release
|
||||
@@ -954,11 +1119,21 @@ jobs:
|
||||
needs:
|
||||
- build
|
||||
- driver_agents
|
||||
# Serialize only the publication stage with stable releases so both
|
||||
# channels cannot pass the retained-byte preflight concurrently.
|
||||
concurrency:
|
||||
group: gonavi-r2-publication
|
||||
cancel-in-progress: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
@@ -966,9 +1141,29 @@ jobs:
|
||||
pattern: dev-build-artifacts-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Add legal documents
|
||||
shell: bash
|
||||
run: install -m 0644 LICENSE NOTICE release-assets/
|
||||
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Download Previous Driver Manifest
|
||||
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
|
||||
env:
|
||||
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
|
||||
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
|
||||
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
|
||||
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
|
||||
else
|
||||
rm -f "$manifest_path"
|
||||
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
|
||||
fi
|
||||
|
||||
- name: Complete Driver Agent Assets
|
||||
if: needs.driver_agents.outputs.has_changes == 'true'
|
||||
env:
|
||||
@@ -1002,11 +1197,19 @@ jobs:
|
||||
drivers \
|
||||
../driver-release-assets
|
||||
|
||||
python3 ../tools/generate-driver-release-manifest.py \
|
||||
--assets-dir drivers \
|
||||
MANIFEST_ARGS=(
|
||||
--assets-dir drivers
|
||||
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
|
||||
)
|
||||
if [ -d driver-provenance ]; then
|
||||
MANIFEST_ARGS+=(--provenance driver-provenance)
|
||||
fi
|
||||
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
|
||||
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
|
||||
fi
|
||||
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
|
||||
|
||||
rm -rf drivers
|
||||
rm -rf drivers driver-provenance
|
||||
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
@@ -1016,6 +1219,9 @@ jobs:
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
case "$file" in
|
||||
SHA256SUMS|latest.json|latest-dev.json) continue ;;
|
||||
esac
|
||||
FILES+=("$file")
|
||||
fi
|
||||
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||
@@ -1050,6 +1256,26 @@ jobs:
|
||||
DEV_VERSION="dev-${SHORT_SHA}"
|
||||
echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 静态更新清单:dev 通道读
|
||||
# https://github.com/Syngnat/GoNavi/releases/download/dev-latest/latest-dev.json
|
||||
- name: Generate static update manifest (latest-dev.json)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEV_VERSION="${{ steps.version.outputs.version }}"
|
||||
python3 tools/generate-update-latest-manifest.py \
|
||||
--assets-dir release-assets \
|
||||
--version "$DEV_VERSION" \
|
||||
--tag dev-latest \
|
||||
--channel dev \
|
||||
--name "Dev Build (${DEV_VERSION})" \
|
||||
--download-base-url "https://download.syngnat.top/gonavi/dev/releases/download" \
|
||||
--download-tag "$DEV_VERSION" \
|
||||
--output release-assets/latest-dev.json
|
||||
test -s release-assets/latest-dev.json
|
||||
echo "📄 latest-dev.json ready:"
|
||||
head -n 40 release-assets/latest-dev.json
|
||||
|
||||
- name: Format Build Time
|
||||
id: build_time
|
||||
shell: bash
|
||||
@@ -1204,3 +1430,249 @@ jobs:
|
||||
> 每次 push 到 `dev` 分支会自动覆盖此 release。
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Mirror, promote, and retain latest dev release in Cloudflare R2
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
DEV_VERSION: ${{ steps.version.outputs.version }}
|
||||
HAS_DRIVER_ASSETS: ${{ steps.driver_assets.outputs.has_driver_assets }}
|
||||
R2_BUCKET: gonavi-downloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" ]]; then
|
||||
echo "Cloudflare R2 release credentials are not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
app_dir="release-assets"
|
||||
driver_dir="driver-release-assets"
|
||||
app_prefix="gonavi/dev/releases/download/${DEV_VERSION}"
|
||||
driver_prefix="drivers/dev/releases/download/${DEV_VERSION}"
|
||||
|
||||
jq -e \
|
||||
--arg version "${DEV_VERSION}" \
|
||||
--arg mirror_base "https://download.syngnat.top/gonavi/dev/releases/download" \
|
||||
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download/dev-latest" \
|
||||
'
|
||||
.channel == "dev"
|
||||
and .tagName == "dev-latest"
|
||||
and .version == $version
|
||||
and (.assets | type == "array" and length > 0)
|
||||
and all(.assets[];
|
||||
(.url | type == "string" and startswith($mirror_base + "/" + $version + "/"))
|
||||
and (.apiUrl | type == "string" and startswith($github_base + "/"))
|
||||
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
|
||||
)
|
||||
' "${app_dir}/latest-dev.json" >/dev/null
|
||||
|
||||
driver_latest_file="${RUNNER_TEMP}/driver-dev-latest-index.json"
|
||||
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
|
||||
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
|
||||
echo "DriverAgents SHA256SUMS is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
|
||||
jq \
|
||||
--arg tag "dev-latest" \
|
||||
--arg mirror_tag "${DEV_VERSION}" \
|
||||
'.tagName = $tag | .mirrorTagName = $mirror_tag | del(.assets["GoNavi-DriverAgents.zip"])' \
|
||||
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
|
||||
> "${driver_latest_file}"
|
||||
jq -e '.assets | type == "object" and length > 0' "${driver_latest_file}" >/dev/null
|
||||
else
|
||||
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
|
||||
if python3 tools/r2-release-retention.py validate-driver-pointer \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--channel dev > "${current_driver_state}"; then
|
||||
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "drivers/dev/releases/download/" \
|
||||
--keep-prefix "${current_driver_keep_prefix}"
|
||||
else
|
||||
echo "::warning::Current dev driver pointer could not be fully validated; preserving every dev driver object"
|
||||
fi
|
||||
fi
|
||||
|
||||
app_manifest_size="$(stat -c '%s' "${app_dir}/latest-dev.json")"
|
||||
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest-dev.json")"
|
||||
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
|
||||
budget_args=(
|
||||
measure
|
||||
--bucket "${R2_BUCKET}"
|
||||
--endpoint-url "${endpoint}"
|
||||
--prefix "gonavi/"
|
||||
--prefix "drivers/"
|
||||
--exclude-prefix "gonavi/dev/releases/download/"
|
||||
--exclude-prefix "gonavi/dev/releases/latest/"
|
||||
)
|
||||
if [[ -s "${driver_latest_file}" ]]; then
|
||||
driver_index_size="$(stat -c '%s' "${driver_latest_file}")"
|
||||
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_latest_file}")"
|
||||
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size * 2))"
|
||||
budget_args+=(
|
||||
--exclude-prefix "drivers/dev/releases/download/"
|
||||
--exclude-prefix "drivers/dev/releases/latest/"
|
||||
)
|
||||
fi
|
||||
python3 tools/r2-release-retention.py "${budget_args[@]}" \
|
||||
--add-bytes "${planned_bytes}" \
|
||||
--max-bytes 9000000000
|
||||
|
||||
upload_immutable() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local content_type="${3:-}"
|
||||
local sha size head_json remote_sha remote_size
|
||||
sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
size="$(stat -c '%s' "${file}")"
|
||||
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "Refusing to overwrite immutable R2 object ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
return
|
||||
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
|
||||
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
|
||||
exit 1
|
||||
fi
|
||||
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
|
||||
if [[ -n "${content_type}" ]]; then
|
||||
args+=(--content-type "${content_type}")
|
||||
fi
|
||||
aws "${args[@]}"
|
||||
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "R2 verification failed for ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r name expected_size expected_sha; do
|
||||
[[ "${name}" == "$(basename "${name}")" ]] || {
|
||||
echo "Invalid dev app asset name: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
file="${app_dir}/${name}"
|
||||
[[ -f "${file}" ]] || {
|
||||
echo "Dev app asset is missing: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_size="$(stat -c '%s' "${file}")"
|
||||
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
|
||||
echo "Dev app asset does not match latest-dev.json: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
upload_immutable "${file}" "${app_prefix}/${name}"
|
||||
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest-dev.json")
|
||||
upload_immutable \
|
||||
"${app_dir}/latest-dev.json" \
|
||||
"${app_prefix}/latest-dev.json" \
|
||||
"application/json; charset=utf-8"
|
||||
|
||||
if [[ "${HAS_DRIVER_ASSETS}" == "true" ]]; then
|
||||
while IFS=$'\t' read -r name expected_size; do
|
||||
[[ "${name}" == "$(basename "${name}")" ]] || {
|
||||
echo "Invalid dev driver asset name: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
file="${driver_dir}/${name}"
|
||||
[[ -f "${file}" ]] || {
|
||||
echo "Dev driver asset is missing: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_size="$(stat -c '%s' "${file}")"
|
||||
if [[ "${actual_size}" != "${expected_size}" ]]; then
|
||||
echo "Dev driver asset size does not match index: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
upload_immutable "${file}" "${driver_prefix}/${name}"
|
||||
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' "${driver_latest_file}")
|
||||
upload_immutable \
|
||||
"${driver_latest_file}" \
|
||||
"${driver_prefix}/GoNavi-DriverAgents-Index.json" \
|
||||
"application/json; charset=utf-8"
|
||||
fi
|
||||
|
||||
promote_mutable() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local sha size head_json remote_sha remote_size
|
||||
sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
size="$(stat -c '%s' "${file}")"
|
||||
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--cache-control "no-cache,must-revalidate" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--metadata "sha256=${sha}" \
|
||||
--only-show-errors
|
||||
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "R2 mutable object verification failed for ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
app_latest_key="gonavi/dev/releases/latest/latest-dev.json"
|
||||
promote_mutable "${app_dir}/latest-dev.json" "${app_latest_key}"
|
||||
curl --fail --silent --show-error \
|
||||
"https://download.syngnat.top/${app_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
| jq -e --arg version "${DEV_VERSION}" '.channel == "dev" and .version == $version' >/dev/null
|
||||
|
||||
if [[ -s "${driver_latest_file}" ]]; then
|
||||
driver_latest_key="drivers/dev/releases/latest/GoNavi-DriverAgents-Index.json"
|
||||
promote_mutable "${driver_latest_file}" "${driver_latest_key}"
|
||||
curl --fail --silent --show-error \
|
||||
"https://download.syngnat.top/${driver_latest_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
| jq -e \
|
||||
--arg mirror_tag "${DEV_VERSION}" \
|
||||
'.tagName == "dev-latest" and .mirrorTagName == $mirror_tag' >/dev/null
|
||||
fi
|
||||
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "gonavi/dev/releases/download/" \
|
||||
--keep-prefix "${app_prefix}/"
|
||||
driver_keep_prefix=""
|
||||
if [[ -s "${driver_latest_file}" ]]; then
|
||||
driver_keep_prefix="${driver_prefix}/"
|
||||
else
|
||||
current_driver_state="${RUNNER_TEMP}/r2-current-dev-driver-state.json"
|
||||
if python3 tools/r2-release-retention.py validate-driver-pointer \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--channel dev > "${current_driver_state}"; then
|
||||
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
|
||||
else
|
||||
echo "::warning::Skipping dev driver cleanup because the current R2 pointer or retained assets are invalid"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${driver_keep_prefix}" ]]; then
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "drivers/dev/releases/download/" \
|
||||
--keep-prefix "${driver_keep_prefix}"
|
||||
fi
|
||||
|
||||
python3 tools/r2-release-retention.py measure \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--prefix "gonavi/" \
|
||||
--prefix "drivers/" \
|
||||
--max-bytes 9000000000
|
||||
|
||||
260
.github/workflows/docker-images.yml
vendored
Normal file
@@ -0,0 +1,260 @@
|
||||
name: Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: docker-images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.image_name }} (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image_name:
|
||||
- gonavi-mcp-server
|
||||
- gonavi-web-server
|
||||
- gonavi-build-env
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- image_name: gonavi-mcp-server
|
||||
dockerfile: Dockerfile.mcp-server
|
||||
description: GoNavi MCP Server container image
|
||||
- image_name: gonavi-web-server
|
||||
dockerfile: Dockerfile.web-server
|
||||
description: GoNavi Web Server (browser UI) container image
|
||||
- image_name: gonavi-build-env
|
||||
dockerfile: Dockerfile.build-env
|
||||
description: GoNavi Linux build environment image
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Validate runtime Dockerfiles
|
||||
if: matrix.image_name == 'gonavi-web-server'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/validate-web-server-dockerfile.test.sh
|
||||
env_file="$(mktemp)"
|
||||
trap 'rm -f "$env_file"' EXIT
|
||||
printf 'GONAVI_HOST_DATA_ROOT=%s\nGONAVI_WEB_PASSWORD=123456\n' "${RUNNER_TEMP}/gonavi-data" >"$env_file"
|
||||
rendered="$(docker compose --env-file "$env_file" -f docker-compose.web-server.yml config)"
|
||||
grep -Eq "GONAVI_WEB_PASSWORD: ['\"]?123456['\"]?$" <<<"$rendered"
|
||||
|
||||
- name: Prepare build variables
|
||||
id: prep
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
echo "platform_pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ matrix.image_name }}
|
||||
org.opencontainers.image.description=${{ matrix.description }}
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
|
||||
- name: Build smoke image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
load: true
|
||||
tags: codex-smoke/${{ matrix.image_name }}:local
|
||||
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
|
||||
- name: Smoke test MCP Server image
|
||||
if: matrix.image_name == 'gonavi-mcp-server'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid="$(docker run -d -p 8765:8765 -e GONAVI_MCP_HTTP_TOKEN=smoke-token codex-smoke/${{ matrix.image_name }}:local http)"
|
||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl --fail --silent http://127.0.0.1:8765/healthz >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$cid"
|
||||
echo "MCP Server image smoke test failed" >&2
|
||||
exit 1
|
||||
|
||||
- name: Smoke test Web Server image
|
||||
if: matrix.image_name == 'gonavi-web-server'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cookie_jar="$(mktemp)"
|
||||
cid=""
|
||||
trap 'rm -f "$cookie_jar"; if [[ -n "$cid" ]]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi' EXIT
|
||||
cid="$(docker run -d -p 127.0.0.1::34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 -e GONAVI_WEB_PASSWORD=123456 codex-smoke/${{ matrix.image_name }}:local web-server)"
|
||||
host_port="$(docker port "$cid" 34116/tcp | awk -F: 'NR == 1 { print $NF }')"
|
||||
if [[ ! "$host_port" =~ ^[0-9]+$ ]]; then
|
||||
docker logs "$cid"
|
||||
echo "Failed to resolve the Web Server smoke-test port" >&2
|
||||
exit 1
|
||||
fi
|
||||
base_url="http://127.0.0.1:${host_port}"
|
||||
ready=false
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
|
||||
if curl --fail --silent "${base_url}/__gonavi/healthz" >/dev/null; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$ready" != "true" ]]; then
|
||||
docker logs "$cid"
|
||||
echo "Web Server image smoke test failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
status="$(curl --fail --silent --show-error "${base_url}/__gonavi/auth/status")"
|
||||
grep -Fq '"configured":true' <<<"$status"
|
||||
login="$(curl --fail --silent --show-error -c "$cookie_jar" -H 'Content-Type: application/json' -d '{"password":"123456","code":""}' "${base_url}/__gonavi/auth/login")"
|
||||
grep -Fq '"success":true' <<<"$login"
|
||||
app_status="$(curl --silent --show-error -o /dev/null -w '%{http_code}' -b "$cookie_jar" "${base_url}/")"
|
||||
[[ "$app_status" == "200" ]]
|
||||
|
||||
- name: Smoke test build environment image
|
||||
if: matrix.image_name == 'gonavi-build-env'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm codex-smoke/${{ matrix.image_name }}:local \
|
||||
bash -lc 'go version && node -v && npm -v && wails version'
|
||||
|
||||
- name: Push by digest
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ghcr.io/${{ steps.prep.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.image_name }}-${{ steps.prep.outputs.platform_pair }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Publish ${{ matrix.image_name }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image_name: gonavi-mcp-server
|
||||
description: GoNavi MCP Server container image
|
||||
- image_name: gonavi-web-server
|
||||
description: GoNavi Web Server (browser UI) container image
|
||||
- image_name: gonavi-build-env
|
||||
description: GoNavi Linux build environment image
|
||||
steps:
|
||||
- name: Normalize image namespace
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "owner_lc=$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-${{ matrix.image_name }}-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=dev-latest,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
type=sha,format=short,prefix=dev-,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ matrix.image_name }}
|
||||
org.opencontainers.image.description=${{ matrix.description }}
|
||||
org.opencontainers.image.licenses=Apache-2.0
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools inspect "ghcr.io/${{ steps.vars.outputs.owner_lc }}/${{ matrix.image_name }}:${{ steps.meta.outputs.version }}"
|
||||
293
.github/workflows/issue-claim.yml
vendored
Normal file
@@ -0,0 +1,293 @@
|
||||
# 评论认领:在 Issue 下评论「我想做」等关键词后,自动指派/标记认领人。
|
||||
# 说明:
|
||||
# - 仓库协作者(write 及以上)可成功写入 Assignees
|
||||
# - 外部贡献者若无写权限,GitHub 不允许设为 assignee;此时会打 claimed 标签并回复确认
|
||||
# - 支持取消认领:取消认领 / /unclaim
|
||||
name: Issue Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
claim:
|
||||
name: Auto claim or unclaim
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Process claim / unclaim comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const comment = context.payload.comment;
|
||||
const actor = comment.user.login;
|
||||
const actorType = comment.user.type;
|
||||
|
||||
// 忽略机器人自己的评论,避免循环
|
||||
if (actorType === 'Bot') {
|
||||
core.info('Skip bot comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = (comment.body || '').replace(/\r\n/g, '\n').trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅认领短指令:整段或首行匹配,避免长讨论误触发
|
||||
const firstLine = raw.split('\n').map((l) => l.trim()).find(Boolean) || '';
|
||||
const normalize = (s) =>
|
||||
s
|
||||
.replace(/^[@/]/g, '')
|
||||
.replace(/[!!。..、,,\s]+$/g, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const claimPhrases = new Set([
|
||||
'我想做',
|
||||
'我来做',
|
||||
'我来',
|
||||
'认领',
|
||||
'我认领',
|
||||
'claim',
|
||||
'i claim',
|
||||
'i want this',
|
||||
'i will take this',
|
||||
'assign me',
|
||||
]);
|
||||
const unclaimPhrases = new Set([
|
||||
'取消认领',
|
||||
'不认领了',
|
||||
'放弃认领',
|
||||
'unclaim',
|
||||
'release',
|
||||
]);
|
||||
|
||||
const nFull = normalize(raw);
|
||||
const nFirst = normalize(firstLine);
|
||||
// 允许 /claim /unclaim 形式
|
||||
const slash = firstLine.match(/^\/(claim|unclaim)\b/i);
|
||||
const isClaim =
|
||||
claimPhrases.has(nFull) ||
|
||||
claimPhrases.has(nFirst) ||
|
||||
(slash && slash[1].toLowerCase() === 'claim');
|
||||
const isUnclaim =
|
||||
unclaimPhrases.has(nFull) ||
|
||||
unclaimPhrases.has(nFirst) ||
|
||||
(slash && slash[1].toLowerCase() === 'unclaim');
|
||||
|
||||
if (!isClaim && !isUnclaim) {
|
||||
core.info('Comment is not a claim/unclaim command');
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue_number = issue.number;
|
||||
const CLAIMED_LABEL = 'claimed';
|
||||
|
||||
async function ensureClaimedLabel() {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: CLAIMED_LABEL });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: CLAIMED_LABEL,
|
||||
color: '0E8A16',
|
||||
description: '已被社区认领,进行中',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLabels(names) {
|
||||
const current = new Set((issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name)));
|
||||
const toAdd = names.filter((n) => !current.has(n));
|
||||
if (toAdd.length) {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLabels(names) {
|
||||
for (const name of names) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function tryAssign(login) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
assignees: [login],
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
const msg = e.message || String(e);
|
||||
core.warning(`assign failed for ${login}: ${msg}`);
|
||||
return { ok: false, message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async function tryUnassign(login) {
|
||||
try {
|
||||
await github.rest.issues.removeAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
assignees: [login],
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
core.warning(`unassign failed for ${login}: ${e.message || e}`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 从 issue 正文底部的认领锚点解析当前认领人
|
||||
const CLAIM_MARKER_RE =
|
||||
/<!--\s*gonavi-claim\s*:\s*([A-Za-z0-9-]+)\s*-->/;
|
||||
const body = issue.body || '';
|
||||
const markerMatch = body.match(CLAIM_MARKER_RE);
|
||||
const markerClaimer = markerMatch ? markerMatch[1] : null;
|
||||
const assignees = (issue.assignees || []).map((a) => a.login);
|
||||
const currentClaimer = assignees[0] || markerClaimer || null;
|
||||
const hasClaimedLabel = (issue.labels || []).some(
|
||||
(l) => (typeof l === 'string' ? l : l.name) === CLAIMED_LABEL
|
||||
);
|
||||
|
||||
if (isUnclaim) {
|
||||
if (!currentClaimer && !hasClaimedLabel) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `ℹ️ @${actor} 当前无人认领,无需取消。`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
|
||||
// 仅认领人本人可取消;维护者可强制取消
|
||||
const isMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
context.payload.comment.author_association
|
||||
);
|
||||
if (!isMaintainer) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `⚠️ @${actor} 本 Issue 当前由 @${currentClaimer} 认领。请对方评论 \`取消认领\`,或联系维护者。`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const who = currentClaimer || actor;
|
||||
await tryUnassign(who);
|
||||
await removeLabels([CLAIMED_LABEL]);
|
||||
|
||||
let newBody = body;
|
||||
if (CLAIM_MARKER_RE.test(newBody)) {
|
||||
newBody = newBody
|
||||
.replace(/\n?<!--\s*gonavi-claim\s*:\s*[A-Za-z0-9-]+\s*-->\s*/g, '\n')
|
||||
.replace(/\n?### 🔐 认领状态[\s\S]*?(?=\n## |\n# |$)/, '\n')
|
||||
.trimEnd();
|
||||
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `🔓 @${actor} 已取消认领${who !== actor ? `(原认领人 @${who})` : ''}。其他人可评论 \`我想做\` 重新认领。`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// —— claim ——
|
||||
if (currentClaimer && currentClaimer.toLowerCase() !== actor.toLowerCase()) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `⚠️ @${actor} 本 Issue 已由 @${currentClaimer} 认领。\n\n- 若对方长期无进展,可评论提醒,或由维护者 \`取消认领\` 后重开\n- 你也可以先在本线程说明想做的方向,维护者可协助拆分子任务`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentClaimer && currentClaimer.toLowerCase() === actor.toLowerCase()) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `✅ @${actor} 你已经是本 Issue 的认领人,无需重复认领。开干吧 💪`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureClaimedLabel();
|
||||
const assignResult = await tryAssign(actor);
|
||||
await ensureLabels([CLAIMED_LABEL]);
|
||||
|
||||
// 在正文写入可机读认领锚点(assignee 失败时仍可防双认领)
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
const claimBlock =
|
||||
`\n\n### 🔐 认领状态\n\n` +
|
||||
`- 认领人:@${actor}\n` +
|
||||
`- 认领日期:${stamp}\n` +
|
||||
`- 取消方式:评论 \`取消认领\` 或 \`/unclaim\`\n` +
|
||||
`<!-- gonavi-claim: ${actor} -->\n`;
|
||||
|
||||
let newBody = body;
|
||||
if (CLAIM_MARKER_RE.test(newBody)) {
|
||||
newBody = newBody.replace(
|
||||
CLAIM_MARKER_RE,
|
||||
`<!-- gonavi-claim: ${actor} -->`
|
||||
);
|
||||
if (!/### 🔐 认领状态/.test(newBody)) {
|
||||
newBody = newBody.trimEnd() + claimBlock;
|
||||
}
|
||||
} else {
|
||||
newBody = newBody.trimEnd() + claimBlock;
|
||||
}
|
||||
await github.rest.issues.update({ owner, repo, issue_number, body: newBody });
|
||||
|
||||
if (assignResult.ok) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body:
|
||||
`✅ @${actor} 已认领本 Issue,并已加入 **Assignees**。\n\n` +
|
||||
`建议下一步:\n` +
|
||||
`1. 从 \`dev\` 拉分支:\`fix/*\` 或 \`feature/*\`\n` +
|
||||
`2. 完成后向 **\`dev\`** 提 PR\n` +
|
||||
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
|
||||
`贡献指南:https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body:
|
||||
`✅ @${actor} 已认领本 Issue(已标记 \`claimed\`)。\n\n` +
|
||||
`> 说明:GitHub 仅允许将**仓库协作者**设为正式 Assignee。你目前可能还没有写权限,因此无法写入 Assignees 字段;**社区认领仍然有效**,其他人再评论「我想做」会被拦截。\n\n` +
|
||||
`建议下一步:\n` +
|
||||
`1. Fork 仓库,从 \`dev\` 拉分支开发\n` +
|
||||
`2. 向 **\`dev\`** 提 PR,并在 PR 描述里写 \`Fixes #${issue_number}\`\n` +
|
||||
`3. 若暂无法继续,请评论 \`取消认领\`\n\n` +
|
||||
`贡献指南:https://github.com/Syngnat/GoNavi/blob/dev/CONTRIBUTING.zh-CN.md`,
|
||||
});
|
||||
}
|
||||
532
.github/workflows/publish-release.yml
vendored
Normal file
@@ -0,0 +1,532 @@
|
||||
name: Publish Draft Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'publish/v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example, v0.8.7)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
# Stable and dev publications share the same retained R2 byte budget.
|
||||
group: gonavi-r2-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Validate, mirror, and publish release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Validate release
|
||||
id: validate
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const requestedTag = String(process.env.RELEASE_TAG || '').trim();
|
||||
const tag = requestedTag.startsWith('publish/')
|
||||
? requestedTag.slice('publish/'.length)
|
||||
: requestedTag;
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(tag)) {
|
||||
core.setFailed(`Invalid release tag: ${tag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const tagRef = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${tag}`,
|
||||
});
|
||||
|
||||
let commitSha = tagRef.data.object.sha;
|
||||
if (tagRef.data.object.type === 'tag') {
|
||||
const annotatedTag = await github.rest.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: tagRef.data.object.sha,
|
||||
});
|
||||
commitSha = annotatedTag.data.object.sha;
|
||||
}
|
||||
|
||||
const workflowRuns = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'release.yml',
|
||||
event: 'push',
|
||||
branch: tag,
|
||||
per_page: 100,
|
||||
});
|
||||
const runs = workflowRuns.data.workflow_runs;
|
||||
const successfulRun = runs.find((run) => (
|
||||
run.head_sha === commitSha
|
||||
&& run.status === 'completed'
|
||||
&& run.conclusion === 'success'
|
||||
));
|
||||
if (!successfulRun) {
|
||||
core.setFailed(`No successful Release workflow found for ${tag} at ${commitSha}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const releaseList = await github.rest.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const releases = releaseList.data;
|
||||
const release = releases.find((item) => item.tag_name === tag);
|
||||
if (!release) {
|
||||
core.setFailed(`Release not found for ${tag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let latestRelease = null;
|
||||
try {
|
||||
latestRelease = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const semverParts = (value) => value.slice(1).split('.').map(Number);
|
||||
const compareSemver = (left, right) => {
|
||||
const a = semverParts(left);
|
||||
const b = semverParts(right);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return a[index] - b[index];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
let shouldPublish = release.draft;
|
||||
if (release.draft) {
|
||||
const latestTag = String(latestRelease?.tag_name || '').trim();
|
||||
if (latestTag && /^v\d+\.\d+\.\d+$/.test(latestTag) && compareSemver(tag, latestTag) <= 0) {
|
||||
core.setFailed(`Refusing to publish ${tag} over current latest ${latestTag}`);
|
||||
return;
|
||||
}
|
||||
} else if (!latestRelease || latestRelease.id !== release.id) {
|
||||
core.setFailed(`Published release ${tag} is not the current GitHub latest release`);
|
||||
return;
|
||||
} else {
|
||||
shouldPublish = false;
|
||||
}
|
||||
|
||||
const version = tag.slice(1);
|
||||
const expectedAssets = [
|
||||
'LICENSE',
|
||||
'NOTICE',
|
||||
'SHA256SUMS',
|
||||
'latest.json',
|
||||
`GoNavi-${version}-Linux-Amd64-WebKit41.tar.gz`,
|
||||
`GoNavi-${version}-Linux-Amd64.tar.gz`,
|
||||
`GoNavi-${version}-Linux-Arm64.tar.gz`,
|
||||
`GoNavi-${version}-MacOS-Amd64.dmg`,
|
||||
`GoNavi-${version}-MacOS-Arm64.dmg`,
|
||||
`GoNavi-${version}-Windows-Amd64-Installer.msi`,
|
||||
`GoNavi-${version}-Windows-Amd64-Portable.exe`,
|
||||
`GoNavi-${version}-Windows-Arm64-Installer.msi`,
|
||||
`GoNavi-${version}-Windows-Arm64-Portable.exe`,
|
||||
];
|
||||
const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
const missingAssets = expectedAssets.filter((name) => {
|
||||
const asset = assetsByName.get(name);
|
||||
return !asset || asset.state !== 'uploaded' || asset.size <= 0;
|
||||
});
|
||||
if (missingAssets.length > 0) {
|
||||
core.setFailed(`Release assets are incomplete: ${missingAssets.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('tag', tag);
|
||||
core.setOutput('release_id', String(release.id));
|
||||
core.setOutput('should_publish', String(shouldPublish));
|
||||
core.notice(`Validated ${release.draft ? 'draft' : 'published'} release ${tag} and its assets`);
|
||||
|
||||
- name: Checkout release tooling
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Mirror and verify stable release in Cloudflare R2
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
|
||||
R2_BUCKET: gonavi-downloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CLOUDFLARE_ACCOUNT_ID}" || -z "${AWS_ACCESS_KEY_ID}" || -z "${AWS_SECRET_ACCESS_KEY}" || -z "${DRIVER_RELEASE_TOKEN}" ]]; then
|
||||
echo "Cloudflare R2 or DriverAgents release credentials are not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
app_dir="${RUNNER_TEMP}/gonavi-release"
|
||||
driver_dir="${RUNNER_TEMP}/driver-release"
|
||||
mkdir -p "${app_dir}" "${driver_dir}"
|
||||
gh release download "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --dir "${app_dir}"
|
||||
|
||||
driver_release_json="${RUNNER_TEMP}/driver-release.json"
|
||||
driver_status="$(curl --silent --show-error \
|
||||
--output "${driver_release_json}" \
|
||||
--write-out '%{http_code}' \
|
||||
--header "Authorization: Bearer ${DRIVER_RELEASE_TOKEN}" \
|
||||
--header 'Accept: application/vnd.github+json' \
|
||||
--header 'X-GitHub-Api-Version: 2022-11-28' \
|
||||
"https://api.github.com/repos/Syngnat/GoNavi-DriverAgents/releases/tags/${RELEASE_TAG}")"
|
||||
has_driver_release=false
|
||||
case "${driver_status}" in
|
||||
200)
|
||||
jq -e --arg tag "${RELEASE_TAG}" \
|
||||
'.tag_name == $tag and .draft == false' "${driver_release_json}" >/dev/null
|
||||
GH_TOKEN="${DRIVER_RELEASE_TOKEN}" gh release download "${RELEASE_TAG}" \
|
||||
--repo Syngnat/GoNavi-DriverAgents --dir "${driver_dir}"
|
||||
has_driver_release=true
|
||||
;;
|
||||
404)
|
||||
echo "No DriverAgents release for ${RELEASE_TAG}; skipping driver mirror"
|
||||
;;
|
||||
*)
|
||||
echo "DriverAgents release lookup failed with HTTP ${driver_status}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
jq -e \
|
||||
--arg tag "${RELEASE_TAG}" \
|
||||
--arg mirror_base "https://download.syngnat.top/gonavi/releases/download" \
|
||||
--arg github_base "https://github.com/${GITHUB_REPOSITORY}/releases/download" \
|
||||
'
|
||||
.tagName == $tag
|
||||
and (.assets | type == "array" and length > 0)
|
||||
and all(.assets[];
|
||||
(.url | type == "string" and startswith($mirror_base + "/" + $tag + "/"))
|
||||
and (.apiUrl | type == "string" and startswith($github_base + "/" + $tag + "/"))
|
||||
and (.sha256 | type == "string" and test("^[0-9a-fA-F]{64}$"))
|
||||
)
|
||||
' "${app_dir}/latest.json" >/dev/null
|
||||
if [[ "${has_driver_release}" == true ]]; then
|
||||
jq -e '.assets | type == "object" and length > 0' \
|
||||
"${driver_dir}/GoNavi-DriverAgents-Index.json" >/dev/null
|
||||
[[ -s "${driver_dir}/SHA256SUMS" ]] || {
|
||||
echo "DriverAgents SHA256SUMS is missing" >&2
|
||||
exit 1
|
||||
}
|
||||
(cd "${driver_dir}" && sha256sum --check SHA256SUMS)
|
||||
jq --arg tag "${RELEASE_TAG}" \
|
||||
'.tagName = $tag' \
|
||||
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
|
||||
> "${RUNNER_TEMP}/driver-latest-index.json"
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r name expected_size expected_sha; do
|
||||
[[ "${name}" == "$(basename "${name}")" ]] || {
|
||||
echo "Invalid app asset name in latest.json: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
file="${app_dir}/${name}"
|
||||
[[ -f "${file}" ]] || {
|
||||
echo "App asset referenced by latest.json is missing: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_size="$(stat -c '%s' "${file}")"
|
||||
actual_sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
if [[ "${actual_size}" != "${expected_size}" || "${actual_sha}" != "${expected_sha,,}" ]]; then
|
||||
echo "App asset does not match latest.json: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(jq -r '.assets[] | [.name, (.size | tostring), .sha256] | @tsv' "${app_dir}/latest.json")
|
||||
|
||||
if [[ "${has_driver_release}" == true ]]; then
|
||||
while IFS=$'\t' read -r name expected_size; do
|
||||
[[ "${name}" == "$(basename "${name}")" ]] || {
|
||||
echo "Invalid driver asset name in index: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
file="${driver_dir}/${name}"
|
||||
[[ -f "${file}" ]] || {
|
||||
echo "Driver asset referenced by index is missing: ${name}" >&2
|
||||
exit 1
|
||||
}
|
||||
actual_size="$(stat -c '%s' "${file}")"
|
||||
if [[ "${actual_size}" != "${expected_size}" ]]; then
|
||||
echo "Driver asset size does not match index: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(jq -r '.assets | to_entries[] | [.key, (.value | tostring)] | @tsv' \
|
||||
"${driver_dir}/GoNavi-DriverAgents-Index.json")
|
||||
else
|
||||
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
|
||||
if python3 tools/r2-release-retention.py validate-driver-pointer \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--channel stable > "${current_driver_state}"; then
|
||||
current_driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "drivers/releases/download/" \
|
||||
--keep-prefix "${current_driver_keep_prefix}"
|
||||
else
|
||||
echo "::warning::Current stable driver pointer could not be fully validated; preserving every stable driver object"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Preflight the final retained footprint before uploading or
|
||||
# publishing. Existing objects in the channel being replaced are
|
||||
# excluded, then the exact local candidate size is added.
|
||||
app_manifest_size="$(stat -c '%s' "${app_dir}/latest.json")"
|
||||
planned_bytes="$(jq '[.assets[].size] | add // 0' "${app_dir}/latest.json")"
|
||||
planned_bytes="$((planned_bytes + app_manifest_size * 2))"
|
||||
budget_args=(
|
||||
measure
|
||||
--bucket "${R2_BUCKET}"
|
||||
--endpoint-url "${endpoint}"
|
||||
--prefix "gonavi/"
|
||||
--prefix "drivers/"
|
||||
--exclude-prefix "gonavi/releases/download/"
|
||||
--exclude-prefix "gonavi/releases/latest/"
|
||||
)
|
||||
if [[ "${has_driver_release}" == true ]]; then
|
||||
driver_index_size="$(stat -c '%s' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
|
||||
driver_latest_size="$(stat -c '%s' "${RUNNER_TEMP}/driver-latest-index.json")"
|
||||
driver_asset_bytes="$(jq '[.assets[]] | add // 0' "${driver_dir}/GoNavi-DriverAgents-Index.json")"
|
||||
planned_bytes="$((planned_bytes + driver_asset_bytes + driver_index_size + driver_latest_size))"
|
||||
budget_args+=(
|
||||
--exclude-prefix "drivers/releases/download/"
|
||||
--exclude-prefix "drivers/releases/latest/"
|
||||
)
|
||||
fi
|
||||
python3 tools/r2-release-retention.py "${budget_args[@]}" \
|
||||
--add-bytes "${planned_bytes}" \
|
||||
--max-bytes 9000000000
|
||||
|
||||
upload_immutable() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local content_type="${3:-}"
|
||||
local sha size head_json remote_sha remote_size
|
||||
sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
size="$(stat -c '%s' "${file}")"
|
||||
if head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json 2>&1)"; then
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "Refusing to overwrite immutable R2 object ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "R2 object already verified: ${key}"
|
||||
return
|
||||
elif ! grep -Eq '\(404\)|Not Found|NoSuchKey' <<<"${head_json}"; then
|
||||
echo "Unable to inspect immutable R2 object ${key}: ${head_json}" >&2
|
||||
exit 1
|
||||
fi
|
||||
args=(s3 cp "${file}" "s3://${R2_BUCKET}/${key}" --endpoint-url "${endpoint}" --cache-control "public,max-age=31536000,immutable" --metadata "sha256=${sha}" --only-show-errors)
|
||||
if [[ -n "${content_type}" ]]; then
|
||||
args+=(--content-type "${content_type}")
|
||||
fi
|
||||
aws "${args[@]}"
|
||||
head_json="$(aws s3api head-object --bucket "${R2_BUCKET}" --key "${key}" --endpoint-url "${endpoint}" --output json)"
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "R2 verification failed for ${key}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r name; do
|
||||
file="${app_dir}/${name}"
|
||||
upload_immutable "${file}" "gonavi/releases/download/${RELEASE_TAG}/${name}"
|
||||
done < <(jq -r '.assets[].name' "${app_dir}/latest.json")
|
||||
|
||||
if [[ "${has_driver_release}" == true ]]; then
|
||||
# Upload only the standalone assets referenced by the index. The
|
||||
# aggregate bundle duplicates them and remains on GitHub fallback.
|
||||
while IFS= read -r name; do
|
||||
file="${driver_dir}/${name}"
|
||||
upload_immutable "${file}" "drivers/releases/download/${RELEASE_TAG}/${name}"
|
||||
done < <(jq -r '.assets | keys[]' "${driver_dir}/GoNavi-DriverAgents-Index.json")
|
||||
fi
|
||||
|
||||
# Metadata is the commit point: publish it only after every referenced object is verified.
|
||||
if [[ "${has_driver_release}" == true ]]; then
|
||||
upload_immutable \
|
||||
"${driver_dir}/GoNavi-DriverAgents-Index.json" \
|
||||
"drivers/releases/download/${RELEASE_TAG}/GoNavi-DriverAgents-Index.json" \
|
||||
"application/json; charset=utf-8"
|
||||
fi
|
||||
upload_immutable \
|
||||
"${app_dir}/latest.json" \
|
||||
"gonavi/releases/download/${RELEASE_TAG}/latest.json" \
|
||||
"application/json; charset=utf-8"
|
||||
|
||||
- name: Publish validated draft
|
||||
if: steps.validate.outputs.should_publish == 'true'
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const tag = String(process.env.RELEASE_TAG || '').trim();
|
||||
const releaseId = Number(process.env.RELEASE_ID || 0);
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(tag) || !Number.isSafeInteger(releaseId) || releaseId <= 0) {
|
||||
core.setFailed(`Invalid validated release: tag=${tag} id=${releaseId}`);
|
||||
return;
|
||||
}
|
||||
const { owner, repo } = context.repo;
|
||||
const published = await github.rest.repos.updateRelease({
|
||||
owner,
|
||||
repo,
|
||||
release_id: releaseId,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
make_latest: 'true',
|
||||
});
|
||||
if (published.data.draft || !published.data.published_at) {
|
||||
core.setFailed(`Release ${tag} was not published successfully`);
|
||||
return;
|
||||
}
|
||||
core.notice(`Published ${tag}: ${published.data.html_url}`);
|
||||
|
||||
- name: Verify GitHub release is published and latest
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.validate.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
const tag = String(process.env.RELEASE_TAG || '').trim();
|
||||
const releaseId = Number(process.env.RELEASE_ID || 0);
|
||||
const { owner, repo } = context.repo;
|
||||
const [releaseResult, latestResult] = await Promise.all([
|
||||
github.rest.repos.getRelease({ owner, repo, release_id: releaseId }),
|
||||
github.rest.repos.getLatestRelease({ owner, repo }),
|
||||
]);
|
||||
const release = releaseResult.data;
|
||||
const latest = latestResult.data;
|
||||
if (release.draft || !release.published_at || release.tag_name !== tag || latest.id !== release.id) {
|
||||
core.setFailed(`Release ${tag} is not the current published GitHub latest release`);
|
||||
return;
|
||||
}
|
||||
core.notice(`Verified GitHub latest release ${tag}`);
|
||||
|
||||
- name: Promote and verify Cloudflare R2 latest manifest
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
RELEASE_TAG: ${{ steps.validate.outputs.tag }}
|
||||
R2_BUCKET: gonavi-downloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
file="${RUNNER_TEMP}/gonavi-release/latest.json"
|
||||
key="gonavi/releases/latest/latest.json"
|
||||
sha="$(sha256sum "${file}" | awk '{print $1}')"
|
||||
size="$(stat -c '%s' "${file}")"
|
||||
|
||||
aws s3 cp "${file}" "s3://${R2_BUCKET}/${key}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--cache-control "no-cache,must-revalidate" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--metadata "sha256=${sha}" \
|
||||
--only-show-errors
|
||||
|
||||
head_json="$(aws s3api head-object \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--key "${key}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--output json)"
|
||||
remote_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${head_json}")"
|
||||
remote_size="$(jq -r '.ContentLength // 0' <<<"${head_json}")"
|
||||
if [[ "${remote_sha}" != "${sha}" || "${remote_size}" != "${size}" ]]; then
|
||||
echo "R2 latest manifest verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
"https://download.syngnat.top/gonavi/releases/latest/latest.json?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
|
||||
|
||||
driver_latest_file="${RUNNER_TEMP}/driver-latest-index.json"
|
||||
if [[ -s "${driver_latest_file}" ]]; then
|
||||
driver_key="drivers/releases/latest/GoNavi-DriverAgents-Index.json"
|
||||
driver_sha="$(sha256sum "${driver_latest_file}" | awk '{print $1}')"
|
||||
driver_size="$(stat -c '%s' "${driver_latest_file}")"
|
||||
aws s3 cp "${driver_latest_file}" "s3://${R2_BUCKET}/${driver_key}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--cache-control "no-cache,must-revalidate" \
|
||||
--content-type "application/json; charset=utf-8" \
|
||||
--metadata "sha256=${driver_sha}" \
|
||||
--only-show-errors
|
||||
driver_head_json="$(aws s3api head-object \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--key "${driver_key}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--output json)"
|
||||
remote_driver_sha="$(jq -r '.Metadata.sha256 // ""' <<<"${driver_head_json}")"
|
||||
remote_driver_size="$(jq -r '.ContentLength // 0' <<<"${driver_head_json}")"
|
||||
if [[ "${remote_driver_sha}" != "${driver_sha}" || "${remote_driver_size}" != "${driver_size}" ]]; then
|
||||
echo "R2 latest driver index verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
curl --fail --silent --show-error \
|
||||
"https://download.syngnat.top/${driver_key}?verify=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
| jq -e --arg tag "${RELEASE_TAG}" '.tagName == $tag' >/dev/null
|
||||
fi
|
||||
|
||||
# Only prune after the mutable pointers are publicly verified. A
|
||||
# failed cleanup leaves extra storage but never breaks the active
|
||||
# updater path; rerunning this job is idempotent.
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "gonavi/releases/download/" \
|
||||
--keep-prefix "gonavi/releases/download/${RELEASE_TAG}/"
|
||||
driver_keep_prefix=""
|
||||
if [[ -s "${driver_latest_file}" ]]; then
|
||||
driver_keep_prefix="drivers/releases/download/${RELEASE_TAG}/"
|
||||
else
|
||||
current_driver_state="${RUNNER_TEMP}/r2-current-stable-driver-state.json"
|
||||
if python3 tools/r2-release-retention.py validate-driver-pointer \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--channel stable > "${current_driver_state}"; then
|
||||
driver_keep_prefix="$(jq -r '.keepPrefix' "${current_driver_state}")"
|
||||
else
|
||||
echo "::warning::Skipping stable driver cleanup because the current R2 pointer or retained assets are invalid"
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${driver_keep_prefix}" ]]; then
|
||||
python3 tools/r2-release-retention.py prune \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--root-prefix "drivers/releases/download/" \
|
||||
--keep-prefix "${driver_keep_prefix}"
|
||||
fi
|
||||
|
||||
# Keep a 1 GB safety margin below the 10 GB-month free allowance.
|
||||
python3 tools/r2-release-retention.py measure \
|
||||
--bucket "${R2_BUCKET}" \
|
||||
--endpoint-url "${endpoint}" \
|
||||
--prefix "gonavi/" \
|
||||
--prefix "drivers/" \
|
||||
--max-bytes 9000000000
|
||||
22
.github/workflows/release-winget.yml
vendored
@@ -1,22 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
description: 'Tag of release you want to publish'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-2025-vs2026
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
with:
|
||||
identifier: Syngnat.GoNavi
|
||||
installers-regex: 'GoNavi-windows-(amd64|arm64)\.exe$'
|
||||
release-tag: ${{ inputs.release_tag || github.ref_name }}
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
318
.github/workflows/release.yml
vendored
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
@@ -21,6 +22,18 @@ jobs:
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Test release asset contracts
|
||||
run: |
|
||||
bash tools/detect-changed-driver-agents.test.sh
|
||||
bash tools/generate-driver-agent-revisions.test.sh
|
||||
python3 tools/generate-driver-release-manifest.test.py
|
||||
python3 tools/generate-update-latest-manifest.test.py
|
||||
python3 tools/r2-release-retention.test.py
|
||||
python3 tools/package-driver-release-assets.test.py
|
||||
python3 tools/legal-release-assets.test.py
|
||||
python3 tools/windows-release-artifacts.test.py
|
||||
python3 tools/generate-release-notes.test.py
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -32,7 +45,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
|
||||
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/patches/*.patch') }}
|
||||
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
@@ -47,6 +60,9 @@ jobs:
|
||||
node frontend/scripts/wails-frontend-install.mjs
|
||||
npm --prefix frontend run build
|
||||
|
||||
- name: Test frontend virtual scrolling
|
||||
run: npm --prefix frontend test -- src/components/dataGridVirtualScroll.test.ts src/components/DataGrid.layout.test.tsx
|
||||
|
||||
- name: Pack frontend dist
|
||||
shell: bash
|
||||
run: tar -cf frontend-dist.tar -C frontend/dist .
|
||||
@@ -177,7 +193,7 @@ jobs:
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
if [[ "$BASE_REF" != "all" ]]; then
|
||||
REVISION_DRIVERS=""
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64; do
|
||||
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
|
||||
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
@@ -263,6 +279,15 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-22.04-arm
|
||||
platform: linux/arm64
|
||||
os_name: Linux
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-build-linux-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
# Debian 13 (trixie) 默认仓库已切到 WebKitGTK 4.1:单独提供 4.1 变体产物
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
@@ -334,6 +359,24 @@ jobs:
|
||||
}
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install WiX Toolset (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$wixVersion = "6.0.2"
|
||||
$wixTools = Join-Path $env:RUNNER_TEMP "wix-tools"
|
||||
dotnet tool install wix --tool-path $wixTools --version $wixVersion
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX Toolset 安装失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
$wixTools | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$env:PATH = "$wixTools;$env:PATH"
|
||||
wix extension add --global "WixToolset.UI.wixext/$wixVersion"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX UI 扩展安装失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
wix --version
|
||||
|
||||
# Linux Dependencies (GTK3, WebKit2GTK required by Wails)
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
@@ -354,6 +397,12 @@ jobs:
|
||||
# AppImage 运行/打包可能需要 FUSE2。不同发行版/版本包名不同,做兼容兜底。
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
if [ "${{ matrix.arch_name }}" != "Amd64" ]; then
|
||||
echo "⚠️ Linux arm64 暂不生成 AppImage,仅产出 tar.gz"
|
||||
touch /tmp/skip-appimage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Download linuxdeploy tools for AppImage packaging
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
@@ -457,6 +506,11 @@ jobs:
|
||||
& "$env:CC" --version
|
||||
& "$env:CXX" --version
|
||||
|
||||
- name: Test Windows Online Updater (Windows AMD64)
|
||||
if: ${{ matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: go test . ./internal/app -run '^(TestShouldEnableWindowsMSISingleInstanceOnlyForInstalledMainGUI|TestPrimaryWindowActivatorQueuesRequestsUntilRuntimeStartup|TestAcquireWindowsMSISingleInstance|TestResolveWindowsUpdateMaintenanceName|TestAcquireWindowsUpdateMaintenance|TestPrepareWindowsUpdateHandoff|TestWindowsPowerShellUpdater|TestExpectedAssetNameForExecutableUsesWindowsPortableSuffix|TestShouldWindowsUpdateLaunchDownloadedAssetDirectly|TestResolveUpdateInstallMode|TestExpectedAssetNameForWindowsInstallMode|TestResolveUpdateWorkspaceDirForPlatformSeparatesMSIFromInstallDirectory|TestValidateUpdatePackageForCurrentInstallMode|TestWindowsUpdateRequiresExplicitCloseConfirmation|TestInstallUpdateAndRestartRequiresCloseConfirmationOnWindows|TestInstallUpdateAndRestartMSI|TestFindOtherWindowsUpdateInstances|TestCloseWindowsUpdateInstances|TestInstallUpdateAndRestartClosesOtherTargetInstances|TestBuildWindowsMSIUpdatePowerShellScript|TestBuildWindowsMSILaunchCommand|TestResolveWindowsMSIExecPath)' -count=1 -timeout=3m
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -535,7 +589,45 @@ jobs:
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE"
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
normalize_driver_token() {
|
||||
local value
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
"") return 1 ;;
|
||||
doris|diros) echo "doris" ;;
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elastic|elasticsearch) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
|
||||
echo "$value"
|
||||
;;
|
||||
*)
|
||||
echo "❌ 不支持的 driver-agent:${1:-}" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_driver_name() {
|
||||
case "$1" in
|
||||
doris) echo "diros" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -a RAW_DRIVERS=()
|
||||
declare -a DRIVERS=()
|
||||
IFS=',' read -r -a RAW_DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
for RAW_DRIVER in "${RAW_DRIVERS[@]}"; do
|
||||
DRIVER="$(normalize_driver_token "$RAW_DRIVER")" || continue
|
||||
DRIVERS+=("$DRIVER")
|
||||
done
|
||||
if [ ${#DRIVERS[@]} -eq 0 ]; then
|
||||
echo "🧭 没有需要构建的 driver-agent"
|
||||
exit 0
|
||||
fi
|
||||
NORMALIZED_CHANGED_DRIVER_AGENTS="$(IFS=,; echo "${DRIVERS[*]}")"
|
||||
echo "🧭 归一后的 driver-agent 构建列表:${NORMALIZED_CHANGED_DRIVER_AGENTS}"
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
@@ -591,10 +683,7 @@ jobs:
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
BUILD_DRIVER="$(build_driver_name "$DRIVER")"
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
@@ -689,7 +778,12 @@ jobs:
|
||||
bash ./tools/verify-driver-agent-revisions.sh \
|
||||
--assets-dir drivers \
|
||||
--platform "$TARGET_PLATFORM" \
|
||||
--drivers "$CHANGED_DRIVER_AGENTS"
|
||||
--drivers "$NORMALIZED_CHANGED_DRIVER_AGENTS"
|
||||
|
||||
mkdir -p driver-provenance
|
||||
python3 ./tools/generate-driver-release-manifest.py \
|
||||
--assets-dir drivers \
|
||||
--provenance-output "driver-provenance/${GOOS}-${GOARCH}.json"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
@@ -706,6 +800,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
mkdir -p "$APP_NAME/Contents/Resources"
|
||||
cp ../../LICENSE "$APP_NAME/Contents/Resources/LICENSE"
|
||||
cp ../../NOTICE "$APP_NAME/Contents/Resources/NOTICE"
|
||||
|
||||
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||
if [ -z "$APP_BIN" ]; then
|
||||
@@ -751,7 +848,7 @@ jobs:
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
# Windows Packaging
|
||||
- name: Package Windows EXE
|
||||
- name: Package Windows Portable EXE and MSI
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -761,7 +858,9 @@ jobs:
|
||||
$version = $version.Substring(1)
|
||||
}
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$version-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}.exe"
|
||||
$archName = "${{ matrix.arch_name }}"
|
||||
$portableExeName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Portable.exe"
|
||||
$msiName = "GoNavi-$version-${{ matrix.os_name }}-$archName${{ matrix.artifact_suffix }}-Installer.msi"
|
||||
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
@@ -804,8 +903,71 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "📦 输出 Windows 可执行文件 $finalExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$finalExeName" -Force
|
||||
Write-Host "📦 输出 Windows Portable 可执行文件 $portableExeName..."
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\\..\\$portableExeName" -Force
|
||||
|
||||
if ($version -match '^(?<major>\d+)\.(?<minor>\d+)\.(?<build>\d+)(?:[-+].*)?$') {
|
||||
$major = [int]$Matches.major
|
||||
$minor = [int]$Matches.minor
|
||||
$build = [int]$Matches.build
|
||||
} else {
|
||||
throw "Windows MSI 版本必须是 major.minor.patch 格式,当前为: $version"
|
||||
}
|
||||
if ($major -gt 255 -or $minor -gt 255 -or $build -gt 65535) {
|
||||
throw "Windows MSI 版本超出范围 (major/minor <= 255, patch <= 65535): $version"
|
||||
}
|
||||
$productVersion = "$major.$minor.$build"
|
||||
$wixArch = if ($archName -eq "Arm64") { "arm64" } else { "x64" }
|
||||
$componentGuid = if ($archName -eq "Arm64") {
|
||||
"7F4C7757-329F-42B0-B312-D4B8AD50415E"
|
||||
} else {
|
||||
"0BCEE70B-9CF2-449C-9ADE-190188493234"
|
||||
}
|
||||
$desktopShortcutComponentGuid = if ($archName -eq "Arm64") {
|
||||
"3CF7DCD2-2658-432D-8677-AB6BB34A9659"
|
||||
} else {
|
||||
"114030DD-DB5F-460A-9CAC-997680E9F938"
|
||||
}
|
||||
|
||||
$licenseFile = (Resolve-Path -LiteralPath "..\\..\\LICENSE").Path
|
||||
$noticeFile = (Resolve-Path -LiteralPath "..\\..\\NOTICE").Path
|
||||
$licenseText = Get-Content -LiteralPath $licenseFile -Raw
|
||||
$escapedLicense = $licenseText.Replace('\', '\\').Replace('{', '\{').Replace('}', '\}')
|
||||
$escapedLicense = $escapedLicense -replace "`r?`n", "\par`r`n"
|
||||
$licenseRtf = Join-Path $env:RUNNER_TEMP "GoNavi-License.rtf"
|
||||
$rtfContent = "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\f0\fs18 $escapedLicense}"
|
||||
Set-Content -LiteralPath $licenseRtf -Value $rtfContent -Encoding ascii
|
||||
|
||||
$sourceExe = (Resolve-Path -LiteralPath $finalExe).Path
|
||||
$wxsPath = (Resolve-Path -LiteralPath "..\\windows\\installer.wxs").Path
|
||||
$iconPath = (Resolve-Path -LiteralPath "..\\windows\\icon.ico").Path
|
||||
$installMarkerPath = Join-Path $env:RUNNER_TEMP ".gonavi-msi-install"
|
||||
Set-Content -LiteralPath $installMarkerPath -Value "MSI" -Encoding ascii -NoNewline
|
||||
$installMarkerPath = (Resolve-Path -LiteralPath $installMarkerPath).Path
|
||||
$wixIntermediateDir = Join-Path $env:RUNNER_TEMP "wixobj-$archName"
|
||||
Write-Host "📦 生成 Windows MSI 安装程序 $msiName (version=$productVersion arch=$wixArch)..."
|
||||
wix build $wxsPath `
|
||||
-arch $wixArch `
|
||||
-ext "WixToolset.UI.wixext/6.0.2" `
|
||||
-d "ProductName=GoNavi" `
|
||||
-d "ProductVersion=$productVersion" `
|
||||
-d "UpgradeCode=CDD6BF2F-ED1E-4345-A0AB-DCDB7E15FB23" `
|
||||
-d "ComponentGuid=$componentGuid" `
|
||||
-d "DesktopShortcutComponentGuid=$desktopShortcutComponentGuid" `
|
||||
-d "InstallFolderName=GoNavi" `
|
||||
-d "RegistryKeyName=GoNavi" `
|
||||
-d "SourceExe=$sourceExe" `
|
||||
-d "InstallMarker=$installMarkerPath" `
|
||||
-d "IconPath=$iconPath" `
|
||||
-d "LicenseRtf=$licenseRtf" `
|
||||
-d "LicenseFile=$licenseFile" `
|
||||
-d "NoticeFile=$noticeFile" `
|
||||
-pdbtype none `
|
||||
-intermediatefolder $wixIntermediateDir `
|
||||
-out "..\\..\\$msiName"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "WiX MSI 构建失败,退出码: $LASTEXITCODE"
|
||||
}
|
||||
|
||||
# Linux Packaging (tar.gz and AppImage)
|
||||
- name: Package Linux
|
||||
@@ -838,7 +1000,7 @@ jobs:
|
||||
|
||||
# 1. Create tar.gz
|
||||
echo "📦 正在打包 $TAR_NAME..."
|
||||
tar -czvf "$TAR_NAME" "$TARGET"
|
||||
tar -czvf "$TAR_NAME" "$TARGET" -C ../.. LICENSE NOTICE
|
||||
mv "$TAR_NAME" ../../
|
||||
|
||||
# 2. Create AppImage (skip for ARM64 or if tools unavailable)
|
||||
@@ -852,9 +1014,12 @@ jobs:
|
||||
# Create AppDir structure
|
||||
mkdir -p AppDir/usr/bin
|
||||
mkdir -p AppDir/usr/share/applications
|
||||
mkdir -p AppDir/usr/share/doc/gonavi
|
||||
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||
cp ../../LICENSE AppDir/usr/share/doc/gonavi/LICENSE
|
||||
cp ../../NOTICE AppDir/usr/share/doc/gonavi/NOTICE
|
||||
|
||||
# Create desktop file
|
||||
printf '%s\n' \
|
||||
@@ -907,9 +1072,11 @@ jobs:
|
||||
path: |
|
||||
GoNavi-*.dmg
|
||||
GoNavi-*.exe
|
||||
GoNavi-*.msi
|
||||
GoNavi-*.tar.gz
|
||||
GoNavi-*.AppImage
|
||||
drivers/**
|
||||
driver-provenance/**
|
||||
retention-days: 1
|
||||
|
||||
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
||||
@@ -923,6 +1090,11 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
@@ -930,9 +1102,29 @@ jobs:
|
||||
pattern: build-artifacts-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Add legal documents
|
||||
shell: bash
|
||||
run: install -m 0644 LICENSE NOTICE release-assets/
|
||||
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Download Previous Driver Manifest
|
||||
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
|
||||
env:
|
||||
DRIVER_RELEASE_SOURCE: ${{ needs.driver_agents.outputs.release_source }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_path="$RUNNER_TEMP/previous-driver-manifest.json"
|
||||
manifest_url="https://github.com/Syngnat/GoNavi-DriverAgents/releases/download/${DRIVER_RELEASE_SOURCE}/GoNavi-DriverAgents-Manifest.json"
|
||||
if curl --retry 3 --retry-delay 2 --retry-all-errors -fsSL "$manifest_url" -o "$manifest_path"; then
|
||||
echo "🧭 已下载上一版 driver manifest 作为复用资产 provenance"
|
||||
else
|
||||
rm -f "$manifest_path"
|
||||
echo "⚠️ 上一版 driver manifest 不可用;未重建资产将由 manifest 生成阶段拒绝"
|
||||
fi
|
||||
|
||||
- name: Complete Driver Agent Assets
|
||||
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
|
||||
env:
|
||||
@@ -1006,12 +1198,20 @@ jobs:
|
||||
drivers \
|
||||
../driver-release-assets
|
||||
|
||||
python3 ../tools/generate-driver-release-manifest.py \
|
||||
--assets-dir drivers \
|
||||
MANIFEST_ARGS=(
|
||||
--assets-dir drivers
|
||||
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
|
||||
)
|
||||
if [ -d driver-provenance ]; then
|
||||
MANIFEST_ARGS+=(--provenance driver-provenance)
|
||||
fi
|
||||
if [ -s "$RUNNER_TEMP/previous-driver-manifest.json" ]; then
|
||||
MANIFEST_ARGS+=(--provenance "$RUNNER_TEMP/previous-driver-manifest.json")
|
||||
fi
|
||||
python3 ../tools/generate-driver-release-manifest.py "${MANIFEST_ARGS[@]}"
|
||||
|
||||
# GoNavi 主仓库只保留主程序包;驱动资产发布到独立仓库。
|
||||
rm -rf drivers
|
||||
rm -rf drivers driver-provenance
|
||||
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
@@ -1021,6 +1221,9 @@ jobs:
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
case "$file" in
|
||||
SHA256SUMS|latest.json|latest-dev.json) continue ;;
|
||||
esac
|
||||
FILES+=("$file")
|
||||
fi
|
||||
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||
@@ -1031,6 +1234,25 @@ jobs:
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
fi
|
||||
|
||||
# 静态更新清单:客户端优先下载 github.com/.../latest/download/latest.json,
|
||||
# 避免终端用户检查更新打爆 api.github.com 未认证配额。
|
||||
- name: Generate static update manifest (latest.json)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ github.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
python3 tools/generate-update-latest-manifest.py \
|
||||
--assets-dir release-assets \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG" \
|
||||
--channel latest \
|
||||
--download-base-url https://download.syngnat.top/gonavi/releases/download \
|
||||
--output release-assets/latest.json
|
||||
test -s release-assets/latest.json
|
||||
echo "📄 latest.json ready:"
|
||||
head -n 40 release-assets/latest.json
|
||||
|
||||
- name: Generate Driver SHA256SUMS
|
||||
if: steps.driver_assets.outputs.has_driver_assets == 'true'
|
||||
shell: bash
|
||||
@@ -1080,65 +1302,19 @@ jobs:
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo-for-changelog
|
||||
TAG="${{ github.ref_name }}"
|
||||
# 获取上一个 tag
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "⚠️ 未找到上一个 tag,使用全部 commit"
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
echo "📋 生成更新日志:$RANGE"
|
||||
|
||||
# 提取 commit 消息(排除 merge commit)
|
||||
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
|
||||
if [ -z "$COMMITS" ]; then
|
||||
BODY="暂无提交记录。"
|
||||
else
|
||||
CAT_FEAT=""
|
||||
CAT_FIX=""
|
||||
CAT_PERF=""
|
||||
CAT_REFACTOR=""
|
||||
CAT_I18N=""
|
||||
CAT_OTHER=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in
|
||||
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
|
||||
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
|
||||
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
|
||||
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
|
||||
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
|
||||
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
esac
|
||||
done <<< "$COMMITS"
|
||||
|
||||
BODY=""
|
||||
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
|
||||
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
|
||||
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
|
||||
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
|
||||
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
|
||||
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
|
||||
|
||||
# 附加 compare 链接
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 写入到文件避免多行环境变量问题
|
||||
printf '%b' "$BODY" > /tmp/changelog.md
|
||||
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
|
||||
CHANGELOG_FILE="$RUNNER_TEMP/changelog.md"
|
||||
python3 tools/generate-release-notes.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--tag "${{ github.ref_name }}" \
|
||||
--repository-url "${{ github.server_url }}/${{ github.repository }}" \
|
||||
--output "$CHANGELOG_FILE"
|
||||
echo "changelog_file=$CHANGELOG_FILE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
4
.gitignore
vendored
@@ -11,10 +11,14 @@ build/bin/
|
||||
# wails / node artifacts (按需)
|
||||
node_modules/
|
||||
frontend/wailsjs/tsconfig.json
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
dist/
|
||||
.DS_Store
|
||||
.gemini-clipboard
|
||||
.env
|
||||
docker.*.env
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
|
||||
58
Dockerfile.build-env
Normal file
@@ -0,0 +1,58 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-bookworm
|
||||
ARG NODE_IMAGE=node:20-bookworm
|
||||
|
||||
FROM ${NODE_IMAGE} AS node
|
||||
|
||||
FROM ${GO_IMAGE}
|
||||
|
||||
ARG WEBKIT_API=4.0
|
||||
ARG WAILS_VERSION=v2.11.0
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PATH=/go/bin:/usr/local/go/bin:${PATH}
|
||||
|
||||
# Debian's /etc/profile resets PATH in login shells (bash -l), dropping the Go
|
||||
# toolchain; restore it via profile.d which is sourced after that reset.
|
||||
RUN printf 'export PATH=/go/bin:/usr/local/go/bin:$PATH\n' > /etc/profile.d/10-gonavi-build-env.sh
|
||||
|
||||
# Copy only Node.js runtime assets so the Go toolchain from the base image
|
||||
# stays intact for smoke tests and interactive build shells.
|
||||
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
|
||||
# npm/npx/corepack are symlinks in the node image; COPY dereferences them into
|
||||
# standalone scripts whose relative requires break, so recreate the symlinks.
|
||||
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||
&& ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
|
||||
&& ln -sf ../lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
file \
|
||||
git \
|
||||
make \
|
||||
pkg-config \
|
||||
python3 \
|
||||
unzip \
|
||||
zip \
|
||||
libgtk-3-dev \
|
||||
&& if [ "${WEBKIT_API}" = "4.1" ]; then \
|
||||
apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libsoup-3.0-dev; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev; \
|
||||
fi \
|
||||
&& go install github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /usr/share/doc/gonavi
|
||||
COPY LICENSE NOTICE /usr/share/doc/gonavi/
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
CMD ["bash"]
|
||||
49
Dockerfile.mcp-server
Normal file
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
|
||||
|
||||
# Declared without defaults so BuildKit injects the real target platform;
|
||||
# a default like TARGETARCH=amd64 would win and ship amd64 binaries to arm64.
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
|
||||
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/gonavi-mcp-server ./cmd/gonavi-mcp-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
|
||||
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
|
||||
&& chown -R gonavi:gonavi /data /var/lib/gonavi
|
||||
|
||||
COPY --from=builder /out/gonavi-mcp-server /usr/local/bin/gonavi-mcp-server
|
||||
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
|
||||
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
|
||||
|
||||
ENV HOME=/var/lib/gonavi \
|
||||
GONAVI_DATA_ROOT=/data \
|
||||
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
|
||||
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765 \
|
||||
GONAVI_MCP_HTTP_PATH=/mcp \
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8765
|
||||
|
||||
USER gonavi
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/gonavi-mcp-server"]
|
||||
CMD ["http"]
|
||||
65
Dockerfile.web-server
Normal file
@@ -0,0 +1,65 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# GoNavi Web Server(浏览器访问版)镜像。
|
||||
# 复用主程序 web-server 模式 + 嵌入 frontend/dist,不依赖桌面 WebView/CGO。
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20-bookworm AS frontend
|
||||
|
||||
# 使用 monorepo 布局:frontend 源码通过相对路径 ../../../shared/i18n 引用仓库 shared/
|
||||
# (见 frontend/src/i18n/catalog.ts、shared/i18n/messages.ts、shared/i18n/translate.ts)。
|
||||
# 仅 COPY frontend/ 会导致 tsc 报 Cannot find module '../../../shared/i18n/*'。
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
COPY frontend/patches ./patches
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
COPY shared/ /src/shared/
|
||||
# wailsjs 绑定已入库;容器内只构建静态前端
|
||||
RUN npm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
COPY third_party/highgo-pq/go.mod ./third_party/highgo-pq/go.mod
|
||||
COPY third_party/go-irisnative/go.mod third_party/go-irisnative/go.sum ./third_party/go-irisnative/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN ./tools/generate-driver-agent-revisions.sh --platform "${TARGETOS}/${TARGETARCH}"
|
||||
COPY --from=frontend /src/frontend/dist ./frontend/dist
|
||||
|
||||
# 纯 Go 构建主程序(web-server 模式不需要 Wails WebView / CGO)
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/gonavi .
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --create-home --home-dir /var/lib/gonavi --uid 10001 gonavi \
|
||||
&& mkdir -p /data /var/lib/gonavi/logs /usr/share/doc/gonavi \
|
||||
&& chown -R gonavi:gonavi /data /var/lib/gonavi
|
||||
|
||||
COPY --from=builder /out/gonavi /usr/local/bin/gonavi
|
||||
COPY --from=builder /src/LICENSE /usr/share/doc/gonavi/LICENSE
|
||||
COPY --from=builder /src/NOTICE /usr/share/doc/gonavi/NOTICE
|
||||
|
||||
ENV HOME=/var/lib/gonavi \
|
||||
GONAVI_DATA_ROOT=/data \
|
||||
GONAVI_LOG_DIR=/var/lib/gonavi/logs \
|
||||
GONAVI_WEB_ADDR=0.0.0.0:34116
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 34116
|
||||
|
||||
USER gonavi
|
||||
|
||||
# 未通过环境变量配置密码时,首次访问 /setup 初始化;认证状态落在数据目录 web_auth.json
|
||||
ENTRYPOINT ["/usr/local/bin/gonavi"]
|
||||
CMD ["web-server"]
|
||||
212
NOTICE
Normal file
@@ -0,0 +1,212 @@
|
||||
GoNavi
|
||||
Copyright 2026 Syngnat
|
||||
|
||||
This product includes software developed by the following projects.
|
||||
|
||||
Apache RocketMQ
|
||||
Copyright 2016-2022 The Apache Software Foundation
|
||||
|
||||
This product includes software developed at
|
||||
The Apache Software Foundation (http://www.apache.org/).
|
||||
|
||||
Apache IoTDB
|
||||
Copyright 2018-2020 The Apache Software Foundation.
|
||||
|
||||
This product includes software developed at
|
||||
The Apache Software Foundation (http://www.apache.org/).
|
||||
|
||||
============================================================================
|
||||
|
||||
IoTDB project uses 4 Chinese Patents:
|
||||
* 201711384490X
|
||||
* 201810111712.9
|
||||
* 201711322631.5
|
||||
* 201711319331.1
|
||||
|
||||
According to the Apache 2.0 License. The owner of the patents, Tsinghua University,
|
||||
grant the users the right to the use of patent under the requirement of Apache 2.0 License.
|
||||
|
||||
============================================================================
|
||||
|
||||
Apache Thrift
|
||||
Copyright (C) 2006 - 2019, The Apache Software Foundation
|
||||
|
||||
This product includes software developed at
|
||||
The Apache Software Foundation (http://www.apache.org/).
|
||||
|
||||
go-yaml
|
||||
Copyright 2011-2016 Canonical Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Eclipse Paho MQTT Go
|
||||
|
||||
This content is produced and maintained by the Eclipse Paho project.
|
||||
|
||||
* Project home: https://www.eclipse.org/paho/
|
||||
|
||||
Note that a separate mqtt v5 client (https://github.com/eclipse/paho.golang) also
|
||||
exists. It is a full rewrite and deliberately incompatible with this library.
|
||||
|
||||
Eclipse Mosquitto trademarks of the Eclipse Foundation. Eclipse, and the
|
||||
Eclipse Logo are registered trademarks of the Eclipse Foundation.
|
||||
|
||||
Paho is a trademark of the Eclipse Foundation. Eclipse, and the Eclipse Logo are
|
||||
registered trademarks of the Eclipse Foundation.
|
||||
|
||||
All content is the property of the respective authors or their employers.
|
||||
For more information regarding authorship of content, please consult the
|
||||
listed source code repository logs.
|
||||
|
||||
This program and the accompanying materials are made available under the terms of the
|
||||
Eclipse Public License v2.0 and Eclipse Distribution License v1.0 which accompany this
|
||||
distribution.
|
||||
|
||||
The Eclipse Public License is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/
|
||||
and the Eclipse Distribution License is available at
|
||||
http://www.eclipse.org/org/documents/edl-v10.php.
|
||||
|
||||
For an explanation of what dual-licensing means to you, see:
|
||||
https://www.eclipse.org/legal/eplfaq.php#DUALLIC
|
||||
|
||||
SPDX-License-Identifier: EPL-2.0 or BSD-3-Clause
|
||||
|
||||
The project maintains the following source code repository:
|
||||
|
||||
* https://github.com/eclipse/paho.mqtt.golang
|
||||
|
||||
This project makes use of the following third-party projects:
|
||||
|
||||
Go Programming Language and Standard Library
|
||||
* License: BSD-style license (https://golang.org/LICENSE)
|
||||
|
||||
Go Networking
|
||||
* License: BSD 3-Clause style license and patent grant.
|
||||
* Project: https://cs.opensource.google/go/x/net
|
||||
|
||||
Go Sync
|
||||
* License: BSD 3-Clause style license and patent grant.
|
||||
* Project: https://cs.opensource.google/go/x/sync/
|
||||
|
||||
Gorilla Websockets v1.4.2
|
||||
* License: BSD 2-Clause "Simplified" License
|
||||
* Project: https://github.com/gorilla/websocket
|
||||
|
||||
Content may contain encryption software. The country in which you are currently
|
||||
may have restrictions on the import, possession, and use, and/or re-export to
|
||||
another country. BEFORE using any encryption software, please check the country's
|
||||
laws, regulations and policies concerning the import, possession, and use, and/or
|
||||
re-export of encryption software, to see if this is permitted.
|
||||
|
||||
Vendored HighGo pq-sm3 (derived from lib/pq)
|
||||
Copyright (c) 2011-2013, 'pq' Contributors
|
||||
Portions Copyright (C) 2011 Blake Mizerany
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
The vendored SCRAM implementation includes the following BSD 3-Clause notice:
|
||||
|
||||
Copyright (c) 2014 - Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Tailscale hujson
|
||||
Project: https://github.com/tailscale/hujson
|
||||
|
||||
Copyright (c) 2019 Tailscale Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google Inc. nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Vendored InterSystems IRIS native driver
|
||||
Copyright (c) 2025 Dmitry Maslennikov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
515
README.md
@@ -1,42 +1,182 @@
|
||||
# GoNavi - A Modern Lightweight Database Client
|
||||
<p align="center">
|
||||
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
|
||||
</p>
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://wails.io)
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
<h1 align="center">GoNavi</h1>
|
||||
|
||||
**Language**: English | [简体中文](README.zh-CN.md)
|
||||
<p align="center">
|
||||
<b>Navigate every data source — native speed, agent-ready, zero Electron bloat.</b>
|
||||
</p>
|
||||
|
||||
GoNavi is a modern, high-performance, cross-platform database client built with **Wails (Go)** and **React**.
|
||||
It delivers native-like responsiveness with low resource usage.
|
||||
<p align="center">
|
||||
A high-performance cross-platform database client built with
|
||||
<a href="https://wails.io">Wails</a> (Go) + <a href="https://react.dev">React</a>.
|
||||
Desktop-first. MCP-ready. ~30MB class binaries.
|
||||
</p>
|
||||
|
||||
Compared with many Electron-based clients, GoNavi is typically smaller in binary size (around 10MB class), starts faster, and uses less memory.
|
||||
<p align="center">
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
|
||||
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
|
||||
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Language</b>: English · <a href="README.zh-CN.md">简体中文</a>
|
||||
·
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ Download</b></a>
|
||||
·
|
||||
<a href="#-quick-start"><b>⚡ Quick Start</b></a>
|
||||
·
|
||||
<a href="#-key-features"><b>✨ Features</b></a>
|
||||
·
|
||||
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
## Why GoNavi?
|
||||
|
||||
GoNavi is designed for developers and DBAs who need a unified desktop experience across multiple databases.
|
||||
Most database GUIs are Electron shells with megabytes of tax. GoNavi takes a different path:
|
||||
|
||||
- **Native-performance architecture**: Wails (Go + WebView) with lightweight runtime overhead.
|
||||
- **Large dataset usability**: virtualized rendering and optimized DataGrid workflows for high-volume tables.
|
||||
- **Unified connectivity**: URI build/parse, SSH tunnel, proxy support, and on-demand driver activation.
|
||||
- **Production-oriented workflow**: SQL editor, object management, batch export/backup, sync tools, execution logs, and update checks.
|
||||
| | Typical Electron client | **GoNavi** |
|
||||
|---|---|---|
|
||||
| Runtime | Chromium + Node | **Go + native WebView** |
|
||||
| Binary size | Hundreds of MB | **~30MB class** |
|
||||
| Startup | Heavy | **Fast** |
|
||||
| Memory | High baseline | **Lean** |
|
||||
| AI / Agents | Bolt-on or absent | **First-class MCP + multi-provider AI** |
|
||||
| Data sources | Mostly RDBMS | **SQL · Cache · Vector · MQ · Search · Time-series · Domestic DBs** |
|
||||
|
||||
## Supported Data Sources
|
||||
> **One cockpit for MySQL, Postgres, Redis, Kafka, Milvus, OceanBase, ClickHouse…**
|
||||
> Query, edit, audit, sync — and hand structured context to coding agents without leaking passwords off-host.
|
||||
|
||||
> `Built-in`: available out of the box.
|
||||
> `Optional driver agent`: install/enable via Driver Manager first.
|
||||
---
|
||||
|
||||
## At a Glance
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ GoNavi Workbench │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Connections │ │ Monaco SQL + AI │ │ Virtualized DataGrid │ │
|
||||
│ │ SSH / Proxy │ │ Schema context │ │ Batch edit · Export │ │
|
||||
│ │ Drivers │ │ Slash commands │ │ Txn submit / rollback │ │
|
||||
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────▼─────────────┐ │
|
||||
│ │ Go core · Audit · Sync │ │
|
||||
│ │ MCP HTTP · Web Server │ │
|
||||
│ └───────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Product screenshots
|
||||
|
||||
Each image is a **full GoNavi application window**, scaled proportionally for README display.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi full window — connections, queries, and workbench" width="560" />
|
||||
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi full window — AI assistant with schema context" width="560" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi full window — new connection data-source selector" width="560" />
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>Real desktop captures · full window</sub></p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🤖 AI that knows your schema
|
||||
- OpenAI · Gemini · Claude · custom OpenAI-compatible APIs
|
||||
- Attach live table schemas as context
|
||||
- Slash commands: generate SQL, explain, optimize, review
|
||||
- **MCP**: install into Claude Code / Codex, or Streamable HTTP for remote agents
|
||||
- Secrets stay on the GoNavi host — agents get tools, not raw passwords
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚡ Built for large data
|
||||
- Virtualized DataGrid for heavy result sets
|
||||
- In-place cell edit · batch CRUD · transaction submit/rollback
|
||||
- Large-field popup editor · smart read/write modes
|
||||
- Export: CSV · XLSX · JSON · Markdown
|
||||
- Monaco editor with context-aware completion
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🔌 Connectivity without drama
|
||||
- URI generate / parse
|
||||
- SSH tunnel · proxy
|
||||
- Connection JSON import / export
|
||||
- On-demand optional driver agents
|
||||
- Custom Driver + DSN extensibility
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🛡️ Observable & shippable
|
||||
- SQL execution logs with timing
|
||||
- Audit center (redacted by default, retention, export)
|
||||
- Desktop + experimental **Web Server** mode
|
||||
- Docker / K8s / Helm / Podman packaging
|
||||
- Auto update checks · multi-arch releases
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🧩 Stack
|
||||
|
||||
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Supported Data Sources
|
||||
|
||||
> **Built-in** — ready out of the box · **Optional agent** — install via Driver Manager
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Built-in** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
|
||||
| **Optional** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · Dameng · Kingbase · HighGo · Vastbase · OpenGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
|
||||
|
||||
<details>
|
||||
<summary><b>Full capability matrix</b></summary>
|
||||
|
||||
| Category | Data Source | Driver Mode | Typical Capabilities |
|
||||
|---|---|---|---|
|
||||
| Relational | MySQL | Built-in | Schema browsing, SQL query, data editing, export/backup |
|
||||
| Domestic DB | GoldenDB | Built-in | MySQL-compatible query workflow and distributed transaction scenarios |
|
||||
| Relational | PostgreSQL | Built-in | Schema browsing, SQL query, data editing, object management |
|
||||
| Relational | Oracle | Built-in | Query execution, object browsing, data editing |
|
||||
| Cache | Redis | Built-in | Key browsing, command execution, encoding/view switch |
|
||||
| Vector Database | Chroma | Built-in | Collection browsing, vector retrieval, metadata filtering |
|
||||
| Vector Database | Qdrant | Built-in | Collection browsing, vector search, payload filtering |
|
||||
| Vector Database | Milvus | Built-in | Collection browsing, vector search, scalar filtering |
|
||||
| Message Queue | RocketMQ | Built-in | Topic browsing, consumer-group inspection, message-oriented workflow |
|
||||
| Message Queue | MQTT | Built-in | Broker and topic-filter workflow with QoS-aware connection settings |
|
||||
| Message Queue | Kafka | Built-in | Topic browsing, broker metadata, consumer-group workflow |
|
||||
| Message Queue | RabbitMQ | Built-in | Queue/exchange browsing, virtual host inspection, management API workflow |
|
||||
| Relational | MariaDB | Optional driver agent | Querying, object management, data editing |
|
||||
| Relational | Doris | Optional driver agent | Querying, object browsing, SQL execution |
|
||||
| Columnar Analytics | StarRocks | Optional driver agent | Querying, object browsing, SQL execution |
|
||||
@@ -44,163 +184,234 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| Relational | SQL Server | Optional driver agent | Schema browsing, SQL query, object management |
|
||||
| File-based | SQLite | Optional driver agent | Local DB browsing, editing, export |
|
||||
| File-based | DuckDB | Optional driver agent | Large-table query, pagination, file-DB workflow |
|
||||
| Domestic DB | OceanBase | Optional driver agent | MySQL / Oracle tenant access, object browsing, query workflow |
|
||||
| Domestic DB | Dameng | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Kingbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | HighGo | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | Vastbase | Optional driver agent | Querying, object browsing, data editing |
|
||||
| Domestic DB | OpenGauss | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
|
||||
| Domestic DB | GaussDB | Optional driver agent | PostgreSQL-like schema browsing, SQL query, object management |
|
||||
| Multi-model | InterSystems IRIS | Optional driver agent | Namespace browsing, SQL query, object management |
|
||||
| Document | MongoDB | Optional driver agent | Document query, collection browsing, connection management |
|
||||
| Time-series | TDengine | Optional driver agent | Time-series schema browsing and querying |
|
||||
| Time-series | Apache IoTDB | Optional driver agent | Storage group / device / timeseries browsing and querying |
|
||||
| Columnar Analytics | ClickHouse | Optional driver agent | Analytical query, object browsing, SQL execution |
|
||||
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
|
||||
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
<h2 align="center">📸 Screenshots</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### AI Assistant (New)
|
||||
- **Multi-provider Support**: OpenAI, Google Gemini, Anthropic Claude, and custom API support.
|
||||
- **Context-Aware Chat**: Attach table schemas to the AI context for accurate SQL generation and assistance.
|
||||
- **Slash Commands**: Quick commands for generating SQL, explaining queries, optimizing performance, and reviewing schema designs.
|
||||
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
|
||||
### Data Management (DataGrid)
|
||||
- In-place cell editing.
|
||||
- Batch insert/update/delete with transaction-oriented submit/rollback.
|
||||
- Large-field popup editor.
|
||||
- Context actions (set NULL, copy/export, etc.).
|
||||
- Smart read/write mode switching based on query context.
|
||||
- Export formats: CSV, Excel (XLSX), JSON, Markdown.
|
||||
|
||||
### SQL Editor
|
||||
- Monaco Editor core.
|
||||
- Context-aware completion for databases/tables/columns.
|
||||
- Multi-tab query workflow.
|
||||
|
||||
### Batch Export / Backup
|
||||
- Database-level and table-level batch export/backup.
|
||||
- Scope-aware operation flow to reduce mistakes.
|
||||
|
||||
### Connectivity
|
||||
- URI generation/parsing.
|
||||
- SSH tunnel support.
|
||||
- Proxy support.
|
||||
- Config import/export (JSON).
|
||||
- Optional driver management and activation.
|
||||
|
||||
### Redis Tools
|
||||
- Multi-view value rendering (auto/raw text/UTF-8/hex).
|
||||
- Built-in command execution panel.
|
||||
|
||||
### Observability and Update
|
||||
- SQL execution logs with timing information.
|
||||
- Startup/scheduled/manual update checks.
|
||||
|
||||
### UI/UX
|
||||
- Ant Design 5 based interface.
|
||||
- Light/Dark themes.
|
||||
- Flexible sidebar and layout behavior.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Go 1.24 + Wails v2
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **UI**: Ant Design 5
|
||||
- **State Management**: Zustand
|
||||
- **Editor**: Monaco Editor
|
||||
|
||||
---
|
||||
|
||||
## Installation and Run
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Go](https://go.dev/dl/) 1.21+
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation)
|
||||
|
||||
### Development Mode
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
```
|
||||
|
||||
```shell
|
||||
# Clone
|
||||
### Develop
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# Start development with hot reload
|
||||
wails dev
|
||||
|
||||
# Faster local startup when exported Go method signatures are unchanged
|
||||
node tools/wails-fast-dev.mjs
|
||||
|
||||
# Refresh Wails JS bindings after changing exported Go method signatures
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
wails dev # full hot reload
|
||||
node tools/wails-fast-dev.mjs # faster when Go exports unchanged
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings # after Go export signature changes
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
wails build
|
||||
|
||||
# Clean build (recommended before release)
|
||||
wails build -clean
|
||||
wails build -clean # clean build before release
|
||||
```
|
||||
|
||||
Artifacts are generated in `build/bin`.
|
||||
Artifacts → `build/bin`.
|
||||
|
||||
### Cross-Platform Release (GitHub Actions)
|
||||
### Prefer a binary?
|
||||
|
||||
The repository includes a release workflow.
|
||||
Push a `v*` tag to trigger automated build and release.
|
||||
Release notes are generated automatically from merged pull requests and categorized by `.github/release.yaml`.
|
||||
|
||||
Target artifacts include:
|
||||
- macOS (AMD64 / ARM64)
|
||||
- Windows (AMD64)
|
||||
- Linux (AMD64, WebKitGTK 4.0 and 4.1 variants)
|
||||
Grab the latest build from **[Releases](https://github.com/Syngnat/GoNavi/releases)**
|
||||
(macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
## 🌐 Web Server (Experimental)
|
||||
|
||||
### macOS: "App is damaged and can’t be opened"
|
||||
Same Go backend + React UI over HTTP — **not** a containerized Wails window.
|
||||
|
||||
Without Apple notarization, Gatekeeper may block startup.
|
||||
```powershell
|
||||
go build .
|
||||
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
1. Move `GoNavi.app` to **Applications**.
|
||||
2. Open **Terminal**.
|
||||
3. Run:
|
||||
- First visit → `/setup` (admin password; optional Google Authenticator)
|
||||
- Bridge: `window.go.*` / `window.runtime.*` → HTTP / SSE
|
||||
- Sessions, recovery codes, login rate limits
|
||||
|
||||
#### Docker / Podman
|
||||
|
||||
```bash
|
||||
cp docker.web-server.env.example docker.web-server.env
|
||||
# set GONAVI_HOST_DATA_ROOT (absolute path)
|
||||
# optional: GONAVI_WEB_PASSWORD (min 6 chars)
|
||||
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:34116`. Mount the active data root at `/data`
|
||||
(`connections.json`, `daily_secrets.json`, optional `drivers/`).
|
||||
Auth state → `web_auth.json`.
|
||||
|
||||
> **Do not expose an unhardened web entry to the public internet.** Use reverse proxy + HTTPS in production.
|
||||
|
||||
Health: `GET /__gonavi/healthz`
|
||||
Local source build: add `-f docker-compose.web-server.local.yml --build`.
|
||||
|
||||
After changing env passwords:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
(`docker restart` does **not** reload env files.)
|
||||
|
||||
---
|
||||
|
||||
## 🤖 MCP & Agents
|
||||
|
||||
Ship schema tools to agents without shipping your vault:
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
| Surface | Entry |
|
||||
|---|---|
|
||||
| MCP container | `docker-compose.mcp-server.yml` → `ghcr.io/syngnat/gonavi-mcp-server` |
|
||||
| Web UI container | `docker-compose.web-server.yml` → `ghcr.io/syngnat/gonavi-web-server` |
|
||||
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
|
||||
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
|
||||
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
|
||||
| Build-only image | `Dockerfile.build-env` → `ghcr.io/syngnat/gonavi-build-env` |
|
||||
|
||||
Safety defaults: remote `schema-only` omits `execute_sql`; mutating SQL requires explicit `allowMutating=true`.
|
||||
Details: [cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md).
|
||||
|
||||
### Linux build environment only
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Release Pipeline
|
||||
|
||||
Push a `v*` tag → GitHub Actions builds multi-arch releases.
|
||||
Notes auto-generated from merged PRs via `.github/release.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
<details>
|
||||
<summary><b>Windows: missing Microsoft Edge WebView2 Runtime (common on intranet images)</b></summary>
|
||||
|
||||
The GoNavi desktop app on Windows depends on the **Microsoft Edge WebView2 Runtime** (a system component, not full Chrome).
|
||||
Some intranet / thin / Server / LTSC images ship without it. Symptoms:
|
||||
|
||||
- Process exits immediately, blank/white window
|
||||
- Errors about missing WebView2 / WebView2 Runtime
|
||||
- Installer blocked by AV or group policy
|
||||
|
||||
### 1. Check whether Runtime is installed
|
||||
|
||||
In **PowerShell**:
|
||||
|
||||
```powershell
|
||||
# Common Evergreen install path (64-bit Windows)
|
||||
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
|
||||
|
||||
# Registry (a `pv` version usually means installed)
|
||||
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Select-Object pv, name
|
||||
```
|
||||
|
||||
If the path is missing and registry has no `pv`, install the Runtime.
|
||||
|
||||
### 2. Online install (simplest)
|
||||
|
||||
1. Open the official download page:
|
||||
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
|
||||
2. Download the **Evergreen Bootstrapper** (small; needs network during install)
|
||||
3. Run as Administrator, then **restart GoNavi**
|
||||
|
||||
### 3. Offline / intranet: Standalone installer (recommended for IT)
|
||||
|
||||
The Bootstrapper fails on fully air-gapped machines. Use the **Evergreen Standalone Installer** instead:
|
||||
|
||||
1. On a machine with internet, download the matching architecture package, e.g.:
|
||||
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe` (most 64-bit PCs)
|
||||
- `…X86.exe` / `…ARM64.exe` as needed
|
||||
2. Copy the installer into the intranet (USB, software center, share)
|
||||
3. Install **as Administrator** on the target PC:
|
||||
|
||||
```powershell
|
||||
# Interactive
|
||||
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
|
||||
|
||||
# Silent (batch / SCCM friendly)
|
||||
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
|
||||
```
|
||||
|
||||
4. Start GoNavi again. If it still fails, sign out or reboot Windows once.
|
||||
|
||||
### 4. Group policy / locked-down PCs
|
||||
|
||||
- Needs local admin, or IT push via SCCM / software center
|
||||
- Ensure policies do not block Edge/WebView2 install or updates
|
||||
- Enterprises may pin a [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) Runtime (most users should prefer Evergreen)
|
||||
|
||||
### 5. Temporary workaround: Web Server mode
|
||||
|
||||
If the desktop WebView cannot be installed yet, run the experimental Web Server and use a normal browser:
|
||||
|
||||
```powershell
|
||||
.\GoNavi.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:34116`. See the **Web Server** section above.
|
||||
Do **not** expose an unhardened Web endpoint to the public internet.
|
||||
|
||||
Tracker / discussion: [#672](https://github.com/Syngnat/GoNavi/issues/672).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS: “App is damaged and can’t be opened”</b></summary>
|
||||
|
||||
Without Apple notarization, Gatekeeper may block the app:
|
||||
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
|
||||
Or right-click the app in Finder and choose **Open** with Control key flow.
|
||||
Or right-click → Open (Control-click flow). Move the app to **Applications** first.
|
||||
|
||||
### Linux: missing `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
</details>
|
||||
|
||||
GoNavi depends on WebKitGTK runtime libraries.
|
||||
<details>
|
||||
<summary><b>Linux: missing WebKitGTK</b></summary>
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
@@ -212,11 +423,12 @@ sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
If you use Linux artifacts with the `-WebKit41` suffix, prefer Debian 13 / Ubuntu 24.04+.
|
||||
Artifacts with `-WebKit41` prefer Debian 13 / Ubuntu 24.04+.
|
||||
|
||||
### Linux: Chinese text appears as square boxes
|
||||
</details>
|
||||
|
||||
Minimal Ubuntu 24.04 LTS desktop/server environments may not include Chinese CJK fonts. Install Noto / WenQuanYi fonts and restart GoNavi:
|
||||
<details>
|
||||
<summary><b>Linux: Chinese glyphs as tofu boxes</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
@@ -224,19 +436,32 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
|
||||
fc-cache -fv
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
## 🤝 Contributing
|
||||
|
||||
Issues and pull requests are welcome.
|
||||
|
||||
For the full workflow, branch model, and maintainer sync rules, see:
|
||||
Issues and PRs welcome. Branch from **`dev`**, PR against **`dev`**.
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Call for contributors: [Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
|
||||
|
||||
External contributors should branch from `dev` and open pull requests against `dev`.
|
||||
---
|
||||
|
||||
## 💬 Contact & Feedback
|
||||
|
||||
| Channel | Notes |
|
||||
|---|---|
|
||||
| **GitHub Issues** | Preferred for bugs, feature requests, and docs — easier to track and reproduce |
|
||||
| **WeChat** | `ygf1140302783` (note “GoNavi”) — maintainer can invite you to the community group for discussion |
|
||||
|
||||
> For technical reports, include version, OS, and repro steps when possible. Windows blank/crash on intranet images: [WebView2 guide](https://github.com/Syngnat/GoNavi/issues/672).
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
## Star History
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=Syngnat/GoNavi&type=date&theme=dark&legend=top-left" />
|
||||
@@ -245,6 +470,8 @@ External contributors should branch from `dev` and open pull requests against `d
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
@@ -252,4 +479,8 @@ External contributors should branch from `dev` and open pull requests against `d
|
||||
|
||||
## License
|
||||
|
||||
Licensed under [Apache-2.0](LICENSE).
|
||||
[Apache-2.0](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<sub>Built for people who live in SQL, schemas, and agent loops.</sub>
|
||||
</p>
|
||||
|
||||
507
README.zh-CN.md
@@ -1,41 +1,181 @@
|
||||
# GoNavi - 现代化轻量级数据库客户端
|
||||
<p align="center">
|
||||
<img src="assets/readme-logo.png" width="128" alt="GoNavi Logo" />
|
||||
</p>
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://wails.io)
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
<h1 align="center">GoNavi</h1>
|
||||
|
||||
**语言**: [English](README.md) | 简体中文
|
||||
<p align="center">
|
||||
<b>一套客户端,航行全部数据源 — 原生性能 · Agent 就绪 · 告别 Electron 膨胀。</b>
|
||||
</p>
|
||||
|
||||
GoNavi 是基于 **Wails (Go)** 与 **React** 构建的跨平台数据库管理工具,强调原生性能、低资源占用与多数据源统一工作流。
|
||||
<p align="center">
|
||||
基于 <a href="https://wails.io">Wails</a>(Go)+ <a href="https://react.dev">React</a> 的跨平台数据库工作台。
|
||||
桌面优先,MCP 原生,二进制约 <b>30MB</b> 量级。
|
||||
</p>
|
||||
|
||||
相比常见 Electron 客户端,GoNavi 在体积、启动速度和内存占用上更轻量。
|
||||
<p align="center">
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/v/release/Syngnat/GoNavi?style=for-the-badge&color=8B5CF6" alt="Release" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><img src="https://img.shields.io/github/downloads/Syngnat/GoNavi/total?style=for-the-badge&color=6366F1&label=downloads" alt="Downloads" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/stargazers"><img src="https://img.shields.io/github/stars/Syngnat/GoNavi?style=for-the-badge&color=F59E0B" alt="Stars" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-22C55E?style=for-the-badge" alt="License" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/github/go-mod/go-version/Syngnat/GoNavi?style=flat-square&logo=go&logoColor=white&label=Go" alt="Go" /></a>
|
||||
<a href="https://wails.io"><img src="https://img.shields.io/badge/Wails-v2-red?style=flat-square" alt="Wails" /></a>
|
||||
<a href="https://reactjs.org"><img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React" /></a>
|
||||
<a href="https://github.com/Syngnat/GoNavi/actions"><img src="https://img.shields.io/github/actions/workflow/status/Syngnat/GoNavi/release.yml?style=flat-square&label=Build" alt="Build" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>语言</b>:<a href="README.md">English</a> · 简体中文
|
||||
·
|
||||
<a href="https://github.com/Syngnat/GoNavi/releases"><b>⬇ 下载</b></a>
|
||||
·
|
||||
<a href="#-快速开始"><b>⚡ 快速开始</b></a>
|
||||
·
|
||||
<a href="#-核心能力"><b>✨ 特性</b></a>
|
||||
·
|
||||
<a href="#-mcp--agents"><b>🤖 MCP</b></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
## 为什么是 GoNavi?
|
||||
|
||||
GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做到“快、稳、统一”。
|
||||
市面上大量数据库 GUI 仍是 Electron 壳:体积大、启动慢、内存高。GoNavi 换了一条路:
|
||||
|
||||
- **原生性能架构**:Wails(Go + WebView),降低运行时开销。
|
||||
- **大数据可用性**:虚拟滚动 + DataGrid 交互优化,提升大结果集可操作性。
|
||||
- **统一连接能力**:支持 URI 生成/解析、SSH 隧道、代理、驱动按需安装。
|
||||
- **工程化能力完整**:覆盖 SQL 编辑、对象管理、批量导出/备份、数据同步、执行日志、在线更新。
|
||||
| | 常见 Electron 客户端 | **GoNavi** |
|
||||
|---|---|---|
|
||||
| 运行时 | Chromium + Node | **Go + 系统 WebView** |
|
||||
| 体积 | 动辄数百 MB | **约 30MB 量级** |
|
||||
| 启动 | 偏重 | **更快** |
|
||||
| 内存 | 基线高 | **更轻** |
|
||||
| AI / Agent | 外挂或缺失 | **MCP + 多模型一等公民** |
|
||||
| 数据源 | 以 RDBMS 为主 | **SQL · 缓存 · 向量 · 消息 · 搜索 · 时序 · 国产库** |
|
||||
|
||||
## 支持的数据源
|
||||
> **MySQL、Postgres、Redis、Kafka、Milvus、OceanBase、ClickHouse… 一个工作台打通。**
|
||||
> 查询、编辑、审计、同步;把结构化上下文交给编码 Agent,密码仍留在本机。
|
||||
|
||||
> `内置`:主程序开箱即用。
|
||||
> `可选驱动代理`:需在驱动管理中安装启用后可用。
|
||||
---
|
||||
|
||||
## 一眼看懂
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ GoNavi 工作台 │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ 连接管理 │ │ Monaco SQL + AI │ │ 虚拟滚动 DataGrid │ │
|
||||
│ │ SSH / 代理 │ │ 表结构上下文 │ │ 批量编辑 · 导出 │ │
|
||||
│ │ 驱动代理 │ │ 快捷指令 │ │ 事务提交 / 回滚 │ │
|
||||
│ └─────────────┘ └────────┬─────────┘ └─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────▼─────────────┐ │
|
||||
│ │ Go 核心 · 审计 · 同步 │ │
|
||||
│ │ MCP HTTP · Web Server │ │
|
||||
│ └───────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 产品截图
|
||||
|
||||
每张均为 **完整 GoNavi 应用窗口**,再通过 README 宽度控件等比例缩小展示。
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/01-home-workbench.png" alt="GoNavi 全窗口 — 连接、查询与工作台" width="560" />
|
||||
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/04-ai-assistant.png" alt="GoNavi 全窗口 — 带表结构上下文的 AI 助手" width="560" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Syngnat/GoNavi/dev/assets/screenshots/06-new-connection.png" alt="GoNavi 全窗口 — 新建连接的数据源选择器" width="560" />
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>真实桌面全窗口截图</sub></p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心能力
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🤖 懂表结构的 AI
|
||||
- OpenAI · Gemini · Claude · 自定义 OpenAI 兼容 API
|
||||
- 将当前库表结构注入对话上下文
|
||||
- 快捷指令:生成 SQL、解释、优化、表设计评审
|
||||
- **MCP**:一键装到 Claude Code / Codex,或 Streamable HTTP 给远端 Agent
|
||||
- 连接与密码留在运行 GoNavi 的主机上
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚡ 大数据仍流畅
|
||||
- 虚拟滚动 DataGrid,扛住大结果集
|
||||
- 单元格编辑 · 批量增删改 · 事务提交/回滚
|
||||
- 大字段弹窗 · 按查询智能切换读写
|
||||
- 导出:CSV · XLSX · JSON · Markdown
|
||||
- Monaco + 库/表/字段上下文补全
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🔌 连接与驱动
|
||||
- URI 生成 / 解析
|
||||
- SSH 隧道 · 代理
|
||||
- 连接配置 JSON 导入导出
|
||||
- 可选驱动代理按需安装
|
||||
- Custom Driver + DSN 扩展
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### 🛡️ 可观测 & 可交付
|
||||
- SQL 执行日志(含耗时)
|
||||
- 审计中心(默认脱敏、保留策略、导出)
|
||||
- 桌面端 + 实验性 **Web Server**
|
||||
- Docker / K8s / Helm / Podman
|
||||
- 更新检查 · 多架构发布
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🧩 技术栈
|
||||
|
||||
`Go 1.24` · `Wails v2` · `React 18` · `TypeScript` · `Vite` · `Ant Design 5` · `Zustand` · `Monaco`
|
||||
|
||||
---
|
||||
|
||||
## 🗄 支持的数据源
|
||||
|
||||
> **内置**:开箱即用 · **可选驱动代理**:在驱动管理中安装启用
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **内置** | MySQL · GoldenDB · PostgreSQL · Oracle · Redis · Chroma · Qdrant · Milvus · RocketMQ · MQTT · Kafka · RabbitMQ |
|
||||
| **可选** | MariaDB · Doris · StarRocks · Sphinx · SQL Server · SQLite · DuckDB · OceanBase · 达梦 · 人大金仓 · 瀚高 · 海量 · openGauss · GaussDB · IRIS · MongoDB · TDengine · IoTDB · ClickHouse · Trino · Elasticsearch · Custom Driver/DSN |
|
||||
|
||||
<details>
|
||||
<summary><b>完整能力矩阵</b></summary>
|
||||
|
||||
| 类别 | 数据源 | 驱动模式 | 典型能力 |
|
||||
|---|---|---|---|
|
||||
| 关系型 | MySQL | 内置 | 库表浏览、SQL 查询、数据编辑、导出/备份 |
|
||||
| 国产数据库 | GoldenDB | 内置 | MySQL 兼容查询工作流、分布式事务场景 |
|
||||
| 关系型 | PostgreSQL | 内置 | 库表浏览、SQL 查询、数据编辑、对象管理 |
|
||||
| 关系型 | Oracle | 内置 | 连接查询、对象浏览、数据编辑 |
|
||||
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
|
||||
| 向量数据库 | Chroma | 内置 | Collection 浏览、向量检索、元数据过滤 |
|
||||
| 向量数据库 | Qdrant | 内置 | Collection 浏览、向量搜索、Payload 过滤 |
|
||||
| 向量数据库 | Milvus | 内置 | Collection 浏览、向量搜索、标量过滤 |
|
||||
| 消息队列 | RocketMQ | 内置 | Topic 浏览、消费组检查、消息型工作流 |
|
||||
| 消息队列 | MQTT | 内置 | Broker / Topic Filter 工作流与 QoS 连接配置 |
|
||||
| 消息队列 | Kafka | 内置 | Topic 浏览、Broker 元数据、消费组工作流 |
|
||||
| 消息队列 | RabbitMQ | 内置 | Queue / Exchange 浏览、Virtual Host 检查、Management API 工作流 |
|
||||
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
|
||||
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
@@ -43,149 +183,235 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
| 文件型 | DuckDB | 可选驱动代理 | 大表查询、分页浏览、文件库管理 |
|
||||
| 国产数据库 | OceanBase | 可选驱动代理 | MySQL / Oracle 租户接入、对象浏览、查询工作流 |
|
||||
| 国产数据库 | Dameng | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Kingbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | HighGo | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | Vastbase | 可选驱动代理 | 连接查询、对象浏览、数据编辑 |
|
||||
| 国产数据库 | OpenGauss | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
|
||||
| 国产数据库 | GaussDB | 可选驱动代理 | 类 PostgreSQL 的库表浏览、SQL 查询、对象管理 |
|
||||
| 多模型数据库 | InterSystems IRIS | 可选驱动代理 | Namespace 浏览、SQL 查询、对象管理 |
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 时序 | Apache IoTDB | 可选驱动代理 | Storage Group / Device / Timeseries 浏览与查询 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、SQL 执行 |
|
||||
| 搜索 | Elasticsearch | 可选驱动代理 | 索引浏览、Mapping 检查、JSON DSL / query_string 查询 |
|
||||
| 扩展接入 | Custom Driver/DSN | 自定义 | 通过 Driver + DSN 接入更多数据源 |
|
||||
|
||||
<h2 align="center">📸 项目截图</h2>
|
||||
|
||||
<div align="center">
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/0eefe07f-2836-44fa-9ddf-a0d2124b90e2" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/6765e539-83ea-4cd6-9c9e-f42790fa05b5" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/60e3d187-171a-4248-94e0-c6b08736e235" />
|
||||
<br />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/7a478602-0f08-4b30-8f6a-879f4a60ae32" />
|
||||
<img width="14%" alt="image" src="https://github.com/user-attachments/assets/6442ca7d-ce9e-46d9-aecd-405ba88f5a5e" />
|
||||
<img width="25%" alt="image" src="https://github.com/user-attachments/assets/bc17895e-02a4-4cc5-b471-c3803cf25a2b" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### AI 智能助手 (New)
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
|
||||
### 数据管理(DataGrid)
|
||||
- 单元格所见即所得编辑。
|
||||
- 批量新增/修改/删除,支持事务提交与回滚。
|
||||
- 大字段弹窗编辑。
|
||||
- 右键上下文操作(NULL、复制、导出等)。
|
||||
- 根据查询上下文智能切换读写模式。
|
||||
- 支持 CSV / XLSX / JSON / Markdown 导出。
|
||||
|
||||
### SQL 编辑器
|
||||
- 基于 Monaco Editor。
|
||||
- 上下文补全(数据库/表/字段)。
|
||||
- 多标签查询工作流。
|
||||
|
||||
### 连接与驱动
|
||||
- URI 生成与解析。
|
||||
- SSH 隧道、代理支持。
|
||||
- 连接配置 JSON 导入/导出。
|
||||
- 可选驱动安装与启用管理。
|
||||
|
||||
### Redis 工具
|
||||
- 自动/原始文本/UTF-8/十六进制等视图模式。
|
||||
- 内置命令执行面板。
|
||||
|
||||
### 可观测性与更新
|
||||
- SQL 执行日志(含耗时)。
|
||||
- 启动/定时/手动更新检查。
|
||||
|
||||
### UI 体验
|
||||
- Ant Design 5 体系。
|
||||
- 深色/浅色主题切换。
|
||||
- 灵活布局与侧边栏行为。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Go 1.24 + Wails v2
|
||||
- **前端**: React 18 + TypeScript + Vite
|
||||
- **UI 框架**: Ant Design 5
|
||||
- **状态管理**: Zustand
|
||||
- **编辑器**: Monaco Editor
|
||||
|
||||
---
|
||||
|
||||
## 安装与运行
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- [Go](https://go.dev/dl/) 1.21+
|
||||
- [Node.js](https://nodejs.org/) 18+
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation):
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
|
||||
- [Wails CLI](https://wails.io/docs/gettingstarted/installation)
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
```
|
||||
|
||||
```shell
|
||||
# 克隆项目
|
||||
### 开发
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
|
||||
# 启动开发(热重载)
|
||||
wails dev
|
||||
|
||||
# 本地快速启动:未修改 Go 导出方法签名时使用
|
||||
node tools/wails-fast-dev.mjs
|
||||
|
||||
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
wails dev # 完整热重载
|
||||
node tools/wails-fast-dev.mjs # Go 导出签名未变时更快
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings # 修改导出方法签名后刷新绑定
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
# 构建当前平台
|
||||
wails build
|
||||
|
||||
# 清理后构建(发布前推荐)
|
||||
wails build -clean
|
||||
wails build -clean # 发布前推荐
|
||||
```
|
||||
|
||||
构建产物位于 `build/bin`。
|
||||
产物位于 `build/bin`。
|
||||
|
||||
### 跨平台发布(GitHub Actions)
|
||||
### 只想直接用?
|
||||
|
||||
仓库内置发布流水线,推送 `v*` Tag 可自动构建并发布 Release。
|
||||
Release 更新说明会基于已合并 Pull Request 自动生成,并按 `.github/release.yaml` 分类。
|
||||
|
||||
支持目标:
|
||||
- macOS (AMD64 / ARM64)
|
||||
- Windows (AMD64)
|
||||
- Linux (AMD64,含 WebKitGTK 4.0 / 4.1 变体)
|
||||
前往 **[Releases](https://github.com/Syngnat/GoNavi/releases)** 下载
|
||||
(macOS AMD64/ARM64 · Windows AMD64 · Linux WebKitGTK 4.0/4.1)。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
## 🌐 Web Server(实验中)
|
||||
|
||||
### macOS 提示“应用已损坏,无法打开”
|
||||
同一套 Go 后端 + React 前端提供浏览器访问(**不是**把 Wails 桌面窗口容器化)。
|
||||
|
||||
在未进行 Apple Notarization 时,Gatekeeper 可能拦截应用。
|
||||
```powershell
|
||||
go build .
|
||||
.\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
- 首次访问进入 `/setup`(管理员密码;可选 Google Authenticator)
|
||||
- 桥接:`window.go.*` / `window.runtime.*` → HTTP / SSE
|
||||
- Session、恢复码、登录限流
|
||||
|
||||
#### Docker / Podman
|
||||
|
||||
```bash
|
||||
cp docker.web-server.env.example docker.web-server.env
|
||||
# 设置 GONAVI_HOST_DATA_ROOT 为绝对路径
|
||||
# 可选:GONAVI_WEB_PASSWORD(至少 6 位)
|
||||
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:34116`。将活动数据目录挂载到 `/data`
|
||||
(`connections.json`、`daily_secrets.json`,可选 `drivers/`)。
|
||||
认证状态写入 `web_auth.json`。
|
||||
|
||||
> **不要把未加固的 Web 入口直接暴露到公网。** 生产环境请配合反向代理与 HTTPS。
|
||||
|
||||
健康检查:`GET /__gonavi/healthz`
|
||||
本地源码构建:叠加 `-f docker-compose.web-server.local.yml --build`。
|
||||
|
||||
修改 env 密码后需重建容器:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
(`docker restart` **不会**重新加载 env 文件。)
|
||||
|
||||
---
|
||||
|
||||
## 🤖 MCP & Agents
|
||||
|
||||
把结构工具交给 Agent,而不是把密钥交出去:
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
| 形态 | 入口 |
|
||||
|---|---|
|
||||
| MCP 容器 | `docker-compose.mcp-server.yml` → `ghcr.io/syngnat/gonavi-mcp-server` |
|
||||
| Web UI 容器 | `docker-compose.web-server.yml` → `ghcr.io/syngnat/gonavi-web-server` |
|
||||
| Podman / Quadlet | [deploy/podman/gonavi-mcp-server](deploy/podman/gonavi-mcp-server) |
|
||||
| Kubernetes | [deploy/k8s/gonavi-mcp-server](deploy/k8s/gonavi-mcp-server) |
|
||||
| Helm | [deploy/helm/gonavi-mcp-server](deploy/helm/gonavi-mcp-server) |
|
||||
| 仅构建镜像 | `Dockerfile.build-env` → `ghcr.io/syngnat/gonavi-build-env` |
|
||||
|
||||
安全默认:远端 `schema-only` 不暴露 `execute_sql`;变更类 SQL 需显式 `allowMutating=true`。
|
||||
详见:[cmd/gonavi-mcp-server/README.md](cmd/gonavi-mcp-server/README.md)。
|
||||
|
||||
### 仅 Linux 构建环境
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 发布流水线
|
||||
|
||||
推送 `v*` Tag → GitHub Actions 自动多架构构建。
|
||||
Release 说明按 `.github/release.yaml` 从已合并 PR 生成。
|
||||
|
||||
---
|
||||
|
||||
## 🛠 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>Windows:缺少 Microsoft Edge WebView2 运行时(内网常见)</b></summary>
|
||||
|
||||
GoNavi 桌面版在 Windows 上依赖 **Microsoft Edge WebView2 Runtime**(系统级组件,不是完整 Chrome)。
|
||||
部分内网 / 精简镜像 / Windows Server / LTSC 机器未预装,会出现:
|
||||
|
||||
- 双击后窗口一闪即关、白屏、空白窗口
|
||||
- 提示找不到 WebView2 / WebView2 Runtime
|
||||
- 杀软或组策略拦截了运行时安装
|
||||
|
||||
### 1. 先确认是否已安装
|
||||
|
||||
在 **PowerShell** 中执行:
|
||||
|
||||
```powershell
|
||||
# Evergreen Runtime 常见安装目录(64 位系统)
|
||||
Test-Path "${env:ProgramFiles(x86)}\Microsoft\EdgeWebView\Application"
|
||||
|
||||
# 注册表(有显示名称通常表示已安装)
|
||||
Get-ItemProperty -Path "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Select-Object pv, name
|
||||
```
|
||||
|
||||
若路径不存在且注册表无 `pv` 版本号,需要安装运行时。
|
||||
|
||||
### 2. 有外网:在线安装(最简单)
|
||||
|
||||
1. 打开微软官方下载页:
|
||||
[Microsoft Edge WebView2](https://developer.microsoft.com/microsoft-edge/webview2/)
|
||||
2. 下载 **Evergreen Bootstrapper**(体积小,安装时联网拉完整运行时)
|
||||
3. 右键「以管理员身份运行」安装完成后,**重新打开 GoNavi**
|
||||
|
||||
### 3. 内网 / 离线:独立安装包(推荐企业分发)
|
||||
|
||||
Bootstrapper 在完全断网环境会失败。请改用 **Evergreen Standalone Installer**:
|
||||
|
||||
1. 在一台能上网的电脑打开同一下载页,下载对应架构的独立安装包,例如:
|
||||
- `MicrosoftEdgeWebView2RuntimeInstallerX64.exe`(绝大多数 64 位 Windows)
|
||||
- `…X86.exe` / `…ARM64.exe`(按机器架构选择)
|
||||
2. 将安装包拷贝到内网(U 盘、软件中心、文件共享均可)
|
||||
3. 在目标机 **管理员权限** 安装:
|
||||
|
||||
```powershell
|
||||
# 交互安装
|
||||
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe
|
||||
|
||||
# 静默安装(便于 IT 批量推送)
|
||||
.\MicrosoftEdgeWebView2RuntimeInstallerX64.exe /silent /install
|
||||
```
|
||||
|
||||
4. 安装完成后重新启动 GoNavi。若仍异常,注销/重启一次 Windows 再试。
|
||||
|
||||
### 4. 组策略 / 权限受限时
|
||||
|
||||
- 需要本地管理员或由 IT 通过 SCCM / 软件中心推送 Standalone 安装包
|
||||
- 确认未禁用 Edge / WebView2 相关更新与安装策略
|
||||
- 企业可固定使用 [Fixed Version](https://developer.microsoft.com/microsoft-edge/webview2/) 运行时并由管理员统一维护(一般用户优先 Evergreen)
|
||||
|
||||
### 5. 临时绕过:Web Server 模式(仍需能跑 GoNavi 后端)
|
||||
|
||||
若桌面 WebView 短期无法装上,可在本机用浏览器访问实验性 Web Server(**不是**把桌面窗口容器化):
|
||||
|
||||
```powershell
|
||||
# 以发布包中的可执行文件为例
|
||||
.\GoNavi.exe web-server --addr 127.0.0.1:34116
|
||||
```
|
||||
|
||||
浏览器打开 `http://127.0.0.1:34116`。详见上文「Web Server」章节。
|
||||
注意:未加固的 Web 入口不要直接暴露到公网。
|
||||
|
||||
更多说明与反馈入口见 Issue:[#672](https://github.com/Syngnat/GoNavi/issues/672)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS:提示「应用已损坏,无法打开」</b></summary>
|
||||
|
||||
未做 Apple Notarization 时,Gatekeeper 可能拦截:
|
||||
|
||||
```bash
|
||||
sudo xattr -rd com.apple.quarantine /Applications/GoNavi.app
|
||||
```
|
||||
|
||||
### Linux 缺少 `libwebkit2gtk` / `libjavascriptcoregtk`
|
||||
也可在 Finder 中右键打开。建议先移到「应用程序」。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux:缺少 WebKitGTK</b></summary>
|
||||
|
||||
```bash
|
||||
# Debian 13 / Ubuntu 24.04+
|
||||
@@ -197,9 +423,12 @@ sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0-18
|
||||
```
|
||||
|
||||
### Linux 中文显示为方框
|
||||
带 `-WebKit41` 后缀的产物更适配 Debian 13 / Ubuntu 24.04+。
|
||||
|
||||
Ubuntu 24.04 LTS 的最小化桌面或服务器环境可能没有安装中文 CJK 字体,GoNavi 打开后中文会显示为方框。安装 Noto / 文泉驿字体后重启 GoNavi:
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux:中文显示为方框</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
@@ -207,19 +436,31 @@ sudo apt-get install -y fonts-noto-cjk fonts-wqy-microhei
|
||||
fc-cache -fv
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 与 Pull Request。
|
||||
|
||||
完整流程、分支模型与维护者同步规则请查看:
|
||||
欢迎 Issue 与 PR。请从 **`dev`** 拉分支,并向 **`dev`** 提 PR。
|
||||
|
||||
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||
- 共建邀请:[Issue #671](https://github.com/Syngnat/GoNavi/issues/671)
|
||||
|
||||
外部贡献者应从 `dev` 拉出分支,并统一向 `dev` 发起 Pull Request。
|
||||
---
|
||||
|
||||
## Star History (Star 增长趋势)
|
||||
## 💬 联系与反馈
|
||||
|
||||
| 渠道 | 说明 |
|
||||
|---|---|
|
||||
| **GitHub Issues** | Bug、功能建议、文档问题优先在此提交,便于跟踪与复现 |
|
||||
| **微信** | `ygf1140302783`(备注「GoNavi」)— 可拉交流群,适合使用讨论与快速沟通 |
|
||||
|
||||
> 技术问题尽量附版本号、系统与复现步骤;内网 Windows 启动白屏/闪退见 [WebView2 说明](https://github.com/Syngnat/GoNavi/issues/672)。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Syngnat%2FGoNavi&type=date&legend=top-left">
|
||||
<picture>
|
||||
@@ -229,6 +470,8 @@ fc-cache -fv
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 友情链接
|
||||
|
||||
- [linux.do](https://linux.do/)
|
||||
@@ -236,4 +479,8 @@ fc-cache -fv
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache-2.0 协议](LICENSE)。
|
||||
[Apache-2.0](LICENSE)
|
||||
|
||||
<p align="center">
|
||||
<sub>为泡在 SQL、表结构与 Agent 循环里的人而造。</sub>
|
||||
</p>
|
||||
|
||||
BIN
assets/readme-logo.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/screenshots/01-home-workbench.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/screenshots/04-ai-assistant.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
assets/screenshots/06-new-connection.png
Normal file
|
After Width: | Height: | Size: 180 KiB |
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch)
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb clickhouse elasticsearch trino)
|
||||
DEFAULT_PLATFORMS=(darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64 linux/arm64)
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
@@ -44,7 +44,7 @@ normalize_driver() {
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elasticsearch|elastic) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse|trino)
|
||||
echo "$name"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -355,6 +355,9 @@ if command -v sha256sum &> /dev/null; then
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$f" in
|
||||
SHA256SUMS|latest.json|latest-dev.json) continue ;;
|
||||
esac
|
||||
sha256sum "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
@@ -363,6 +366,9 @@ elif command -v shasum &> /dev/null; then
|
||||
: > SHA256SUMS
|
||||
for f in *; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$f" in
|
||||
SHA256SUMS|latest.json|latest-dev.json) continue ;;
|
||||
esac
|
||||
shasum -a 256 "$f" >> SHA256SUMS
|
||||
done
|
||||
cd ..
|
||||
@@ -370,6 +376,35 @@ else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 sha256sum/shasum,跳过校验文件生成。${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}📄 生成静态更新清单...${NC}"
|
||||
if command -v python3 &> /dev/null; then
|
||||
# 正式版:latest.json;dev/test 版本:latest-dev.json(对应客户端 dev 通道)
|
||||
case "$VERSION" in
|
||||
dev-*|*-dev*|*dev*|*test*)
|
||||
TAG_FOR_MANIFEST="dev-latest"
|
||||
CHANNEL_FOR_MANIFEST="dev"
|
||||
OUT_MANIFEST="$DIST_DIR/latest-dev.json"
|
||||
;;
|
||||
*)
|
||||
TAG_FOR_MANIFEST="v${VERSION#v}"
|
||||
CHANNEL_FOR_MANIFEST="latest"
|
||||
OUT_MANIFEST="$DIST_DIR/latest.json"
|
||||
;;
|
||||
esac
|
||||
if python3 tools/generate-update-latest-manifest.py \
|
||||
--assets-dir "$DIST_DIR" \
|
||||
--version "$VERSION" \
|
||||
--tag "$TAG_FOR_MANIFEST" \
|
||||
--channel "$CHANNEL_FOR_MANIFEST" \
|
||||
--output "$OUT_MANIFEST"; then
|
||||
echo -e "${GREEN} ✅ 已生成 $OUT_MANIFEST (channel=${CHANNEL_FOR_MANIFEST})${NC}"
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 生成更新清单失败(不影响本地产物,正式/dev 发版由 CI 生成)${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 python3,跳过更新清单${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
|
||||
|
||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 406 KiB |
BIN
build/windows/appicon-transparent.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 85 KiB |
100
build/windows/installer.wxs
Normal file
@@ -0,0 +1,100 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<Package Name="$(var.ProductName)"
|
||||
Manufacturer="Syngnat"
|
||||
Language="1033"
|
||||
Version="$(var.ProductVersion)"
|
||||
ProductCode="*"
|
||||
UpgradeCode="$(var.UpgradeCode)"
|
||||
Scope="perMachine"
|
||||
InstallerVersion="500"
|
||||
Compressed="yes">
|
||||
<MajorUpgrade AllowDowngrades="yes"
|
||||
Schedule="afterInstallInitialize" />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<Icon Id="GoNaviIcon" SourceFile="$(var.IconPath)" />
|
||||
<Property Id="ARPPRODUCTICON" Value="GoNaviIcon" />
|
||||
<Property Id="ARPURLINFOABOUT" Value="https://github.com/Syngnat/GoNavi" />
|
||||
<Property Id="INSTALLDESKTOPSHORTCUT" Value="1" Secure="yes" />
|
||||
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" />
|
||||
<WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseRtf)" />
|
||||
|
||||
<Feature Id="MainFeature" Title="$(var.ProductName)" Level="1">
|
||||
<ComponentRef Id="MainExecutable" />
|
||||
<ComponentRef Id="DesktopShortcutComponent" />
|
||||
</Feature>
|
||||
|
||||
<Component Id="MainExecutable"
|
||||
Directory="INSTALLFOLDER"
|
||||
Guid="$(var.ComponentGuid)"
|
||||
Bitness="always64">
|
||||
<File Id="GoNaviExe"
|
||||
Source="$(var.SourceExe)"
|
||||
Name="GoNavi.exe"
|
||||
KeyPath="yes">
|
||||
<!-- Keep shortcut icons bound to the stable executable path. Referencing the MSI Icon table
|
||||
can leave Windows shell caches pointing at the removed product cache after a major upgrade. -->
|
||||
<Shortcut Id="StartMenuShortcut"
|
||||
Directory="ProgramMenuFolder"
|
||||
Name="$(var.ProductName)"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Advertise="no" />
|
||||
</File>
|
||||
<File Id="GoNaviMsiInstallMarker"
|
||||
Source="$(var.InstallMarker)"
|
||||
Name=".gonavi-msi-install" />
|
||||
<File Id="GoNaviLicense"
|
||||
Source="$(var.LicenseFile)"
|
||||
Name="LICENSE.txt" />
|
||||
<File Id="GoNaviNotice"
|
||||
Source="$(var.NoticeFile)"
|
||||
Name="NOTICE.txt" />
|
||||
<RegistryValue Root="HKLM"
|
||||
Key="Software\Syngnat\$(var.RegistryKeyName)"
|
||||
Name="InstallType"
|
||||
Value="MSI"
|
||||
Type="string"
|
||||
KeyPath="no" />
|
||||
<RegistryValue Root="HKLM"
|
||||
Key="Software\Syngnat\$(var.RegistryKeyName)"
|
||||
Name="InstallPath"
|
||||
Value="[INSTALLFOLDER]GoNavi.exe"
|
||||
Type="string"
|
||||
KeyPath="no" />
|
||||
</Component>
|
||||
|
||||
<!-- Online updates pass the user's pre-update desktop shortcut state. Keeping this resource
|
||||
in its own component lets a major upgrade remove an unwanted old shortcut without
|
||||
recreating it, while first-time and manual installs retain the existing default. -->
|
||||
<Component Id="DesktopShortcutComponent"
|
||||
Directory="DesktopFolder"
|
||||
Guid="$(var.DesktopShortcutComponentGuid)"
|
||||
Bitness="always64"
|
||||
Condition="INSTALLDESKTOPSHORTCUT = 1">
|
||||
<Shortcut Id="DesktopShortcut"
|
||||
Name="$(var.ProductName)"
|
||||
Target="[#GoNaviExe]"
|
||||
WorkingDirectory="INSTALLFOLDER"
|
||||
Advertise="no" />
|
||||
<RegistryValue Root="HKLM"
|
||||
Key="Software\Syngnat\$(var.RegistryKeyName)"
|
||||
Name="DesktopShortcutInstalled"
|
||||
Value="1"
|
||||
Type="integer"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
<ui:WixUI Id="WixUI_InstallDir"
|
||||
InstallDirectory="INSTALLFOLDER"
|
||||
ExtendedPathValidation="yes" />
|
||||
</Package>
|
||||
|
||||
<Fragment>
|
||||
<StandardDirectory Id="ProgramFiles6432Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="$(var.InstallFolderName)" />
|
||||
</StandardDirectory>
|
||||
<StandardDirectory Id="ProgramMenuFolder" />
|
||||
<StandardDirectory Id="DesktopFolder" />
|
||||
</Fragment>
|
||||
</Wix>
|
||||
@@ -10,6 +10,13 @@
|
||||
- 入参:`connectionId`
|
||||
- `get_tables`
|
||||
- 入参:`connectionId`、可选 `dbName`
|
||||
- 返回表列表,并在 `views` 字段附带视图列表,兼容旧客户端只调用 `get_tables` 的场景
|
||||
- `get_views`
|
||||
- 入参:`connectionId`、可选 `dbName`
|
||||
- 返回视图列表
|
||||
- `get_objects`
|
||||
- 入参:`connectionId`、可选 `dbName`、可选 `objectTypes`
|
||||
- 返回表、视图、触发器、函数、过程、序列、包、事件,以及消息队列类 `topic/queue/exchange` 等对象清单
|
||||
- `get_columns`
|
||||
- 入参:`connectionId`、可选 `dbName`、`tableName`
|
||||
- `get_table_ddl`
|
||||
@@ -20,7 +27,7 @@
|
||||
- 如果 SQL 包含 DDL/DML,必须显式传 `allowMutating=true`
|
||||
- `maxRowsPerResult` 用来限制单个结果集返回的行数,默认 `200`
|
||||
|
||||
远程 Agent 只需要库表结构时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、库表、字段、索引、外键、触发器和 DDL 工具。
|
||||
远程 Agent 只需要结构元数据时,启动 HTTP 模式请加 `--schema-only`。该模式不注册 `execute_sql`,只保留连接摘要、对象清单、表/视图、字段、索引、外键、触发器和 DDL 工具。
|
||||
|
||||
## 运行方式
|
||||
|
||||
@@ -42,6 +49,187 @@ go run ./cmd/gonavi-mcp-server stdio
|
||||
go build -o .\bin\gonavi-mcp-server.exe .\cmd\gonavi-mcp-server
|
||||
```
|
||||
|
||||
## Docker / Podman / Compose
|
||||
|
||||
当前容器化支持仅覆盖 `gonavi-mcp-server`,不包含 Wails 桌面 GUI。
|
||||
|
||||
当前支持矩阵:
|
||||
|
||||
- Docker Desktop / Linux 服务器 / NAS:直接使用 Compose 或 `docker run`
|
||||
- Podman / Quadlet:使用 `deploy/podman/gonavi-mcp-server`
|
||||
- Kubernetes:使用 `deploy/k8s/gonavi-mcp-server`
|
||||
- Helm:使用 `deploy/helm/gonavi-mcp-server`
|
||||
- 仅构建环境:使用仓库根目录 `Dockerfile.build-env`
|
||||
- 桌面 GUI 浏览器访问版:当前不提供,此仓库主应用仍是 Wails 桌面程序,不是现成的 Web 服务
|
||||
|
||||
仓库根目录已提供以下文件:
|
||||
|
||||
- `Dockerfile.mcp-server`
|
||||
- `docker-compose.mcp-server.yml`
|
||||
- `docker.mcp-server.env.example`
|
||||
- `deploy/podman/gonavi-mcp-server/*`
|
||||
|
||||
推荐流程:
|
||||
|
||||
```bash
|
||||
cp docker.mcp-server.env.example docker.mcp-server.env
|
||||
docker compose --env-file docker.mcp-server.env -f docker-compose.mcp-server.yml up -d
|
||||
```
|
||||
|
||||
默认 Compose 会拉取 GHCR 预构建镜像。如果你要基于当前工作区源码本地构建,再叠加:
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker.mcp-server.env \
|
||||
-f docker-compose.mcp-server.yml \
|
||||
-f docker-compose.mcp-server.local.yml \
|
||||
up -d --build
|
||||
```
|
||||
|
||||
其中 `GONAVI_HOST_DATA_ROOT` 必须指向 GoNavi 当前活动数据目录。该目录内至少应包含:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
- `drivers/`(如果目标连接依赖可选 driver agent)
|
||||
|
||||
容器内默认会设置:
|
||||
|
||||
- `GONAVI_DATA_ROOT=/data`
|
||||
- `GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765`
|
||||
- `GONAVI_MCP_HTTP_PATH=/mcp`
|
||||
|
||||
`GONAVI_DATA_ROOT` 会覆盖默认活动数据目录解析逻辑,避免宿主机路径与容器内路径不一致时依赖 `storage_root.json` 的绝对路径。
|
||||
|
||||
如果你只想手动构建镜像:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
|
||||
docker run --rm -p 8765:8765 \
|
||||
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
|
||||
-e GONAVI_MCP_SCHEMA_ONLY=true \
|
||||
-e GONAVI_DATA_ROOT=/data \
|
||||
-v /absolute/path/to/gonavi-data:/data \
|
||||
gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
如果你直接使用已发布镜像:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8765:8765 \
|
||||
-e GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token \
|
||||
-e GONAVI_MCP_SCHEMA_ONLY=true \
|
||||
-e GONAVI_DATA_ROOT=/data \
|
||||
-v /absolute/path/to/gonavi-data:/data \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
### Podman
|
||||
|
||||
仓库内还提供了 Podman 原生部署样例:
|
||||
|
||||
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example`
|
||||
- `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container`
|
||||
- `deploy/podman/gonavi-mcp-server/README.md`
|
||||
|
||||
直接运行已发布镜像:
|
||||
|
||||
```bash
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
如果你要基于当前源码本地构建:
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
localhost/gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `gonavi-mcp-server.env` 用 `deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example` 初始化
|
||||
- `:Z` 适用于开启 SELinux 的宿主机;未启用 SELinux 可去掉
|
||||
- 更适合长期运行的方式见 [deploy/podman/gonavi-mcp-server/README.md](../../deploy/podman/gonavi-mcp-server/README.md) 中的 Quadlet 示例
|
||||
|
||||
`podman compose` 本身依赖外部 compose provider,所以仓库对 Podman 的主支持路径是 `podman run` 与 Quadlet,而不是假设所有环境都能直接复用 Compose。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
仓库内置了最小 K8s 示例:
|
||||
|
||||
- `deploy/k8s/gonavi-mcp-server/kustomization.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/base/deployment.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/base/service.yaml`
|
||||
- `deploy/k8s/gonavi-mcp-server/README.md`
|
||||
- `deploy/k8s/gonavi-mcp-server/overlays/*`
|
||||
|
||||
推荐先从现有 GoNavi 数据目录生成 Secret:
|
||||
|
||||
```bash
|
||||
kubectl create namespace gonavi
|
||||
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果需要 NAS hostPath、可选 driver agent PVC、Ingress,或两者组合,可直接使用 `overlays/nas-hostpath`、`overlays/drivers-pvc`、`overlays/ingress`、`overlays/ingress-with-drivers-pvc`。
|
||||
|
||||
更完整的说明见 [deploy/k8s/gonavi-mcp-server/README.md](../../deploy/k8s/gonavi-mcp-server/README.md)。
|
||||
|
||||
## Helm
|
||||
|
||||
如果你希望把镜像、Secret、Ingress、hostPath / PVC 挂载做成参数化部署,而不是维护多份 Kustomize overlay,可直接使用:
|
||||
|
||||
- `deploy/helm/gonavi-mcp-server`
|
||||
|
||||
快速安装:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
|
||||
```
|
||||
|
||||
Chart 详细说明见 [deploy/helm/gonavi-mcp-server/README.md](../../deploy/helm/gonavi-mcp-server/README.md)。
|
||||
|
||||
## Docker / Podman Build Environment
|
||||
|
||||
如果你的目标不是运行 MCP,而是给 Linux 服务器 / NAS / CI 准备一套可重复的 Wails 构建环境,可直接使用仓库根目录的 `Dockerfile.build-env`:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.build-env -t gonavi-build-env:local .
|
||||
docker run --rm -it -v "$PWD:/workspace" -w /workspace gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
如果你使用 Podman,也可以直接执行:
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.build-env -t localhost/gonavi-build-env:local .
|
||||
podman run --rm -it -v "$PWD:/workspace" -w /workspace localhost/gonavi-build-env:local bash
|
||||
```
|
||||
|
||||
镜像内已预装 Go、Node、Wails CLI、GTK3 与 WebKitGTK 开发依赖,适合执行:
|
||||
|
||||
```bash
|
||||
wails build
|
||||
```
|
||||
|
||||
这个镜像默认安装 WebKitGTK 4.0 构建依赖,适合作为通用 Linux / NAS 构建环境。镜像基座支持多架构,`amd64` / `arm64` 会跟随容器平台。
|
||||
|
||||
预构建镜像会发布到 GHCR:
|
||||
|
||||
- `ghcr.io/syngnat/gonavi-mcp-server:latest`
|
||||
- `ghcr.io/syngnat/gonavi-build-env:latest`
|
||||
|
||||
它只负责构建 Linux 产物,不会把 Wails 主程序变成浏览器版服务。
|
||||
|
||||
远程 Agent 使用 Streamable HTTP 时必须设置 bearer token:
|
||||
|
||||
```powershell
|
||||
@@ -121,7 +309,7 @@ OpenClaw、Hermans 这类部署在云端或远端 Linux 的 Agent,不能直接
|
||||
2. 在 Windows 本机启动 `GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only`。
|
||||
3. 通过 SSH 隧道、反向代理或内网网关把 `http://127.0.0.1:8765/mcp` 暴露为云端 Agent 可访问的 HTTPS 地址。
|
||||
4. 在 OpenClaw / Hermans 中添加远程 MCP Server,transport 选择 Streamable HTTP,URL 指向 `/mcp` 地址,并设置请求头 `Authorization: Bearer <随机token>`。
|
||||
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_tables`、`get_columns`、`get_table_ddl` 等工具读取结构。
|
||||
5. 先调用 `get_connections` 获取 `connectionId`,再调用 `get_databases`、`get_objects`、`get_tables`、`get_views`、`get_columns`、`get_table_ddl` 等工具读取结构。
|
||||
|
||||
如果目标 Agent 支持 `mcpServers` JSON,可按下面的通用片段配置:
|
||||
|
||||
|
||||
7
deploy/helm/gonavi-mcp-server/Chart.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
apiVersion: v2
|
||||
name: gonavi-mcp-server
|
||||
description: Helm chart for deploying GoNavi MCP Server
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "latest"
|
||||
kubeVersion: ">=1.24.0-0"
|
||||
94
deploy/helm/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# GoNavi MCP Server Helm Chart
|
||||
|
||||
这个 Chart 用来部署 `gonavi-mcp-server`,覆盖当前仓库 K8s 示例里的几类常见场景:
|
||||
|
||||
- 基于现有 Secret 直接部署
|
||||
- 通过 Helm values 内联创建测试用 Secret
|
||||
- 启用 Ingress
|
||||
- 追加 `drivers/` PVC
|
||||
- NAS / k3s 单机节点直接挂宿主机数据目录
|
||||
|
||||
Chart 目录:
|
||||
|
||||
- `Chart.yaml`
|
||||
- `values.yaml`
|
||||
- `values-examples/*.yaml`
|
||||
- `templates/*.yaml`
|
||||
|
||||
## 1. 默认安装
|
||||
|
||||
默认值假设你已经有一个现成 Secret:
|
||||
|
||||
- 名称:`gonavi-mcp-server-data`
|
||||
- 至少包含:
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
- `GONAVI_MCP_HTTP_TOKEN`
|
||||
|
||||
安装命令:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server -n gonavi --create-namespace
|
||||
```
|
||||
|
||||
## 2. 直接创建测试用 Secret
|
||||
|
||||
如果只是本地验证模板或快速试跑,可以使用示例 values:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/inline-secret.yaml
|
||||
```
|
||||
|
||||
这个示例只适合最小联调,不适合真实生产连接数据。
|
||||
|
||||
## 3. 常用示例
|
||||
|
||||
启用 Ingress:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
|
||||
```
|
||||
|
||||
挂载 drivers PVC:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/drivers-pvc.yaml
|
||||
```
|
||||
|
||||
同时启用 Ingress 与 drivers PVC:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/ingress-with-drivers-pvc.yaml
|
||||
```
|
||||
|
||||
NAS / k3s hostPath:
|
||||
|
||||
```bash
|
||||
helm upgrade --install gonavi-mcp-server deploy/helm/gonavi-mcp-server \
|
||||
-n gonavi --create-namespace \
|
||||
-f deploy/helm/gonavi-mcp-server/values-examples/nas-hostpath.yaml
|
||||
```
|
||||
|
||||
## 4. 关键参数
|
||||
|
||||
- `image.repository` / `image.tag`
|
||||
- `secret.create`
|
||||
- `secret.name`
|
||||
- `data.mode=secret|hostPath|pvc`
|
||||
- `drivers.mode=none|pvc|hostPath`
|
||||
- `ingress.enabled`
|
||||
|
||||
## 5. 约束
|
||||
|
||||
- `data.mode=secret` 时,Secret 必须能提供 `connections.json` 和 `daily_secrets.json`
|
||||
- `data.mode=hostPath` 时,宿主机目录需要包含 GoNavi 活动数据目录内容
|
||||
- `drivers.mode!=none` 时,会在 `/data/drivers` 追加独立挂载
|
||||
- Chart 默认只部署 MCP Server,不部署桌面 GUI
|
||||
49
deploy/helm/gonavi-mcp-server/templates/_helpers.tpl
Normal file
@@ -0,0 +1,49 @@
|
||||
{{- define "gonavi-mcp-server.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := include "gonavi-mcp-server.name" . -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.labels" -}}
|
||||
helm.sh/chart: {{ include "gonavi-mcp-server.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "gonavi-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.namespace" -}}
|
||||
{{- default .Release.Namespace .Values.namespaceOverride -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.secretName" -}}
|
||||
{{- if .Values.secret.name -}}
|
||||
{{- .Values.secret.name -}}
|
||||
{{- else -}}
|
||||
{{- include "gonavi-mcp-server.fullname" . -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "gonavi-mcp-server.serviceName" -}}
|
||||
{{- include "gonavi-mcp-server.fullname" . -}}
|
||||
{{- end -}}
|
||||
130
deploy/helm/gonavi-mcp-server/templates/deployment.yaml
Normal file
@@ -0,0 +1,130 @@
|
||||
{{- $dataMode := .Values.data.mode -}}
|
||||
{{- $driversMode := .Values.drivers.mode -}}
|
||||
{{- if not (or (eq $dataMode "secret") (eq $dataMode "hostPath") (eq $dataMode "pvc")) -}}
|
||||
{{- fail "data.mode must be one of: secret, hostPath, pvc" -}}
|
||||
{{- end -}}
|
||||
{{- if not (or (eq $driversMode "none") (eq $driversMode "pvc") (eq $driversMode "hostPath")) -}}
|
||||
{{- fail "drivers.mode must be one of: none, pvc, hostPath" -}}
|
||||
{{- end -}}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.fullname" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args:
|
||||
- http
|
||||
{{- range .Values.mcp.extraArgs }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GONAVI_DATA_ROOT
|
||||
value: {{ .Values.mcp.dataRoot | quote }}
|
||||
- name: GONAVI_LOG_DIR
|
||||
value: {{ .Values.mcp.logDir | quote }}
|
||||
- name: GONAVI_MCP_HTTP_ADDR
|
||||
value: {{ .Values.mcp.httpAddr | quote }}
|
||||
- name: GONAVI_MCP_HTTP_PATH
|
||||
value: {{ .Values.mcp.httpPath | quote }}
|
||||
- name: GONAVI_MCP_SCHEMA_ONLY
|
||||
value: {{ ternary "true" "false" .Values.mcp.schemaOnly | quote }}
|
||||
- name: GONAVI_MCP_HTTP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
key: {{ .Values.secret.tokenKey }}
|
||||
{{- with .Values.mcp.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumeMounts:
|
||||
- name: gonavi-data
|
||||
mountPath: {{ .Values.data.mountPath | quote }}
|
||||
readOnly: true
|
||||
{{- if ne $driversMode "none" }}
|
||||
- name: gonavi-drivers
|
||||
mountPath: {{ .Values.drivers.mountPath | quote }}
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.containerSecurityContext | nindent 12 }}
|
||||
volumes:
|
||||
- name: gonavi-data
|
||||
{{- if eq $dataMode "secret" }}
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
items:
|
||||
- key: {{ .Values.secret.connectionsJsonKey }}
|
||||
path: connections.json
|
||||
- key: {{ .Values.secret.dailySecretsJsonKey }}
|
||||
path: daily_secrets.json
|
||||
{{- else if eq $dataMode "hostPath" }}
|
||||
hostPath:
|
||||
path: {{ required "data.hostPath.path is required when data.mode=hostPath" .Values.data.hostPath.path | quote }}
|
||||
type: {{ default "Directory" .Values.data.hostPath.type | quote }}
|
||||
{{- else if eq $dataMode "pvc" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "data.pvc.claimName is required when data.mode=pvc" .Values.data.pvc.claimName | quote }}
|
||||
{{- end }}
|
||||
{{- if eq $driversMode "pvc" }}
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ required "drivers.pvc.claimName is required when drivers.mode=pvc" .Values.drivers.pvc.claimName | quote }}
|
||||
{{- else if eq $driversMode "hostPath" }}
|
||||
- name: gonavi-drivers
|
||||
hostPath:
|
||||
path: {{ required "drivers.hostPath.path is required when drivers.mode=hostPath" .Values.drivers.hostPath.path | quote }}
|
||||
type: {{ default "Directory" .Values.drivers.hostPath.type | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
34
deploy/helm/gonavi-mcp-server/templates/ingress.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.fullname" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
{{- with .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path | quote }}
|
||||
pathType: {{ .pathType | quote }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "gonavi-mcp-server.serviceName" $ }}
|
||||
port:
|
||||
number: {{ $.Values.service.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
27
deploy/helm/gonavi-mcp-server/templates/secret.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
{{- if .Values.secret.create }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.secretName" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.secret.tokenKey }}: {{ required "secret.stringData.token is required when secret.create=true" .Values.secret.stringData.token | quote }}
|
||||
{{- if eq .Values.data.mode "secret" }}
|
||||
{{ .Values.secret.connectionsJsonKey }}: |
|
||||
{{ required "secret.stringData.connectionsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.connectionsJson | nindent 4 }}
|
||||
{{ .Values.secret.dailySecretsJsonKey }}: |
|
||||
{{ required "secret.stringData.dailySecretsJson is required when data.mode=secret and secret.create=true" .Values.secret.stringData.dailySecretsJson | nindent 4 }}
|
||||
{{- else }}
|
||||
{{- if .Values.secret.stringData.connectionsJson }}
|
||||
{{ .Values.secret.connectionsJsonKey }}: |
|
||||
{{ .Values.secret.stringData.connectionsJson | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- if .Values.secret.stringData.dailySecretsJson }}
|
||||
{{ .Values.secret.dailySecretsJsonKey }}: |
|
||||
{{ .Values.secret.stringData.dailySecretsJson | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
20
deploy/helm/gonavi-mcp-server/templates/service.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "gonavi-mcp-server.serviceName" . }}
|
||||
namespace: {{ include "gonavi-mcp-server.namespace" . }}
|
||||
labels:
|
||||
{{- include "gonavi-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
{{- include "gonavi-mcp-server.selectorLabels" . | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,4 @@
|
||||
drivers:
|
||||
mode: pvc
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
@@ -0,0 +1,19 @@
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
|
||||
drivers:
|
||||
mode: pvc
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
14
deploy/helm/gonavi-mcp-server/values-examples/ingress.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
@@ -0,0 +1,13 @@
|
||||
secret:
|
||||
create: true
|
||||
stringData:
|
||||
token: replace-with-a-random-token
|
||||
connectionsJson: |
|
||||
{
|
||||
"connections": []
|
||||
}
|
||||
dailySecretsJson: |
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"connections": {}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
data:
|
||||
mode: hostPath
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
|
||||
secret:
|
||||
create: true
|
||||
stringData:
|
||||
token: replace-with-a-random-token
|
||||
99
deploy/helm/gonavi-mcp-server/values.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
namespaceOverride: ""
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/syngnat/gonavi-mcp-server
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8765
|
||||
annotations: {}
|
||||
|
||||
mcp:
|
||||
dataRoot: /data
|
||||
logDir: /var/lib/gonavi/logs
|
||||
httpAddr: 0.0.0.0:8765
|
||||
httpPath: /mcp
|
||||
schemaOnly: true
|
||||
extraArgs: []
|
||||
extraEnv: []
|
||||
|
||||
secret:
|
||||
create: false
|
||||
name: gonavi-mcp-server-data
|
||||
tokenKey: GONAVI_MCP_HTTP_TOKEN
|
||||
connectionsJsonKey: connections.json
|
||||
dailySecretsJsonKey: daily_secrets.json
|
||||
stringData:
|
||||
token: ""
|
||||
connectionsJson: ""
|
||||
dailySecretsJson: ""
|
||||
|
||||
data:
|
||||
mode: secret
|
||||
mountPath: /data
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
pvc:
|
||||
claimName: ""
|
||||
|
||||
drivers:
|
||||
mode: none
|
||||
mountPath: /data/drivers
|
||||
pvc:
|
||||
claimName: gonavi-drivers
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/drivers
|
||||
type: Directory
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
className: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
hosts:
|
||||
- host: gonavi-mcp.example.com
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: gonavi-mcp-server-tls
|
||||
hosts:
|
||||
- gonavi-mcp.example.com
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
||||
podAnnotations: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
containerSecurityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
151
deploy/k8s/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# GoNavi MCP Server Kubernetes 示例
|
||||
|
||||
这个目录提供 `gonavi-mcp-server` 的最小 K8s 部署清单,适合:
|
||||
|
||||
- Linux 服务器上的单集群部署
|
||||
- NAS 自带 K8s / k3s / k8s-lite 环境
|
||||
- 需要把 GoNavi MCP 通过 Ingress / Gateway 暴露给远端 Agent 的场景
|
||||
|
||||
如果你希望通过 values 参数统一控制镜像、Secret、Ingress、PVC/hostPath,而不是维护多份 overlay,请直接使用 [deploy/helm/gonavi-mcp-server](../../helm/gonavi-mcp-server)。
|
||||
|
||||
目录结构:
|
||||
|
||||
- `kustomization.yaml`:基础部署入口
|
||||
- `base/kustomization.yaml`:基础资源集合
|
||||
- `overlays/nas-hostpath`:直接挂 NAS / 单机节点上的 GoNavi 数据目录
|
||||
- `base/deployment.yaml` / `base/service.yaml`:基础资源
|
||||
- `overlays/drivers-pvc`:为 `/data/drivers` 增加 PVC 挂载
|
||||
- `overlays/ingress`:增加 Ingress 暴露 `/mcp`
|
||||
- `overlays/ingress-with-drivers-pvc`:同时启用 Ingress 和 drivers PVC
|
||||
|
||||
## 前提
|
||||
|
||||
容器仍然依赖 GoNavi 的活动数据目录。至少要准备:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
|
||||
如果目标连接依赖可选 driver agent,还需要额外挂载 `/data/drivers`。
|
||||
|
||||
`base/deployment.yaml` 默认镜像指向:
|
||||
|
||||
```text
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
```
|
||||
|
||||
如果你要跟随 `dev` 分支,可以改成:
|
||||
|
||||
```text
|
||||
ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
```
|
||||
|
||||
如果你要使用自建镜像,再按下面方式覆盖 `image`:
|
||||
|
||||
- 在当前节点可访问的 Docker / Podman / containerd 环境中构建并导入这个镜像
|
||||
- 把镜像推送到你的私有仓库,并同步修改 `base/deployment.yaml` 里的 `image`
|
||||
|
||||
例如:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.mcp-server -t gonavi-mcp-server:local .
|
||||
# or
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
```
|
||||
|
||||
## 1. 创建 Secret
|
||||
|
||||
推荐直接从现有 GoNavi 数据文件生成:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
```
|
||||
|
||||
如需单独 namespace,请先执行:
|
||||
|
||||
```bash
|
||||
kubectl create namespace gonavi
|
||||
kubectl -n gonavi create secret generic gonavi-mcp-server-data \
|
||||
--from-file=connections.json=/absolute/path/to/gonavi-data/connections.json \
|
||||
--from-file=daily_secrets.json=/absolute/path/to/gonavi-data/daily_secrets.json \
|
||||
--from-literal=GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
```
|
||||
|
||||
## 2. 应用清单
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果使用独立 namespace,请先把 YAML 里的 `namespace` 改成你的目标值,或自行用 `kustomize` / Helm 做二次封装。
|
||||
|
||||
清单内已经带了基础安全上下文和默认资源配额;如果你的连接数量、对象规模或并发访问更高,建议按实际负载调整 `resources`。
|
||||
|
||||
### 常用 Overlay
|
||||
|
||||
仅基础部署:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server
|
||||
```
|
||||
|
||||
需要可选 driver agent:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/drivers-pvc
|
||||
```
|
||||
|
||||
NAS / k3s 单机节点直接挂宿主机目录:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/nas-hostpath
|
||||
```
|
||||
|
||||
需要对外暴露 `/mcp`:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress
|
||||
```
|
||||
|
||||
同时需要 Ingress 与 drivers PVC:
|
||||
|
||||
```bash
|
||||
kubectl apply -k deploy/k8s/gonavi-mcp-server/overlays/ingress-with-drivers-pvc
|
||||
```
|
||||
|
||||
应用 overlay 前,记得先改里面的占位值,例如:
|
||||
|
||||
- `overlays/ingress/ingress.yaml` 里的域名、TLS Secret、`ingressClassName`
|
||||
- `overlays/drivers-pvc/patch-deployment.yaml` 里的 PVC 名称 `gonavi-drivers`
|
||||
- `overlays/nas-hostpath/patch-deployment.yaml` 里的宿主机目录 `/volume1/docker/gonavi/data`
|
||||
|
||||
`nas-hostpath` overlay 会直接把整个 GoNavi 数据目录挂到 `/data`,因此 `connections.json`、`daily_secrets.json`、`drivers/` 都从宿主机目录读取;此时仍建议保留 `GONAVI_MCP_HTTP_TOKEN` 的 Secret 注入,不要把 token 直接硬编码进 Deployment。
|
||||
|
||||
## 3. 校验
|
||||
|
||||
```bash
|
||||
kubectl get pods -n gonavi
|
||||
kubectl get svc -n gonavi
|
||||
kubectl port-forward -n gonavi svc/gonavi-mcp-server 8765:8765
|
||||
curl -H "Authorization: Bearer replace-with-a-random-token" http://127.0.0.1:8765/healthz
|
||||
```
|
||||
|
||||
`/healthz` 返回 `ok` 说明 Pod 已对外提供 HTTP 服务。
|
||||
|
||||
## 4. 暴露给远端 Agent
|
||||
|
||||
- 集群内使用:直接访问 `http://gonavi-mcp-server.gonavi.svc.cluster.local:8765/mcp`
|
||||
- 集群外使用:通过 Ingress / Gateway / 反向代理暴露 `/mcp`
|
||||
- 远端 Agent 请求头:`Authorization: Bearer <你的 token>`
|
||||
|
||||
## 5. drivers 挂载
|
||||
|
||||
当前示例默认只挂载 `connections.json` 与 `daily_secrets.json`。如果保存连接里包含 Dameng、ClickHouse、DuckDB 等依赖 driver agent 的数据源,请在 Deployment 中额外补一个卷,把宿主机或 PVC 中的 `drivers/` 目录挂载到:
|
||||
|
||||
```text
|
||||
/data/drivers
|
||||
```
|
||||
|
||||
否则对应连接会因为缺少 driver agent 无法使用。
|
||||
89
deploy/k8s/gonavi-mcp-server/base/deployment.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
# 如需跟随 dev 分支,可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
image: ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- http
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8765
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: GONAVI_DATA_ROOT
|
||||
value: /data
|
||||
- name: GONAVI_LOG_DIR
|
||||
value: /var/lib/gonavi/logs
|
||||
- name: GONAVI_MCP_HTTP_ADDR
|
||||
value: 0.0.0.0:8765
|
||||
- name: GONAVI_MCP_HTTP_PATH
|
||||
value: /mcp
|
||||
- name: GONAVI_MCP_SCHEMA_ONLY
|
||||
value: "true"
|
||||
- name: GONAVI_MCP_HTTP_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: gonavi-mcp-server-data
|
||||
key: GONAVI_MCP_HTTP_TOKEN
|
||||
volumeMounts:
|
||||
- name: gonavi-data
|
||||
mountPath: /data
|
||||
readOnly: true
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
volumes:
|
||||
- name: gonavi-data
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: gonavi-mcp-server-data
|
||||
items:
|
||||
- key: connections.json
|
||||
path: connections.json
|
||||
- key: daily_secrets.json
|
||||
path: daily_secrets.json
|
||||
6
deploy/k8s/gonavi-mcp-server/base/kustomization.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
15
deploy/k8s/gonavi-mcp-server/base/service.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
labels:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: gonavi-mcp-server
|
||||
ports:
|
||||
- name: http
|
||||
port: 8765
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
5
deploy/k8s/gonavi-mcp-server/kustomization.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- base
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
volumeMounts:
|
||||
- name: gonavi-drivers
|
||||
mountPath: /data/drivers
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: gonavi-drivers
|
||||
@@ -0,0 +1,33 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- gonavi-mcp.example.com
|
||||
secretName: gonavi-mcp-server-tls
|
||||
rules:
|
||||
- host: gonavi-mcp.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
- ingress.yaml
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: gonavi-mcp-server
|
||||
volumeMounts:
|
||||
- name: gonavi-drivers
|
||||
mountPath: /data/drivers
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: gonavi-drivers
|
||||
persistentVolumeClaim:
|
||||
claimName: gonavi-drivers
|
||||
33
deploy/k8s/gonavi-mcp-server/overlays/ingress/ingress.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- gonavi-mcp.example.com
|
||||
secretName: gonavi-mcp-server-tls
|
||||
rules:
|
||||
- host: gonavi-mcp.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /mcp
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
- path: /healthz
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: gonavi-mcp-server
|
||||
port:
|
||||
number: 8765
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- ../../base
|
||||
|
||||
patches:
|
||||
- path: patch-deployment.yaml
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gonavi-mcp-server
|
||||
namespace: gonavi
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
volumes:
|
||||
- $patch: replace
|
||||
name: gonavi-data
|
||||
hostPath:
|
||||
path: /volume1/docker/gonavi/data
|
||||
type: Directory
|
||||
101
deploy/podman/gonavi-mcp-server/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# GoNavi MCP Server Podman 示例
|
||||
|
||||
这个目录提供 `gonavi-mcp-server` 的 Podman 原生部署入口,覆盖两类常见场景:
|
||||
|
||||
- 直接用 `podman run` 在 Linux 服务器 / NAS 上启动
|
||||
- 用 rootless Quadlet + systemd 做常驻服务
|
||||
|
||||
目录内容:
|
||||
|
||||
- `gonavi-mcp-server.env.example`:给 `podman run` / Quadlet 共用的容器环境变量示例
|
||||
- `gonavi-mcp-server.container`:Quadlet 示例
|
||||
|
||||
前提仍然一样:宿主机 GoNavi 活动数据目录内至少应包含:
|
||||
|
||||
- `connections.json`
|
||||
- `daily_secrets.json`
|
||||
|
||||
如果目标连接依赖可选 driver agent,还要保证同一数据目录下已有 `drivers/`。
|
||||
|
||||
## 1. 直接运行已发布镜像
|
||||
|
||||
```bash
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ./gonavi-mcp-server.env
|
||||
```
|
||||
|
||||
把 `GONAVI_MCP_HTTP_TOKEN` 改成随机值后运行:
|
||||
|
||||
```bash
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
ghcr.io/syngnat/gonavi-mcp-server:latest http
|
||||
```
|
||||
|
||||
查看状态:
|
||||
|
||||
```bash
|
||||
podman ps
|
||||
podman logs -f gonavi-mcp-server
|
||||
```
|
||||
|
||||
如果你的宿主机没有启用 SELinux,可把挂载参数末尾的 `:Z` 去掉;如果启用了 SELinux,建议保留它。
|
||||
|
||||
## 2. 本地源码构建镜像
|
||||
|
||||
仓库根目录已经提供 `.containerignore`,Podman 会优先读取它来裁剪构建上下文。
|
||||
|
||||
```bash
|
||||
podman build -f Dockerfile.mcp-server -t localhost/gonavi-mcp-server:local .
|
||||
podman run -d --name gonavi-mcp-server --replace \
|
||||
-p 8765:8765 \
|
||||
--env-file ./gonavi-mcp-server.env \
|
||||
-v /absolute/path/to/gonavi-data:/data:Z \
|
||||
localhost/gonavi-mcp-server:local http
|
||||
```
|
||||
|
||||
## 3. Rootless Quadlet 常驻服务
|
||||
|
||||
Podman Quadlet 适合 Linux 服务器 / NAS 上做 rootless 常驻服务。
|
||||
|
||||
把文件放到用户级目录:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container ~/.config/containers/systemd/
|
||||
cp deploy/podman/gonavi-mcp-server/gonavi-mcp-server.env.example ~/.config/containers/systemd/gonavi-mcp-server.env
|
||||
```
|
||||
|
||||
然后修改两处:
|
||||
|
||||
- `~/.config/containers/systemd/gonavi-mcp-server.container` 里的 `Volume=/absolute/path/to/gonavi-data:/data:Z`
|
||||
- `~/.config/containers/systemd/gonavi-mcp-server.env` 里的 `GONAVI_MCP_HTTP_TOKEN`
|
||||
|
||||
启动服务:
|
||||
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now gonavi-mcp-server.service
|
||||
systemctl --user status gonavi-mcp-server.service
|
||||
journalctl --user -u gonavi-mcp-server.service -f
|
||||
```
|
||||
|
||||
如果你希望用户退出登录后服务仍然保持运行,再执行:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
Rootless Quadlet 默认搜索路径是 `~/.config/containers/systemd/`;如果你要做系统级 rootful 部署,可改放到 `/etc/containers/systemd/`。
|
||||
|
||||
## 4. 关于 Compose
|
||||
|
||||
Podman 的 `podman compose` 依赖外部 compose provider。也就是说,是否能直接复用仓库根目录的 `docker-compose.mcp-server.yml`,取决于你的 Podman 环境是否已经安装并配置了对应 provider。
|
||||
|
||||
因此,这个仓库对 Podman 的主支持路径是:
|
||||
|
||||
- `podman run`
|
||||
- Quadlet(推荐长期运行)
|
||||
|
||||
如果你的环境已经有可用的 compose provider,再去复用根目录 Compose 文件即可。
|
||||
22
deploy/podman/gonavi-mcp-server/gonavi-mcp-server.container
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=GoNavi MCP Server (rootless Podman)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=gonavi-mcp-server
|
||||
Image=ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
Pull=newer
|
||||
EnvironmentFile=./gonavi-mcp-server.env
|
||||
# 修改成你的 GoNavi 活动数据目录;SELinux 主机建议保留 :Z
|
||||
Volume=/absolute/path/to/gonavi-data:/data:Z
|
||||
PublishPort=8765:8765
|
||||
Exec=http
|
||||
LogDriver=journald
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
TimeoutStartSec=900
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,15 @@
|
||||
# 容器内 GoNavi 活动数据根目录。通常不需要修改;Volume= 已把宿主机目录挂到 /data。
|
||||
GONAVI_DATA_ROOT=/data
|
||||
|
||||
# 日志目录。默认与镜像内目录保持一致。
|
||||
GONAVI_LOG_DIR=/var/lib/gonavi/logs
|
||||
|
||||
# MCP HTTP 监听地址与路径。
|
||||
GONAVI_MCP_HTTP_ADDR=0.0.0.0:8765
|
||||
GONAVI_MCP_HTTP_PATH=/mcp
|
||||
|
||||
# 远程 MCP 客户端访问时必须携带的 Bearer Token
|
||||
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
|
||||
# true = 只暴露结构查询工具,不注册 execute_sql
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
7
docker-compose.mcp-server.local.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
gonavi-mcp-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.mcp-server
|
||||
image: ${GONAVI_MCP_IMAGE_LOCAL:-gonavi-mcp-server:local}
|
||||
pull_policy: never
|
||||
20
docker-compose.mcp-server.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
gonavi-mcp-server:
|
||||
image: ${GONAVI_MCP_IMAGE:-ghcr.io/syngnat/gonavi-mcp-server:latest}
|
||||
pull_policy: ${GONAVI_MCP_PULL_POLICY:-missing}
|
||||
container_name: gonavi-mcp-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GONAVI_MCP_HTTP_PORT:-8765}:8765"
|
||||
environment:
|
||||
GONAVI_DATA_ROOT: /data
|
||||
GONAVI_LOG_DIR: /var/lib/gonavi/logs
|
||||
GONAVI_MCP_HTTP_ADDR: 0.0.0.0:8765
|
||||
GONAVI_MCP_HTTP_PATH: ${GONAVI_MCP_HTTP_PATH:-/mcp}
|
||||
GONAVI_MCP_HTTP_TOKEN: ${GONAVI_MCP_HTTP_TOKEN}
|
||||
GONAVI_MCP_SCHEMA_ONLY: ${GONAVI_MCP_SCHEMA_ONLY:-true}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${GONAVI_HOST_DATA_ROOT}
|
||||
target: /data
|
||||
command: ["http"]
|
||||
7
docker-compose.web-server.local.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
gonavi-web-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web-server
|
||||
image: ${GONAVI_WEB_IMAGE_LOCAL:-gonavi-web-server:local}
|
||||
pull_policy: never
|
||||
24
docker-compose.web-server.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
gonavi-web-server:
|
||||
image: ${GONAVI_WEB_IMAGE:-ghcr.io/syngnat/gonavi-web-server:latest}
|
||||
pull_policy: ${GONAVI_WEB_PULL_POLICY:-missing}
|
||||
container_name: gonavi-web-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${GONAVI_WEB_HTTP_PORT:-34116}:34116"
|
||||
environment:
|
||||
GONAVI_DATA_ROOT: /data
|
||||
GONAVI_LOG_DIR: /var/lib/gonavi/logs
|
||||
GONAVI_WEB_ADDR: 0.0.0.0:34116
|
||||
GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${GONAVI_HOST_DATA_ROOT}
|
||||
target: /data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:34116/__gonavi/healthz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
command: ["web-server"]
|
||||
20
docker.mcp-server.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# 宿主机 GoNavi 活动数据目录,目录内至少应包含 connections.json 与 daily_secrets.json
|
||||
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
|
||||
|
||||
# 预构建镜像地址。稳定版通常使用 latest,跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-mcp-server:dev-latest
|
||||
GONAVI_MCP_IMAGE=ghcr.io/syngnat/gonavi-mcp-server:latest
|
||||
|
||||
# 预构建镜像拉取策略:always / missing / never
|
||||
GONAVI_MCP_PULL_POLICY=missing
|
||||
|
||||
# 远程 MCP 客户端访问时必须携带的 Bearer Token
|
||||
GONAVI_MCP_HTTP_TOKEN=replace-with-a-random-token
|
||||
|
||||
# 宿主机暴露端口
|
||||
GONAVI_MCP_HTTP_PORT=8765
|
||||
|
||||
# MCP HTTP 路径
|
||||
GONAVI_MCP_HTTP_PATH=/mcp
|
||||
|
||||
# true = 只暴露结构查询工具,不注册 execute_sql
|
||||
GONAVI_MCP_SCHEMA_ONLY=true
|
||||
19
docker.web-server.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# 宿主机 GoNavi 活动数据目录。
|
||||
# 建议至少包含:connections.json、daily_secrets.json;
|
||||
# 如需可选驱动代理,一并挂载 drivers/。
|
||||
# Web 认证状态会写入 web_auth.json;未设置环境密码时首次访问 /setup 初始化。
|
||||
GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data
|
||||
|
||||
# 预构建镜像地址。稳定版通常使用 latest,跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-web-server:dev-latest
|
||||
GONAVI_WEB_IMAGE=ghcr.io/syngnat/gonavi-web-server:latest
|
||||
|
||||
# 预构建镜像拉取策略:always / missing / never
|
||||
GONAVI_WEB_PULL_POLICY=missing
|
||||
|
||||
# 宿主机暴露端口(映射到容器 34116)
|
||||
GONAVI_WEB_HTTP_PORT=34116
|
||||
|
||||
# Web 管理员密码(至少 6 位)。留空时首次访问 /setup 初始化;公网部署请使用更强密码。
|
||||
# 设置或修改后请执行 docker compose up -d --force-recreate;请限制本文件的读取权限。
|
||||
# 新部署如需启用 2FA,请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。
|
||||
GONAVI_WEB_PASSWORD=
|
||||
355
docs/superpowers/specs/2026-07-20-tab-close-shortcut-design.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Cmd/Ctrl+W 标签关闭快捷键设计
|
||||
|
||||
- 日期:2026-07-20
|
||||
- 状态:已确认,待实施
|
||||
- 适用范围:GoNavi 主窗口的工作区标签和 SQL 查询结果区标签
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoNavi 当前没有统一的 `Cmd+W` / `Ctrl+W` 应用内关闭语义。工作区标签由
|
||||
`TabManager` 管理,查询结果标签由每个 `QueryEditor` 的本地状态管理,两套关闭
|
||||
流程拥有不同的保护规则。若分别注册全局键盘监听,同一次按键可能同时关闭结果
|
||||
标签和工作区标签。
|
||||
|
||||
本设计增加一个可配置动作 `closeActiveTab`,由唯一的全局入口根据用户最后一次
|
||||
明确交互的区域,将关闭请求路由到结果区或工作区。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
1. macOS 默认使用 `Meta+W`,Windows/Linux 默认使用 `Ctrl+W`。
|
||||
2. 快捷键在设置中心可修改、启用或禁用。
|
||||
3. 同一次按键最多执行一个关闭动作,结果区与工作区之间不允许穿透。
|
||||
4. 复用现有关闭流程,保留未保存 SQL、运行中导入任务等保护。
|
||||
5. 结果区日志标签保持常驻;日志标签活跃时,快捷键隐藏整个结果区。
|
||||
6. 结果区被快捷键隐藏后,连续按键不得继续关闭工作区标签。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
1. 不新增“分别关闭结果标签”和“分别关闭工作区标签”两个可配置动作。
|
||||
2. 不改变鼠标关闭按钮、中键关闭或右键批量关闭的产品语义。
|
||||
3. 不关闭或清空 SQL 日志数据。
|
||||
4. 不改变后台导出、同步任务在工作区标签关闭后的运行方式。
|
||||
5. 不统一重构所有历史浮动窗口的关闭入口。
|
||||
6. 不让主窗口快捷键关闭已经拆出的独立窗口标签或独立结果窗口。
|
||||
|
||||
## 4. 方案选择
|
||||
|
||||
### 4.1 采用方案:单一全局路由器
|
||||
|
||||
`App` 保持唯一的全局 `keydown` capture 监听入口,维护最后一次明确交互区域,
|
||||
并向目标组件派发单一、明确的关闭命令。
|
||||
|
||||
采用该方案的原因:
|
||||
|
||||
- 键盘事件只匹配和消费一次,天然避免双重关闭。
|
||||
- `TabManager` 和 `QueryEditor` 继续拥有各自的状态与保护逻辑。
|
||||
- 路由状态可以独立测试,不需要把查询结果状态上移到全局 store。
|
||||
- 后续自定义快捷键仍复用现有快捷键设置与冲突检测体系。
|
||||
|
||||
### 4.2 未采用方案
|
||||
|
||||
- 分区独立监听:工作区和结果区分别监听 `window.keydown`。该方案存在监听顺序、
|
||||
冒泡阻断和同一 `window` 多监听器重复执行问题。
|
||||
- 完全依赖 DOM 焦点:Monaco、DataGrid、Ant Design Portal 和 `body` 级事件目标会
|
||||
让焦点不足以可靠表达用户的区域意图。
|
||||
|
||||
## 5. 快捷键配置
|
||||
|
||||
### 5.1 动作定义
|
||||
|
||||
新增动作 `closeActiveTab`,纳入:
|
||||
|
||||
- `ShortcutAction`
|
||||
- `SHORTCUT_ACTION_ORDER`
|
||||
- `SHORTCUT_ACTION_META`
|
||||
- `DEFAULT_SHORTCUT_OPTIONS`
|
||||
- 快捷键设置界面和全部语言目录
|
||||
|
||||
动作允许在 Monaco、输入框和 DataGrid 等可编辑目标中触发,因为焦点位于编辑器
|
||||
时仍应能关闭当前工作区标签。
|
||||
|
||||
默认绑定:
|
||||
|
||||
| 平台 | 组合键 | 默认状态 |
|
||||
| --- | --- | --- |
|
||||
| macOS | `Meta+W` | 启用 |
|
||||
| Windows/Linux | `Ctrl+W` | 启用 |
|
||||
|
||||
GoNavi 正式接管该组合键,因此删除现有“浏览器关闭标签页”的 `Ctrl+W` 保留项;
|
||||
其他浏览器、Monaco 和 DataGrid 保留项不变。
|
||||
|
||||
### 5.2 旧配置迁移
|
||||
|
||||
旧持久化配置中不存在 `closeActiveTab` 时,按平台分别迁移:
|
||||
|
||||
1. 若该平台没有任何已启用动作占用新默认组合键,补入启用的新动作。
|
||||
2. 若已有动作占用 `Meta+W` 或 `Ctrl+W`,保留原动作及绑定,补入组合键相同但
|
||||
`enabled: false` 的新动作。
|
||||
3. 用户可在快捷键设置中修改冲突绑定并重新启用新动作。
|
||||
4. “恢复默认值”和全新安装始终得到启用的默认绑定。
|
||||
|
||||
迁移只处理缺少新动作的旧配置,不覆盖用户已经保存的 `closeActiveTab` 设置。
|
||||
|
||||
禁用动作或把动作改绑到其他组合键后,默认 `Meta+W` / `Ctrl+W` 不再执行标签
|
||||
关闭,但主窗口仍必须消费该平台原生关闭组合键,防止 WebView 把它解释为关闭应用
|
||||
窗口或浏览器标签。
|
||||
|
||||
## 6. 路由状态模型
|
||||
|
||||
全局路由器维护 `CloseShortcutScope`:
|
||||
|
||||
```ts
|
||||
type CloseShortcutScope = 'workspace' | 'result' | 'blocked';
|
||||
```
|
||||
|
||||
初始状态为 `workspace`。
|
||||
|
||||
| 当前/触发条件 | 下一状态 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| 用户点击或聚焦结果区 | `result` | 关闭请求只发送给当前查询结果区 |
|
||||
| 用户点击或聚焦编辑器、工作区标签栏或侧边栏 | `workspace` | 关闭请求只发送给主工作区 |
|
||||
| 快捷键在日志标签上隐藏结果区 | `blocked` | 后续重复按键只消费,不关闭任何标签 |
|
||||
| `blocked` 后用户明确交互工作区 | `workspace` | 允许重新关闭工作区标签 |
|
||||
| `blocked` 后用户明确交互重新显示的结果区 | `result` | 允许关闭结果标签或再次隐藏结果区 |
|
||||
| 用户点击或聚焦独立结果窗口 | `blocked` | 独立窗口交互不得沿用后台工作区归属 |
|
||||
|
||||
SQL 执行后程序化展示结果、程序化切换结果标签、store 激活标签等行为不得修改
|
||||
该状态。只有真实的 `pointerdown` 或 `focusin` 才能改变区域归属。
|
||||
|
||||
结果区根节点增加 `data-gonavi-close-shortcut-scope="result"`。路由器在 document
|
||||
capture 阶段监听 `pointerdown` 和 `focusin`,通过该标记识别结果区;编辑器、
|
||||
工作区标签栏和侧边栏归入工作区。
|
||||
|
||||
交互浮层与独立结果窗口使用稳定的
|
||||
`data-gonavi-close-shortcut-guard="true"` 标记或统一 guard class。guard 包含当前可见
|
||||
的模态框、抽屉、Dropdown、上下文菜单、Select/Picker 弹层、自建交互 Portal 和
|
||||
`.gn-detached-result-window`。标记仅在浮层打开时处于 active 状态;若组件会保留
|
||||
隐藏 DOM,公共 helper 还必须检查元素的可见性。Tooltip、拖拽预览等不可获得输入
|
||||
焦点的纯展示 Portal 不属于 guard。
|
||||
|
||||
普通浮层 guard 内的 `pointerdown` 和 `focusin` 不改变后台区域归属。关闭快捷键
|
||||
来自 guard 内部时,只消费事件,不向工作区或结果区派发命令。
|
||||
|
||||
独立结果窗口是例外:其根节点同时标记
|
||||
`data-gonavi-close-shortcut-scope="blocked"`。可信的 `pointerdown` 或 `focusin`
|
||||
发生在窗口内部时,立即把路由 ref 写为 `blocked`。这覆盖标题栏拖动等不会夺取
|
||||
后台 Monaco 焦点的交互;之后即使 `keydown.target` 仍是 Monaco,也不得关闭主
|
||||
工作区。只有用户再次明确交互主工作区或主结果区才恢复对应归属。
|
||||
|
||||
会遮挡后台交互的浮层另加
|
||||
`data-gonavi-close-shortcut-blocks-background="true"`;只要此标记的节点当前可见,
|
||||
即使键盘事件目标落在 `body`,也只消费、不关闭后台标签。模态框、抽屉、交互
|
||||
Dropdown/上下文菜单、Select/Picker 弹层使用该标记。独立结果窗口只使用普通
|
||||
guard,不使用 background blocker,避免窗口存在时永久禁用主工作区快捷键。
|
||||
|
||||
## 7. 键盘事件流
|
||||
|
||||
```text
|
||||
全局 keydown capture
|
||||
-> 不受 IME 过滤的底层组合键解析
|
||||
-> 是否命中平台原生关闭键,或已启用的 closeActiveTab 绑定
|
||||
-> 快捷键录制器优先接管
|
||||
-> 命中平台原生关闭键时无条件 preventDefault
|
||||
-> IME/guard 命中时只消费,不派发
|
||||
-> 解析当前组合键的已启用动作 owner
|
||||
其他动作 -> 交给原动作处理一次
|
||||
closeActiveTab -> 读取 CloseShortcutScope 并路由
|
||||
无 owner 且为平台原生关闭键 -> 消费为 no-op
|
||||
```
|
||||
|
||||
事件处理约束:
|
||||
|
||||
1. `App` 是主窗口唯一的全局关闭快捷键入口。
|
||||
2. 增加一个只做键位与修饰键比较、不会因 IME 状态返回 false 的底层 matcher。
|
||||
它仅用于判断事件是否需要被应用接管;正常动作执行仍使用现有
|
||||
`isShortcutMatch` 语义。
|
||||
3. 路由器分别计算“平台原生关闭组合键”和“当前已启用动作绑定”的底层匹配,
|
||||
同时按 `SHORTCUT_ACTION_ORDER` 解析当前组合键的已启用 owner。快捷键设置继续
|
||||
保证同一平台不会新增多个启用 owner;遇到历史重复数据时按现有动作顺序只取
|
||||
第一个。
|
||||
4. 快捷键录制器拥有优先权。录制期间全局路由器直接退出,由录制器负责
|
||||
`preventDefault` 和保存绑定,不执行关闭动作。
|
||||
5. 除录制器分支外,底层 matcher 一旦命中平台原生关闭组合键,必须立即
|
||||
`preventDefault`,再判断 owner 和当前上下文。绑定 owner 存在但因可编辑目标、
|
||||
当前标签类型或组件未挂载而无法执行时,动作可以 no-op,WebView 默认关闭仍然
|
||||
必须被抑制。
|
||||
6. IME composing、全局 composition 状态或 keyCode/which 229 命中时,底层 matcher
|
||||
仍能识别组合键并消费,但不得派发关闭。
|
||||
7. 事件目标位于 guard 内,或存在可见的 background blocker 时,只消费、不关闭
|
||||
后台标签。
|
||||
8. 若 owner 是 `closeActiveTab`,才进入本设计的工作区/结果区路由。若 owner 是
|
||||
其他历史动作,关闭路由不得抢占或改成 no-op:全局动作继续由 App switch 执行,
|
||||
查询编辑器动作继续交给其现有 owner,并且整条链路只执行该动作一次。
|
||||
9. 只有平台原生关闭键没有任何已启用 owner 时,才由保护分支消费为 no-op。
|
||||
10. 关闭路由、IME/guard 保护和无 owner 的平台 no-op 在 `preventDefault` 后使用
|
||||
`stopImmediatePropagation`,避免 WebView 或同一 `window` 上的其他监听器再次
|
||||
处理。交给其他 owner 的事件不得在 owner 执行前被关闭路由
|
||||
`stopImmediatePropagation`,但第 5 条的 `preventDefault` 仍已生效。
|
||||
11. 一次事件只允许派发一个目标命令,不允许在目标无效时回退到另一区域。
|
||||
12. `document.body`、`document.documentElement` 等文档级目标按已记录状态路由。
|
||||
13. `closeActiveTab` 禁用或改绑,且没有其他已启用 owner 时,平台原生
|
||||
`Meta+W` / `Ctrl+W` 仍被消费为 no-op;自定义绑定只有在动作启用时才被接管。
|
||||
14. 若真机验证发现 WebView 不向前端交付 `Cmd/Ctrl+W`,才增加 Wails 原生事件
|
||||
fallback;fallback 必须汇入同一路由器并具备单次事件去重,不能形成第二套关闭
|
||||
流程。
|
||||
|
||||
## 8. 工作区关闭契约
|
||||
|
||||
工作区命令由 `TabManager` 处理,目标必须是 `dockedActiveTabId`,不得使用全局
|
||||
`activeTabId`。不存在主窗口活动标签时,命令为 no-op。
|
||||
|
||||
关闭必须复用:
|
||||
|
||||
```ts
|
||||
closeTabsWithSQLFilePrompt(
|
||||
[dockedActiveTabId],
|
||||
() => closeTab(dockedActiveTabId),
|
||||
);
|
||||
```
|
||||
|
||||
由此保留:
|
||||
|
||||
- 外部 SQL 文件未保存时的保存、丢弃、取消确认。
|
||||
- 外部 SQL 文件被移动或删除时的确认。
|
||||
- 防止重复打开关闭确认。
|
||||
- 运行中的 `data-import` 工作台关闭保护。
|
||||
- store 已有的关闭后活动标签选择和关联资源清理。
|
||||
|
||||
`table-export` 与 `data-sync` 的任务状态独立于标签组件,仍允许关闭工作区标签并
|
||||
在后台继续运行。
|
||||
|
||||
## 9. 结果区关闭契约
|
||||
|
||||
结果区命令只发送给主窗口当前活动的 `QueryEditor`,由它读取本地
|
||||
`activeResultKey` 和 `resultSets`。组件提取一个共享的纯函数
|
||||
`resolveEffectiveActiveResultKey`,供结果区渲染和快捷键命令共同使用,不能一方
|
||||
读取原始 key、另一方使用视觉回退 key。
|
||||
|
||||
在支持嵌入 SQL 日志标签的结果区中,日志标签只要结果区可见就无条件存在,日志
|
||||
数量为 0 时也显示;其 key 统一使用 `QUERY_EDITOR_SQL_LOG_TAB_KEY`。
|
||||
|
||||
具体行为:
|
||||
|
||||
1. 当前键对应真实结果标签时,调用现有 `handleCloseResult`。
|
||||
2. 关闭后由现有逻辑选择相邻结果标签,不在路由器中重复计算。
|
||||
3. 关闭最后一个真实结果标签后,活动状态明确切到日志 key,结果区继续显示常驻
|
||||
日志标签;即使历史状态中原始 key 为空,共享 resolver 也必须得到同一日志 key。
|
||||
4. 当前键为日志标签时,不删除日志;隐藏整个结果区,并向路由器回报
|
||||
`hidden`,使路由状态进入 `blocked`。
|
||||
5. 当前键缺失、过期或不属于真实结果与日志时,消费请求并回报 `ignored`,不得
|
||||
回退关闭工作区。
|
||||
6. 结果区已经隐藏时,结果命令为 no-op,仍不得穿透到工作区。
|
||||
|
||||
结果命令需要一个明确的完成回报 `closed | hidden | ignored`。命令派发和回报必须
|
||||
在当前 `keydown` 调用栈内同步完成,不使用 Promise 或等待 React render。可通过
|
||||
同步 `dispatchEvent` 的可变 request detail 或等价的同步命令注册表返回 outcome。
|
||||
|
||||
路由状态以 ref/原子值为准,键盘监听不得读取可能滞后的 React state。收到
|
||||
`hidden` 后,必须在监听器返回前写入 `scopeRef.current = 'blocked'`;`closed` 和
|
||||
`ignored` 保持 `result`。因此即使同一 task 内连续派发两个 `keydown`,第二次也会
|
||||
读到 `blocked`。
|
||||
|
||||
结果命令同样读取 `resultSetsRef` 与 `activeResultKeyRef`。关闭动作在提交 React
|
||||
state 更新的同时同步写入这两个 ref,使同一 task 内的下一次命令能看到相邻结果
|
||||
或日志 key,而不是重复处理已经关闭的结果。
|
||||
|
||||
独立结果窗口不向主窗口发送工作区关闭命令。主窗口路由器也不控制独立结果窗口
|
||||
的生命周期。
|
||||
|
||||
## 10. 错误与竞态处理
|
||||
|
||||
- 快速重复按键:工作区沿用关闭确认的互斥保护;结果区每次根据最新本地状态
|
||||
处理。日志隐藏后立即切换为 `blocked`。
|
||||
- 结果标签关闭与查询完成并发:以命令处理时的最新 `activeResultKey` 为准;无效
|
||||
key 返回 `ignored`。
|
||||
- 活动工作区标签在事件间切换:`TabManager` 在命令处理时读取最新
|
||||
`dockedActiveTabId`。
|
||||
- 组件卸载:命令没有有效接收者时 no-op,不向另一区域补发。
|
||||
- 自定义绑定冲突:继续使用现有冲突提示;运行时不同时执行两个动作。
|
||||
|
||||
## 11. 测试设计
|
||||
|
||||
### 11.1 快捷键工具测试
|
||||
|
||||
- 新动作默认绑定、动作顺序、元信息和平台显示。
|
||||
- 新动作允许在可编辑目标触发。
|
||||
- `Ctrl+W` 不再报告浏览器保留冲突。
|
||||
- 自定义绑定、禁用和恢复默认值。
|
||||
- 动作禁用或改绑后,平台原生关闭组合键仍被消费但不关闭标签或窗口。
|
||||
- 旧配置无冲突时自动启用新动作。
|
||||
- 旧配置占用默认组合键时保留旧动作并禁用新动作,且按平台隔离。
|
||||
- 迁移后按原生关闭组合键仍只执行原动作一次,不执行关闭动作,WebView 也不关闭。
|
||||
- 历史 owner 因可编辑目标、非查询标签或组件未挂载而无法执行时,不执行任何动作,
|
||||
但仍阻止 WebView 原生关闭。
|
||||
- 所有语言包含新动作的 label/description。
|
||||
|
||||
### 11.2 路由器测试
|
||||
|
||||
- 初始状态关闭工作区,不触发结果区。
|
||||
- 明确点击结果区后只关闭结果标签。
|
||||
- 点击 Monaco、工作区标签栏或侧边栏后只关闭工作区标签。
|
||||
- SQL 执行后程序化激活结果不抢占归属。
|
||||
- 日志标签隐藏结果区并进入 `blocked`。
|
||||
- `blocked` 状态连续按键不关闭工作区;明确交互工作区后才恢复。
|
||||
- 无效结果状态只消费,不穿透工作区。
|
||||
- `body` 和 `documentElement` 目标使用最后明确区域。
|
||||
- 快捷键录制期间不执行关闭。
|
||||
- IME composing / keyCode 229 下,底层 matcher 仍消费默认或自定义关闭组合键,但
|
||||
不执行关闭。
|
||||
- 模态框、抽屉、下拉菜单和 Portal 活跃时不关闭后台标签。
|
||||
- Tooltip 和拖拽预览等纯展示 Portal 不触发 guard。
|
||||
- 保持后台 Monaco 焦点,初始状态为 `workspace` 时点击不可聚焦的独立结果窗口
|
||||
标题栏,再按快捷键也不得关闭主工作区。
|
||||
- 独立结果窗口仅仅存在时,不阻止主工作区内正常使用关闭快捷键。
|
||||
- 可见 background blocker 存在且事件目标为 `body` 时也不得关闭后台标签。
|
||||
- 同一个键盘事件最多派发一个关闭命令。
|
||||
- `hidden` 回报后不等待重渲染,立即连续派发第二个事件仍保持 blocked。
|
||||
|
||||
### 11.3 工作区集成测试
|
||||
|
||||
- 使用 `dockedActiveTabId`,独立窗口处于全局 active 时也不关错标签。
|
||||
- 外部 SQL 脏文件保留保存/丢弃/取消流程。
|
||||
- 外部 SQL 文件丢失确认仍生效。
|
||||
- 运行中数据导入工作台无法被快捷键关闭。
|
||||
- 导出、同步工作台标签允许关闭,后台任务继续。
|
||||
- 没有 docked 标签时 no-op。
|
||||
|
||||
### 11.4 结果区集成测试
|
||||
|
||||
- 关闭真实结果标签并正确选择相邻标签。
|
||||
- 关闭最后一个真实结果后显示日志标签。
|
||||
- 关闭最后一个真实结果后,命令和渲染共同解析为日志 key;下一次快捷键隐藏
|
||||
结果区。
|
||||
- 日志标签不会被删除,结果区整体隐藏。
|
||||
- 计数请求等现有清理逻辑仍由 `handleCloseResult` 执行。
|
||||
- 结果区隐藏、active key 过期、组件卸载时均不关闭工作区。
|
||||
- 独立结果窗口不会把命令路由回主工作区。
|
||||
|
||||
### 11.5 真机验收
|
||||
|
||||
- macOS 触摸板、Monaco、DataGrid 和普通面板焦点下验证 `Cmd+W`。
|
||||
- Windows/Linux 对应验证 `Ctrl+W`。
|
||||
- 验证 WebView 不执行原生窗口或浏览器标签关闭。
|
||||
- 验证自定义组合键、完全禁用状态及 IME 组合输入;改绑或禁用后默认平台关闭键
|
||||
仍是应用内 no-op。
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
1. 默认快捷键、自定义和禁用均按平台生效。
|
||||
2. 结果区、工作区和 `blocked` 三种状态的路由符合本设计。
|
||||
3. 一次按键不会同时关闭结果标签和工作区标签。
|
||||
4. 日志标签始终保留;日志活跃时隐藏的是整个结果区。
|
||||
5. 隐藏结果区后连续按键不会误关工作区标签。
|
||||
6. 现有 SQL 文件和数据导入保护没有被绕过。
|
||||
7. 旧用户配置不会因升级被静默覆盖或产生双动作。
|
||||
8. 自动化测试和 macOS/Windows 真机验证通过。
|
||||
|
||||
## 13. 预计影响文件
|
||||
|
||||
- `frontend/src/utils/shortcuts.ts`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/components/TabManager.tsx`
|
||||
- `frontend/src/components/QueryEditor.tsx`
|
||||
- `frontend/src/components/QueryEditorResultsPanel.tsx`
|
||||
- `frontend/src/i18n/catalog.ts` 及目录完整性测试
|
||||
- 快捷键、App、TabManager、QueryEditor 相关测试文件
|
||||
45
docs/需求追踪/需求进度追踪-Oracle对象跳转与存储过程修改执行-20260625.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:Oracle对象跳转与存储过程修改执行
|
||||
- 提出日期:2026-06-25
|
||||
- 负责人:Codex
|
||||
- 目标:SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
|
||||
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
|
||||
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
|
||||
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
|
||||
- [x] 阶段 2(影响分析):影响前端 QueryEditor、DefinitionViewer、i18n、测试;后端拆分已有基础保护。
|
||||
- [x] 阶段 3(方案设计):补齐元数据与跳转类型;对象编辑 SQL 保留 SQLPlus `/` 语义。
|
||||
- [x] 阶段 4(实施计划):先补导航闭环,再修 PL/SQL 生成/执行,最后跑定向测试。
|
||||
- [x] 阶段 5(实现与自检):已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
|
||||
- [ ] 阶段 6(评审与交付):待提交推送。
|
||||
- [ ] 阶段 7(发布与观察):发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页;修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`。
|
||||
- 进行中:提交并推送。
|
||||
- 待处理:发布后验证 Oracle 实库。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:三段名称 `schema.package.proc` 与 `db.schema.table` 存在歧义。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
|
||||
- 决策 2:Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter,避免前端用分号重组造成 ORA-00900。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
|
||||
- 结果:通过。
|
||||
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx`;`npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts`;`go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'`;`npm run build`。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送到 dev。
|
||||
- 负责人:Codex
|
||||
@@ -4,24 +4,24 @@
|
||||
- 需求名称:生产连接只读保护
|
||||
- 提出日期:2026-06-23
|
||||
- 负责人:Codex
|
||||
- 目标:为 SQL 类数据库与 MongoDB 连接增加类似 DBeaver 的生产连接保护,支持按项勾选数据编辑、结构编辑、脚本执行、数据导入四类限制
|
||||
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作
|
||||
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 连接配置模型、保存/回填、持久化与 RPC 序列化链路
|
||||
- 连接弹窗生产保护多选项 UI、查询编辑器本地拦截、DataGrid 导入与表设计器只读态
|
||||
- 后端 SQL/Mongo 查询判定与分类型写操作守卫
|
||||
- 连接配置模型、保存/回填与 RPC 序列化链路
|
||||
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
|
||||
- 后端 SQL/Mongo 查询判定与写操作统一守卫
|
||||
- 验收标准:
|
||||
- 支持的数据源出现可多选的“生产连接保护”限制项
|
||||
- 勾选“限制脚本执行”后,普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
|
||||
- 勾选“限制数据编辑 / 结构编辑 / 数据导入”后,对应 UI 与后端入口分别按类别拒绝
|
||||
- 支持的数据源出现“生产连接/只允许查询”开关
|
||||
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
|
||||
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
|
||||
- 依赖与约束:
|
||||
- 保持现有数据源能力判定与 QueryEditor 执行链路
|
||||
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认采用多限制项保护,而不是单一 `readOnly` 开关
|
||||
- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统
|
||||
- [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口
|
||||
- [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护
|
||||
- [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫
|
||||
@@ -31,23 +31,21 @@
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 新增连接级 `protection` 多限制项配置,并保留旧 `readOnly` 兼容映射
|
||||
- 连接弹窗为 SQL 类数据库与 MongoDB 改为 DBeaver 风格的生产连接保护多选项
|
||||
- 修复生产连接保护限制项卡片的 checkbox 对齐与选中态漂移问题,并保留整卡点击切换
|
||||
- QueryEditor 改为仅在“限制脚本执行”下拦截非查询 SQL / Mongo 写命令
|
||||
- DataGrid / TableDesigner / Sidebar 能力分别按数据编辑、结构编辑、数据导入限制收口
|
||||
- 后端为查询、DDL、导入、清表、同步等写入口增加分类型守卫
|
||||
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
|
||||
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
|
||||
- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用
|
||||
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
|
||||
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
|
||||
- 补充前端/后端定向测试与需求追踪文档
|
||||
- 进行中:
|
||||
- 等待体验包验证多限制项 UI、分类型限制是否与实际入口完全一致
|
||||
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
|
||||
- 待处理:
|
||||
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 前端 SQL/Mongo 脚本判定是保守策略,边界命令仍需后端兜底
|
||||
- 现有部分侧栏写菜单仍可能显示,但会按新的结构编辑限制在执行时被后端拒绝
|
||||
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
|
||||
- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝
|
||||
- 阻塞:
|
||||
- 暂无
|
||||
- 缓解措施:
|
||||
@@ -55,25 +53,22 @@
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
|
||||
- 决策 2:新增 `protection` 多限制项配置,旧 `readOnly=true` 自动映射为四项全开
|
||||
- 决策 3:脚本执行、数据编辑、结构编辑、数据导入分别在不同前后端入口生效
|
||||
- 决策 4:MongoDB 只读判定按命令白名单处理,防止把写命令误放行
|
||||
- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
|
||||
- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- 前端数据源能力判定、RPC 配置、连接配置测试
|
||||
- 后端生产保护守卫与 SQL/Mongo 查询判定测试
|
||||
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
|
||||
- 结果:
|
||||
- 通过
|
||||
- 证据(日志/截图/链接):
|
||||
- `go test ./internal/app -run 'TestEnsureReadOnlyConnectionAllowsQuery|TestEnsureReadOnlyConnectionAllowsAction|TestEnsureConnectionProtectionSeparatesActionCategories'`
|
||||
- `npm --prefix frontend run build`
|
||||
- `npm --prefix frontend test -- src/utils/connectionReadOnly.test.ts src/utils/dataSourceCapabilities.test.ts src/i18n/i18n.test.ts src/components/ConnectionModal.i18n.test.tsx -t 'readOnly|production-guard'`
|
||||
- 说明:`src/components/ConnectionModal.i18n.test.tsx` 全量仍有 2 条既有基线红测,与本次生产连接保护改动无关
|
||||
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
|
||||
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:
|
||||
- 用体验包回归验证四类限制项在查询、结果编辑、表设计、导入、同步目标和 Mongo 命令上的实际表现
|
||||
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
|
||||
- 如需更完整 UX,再补侧栏写菜单的只读态收口
|
||||
- 负责人:
|
||||
- Codex
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/bootstrap.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
740
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "gonavi-client",
|
||||
"version": "0.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -40,6 +41,7 @@
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
@@ -2339,6 +2341,13 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2351,6 +2360,22 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "5.29.3",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
|
||||
@@ -2452,6 +2477,19 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -2496,6 +2534,56 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
@@ -2544,6 +2632,23 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||
@@ -2620,6 +2725,22 @@
|
||||
"chevrotain": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ci-info": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sibiraj-s"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz",
|
||||
@@ -2641,6 +2762,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -2694,6 +2835,21 @@
|
||||
"layout-base": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -3270,6 +3426,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
@@ -3317,6 +3491,21 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.283",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz",
|
||||
@@ -3324,6 +3513,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -3331,6 +3540,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
@@ -3481,6 +3703,29 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/find-yarn-workspace-root": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
@@ -3489,6 +3734,21 @@
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3504,6 +3764,16 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3514,6 +3784,65 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/graphlib/-/graphlib-2.1.8.tgz",
|
||||
@@ -3529,6 +3858,55 @@
|
||||
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-parse-selector": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||
@@ -3695,6 +4073,22 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-hexadecimal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
||||
@@ -3705,6 +4099,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||
@@ -3717,6 +4121,33 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -3736,6 +4167,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/json2mq": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
|
||||
@@ -3758,6 +4209,29 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.40",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz",
|
||||
@@ -3788,6 +4262,16 @@
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/klaw-sync": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
|
||||
@@ -3909,6 +4393,16 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
@@ -4805,6 +5299,43 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
@@ -4897,6 +5428,33 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^2.0.0",
|
||||
"is-wsl": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||
@@ -4928,12 +5486,65 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/patch-package": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"chalk": "^4.1.2",
|
||||
"ci-info": "^3.7.0",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"find-yarn-workspace-root": "^2.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"json-stable-stringify": "^1.0.2",
|
||||
"klaw-sync": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"open": "^7.4.2",
|
||||
"semver": "^7.5.3",
|
||||
"slash": "^2.0.0",
|
||||
"tmp": "^0.2.4",
|
||||
"yaml": "^2.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"patch-package": "index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">5"
|
||||
}
|
||||
},
|
||||
"node_modules/patch-package/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/path-data-parser": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
|
||||
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -6124,6 +6735,47 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -6131,6 +6783,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -6248,6 +6910,19 @@
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
@@ -6324,6 +6999,29 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toggle-selection": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
|
||||
@@ -6472,6 +7170,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -6780,6 +7488,22 @@
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
@@ -6804,6 +7528,22 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
"name": "gonavi-client",
|
||||
"private": true,
|
||||
"version": "0.6.5",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"postinstall": "patch-package --error-on-fail",
|
||||
"i18n:scan": "go run ../tools/i18n-scan --root .."
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
|
||||
@@ -1 +1 @@
|
||||
84ec3a6d42105c92f224232f0d83a33b
|
||||
339d5c974e6ab5cba2d5def171f5ce0a
|
||||
|
||||
461
frontend/patches/rc-table+7.54.0.patch
Normal file
@@ -0,0 +1,461 @@
|
||||
diff --git a/node_modules/rc-table/es/VirtualTable/BodyGrid.js b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
|
||||
index 42dcde8..2cca743 100644
|
||||
--- a/node_modules/rc-table/es/VirtualTable/BodyGrid.js
|
||||
+++ b/node_modules/rc-table/es/VirtualTable/BodyGrid.js
|
||||
@@ -23,6 +23,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
sticky = _useContext2.sticky,
|
||||
scrollY = _useContext2.scrollY,
|
||||
listItemHeight = _useContext2.listItemHeight,
|
||||
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
|
||||
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
|
||||
getComponent = _useContext2.getComponent,
|
||||
onTablePropScroll = _useContext2.onScroll;
|
||||
|
||||
@@ -113,8 +115,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
var extraRender = function extraRender(info) {
|
||||
+ // The column-window fast path explicitly requires rows without spans.
|
||||
+ // Avoid scanning every column on each vertical window update.
|
||||
+ if (listItemColumnVirtual) {
|
||||
+ return null;
|
||||
+ }
|
||||
var start = info.start,
|
||||
end = info.end,
|
||||
getSize = info.getSize,
|
||||
- offsetY = info.offsetY;
|
||||
+ offsetY = info.offsetY,
|
||||
+ offsetX = info.offsetX;
|
||||
|
||||
// Do nothing if no data
|
||||
if (end < 0) {
|
||||
@@ -198,6 +200,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
style: {
|
||||
top: -offsetY + sizeInfo.top
|
||||
},
|
||||
+ offsetX: offsetX,
|
||||
extra: true,
|
||||
getHeight: getHeight
|
||||
});
|
||||
@@ -239,6 +242,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
className: tblPrefixCls,
|
||||
height: scrollY,
|
||||
itemHeight: listItemHeight || 24,
|
||||
+ itemHeightFixed: listItemHeightFixed,
|
||||
data: flattenData,
|
||||
itemKey: function itemKey(item) {
|
||||
return getRowKey(item.record);
|
||||
@@ -262,6 +266,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
data: item,
|
||||
rowKey: rowKey,
|
||||
index: index,
|
||||
+ offsetX: itemProps.offsetX,
|
||||
style: itemProps.style
|
||||
});
|
||||
}));
|
||||
diff --git a/node_modules/rc-table/es/VirtualTable/BodyLine.js b/node_modules/rc-table/es/VirtualTable/BodyLine.js
|
||||
index c760819..1a6c396 100644
|
||||
--- a/node_modules/rc-table/es/VirtualTable/BodyLine.js
|
||||
+++ b/node_modules/rc-table/es/VirtualTable/BodyLine.js
|
||||
@@ -2,7 +2,7 @@ import _extends from "@babel/runtime/helpers/esm/extends";
|
||||
import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
|
||||
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
|
||||
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
|
||||
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
|
||||
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
|
||||
import { useContext } from '@rc-component/context';
|
||||
import classNames from 'classnames';
|
||||
import * as React from 'react';
|
||||
@@ -10,7 +10,7 @@ import Cell from "../Cell";
|
||||
import TableContext, { responseImmutable } from "../context/TableContext";
|
||||
import useRowInfo from "../hooks/useRowInfo";
|
||||
import VirtualCell from "./VirtualCell";
|
||||
-import { StaticContext } from "./context";
|
||||
+import { GridContext, StaticContext } from "./context";
|
||||
import { computedExpandedClassName } from "../utils/expandUtil";
|
||||
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
var data = props.data,
|
||||
@@ -18,20 +18,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
className = props.className,
|
||||
rowKey = props.rowKey,
|
||||
style = props.style,
|
||||
+ offsetX = props.offsetX,
|
||||
extra = props.extra,
|
||||
getHeight = props.getHeight,
|
||||
restProps = _objectWithoutProperties(props, _excluded);
|
||||
var record = data.record,
|
||||
indent = data.indent,
|
||||
renderIndex = data.index;
|
||||
- var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
|
||||
+ var _useContext = useContext(TableContext, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
|
||||
scrollX = _useContext.scrollX,
|
||||
flattenColumns = _useContext.flattenColumns,
|
||||
prefixCls = _useContext.prefixCls,
|
||||
fixColumn = _useContext.fixColumn,
|
||||
- componentWidth = _useContext.componentWidth;
|
||||
- var _useContext2 = useContext(StaticContext, ['getComponent']),
|
||||
- getComponent = _useContext2.getComponent;
|
||||
+ componentWidth = _useContext.componentWidth,
|
||||
+ direction = _useContext.direction;
|
||||
+ var _useContext2 = useContext(StaticContext, ['getComponent', 'listItemColumnVirtual']),
|
||||
+ getComponent = _useContext2.getComponent,
|
||||
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
|
||||
+ var _useContext3 = useContext(GridContext, ['columnsOffset']),
|
||||
+ columnsOffset = _useContext3.columnsOffset;
|
||||
var rowInfo = useRowInfo(record, rowKey, index, indent);
|
||||
var RowComponent = getComponent(['body', 'row'], 'div');
|
||||
var cellComponent = getComponent(['body', 'cell'], 'div');
|
||||
@@ -71,12 +76,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
rowStyle.position = 'absolute';
|
||||
rowStyle.pointerEvents = 'none';
|
||||
}
|
||||
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
|
||||
- "data-row-key": rowKey,
|
||||
- ref: rowSupportExpand ? null : ref,
|
||||
- className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
|
||||
- style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
|
||||
- }), flattenColumns.map(function (column, colIndex) {
|
||||
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
|
||||
return /*#__PURE__*/React.createElement(VirtualCell, {
|
||||
key: colIndex,
|
||||
component: cellComponent,
|
||||
@@ -90,7 +90,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
inverse: extra,
|
||||
getHeight: getHeight
|
||||
});
|
||||
- }));
|
||||
+ };
|
||||
+ var columnNodes;
|
||||
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
|
||||
+ var mergedOffsetX = Math.max(0, offsetX || 0);
|
||||
+ var viewportWidth = Math.max(0, componentWidth || 0);
|
||||
+ var overscanWidth = Math.max(320, viewportWidth);
|
||||
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
|
||||
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
|
||||
+ var nodes = [];
|
||||
+ var spacerStart = -1;
|
||||
+ var spacerWidth = 0;
|
||||
+ var flushSpacer = function flushSpacer() {
|
||||
+ if (spacerWidth <= 0) return;
|
||||
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
|
||||
+ "aria-hidden": true,
|
||||
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
|
||||
+ key: "virtual-spacer-".concat(spacerStart),
|
||||
+ style: {
|
||||
+ border: 0,
|
||||
+ flex: "0 0 ".concat(spacerWidth, "px"),
|
||||
+ minWidth: 0,
|
||||
+ padding: 0,
|
||||
+ pointerEvents: 'none',
|
||||
+ visibility: 'hidden',
|
||||
+ width: "".concat(spacerWidth, "px")
|
||||
+ }
|
||||
+ }));
|
||||
+ spacerStart = -1;
|
||||
+ spacerWidth = 0;
|
||||
+ };
|
||||
+ flattenColumns.forEach(function (column, colIndex) {
|
||||
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
|
||||
+ var columnEnd = columnsOffset[colIndex] || columnStart;
|
||||
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
|
||||
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
|
||||
+ if (visibleColumn) {
|
||||
+ flushSpacer();
|
||||
+ nodes.push(renderVirtualCell(column, colIndex));
|
||||
+ } else {
|
||||
+ if (spacerStart < 0) spacerStart = colIndex;
|
||||
+ spacerWidth += Math.max(0, columnEnd - columnStart);
|
||||
+ }
|
||||
+ });
|
||||
+ flushSpacer();
|
||||
+ columnNodes = nodes;
|
||||
+ } else {
|
||||
+ columnNodes = flattenColumns.map(renderVirtualCell);
|
||||
+ }
|
||||
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, _extends({}, rowProps, restProps, {
|
||||
+ "data-row-key": rowKey,
|
||||
+ ref: rowSupportExpand ? null : ref,
|
||||
+ className: classNames(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, _defineProperty({}, "".concat(prefixCls, "-row-extra"), extra)),
|
||||
+ style: _objectSpread(_objectSpread({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
|
||||
+ }), columnNodes);
|
||||
if (rowSupportExpand) {
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
ref: ref
|
||||
diff --git a/node_modules/rc-table/es/VirtualTable/context.d.ts b/node_modules/rc-table/es/VirtualTable/context.d.ts
|
||||
index e558165..ccd74fc 100644
|
||||
--- a/node_modules/rc-table/es/VirtualTable/context.d.ts
|
||||
+++ b/node_modules/rc-table/es/VirtualTable/context.d.ts
|
||||
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
|
||||
export interface StaticContextProps {
|
||||
scrollY: number;
|
||||
listItemHeight: number;
|
||||
+ listItemHeightFixed?: boolean;
|
||||
+ listItemColumnVirtual?: boolean;
|
||||
sticky: boolean | TableSticky;
|
||||
getComponent: GetComponent;
|
||||
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
||||
diff --git a/node_modules/rc-table/es/VirtualTable/index.d.ts b/node_modules/rc-table/es/VirtualTable/index.d.ts
|
||||
index 31fe8e0..c5097b2 100644
|
||||
--- a/node_modules/rc-table/es/VirtualTable/index.d.ts
|
||||
+++ b/node_modules/rc-table/es/VirtualTable/index.d.ts
|
||||
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
|
||||
y: number;
|
||||
};
|
||||
listItemHeight?: number;
|
||||
+ listItemHeightFixed?: boolean;
|
||||
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
|
||||
+ listItemColumnVirtual?: boolean;
|
||||
}
|
||||
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
|
||||
displayName?: string;
|
||||
diff --git a/node_modules/rc-table/es/VirtualTable/index.js b/node_modules/rc-table/es/VirtualTable/index.js
|
||||
index fb00e9b..a778aa6 100644
|
||||
--- a/node_modules/rc-table/es/VirtualTable/index.js
|
||||
+++ b/node_modules/rc-table/es/VirtualTable/index.js
|
||||
@@ -27,6 +27,8 @@ function VirtualTable(props, ref) {
|
||||
prefixCls = _props$prefixCls === void 0 ? DEFAULT_PREFIX : _props$prefixCls,
|
||||
className = props.className,
|
||||
listItemHeight = props.listItemHeight,
|
||||
+ listItemHeightFixed = props.listItemHeightFixed,
|
||||
+ listItemColumnVirtual = props.listItemColumnVirtual,
|
||||
components = props.components,
|
||||
onScroll = props.onScroll;
|
||||
var _ref = scroll || {},
|
||||
@@ -61,10 +63,12 @@ function VirtualTable(props, ref) {
|
||||
sticky: sticky,
|
||||
scrollY: scrollY,
|
||||
listItemHeight: listItemHeight,
|
||||
+ listItemHeightFixed: listItemHeightFixed,
|
||||
+ listItemColumnVirtual: listItemColumnVirtual,
|
||||
getComponent: getComponent,
|
||||
onScroll: onInternalScroll
|
||||
};
|
||||
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
|
||||
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
|
||||
|
||||
// ========================== Render ==========================
|
||||
return /*#__PURE__*/React.createElement(StaticContext.Provider, {
|
||||
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
|
||||
index dee2539..b077789 100644
|
||||
--- a/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
|
||||
+++ b/node_modules/rc-table/lib/VirtualTable/BodyGrid.js
|
||||
@@ -33,6 +33,8 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
sticky = _useContext2.sticky,
|
||||
scrollY = _useContext2.scrollY,
|
||||
listItemHeight = _useContext2.listItemHeight,
|
||||
+ listItemHeightFixed = _useContext2.listItemHeightFixed,
|
||||
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual,
|
||||
getComponent = _useContext2.getComponent,
|
||||
onTablePropScroll = _useContext2.onScroll;
|
||||
|
||||
@@ -123,8 +125,14 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
var extraRender = function extraRender(info) {
|
||||
+ // The column-window fast path explicitly requires rows without spans.
|
||||
+ // Avoid scanning every column on each vertical window update.
|
||||
+ if (listItemColumnVirtual) {
|
||||
+ return null;
|
||||
+ }
|
||||
var start = info.start,
|
||||
end = info.end,
|
||||
getSize = info.getSize,
|
||||
- offsetY = info.offsetY;
|
||||
+ offsetY = info.offsetY,
|
||||
+ offsetX = info.offsetX;
|
||||
|
||||
// Do nothing if no data
|
||||
if (end < 0) {
|
||||
@@ -208,6 +210,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
style: {
|
||||
top: -offsetY + sizeInfo.top
|
||||
},
|
||||
+ offsetX: offsetX,
|
||||
extra: true,
|
||||
getHeight: getHeight
|
||||
});
|
||||
@@ -249,6 +252,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
className: tblPrefixCls,
|
||||
height: scrollY,
|
||||
itemHeight: listItemHeight || 24,
|
||||
+ itemHeightFixed: listItemHeightFixed,
|
||||
data: flattenData,
|
||||
itemKey: function itemKey(item) {
|
||||
return getRowKey(item.record);
|
||||
@@ -272,6 +276,7 @@ var Grid = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
data: item,
|
||||
rowKey: rowKey,
|
||||
index: index,
|
||||
+ offsetX: itemProps.offsetX,
|
||||
style: itemProps.style
|
||||
});
|
||||
}));
|
||||
diff --git a/node_modules/rc-table/lib/VirtualTable/BodyLine.js b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
|
||||
index e1275fb..d962380 100644
|
||||
--- a/node_modules/rc-table/lib/VirtualTable/BodyLine.js
|
||||
+++ b/node_modules/rc-table/lib/VirtualTable/BodyLine.js
|
||||
@@ -19,7 +19,7 @@ var _useRowInfo = _interopRequireDefault(require("../hooks/useRowInfo"));
|
||||
var _VirtualCell = _interopRequireDefault(require("./VirtualCell"));
|
||||
var _context2 = require("./context");
|
||||
var _expandUtil = require("../utils/expandUtil");
|
||||
-var _excluded = ["data", "index", "className", "rowKey", "style", "extra", "getHeight"];
|
||||
+var _excluded = ["data", "index", "className", "rowKey", "style", "offsetX", "extra", "getHeight"];
|
||||
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
|
||||
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
|
||||
var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
@@ -28,20 +28,25 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
className = props.className,
|
||||
rowKey = props.rowKey,
|
||||
style = props.style,
|
||||
+ offsetX = props.offsetX,
|
||||
extra = props.extra,
|
||||
getHeight = props.getHeight,
|
||||
restProps = (0, _objectWithoutProperties2.default)(props, _excluded);
|
||||
var record = data.record,
|
||||
indent = data.indent,
|
||||
renderIndex = data.index;
|
||||
- var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX']),
|
||||
+ var _useContext = (0, _context.useContext)(_TableContext.default, ['prefixCls', 'flattenColumns', 'fixColumn', 'componentWidth', 'scrollX', 'direction']),
|
||||
scrollX = _useContext.scrollX,
|
||||
flattenColumns = _useContext.flattenColumns,
|
||||
prefixCls = _useContext.prefixCls,
|
||||
fixColumn = _useContext.fixColumn,
|
||||
- componentWidth = _useContext.componentWidth;
|
||||
- var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent']),
|
||||
- getComponent = _useContext2.getComponent;
|
||||
+ componentWidth = _useContext.componentWidth,
|
||||
+ direction = _useContext.direction;
|
||||
+ var _useContext2 = (0, _context.useContext)(_context2.StaticContext, ['getComponent', 'listItemColumnVirtual']),
|
||||
+ getComponent = _useContext2.getComponent,
|
||||
+ listItemColumnVirtual = _useContext2.listItemColumnVirtual;
|
||||
+ var _useContext3 = (0, _context.useContext)(_context2.GridContext, ['columnsOffset']),
|
||||
+ columnsOffset = _useContext3.columnsOffset;
|
||||
var rowInfo = (0, _useRowInfo.default)(record, rowKey, index, indent);
|
||||
var RowComponent = getComponent(['body', 'row'], 'div');
|
||||
var cellComponent = getComponent(['body', 'cell'], 'div');
|
||||
@@ -81,12 +86,7 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
rowStyle.position = 'absolute';
|
||||
rowStyle.pointerEvents = 'none';
|
||||
}
|
||||
- var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
|
||||
- "data-row-key": rowKey,
|
||||
- ref: rowSupportExpand ? null : ref,
|
||||
- className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
|
||||
- style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
|
||||
- }), flattenColumns.map(function (column, colIndex) {
|
||||
+ var renderVirtualCell = function renderVirtualCell(column, colIndex) {
|
||||
return /*#__PURE__*/React.createElement(_VirtualCell.default, {
|
||||
key: colIndex,
|
||||
component: cellComponent,
|
||||
@@ -100,7 +100,60 @@ var BodyLine = /*#__PURE__*/React.forwardRef(function (props, ref) {
|
||||
inverse: extra,
|
||||
getHeight: getHeight
|
||||
});
|
||||
- }));
|
||||
+ };
|
||||
+ var columnNodes;
|
||||
+ if (listItemColumnVirtual && direction !== 'rtl' && !extra && columnsOffset && columnsOffset.length === flattenColumns.length) {
|
||||
+ var mergedOffsetX = Math.max(0, offsetX || 0);
|
||||
+ var viewportWidth = Math.max(0, componentWidth || 0);
|
||||
+ var overscanWidth = Math.max(320, viewportWidth);
|
||||
+ var visibleStart = Math.max(0, mergedOffsetX - overscanWidth);
|
||||
+ var visibleEnd = mergedOffsetX + viewportWidth + overscanWidth;
|
||||
+ var nodes = [];
|
||||
+ var spacerStart = -1;
|
||||
+ var spacerWidth = 0;
|
||||
+ var flushSpacer = function flushSpacer() {
|
||||
+ if (spacerWidth <= 0) return;
|
||||
+ nodes.push(/*#__PURE__*/React.createElement(cellComponent, {
|
||||
+ "aria-hidden": true,
|
||||
+ className: "".concat(prefixCls, "-cell-virtual-spacer"),
|
||||
+ key: "virtual-spacer-".concat(spacerStart),
|
||||
+ style: {
|
||||
+ border: 0,
|
||||
+ flex: "0 0 ".concat(spacerWidth, "px"),
|
||||
+ minWidth: 0,
|
||||
+ padding: 0,
|
||||
+ pointerEvents: 'none',
|
||||
+ visibility: 'hidden',
|
||||
+ width: "".concat(spacerWidth, "px")
|
||||
+ }
|
||||
+ }));
|
||||
+ spacerStart = -1;
|
||||
+ spacerWidth = 0;
|
||||
+ };
|
||||
+ flattenColumns.forEach(function (column, colIndex) {
|
||||
+ var columnStart = colIndex > 0 ? columnsOffset[colIndex - 1] : 0;
|
||||
+ var columnEnd = columnsOffset[colIndex] || columnStart;
|
||||
+ var fixedColumn = column.fixed === true || column.fixed === 'left' || column.fixed === 'right';
|
||||
+ var visibleColumn = fixedColumn || columnEnd >= visibleStart && columnStart <= visibleEnd;
|
||||
+ if (visibleColumn) {
|
||||
+ flushSpacer();
|
||||
+ nodes.push(renderVirtualCell(column, colIndex));
|
||||
+ } else {
|
||||
+ if (spacerStart < 0) spacerStart = colIndex;
|
||||
+ spacerWidth += Math.max(0, columnEnd - columnStart);
|
||||
+ }
|
||||
+ });
|
||||
+ flushSpacer();
|
||||
+ columnNodes = nodes;
|
||||
+ } else {
|
||||
+ columnNodes = flattenColumns.map(renderVirtualCell);
|
||||
+ }
|
||||
+ var rowNode = /*#__PURE__*/React.createElement(RowComponent, (0, _extends2.default)({}, rowProps, restProps, {
|
||||
+ "data-row-key": rowKey,
|
||||
+ ref: rowSupportExpand ? null : ref,
|
||||
+ className: (0, _classnames.default)(className, "".concat(prefixCls, "-row"), rowProps === null || rowProps === void 0 ? void 0 : rowProps.className, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-row-extra"), extra)),
|
||||
+ style: (0, _objectSpread2.default)((0, _objectSpread2.default)({}, rowStyle), rowProps === null || rowProps === void 0 ? void 0 : rowProps.style)
|
||||
+ }), columnNodes);
|
||||
if (rowSupportExpand) {
|
||||
return /*#__PURE__*/React.createElement("div", {
|
||||
ref: ref
|
||||
diff --git a/node_modules/rc-table/lib/VirtualTable/context.d.ts b/node_modules/rc-table/lib/VirtualTable/context.d.ts
|
||||
index e558165..ccd74fc 100644
|
||||
--- a/node_modules/rc-table/lib/VirtualTable/context.d.ts
|
||||
+++ b/node_modules/rc-table/lib/VirtualTable/context.d.ts
|
||||
@@ -3,6 +3,8 @@ import type { GetComponent, TableSticky } from '../interface';
|
||||
export interface StaticContextProps {
|
||||
scrollY: number;
|
||||
listItemHeight: number;
|
||||
+ listItemHeightFixed?: boolean;
|
||||
+ listItemColumnVirtual?: boolean;
|
||||
sticky: boolean | TableSticky;
|
||||
getComponent: GetComponent;
|
||||
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
||||
diff --git a/node_modules/rc-table/lib/VirtualTable/index.d.ts b/node_modules/rc-table/lib/VirtualTable/index.d.ts
|
||||
index 31fe8e0..c5097b2 100644
|
||||
--- a/node_modules/rc-table/lib/VirtualTable/index.d.ts
|
||||
+++ b/node_modules/rc-table/lib/VirtualTable/index.d.ts
|
||||
@@ -8,6 +8,9 @@ export interface VirtualTableProps<RecordType> extends Omit<TableProps<RecordTyp
|
||||
y: number;
|
||||
};
|
||||
listItemHeight?: number;
|
||||
+ listItemHeightFixed?: boolean;
|
||||
+ /** LTR-only fast path; requires body cells without colSpan/rowSpan. */
|
||||
+ listItemColumnVirtual?: boolean;
|
||||
}
|
||||
export type ForwardGenericVirtualTable = (<RecordType>(props: TableProps<RecordType> & React.RefAttributes<Reference>) => React.ReactElement) & {
|
||||
displayName?: string;
|
||||
diff --git a/node_modules/rc-table/lib/VirtualTable/index.js b/node_modules/rc-table/lib/VirtualTable/index.js
|
||||
index 8e31a9b..24ab161 100644
|
||||
--- a/node_modules/rc-table/lib/VirtualTable/index.js
|
||||
+++ b/node_modules/rc-table/lib/VirtualTable/index.js
|
||||
@@ -38,6 +38,8 @@ function VirtualTable(props, ref) {
|
||||
prefixCls = _props$prefixCls === void 0 ? _Table.DEFAULT_PREFIX : _props$prefixCls,
|
||||
className = props.className,
|
||||
listItemHeight = props.listItemHeight,
|
||||
+ listItemHeightFixed = props.listItemHeightFixed,
|
||||
+ listItemColumnVirtual = props.listItemColumnVirtual,
|
||||
components = props.components,
|
||||
onScroll = props.onScroll;
|
||||
var _ref = scroll || {},
|
||||
@@ -72,10 +74,12 @@ function VirtualTable(props, ref) {
|
||||
sticky: sticky,
|
||||
scrollY: scrollY,
|
||||
listItemHeight: listItemHeight,
|
||||
+ listItemHeightFixed: listItemHeightFixed,
|
||||
+ listItemColumnVirtual: listItemColumnVirtual,
|
||||
getComponent: getComponent,
|
||||
onScroll: onInternalScroll
|
||||
};
|
||||
- }, [sticky, scrollY, listItemHeight, getComponent, onInternalScroll]);
|
||||
+ }, [sticky, scrollY, listItemHeight, listItemHeightFixed, listItemColumnVirtual, getComponent, onInternalScroll]);
|
||||
|
||||
// ========================== Render ==========================
|
||||
return /*#__PURE__*/React.createElement(_context.StaticContext.Provider, {
|
||||
336
frontend/patches/rc-virtual-list+3.19.2.patch
Normal file
@@ -0,0 +1,336 @@
|
||||
diff --git a/node_modules/rc-virtual-list/es/List.d.ts b/node_modules/rc-virtual-list/es/List.d.ts
|
||||
index c517366..e414513 100644
|
||||
--- a/node_modules/rc-virtual-list/es/List.d.ts
|
||||
+++ b/node_modules/rc-virtual-list/es/List.d.ts
|
||||
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
|
||||
data: T[];
|
||||
height?: number;
|
||||
itemHeight?: number;
|
||||
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
|
||||
+ itemHeightFixed?: boolean;
|
||||
/** If not match virtual scroll condition, Set List still use height of container. */
|
||||
fullHeight?: boolean;
|
||||
itemKey: React.Key | ((item: T) => React.Key);
|
||||
diff --git a/node_modules/rc-virtual-list/es/List.js b/node_modules/rc-virtual-list/es/List.js
|
||||
index 2926056..e917ba1 100644
|
||||
--- a/node_modules/rc-virtual-list/es/List.js
|
||||
+++ b/node_modules/rc-virtual-list/es/List.js
|
||||
@@ -4,7 +4,7 @@ import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
|
||||
import _defineProperty from "@babel/runtime/helpers/esm/defineProperty";
|
||||
import _slicedToArray from "@babel/runtime/helpers/esm/slicedToArray";
|
||||
import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
|
||||
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
|
||||
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
|
||||
import classNames from 'classnames';
|
||||
import ResizeObserver from 'rc-resize-observer';
|
||||
import { useEvent } from 'rc-util';
|
||||
@@ -35,6 +35,7 @@ export function RawList(props, ref) {
|
||||
className = props.className,
|
||||
height = props.height,
|
||||
itemHeight = props.itemHeight,
|
||||
+ itemHeightFixed = props.itemHeightFixed,
|
||||
_props$fullHeight = props.fullHeight,
|
||||
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
|
||||
style = props.style,
|
||||
@@ -65,7 +66,7 @@ export function RawList(props, ref) {
|
||||
}, [itemKey]);
|
||||
|
||||
// ================================ Height ================================
|
||||
- var _useHeights = useHeights(getKey, null, null),
|
||||
+ var _useHeights = useHeights(getKey, null, null, itemHeightFixed),
|
||||
_useHeights2 = _slicedToArray(_useHeights, 4),
|
||||
setInstanceRef = _useHeights2[0],
|
||||
collectHeight = _useHeights2[1],
|
||||
@@ -74,11 +75,12 @@ export function RawList(props, ref) {
|
||||
|
||||
// ================================= MISC =================================
|
||||
var useVirtual = !!(virtual !== false && height && itemHeight);
|
||||
- var containerHeight = React.useMemo(function () {
|
||||
+ var measuredContainerHeight = React.useMemo(function () {
|
||||
return Object.values(heights.maps).reduce(function (total, curr) {
|
||||
return total + curr;
|
||||
}, 0);
|
||||
}, [heights.id, heights.maps]);
|
||||
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
|
||||
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
|
||||
var isRTL = direction === 'rtl';
|
||||
var mergedClassName = classNames(prefixCls, _defineProperty({}, "".concat(prefixCls, "-rtl"), isRTL), className);
|
||||
@@ -159,6 +161,28 @@ export function RawList(props, ref) {
|
||||
offset: undefined
|
||||
};
|
||||
}
|
||||
+ if (itemHeightFixed && itemHeight > 0) {
|
||||
+ var fixedDataLen = mergedData.length;
|
||||
+ var fixedScrollHeight = fixedDataLen * itemHeight;
|
||||
+ if (fixedDataLen === 0) {
|
||||
+ return {
|
||||
+ scrollHeight: 0,
|
||||
+ start: 0,
|
||||
+ end: -1,
|
||||
+ offset: 0
|
||||
+ };
|
||||
+ }
|
||||
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
|
||||
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
|
||||
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
|
||||
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
|
||||
+ return {
|
||||
+ scrollHeight: fixedScrollHeight,
|
||||
+ start: fixedStartIndex,
|
||||
+ end: fixedEndIndex,
|
||||
+ offset: fixedStartIndex * itemHeight
|
||||
+ };
|
||||
+ }
|
||||
var itemTop = 0;
|
||||
var startIndex;
|
||||
var startOffset;
|
||||
@@ -201,7 +225,7 @@ export function RawList(props, ref) {
|
||||
end: endIndex,
|
||||
offset: startOffset
|
||||
};
|
||||
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
|
||||
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
|
||||
scrollHeight = _React$useMemo.scrollHeight,
|
||||
start = _React$useMemo.start,
|
||||
end = _React$useMemo.end,
|
||||
@@ -213,6 +237,10 @@ export function RawList(props, ref) {
|
||||
// Which will make scroll jump.
|
||||
// Let's sync scroll top to avoid jump
|
||||
React.useLayoutEffect(function () {
|
||||
+ if (itemHeightFixed) {
|
||||
+ heights.resetRecord();
|
||||
+ return;
|
||||
+ }
|
||||
var changedRecord = heights.getRecord();
|
||||
if (changedRecord.size === 1) {
|
||||
var recordKey = Array.from(changedRecord.keys())[0];
|
||||
@@ -232,7 +260,7 @@ export function RawList(props, ref) {
|
||||
}
|
||||
}
|
||||
heights.resetRecord();
|
||||
- }, [scrollHeight]);
|
||||
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
|
||||
|
||||
// ================================= Size =================================
|
||||
var _React$useState = React.useState({
|
||||
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
|
||||
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
|
||||
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetKey } from '../interface';
|
||||
import CacheMap from '../utils/CacheMap';
|
||||
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
|
||||
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
|
||||
setInstanceRef: (item: T, instance: HTMLElement) => void,
|
||||
collectHeight: (sync?: boolean) => void,
|
||||
cacheMap: CacheMap,
|
||||
diff --git a/node_modules/rc-virtual-list/es/hooks/useHeights.js b/node_modules/rc-virtual-list/es/hooks/useHeights.js
|
||||
index 0323aae..38e6e75 100644
|
||||
--- a/node_modules/rc-virtual-list/es/hooks/useHeights.js
|
||||
+++ b/node_modules/rc-virtual-list/es/hooks/useHeights.js
|
||||
@@ -6,7 +6,7 @@ function parseNumber(value) {
|
||||
var num = parseFloat(value);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
-export default function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
+export default function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
|
||||
var _React$useState = React.useState(0),
|
||||
_React$useState2 = _slicedToArray(_React$useState, 2),
|
||||
updatedMark = _React$useState2[0],
|
||||
@@ -14,12 +14,17 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
var instanceRef = useRef(new Map());
|
||||
var heightsRef = useRef(new CacheMap());
|
||||
var promiseIdRef = useRef(0);
|
||||
+ var disabledRef = useRef(disabled);
|
||||
+ disabledRef.current = disabled;
|
||||
function cancelRaf() {
|
||||
promiseIdRef.current += 1;
|
||||
}
|
||||
function collectHeight() {
|
||||
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
||||
cancelRaf();
|
||||
+ if (disabledRef.current) {
|
||||
+ return;
|
||||
+ }
|
||||
var doCollect = function doCollect() {
|
||||
var changed = false;
|
||||
instanceRef.current.forEach(function (element, key) {
|
||||
@@ -79,5 +84,10 @@ export default function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
useEffect(function () {
|
||||
return cancelRaf;
|
||||
}, []);
|
||||
+ useEffect(function () {
|
||||
+ if (!disabled) {
|
||||
+ collectHeight(true);
|
||||
+ }
|
||||
+ }, [disabled]);
|
||||
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
|
||||
}
|
||||
diff --git a/node_modules/rc-virtual-list/lib/List.d.ts b/node_modules/rc-virtual-list/lib/List.d.ts
|
||||
index c517366..e414513 100644
|
||||
--- a/node_modules/rc-virtual-list/lib/List.d.ts
|
||||
+++ b/node_modules/rc-virtual-list/lib/List.d.ts
|
||||
@@ -20,6 +20,8 @@ export interface ListProps<T> extends Omit<React.HTMLAttributes<any>, 'children'
|
||||
data: T[];
|
||||
height?: number;
|
||||
itemHeight?: number;
|
||||
+ /** Treat every item as exactly itemHeight and calculate the visible range in O(1). */
|
||||
+ itemHeightFixed?: boolean;
|
||||
/** If not match virtual scroll condition, Set List still use height of container. */
|
||||
fullHeight?: boolean;
|
||||
itemKey: React.Key | ((item: T) => React.Key);
|
||||
diff --git a/node_modules/rc-virtual-list/lib/List.js b/node_modules/rc-virtual-list/lib/List.js
|
||||
index f0cb64d..d24a9ad 100644
|
||||
--- a/node_modules/rc-virtual-list/lib/List.js
|
||||
+++ b/node_modules/rc-virtual-list/lib/List.js
|
||||
@@ -32,7 +32,7 @@ var _useScrollDrag = _interopRequireDefault(require("./hooks/useScrollDrag"));
|
||||
var _useScrollTo = _interopRequireDefault(require("./hooks/useScrollTo"));
|
||||
var _ScrollBar = _interopRequireDefault(require("./ScrollBar"));
|
||||
var _scrollbarUtil = require("./utils/scrollbarUtil");
|
||||
-var _excluded = ["prefixCls", "className", "height", "itemHeight", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
|
||||
+var _excluded = ["prefixCls", "className", "height", "itemHeight", "itemHeightFixed", "fullHeight", "style", "data", "children", "itemKey", "virtual", "direction", "scrollWidth", "component", "onScroll", "onVirtualScroll", "onVisibleChange", "innerProps", "extraRender", "styles", "showScrollBar"];
|
||||
var EMPTY_DATA = [];
|
||||
var ScrollStyle = {
|
||||
overflowY: 'auto',
|
||||
@@ -44,6 +44,7 @@ function RawList(props, ref) {
|
||||
className = props.className,
|
||||
height = props.height,
|
||||
itemHeight = props.itemHeight,
|
||||
+ itemHeightFixed = props.itemHeightFixed,
|
||||
_props$fullHeight = props.fullHeight,
|
||||
fullHeight = _props$fullHeight === void 0 ? true : _props$fullHeight,
|
||||
style = props.style,
|
||||
@@ -74,7 +75,7 @@ function RawList(props, ref) {
|
||||
}, [itemKey]);
|
||||
|
||||
// ================================ Height ================================
|
||||
- var _useHeights = (0, _useHeights3.default)(getKey, null, null),
|
||||
+ var _useHeights = (0, _useHeights3.default)(getKey, null, null, itemHeightFixed),
|
||||
_useHeights2 = (0, _slicedToArray2.default)(_useHeights, 4),
|
||||
setInstanceRef = _useHeights2[0],
|
||||
collectHeight = _useHeights2[1],
|
||||
@@ -83,11 +84,12 @@ function RawList(props, ref) {
|
||||
|
||||
// ================================= MISC =================================
|
||||
var useVirtual = !!(virtual !== false && height && itemHeight);
|
||||
- var containerHeight = React.useMemo(function () {
|
||||
+ var measuredContainerHeight = React.useMemo(function () {
|
||||
return Object.values(heights.maps).reduce(function (total, curr) {
|
||||
return total + curr;
|
||||
}, 0);
|
||||
}, [heights.id, heights.maps]);
|
||||
+ var containerHeight = itemHeightFixed ? itemHeight * (data || EMPTY_DATA).length : measuredContainerHeight;
|
||||
var inVirtual = useVirtual && data && (Math.max(itemHeight * data.length, containerHeight) > height || !!scrollWidth);
|
||||
var isRTL = direction === 'rtl';
|
||||
var mergedClassName = (0, _classnames.default)(prefixCls, (0, _defineProperty2.default)({}, "".concat(prefixCls, "-rtl"), isRTL), className);
|
||||
@@ -168,6 +170,28 @@ function RawList(props, ref) {
|
||||
offset: undefined
|
||||
};
|
||||
}
|
||||
+ if (itemHeightFixed && itemHeight > 0) {
|
||||
+ var fixedDataLen = mergedData.length;
|
||||
+ var fixedScrollHeight = fixedDataLen * itemHeight;
|
||||
+ if (fixedDataLen === 0) {
|
||||
+ return {
|
||||
+ scrollHeight: 0,
|
||||
+ start: 0,
|
||||
+ end: -1,
|
||||
+ offset: 0
|
||||
+ };
|
||||
+ }
|
||||
+ var fixedMaxScrollTop = Math.max(0, fixedScrollHeight - height);
|
||||
+ var fixedOffsetTop = Math.max(0, Math.min(fixedMaxScrollTop, offsetTop));
|
||||
+ var fixedStartIndex = Math.min(fixedDataLen - 1, Math.max(0, Math.ceil(fixedOffsetTop / itemHeight) - 1));
|
||||
+ var fixedEndIndex = Math.min(fixedDataLen - 1, Math.floor((fixedOffsetTop + height) / itemHeight) + 1);
|
||||
+ return {
|
||||
+ scrollHeight: fixedScrollHeight,
|
||||
+ start: fixedStartIndex,
|
||||
+ end: fixedEndIndex,
|
||||
+ offset: fixedStartIndex * itemHeight
|
||||
+ };
|
||||
+ }
|
||||
var itemTop = 0;
|
||||
var startIndex;
|
||||
var startOffset;
|
||||
@@ -210,7 +234,7 @@ function RawList(props, ref) {
|
||||
end: endIndex,
|
||||
offset: startOffset
|
||||
};
|
||||
- }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height]),
|
||||
+ }, [inVirtual, useVirtual, offsetTop, mergedData, heightUpdatedMark, height, itemHeightFixed, itemHeight]),
|
||||
scrollHeight = _React$useMemo.scrollHeight,
|
||||
start = _React$useMemo.start,
|
||||
end = _React$useMemo.end,
|
||||
@@ -222,6 +246,10 @@ function RawList(props, ref) {
|
||||
// Which will make scroll jump.
|
||||
// Let's sync scroll top to avoid jump
|
||||
React.useLayoutEffect(function () {
|
||||
+ if (itemHeightFixed) {
|
||||
+ heights.resetRecord();
|
||||
+ return;
|
||||
+ }
|
||||
var changedRecord = heights.getRecord();
|
||||
if (changedRecord.size === 1) {
|
||||
var recordKey = Array.from(changedRecord.keys())[0];
|
||||
@@ -241,7 +269,7 @@ function RawList(props, ref) {
|
||||
}
|
||||
}
|
||||
heights.resetRecord();
|
||||
- }, [scrollHeight]);
|
||||
+ }, [scrollHeight, itemHeightFixed, heightUpdatedMark]);
|
||||
|
||||
// ================================= Size =================================
|
||||
var _React$useState = React.useState({
|
||||
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
|
||||
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
|
||||
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GetKey } from '../interface';
|
||||
import CacheMap from '../utils/CacheMap';
|
||||
-export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void): [
|
||||
+export default function useHeights<T>(getKey: GetKey<T>, onItemAdd?: (item: T) => void, onItemRemove?: (item: T) => void, disabled?: boolean): [
|
||||
setInstanceRef: (item: T, instance: HTMLElement) => void,
|
||||
collectHeight: (sync?: boolean) => void,
|
||||
cacheMap: CacheMap,
|
||||
diff --git a/node_modules/rc-virtual-list/lib/hooks/useHeights.js b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
|
||||
index 471ffc6..a6d2ce5 100644
|
||||
--- a/node_modules/rc-virtual-list/lib/hooks/useHeights.js
|
||||
+++ b/node_modules/rc-virtual-list/lib/hooks/useHeights.js
|
||||
@@ -14,7 +14,7 @@ function parseNumber(value) {
|
||||
var num = parseFloat(value);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
-function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
+function useHeights(getKey, onItemAdd, onItemRemove, disabled) {
|
||||
var _React$useState = React.useState(0),
|
||||
_React$useState2 = (0, _slicedToArray2.default)(_React$useState, 2),
|
||||
updatedMark = _React$useState2[0],
|
||||
@@ -22,12 +22,17 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
var instanceRef = (0, _react.useRef)(new Map());
|
||||
var heightsRef = (0, _react.useRef)(new _CacheMap.default());
|
||||
var promiseIdRef = (0, _react.useRef)(0);
|
||||
+ var disabledRef = (0, _react.useRef)(disabled);
|
||||
+ disabledRef.current = disabled;
|
||||
function cancelRaf() {
|
||||
promiseIdRef.current += 1;
|
||||
}
|
||||
function collectHeight() {
|
||||
var sync = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
|
||||
cancelRaf();
|
||||
+ if (disabledRef.current) {
|
||||
+ return;
|
||||
+ }
|
||||
var doCollect = function doCollect() {
|
||||
var changed = false;
|
||||
instanceRef.current.forEach(function (element, key) {
|
||||
@@ -87,5 +92,10 @@ function useHeights(getKey, onItemAdd, onItemRemove) {
|
||||
(0, _react.useEffect)(function () {
|
||||
return cancelRaf;
|
||||
}, []);
|
||||
+ (0, _react.useEffect)(function () {
|
||||
+ if (!disabled) {
|
||||
+ collectHeight(true);
|
||||
+ }
|
||||
+ }, [disabled]);
|
||||
return [setInstanceRef, collectHeight, heightsRef.current, updatedMark];
|
||||
}
|
||||
BIN
frontend/public/brand-icons/01-database-hug.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/brand-icons/02-database-search.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
frontend/public/brand-icons/03-bandana-badge.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
frontend/public/brand-icons/04-magnifier-wink.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
frontend/public/brand-icons/05-window-peek.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
frontend/public/brand-icons/06-hex-collar.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/brand-icons/07-graph-sit.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
frontend/public/brand-icons/08-cloud-banner.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
frontend/public/brand-icons/09-terminal-sit.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/brand-icons/10-compass-bandana.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
86
frontend/public/brand-icons/manifest.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": 9,
|
||||
"defaultId": "02",
|
||||
"icons": [
|
||||
{
|
||||
"id": "01",
|
||||
"slug": "database-hug",
|
||||
"titleZh": "抱库小狗",
|
||||
"titleEn": "Database hug",
|
||||
"iconPath": "/brand-icons/01-database-hug.webp",
|
||||
"fullPath": "/brand-icons/01-database-hug.webp"
|
||||
},
|
||||
{
|
||||
"id": "02",
|
||||
"slug": "database-search",
|
||||
"titleZh": "搜库小狗",
|
||||
"titleEn": "Database search",
|
||||
"iconPath": "/brand-icons/02-database-search.webp",
|
||||
"fullPath": "/brand-icons/02-database-search.webp"
|
||||
},
|
||||
{
|
||||
"id": "03",
|
||||
"slug": "bandana-badge",
|
||||
"titleZh": "头巾徽章",
|
||||
"titleEn": "Bandana badge",
|
||||
"iconPath": "/brand-icons/03-bandana-badge.webp",
|
||||
"fullPath": "/brand-icons/03-bandana-badge.webp"
|
||||
},
|
||||
{
|
||||
"id": "04",
|
||||
"slug": "magnifier-wink",
|
||||
"titleZh": "放大镜眨眼",
|
||||
"titleEn": "Magnifier wink",
|
||||
"iconPath": "/brand-icons/04-magnifier-wink.webp",
|
||||
"fullPath": "/brand-icons/04-magnifier-wink.webp"
|
||||
},
|
||||
{
|
||||
"id": "05",
|
||||
"slug": "window-peek",
|
||||
"titleZh": "窗口探头",
|
||||
"titleEn": "Window peek",
|
||||
"iconPath": "/brand-icons/05-window-peek.webp",
|
||||
"fullPath": "/brand-icons/05-window-peek.webp"
|
||||
},
|
||||
{
|
||||
"id": "06",
|
||||
"slug": "hex-collar",
|
||||
"titleZh": "六边项圈",
|
||||
"titleEn": "Hex collar",
|
||||
"iconPath": "/brand-icons/06-hex-collar.webp",
|
||||
"fullPath": "/brand-icons/06-hex-collar.webp"
|
||||
},
|
||||
{
|
||||
"id": "07",
|
||||
"slug": "graph-sit",
|
||||
"titleZh": "关系图",
|
||||
"titleEn": "Graph sit",
|
||||
"iconPath": "/brand-icons/07-graph-sit.webp",
|
||||
"fullPath": "/brand-icons/07-graph-sit.webp"
|
||||
},
|
||||
{
|
||||
"id": "08",
|
||||
"slug": "cloud-banner",
|
||||
"titleZh": "云朵横幅",
|
||||
"titleEn": "Cloud banner",
|
||||
"iconPath": "/brand-icons/08-cloud-banner.webp",
|
||||
"fullPath": "/brand-icons/08-cloud-banner.webp"
|
||||
},
|
||||
{
|
||||
"id": "09",
|
||||
"slug": "terminal-sit",
|
||||
"titleZh": "终端旁坐",
|
||||
"titleEn": "Terminal sit",
|
||||
"iconPath": "/brand-icons/09-terminal-sit.webp",
|
||||
"fullPath": "/brand-icons/09-terminal-sit.webp"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"slug": "compass-bandana",
|
||||
"titleZh": "罗盘头巾",
|
||||
"titleEn": "Compass bandana",
|
||||
"iconPath": "/brand-icons/10-compass-bandana.webp",
|
||||
"fullPath": "/brand-icons/10-compass-bandana.webp"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/brand-marks/02-database-search-transparent.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/db-icons/milvus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#00A1EA"><title>Milvus</title><path d="M15.536 11.997a4.005 4.005 0 0 1-4.015 4.018c-2.36-.005-4.05-1.954-4.01-4.08a4.013 4.013 0 0 1 8.025.061zm8.463-.04a.823.823 0 0 1-.25.616q-1.122 1.128-2.248 2.25c-.082.083-.165.1-.25.054-.091-.048-.124-.121-.098-.242a12.764 12.764 0 0 0 .25-1.626 12.153 12.153 0 0 0 .028-1.49 11.56 11.56 0 0 0-.27-2.114.255.255 0 0 1 .017-.213.194.194 0 0 1 .273-.052.628.628 0 0 1 .086.075l2.174 2.174c.171.17.29.366.289.567zM11.226 20c-1.89-.002-3.488-.565-4.926-1.611a8.317 8.317 0 0 1-.952-.816q-1.125-1.112-2.253-2.221l-1.931-1.899c-.291-.285-.583-.57-.873-.857-.393-.39-.387-.83.012-1.217.296-.288.59-.579.885-.868q1.433-1.41 2.867-2.818c.532-.522 1.057-1.051 1.604-1.555a7.996 7.996 0 0 1 3.786-1.95A7.668 7.668 0 0 1 11.135 4c1.89.015 3.612.546 5.113 1.723a7.987 7.987 0 0 1 2.282 2.77 7.567 7.567 0 0 1 .76 2.556c.255 2.404-.414 4.518-2.046 6.312a7.772 7.772 0 0 1-3.927 2.366 8.995 8.995 0 0 1-2.091.274zm.364-2.137a5.819 5.819 0 0 0 .605-.036 5.35 5.35 0 0 0 .38-.05c1.525-.28 2.76-1.051 3.704-2.263.92-1.178 1.28-2.542 1.168-4.028a5.626 5.626 0 0 0-.631-2.168c-.627-1.213-1.596-2.067-2.817-2.646-1.096-.519-2.248-.649-3.443-.464A5.814 5.814 0 0 0 7.33 7.8c-.858.83-1.707 1.669-2.56 2.504-.426.419-.85.841-1.28 1.257-.274.266-.286.588-.013.857q.804.793 1.61 1.583c.722.709 1.44 1.42 2.165 2.126 1.184 1.153 2.604 1.737 4.339 1.738z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
845
frontend/public/gonavi-theme-settings.extract.css
Normal file
@@ -0,0 +1,845 @@
|
||||
/* ── Theme settings (v2 only): clean desktop settings ─── */
|
||||
body .gonavi-theme-settings {
|
||||
--gn-settings-fg: #0f172a;
|
||||
--gn-settings-muted: #64748b;
|
||||
--gn-settings-line: #e2e8f0;
|
||||
--gn-settings-track: #e2e8f0;
|
||||
--gn-settings-chip-bg: #f8fafc;
|
||||
--gn-settings-chip-fg: #0f172a;
|
||||
--gn-settings-seg-bg: #f8fafc;
|
||||
--gn-settings-seg-border: #e2e8f0;
|
||||
--gn-settings-seg-item: #64748b;
|
||||
--gn-settings-seg-selected-bg: #fff;
|
||||
--gn-settings-seg-selected-fg: #0f172a;
|
||||
--gn-settings-seg-shadow: none;
|
||||
--gn-settings-accent: #16a34a;
|
||||
--gn-settings-accent-soft: #dcfce7;
|
||||
--gn-settings-accent-border: #86efac;
|
||||
--gn-settings-card-bg: #ffffff;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings {
|
||||
--gn-settings-fg: rgba(248, 250, 252, 0.94);
|
||||
--gn-settings-muted: rgba(226, 232, 240, 0.58);
|
||||
--gn-settings-line: rgba(255, 255, 255, 0.10);
|
||||
--gn-settings-track: rgba(255, 255, 255, 0.12);
|
||||
--gn-settings-chip-bg: rgba(255, 255, 255, 0.06);
|
||||
--gn-settings-chip-fg: rgba(248, 250, 252, 0.88);
|
||||
--gn-settings-seg-bg: rgba(255, 255, 255, 0.04);
|
||||
--gn-settings-seg-border: rgba(148, 163, 184, 0.18);
|
||||
--gn-settings-seg-item: rgba(226, 232, 240, 0.58);
|
||||
--gn-settings-seg-selected-bg: rgba(30, 41, 59, 0.96);
|
||||
--gn-settings-seg-selected-fg: #f8fafc;
|
||||
--gn-settings-accent: #22c55e;
|
||||
--gn-settings-accent-soft: rgba(34, 197, 94, 0.18);
|
||||
--gn-settings-accent-border: rgba(74, 222, 128, 0.55);
|
||||
--gn-settings-card-bg: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-section {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-section + .gonavi-settings-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
color: var(--gn-settings-fg);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-section-hint {
|
||||
margin: -2px 0 10px;
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 168px) minmax(0, 1fr);
|
||||
gap: 12px 16px;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-row.is-stacked {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-row + .gonavi-settings-row {
|
||||
border-top: 1px solid var(--gn-settings-line);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-label {
|
||||
color: var(--gn-settings-fg);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-label-hint {
|
||||
margin-top: 3px;
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 2px 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-row.has-marks {
|
||||
align-items: flex-start;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-row.has-marks .gonavi-settings-slider-value {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
* antd Slider 手柄用 translateX(-50%),在 0%/100% 会各伸出半个圆 + 描边阴影。
|
||||
* 外层 padding 需 ≥ 半宽手柄(9) + 描边(2~3),否则仍会缺一截。
|
||||
*/
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-track-wrap {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-inline: 14px;
|
||||
overflow: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-track-wrap .ant-slider {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
padding-block: 14px !important;
|
||||
padding-inline: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 预设刻度:独立一行,严格在进度条下方,不叠在轨道上 */
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-presets {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
margin: 0 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-preset {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-preset:hover:not(:disabled) {
|
||||
color: var(--gn-settings-fg);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-preset.is-active {
|
||||
color: var(--gn-settings-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-preset:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number,
|
||||
body .gonavi-theme-settings .ant-input-number-group.ant-input-number-group-wrapper.gonavi-settings-slider-value-input,
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-group-wrapper {
|
||||
flex: 0 0 auto !important;
|
||||
width: 64px !important;
|
||||
min-width: 56px !important;
|
||||
max-width: 72px !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number,
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number {
|
||||
width: 100% !important;
|
||||
border-radius: 999px 0 0 999px !important;
|
||||
border-color: color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 8%, transparent) !important;
|
||||
}
|
||||
|
||||
/* 无 addon 时整颗胶囊 */
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number:not(.ant-input-number-group-wrapper) {
|
||||
width: 52px !important;
|
||||
min-width: 48px !important;
|
||||
max-width: 56px !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number-input {
|
||||
height: 28px !important;
|
||||
padding: 0 2px 0 6px !important;
|
||||
color: var(--gn-settings-fg) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 700 !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input .ant-input-number-group-addon {
|
||||
min-width: 18px !important;
|
||||
width: 18px !important;
|
||||
padding: 0 6px 0 0 !important;
|
||||
border: 1px solid color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
|
||||
border-left: none !important;
|
||||
border-radius: 0 999px 999px 0 !important;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 8%, transparent) !important;
|
||||
color: var(--gn-settings-muted) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: 26px !important;
|
||||
}
|
||||
|
||||
/* 带 % / px 的整体圆角胶囊外观 */
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-group-wrapper .ant-input-number {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-slider-value-input.ant-input-number-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-rail {
|
||||
height: 8px !important;
|
||||
border-radius: 999px !important;
|
||||
background: var(--gn-settings-track) !important;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-track {
|
||||
height: 8px !important;
|
||||
border-radius: 999px !important;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--gn-settings-accent) 78%, #fff) 0%, var(--gn-settings-accent) 100%) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-handle {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
inset-block-start: 10px !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 关掉 antd 默认 before 光晕,避免叠成空心环 */
|
||||
body .gonavi-theme-settings .ant-slider-handle::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-handle::after {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-block-start: 0 !important;
|
||||
border-radius: 50% !important;
|
||||
/* 实心白底 + 绿描边,禁用时也不靠整控件 opacity 变空心 */
|
||||
background: #fff !important;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--gn-settings-accent),
|
||||
0 1px 4px rgba(15, 23, 42, 0.14) !important;
|
||||
opacity: 1 !important;
|
||||
transition: box-shadow 0.12s ease !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-handle:hover::after,
|
||||
body .gonavi-theme-settings .ant-slider-handle:focus::after {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--gn-settings-accent),
|
||||
0 0 0 4px color-mix(in srgb, var(--gn-settings-accent) 22%, transparent),
|
||||
0 1px 4px rgba(15, 23, 42, 0.14) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider:hover .ant-slider-track,
|
||||
body .gonavi-theme-settings .ant-slider:focus-within .ant-slider-track {
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--gn-settings-accent) 78%, #fff) 0%, var(--gn-settings-accent) 100%) !important;
|
||||
}
|
||||
|
||||
/* 禁用:不要整条 opacity,否则白心被透掉只剩绿环 */
|
||||
body .gonavi-theme-settings .ant-slider-disabled {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-rail {
|
||||
background: color-mix(in srgb, var(--gn-settings-track) 70%, transparent) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-track {
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 35%, var(--gn-settings-track)) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-handle {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .ant-slider-disabled .ant-slider-handle::after {
|
||||
background: #fff !important;
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--gn-settings-accent) 40%, #94a3b8),
|
||||
0 1px 3px rgba(15, 23, 42, 0.10) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* 选项 Segmented:小胶囊;is-compact 不拉满整行 */
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice.ant-segmented {
|
||||
width: 100%;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact.ant-segmented {
|
||||
width: auto !important;
|
||||
max-width: 100%;
|
||||
display: inline-flex !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-group {
|
||||
display: flex !important;
|
||||
width: 100%;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact .ant-segmented-group {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid var(--gn-settings-line) !important;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice.is-compact .ant-segmented-item {
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:not(.ant-segmented-item-selected) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:hover:not(.ant-segmented-item-selected) {
|
||||
background: var(--gn-settings-seg-bg) !important;
|
||||
border-color: color-mix(in srgb, var(--gn-settings-accent) 28%, var(--gn-settings-line)) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item-label {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-height: 30px !important;
|
||||
padding: 0 12px !important;
|
||||
font-size: 12.5px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 30px !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item:not(.ant-segmented-item-selected) .ant-segmented-item-label {
|
||||
color: var(--gn-settings-muted) !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-thumb {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected,
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] {
|
||||
border-color: var(--gn-settings-accent) !important;
|
||||
background: var(--gn-settings-accent-soft) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected .ant-segmented-item-label,
|
||||
body .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] .ant-segmented-item-label {
|
||||
color: var(--gn-settings-accent) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected,
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] {
|
||||
border-color: color-mix(in srgb, var(--gn-settings-accent) 70%, rgba(255, 255, 255, 0.2)) !important;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item.ant-segmented-item-selected .ant-segmented-item-label,
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-segmented-choice .ant-segmented-item[aria-selected='true'] .ant-segmented-item-label {
|
||||
color: #86efac !important;
|
||||
}
|
||||
|
||||
/* 界面版本预览卡(紧凑双列) */
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--gn-settings-line);
|
||||
border-radius: 12px;
|
||||
background: var(--gn-settings-card-bg);
|
||||
color: var(--gn-settings-fg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-tile:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-tile.is-active {
|
||||
border-color: var(--gn-settings-accent);
|
||||
box-shadow: 0 0 0 1px var(--gn-settings-accent-border);
|
||||
background: var(--gn-settings-card-bg);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview {
|
||||
position: relative;
|
||||
height: 58px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gn-settings-line);
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-legacy {
|
||||
display: grid;
|
||||
grid-template-columns: 34% 1fr;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 8px 6px;
|
||||
border-right: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-side {
|
||||
border-right-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-side {
|
||||
align-items: center;
|
||||
padding: 8px 3px;
|
||||
background: linear-gradient(180deg, #16a34a 0%, #15803d 100%);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar {
|
||||
background: rgba(248, 250, 252, 0.12);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-bar.is-accent {
|
||||
width: 42%;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 55%, rgba(15, 23, 42, 0.08));
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-v2 .uv-bar.is-accent {
|
||||
width: 48%;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 70%, #fff);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-card {
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-preview .uv-card {
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-preview.is-legacy .uv-card {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-ui-version-badge.is-new {
|
||||
background: rgba(2, 132, 199, 0.12);
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-badge {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-ui-version-badge.is-new {
|
||||
background: rgba(56, 189, 248, 0.18);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
/* 顶部分段:原生 button Tab(不依赖 antd Segmented) */
|
||||
body .gonavi-theme-settings .gonavi-settings-tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--gn-settings-line);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 -1px;
|
||||
padding: 10px 8px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--gn-settings-muted);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-tab:hover {
|
||||
color: var(--gn-settings-fg);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-tab.is-active {
|
||||
color: var(--gn-settings-accent);
|
||||
border-bottom-color: var(--gn-settings-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-tab-icon {
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-inline-choice-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--gn-settings-line);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-pills {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-pill {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--gn-settings-line);
|
||||
border-radius: 8px;
|
||||
background: var(--gn-settings-card-bg);
|
||||
color: var(--gn-settings-muted);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-pill:hover {
|
||||
border-color: #cbd5e1;
|
||||
color: var(--gn-settings-fg);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-pill.is-active {
|
||||
border-color: var(--gn-settings-accent);
|
||||
background: var(--gn-settings-accent-soft);
|
||||
color: var(--gn-settings-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-pill.is-active {
|
||||
color: #86efac;
|
||||
border-color: color-mix(in srgb, var(--gn-settings-accent) 70%, rgba(255, 255, 255, 0.2));
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-inline-meta {
|
||||
margin-top: 8px;
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Beta:弱提示,不要大黄条 */
|
||||
body .gonavi-theme-settings .gonavi-settings-beta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--gn-settings-accent) 12%, transparent);
|
||||
color: var(--gn-settings-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-alert {
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gn-settings-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
body[data-theme='dark'] .gonavi-theme-settings .gonavi-settings-alert {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--gn-settings-muted);
|
||||
}
|
||||
|
||||
/* Compact theme-mode previews */
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--gn-settings-line);
|
||||
border-radius: 12px;
|
||||
background: var(--gn-settings-card-bg);
|
||||
color: var(--gn-settings-fg);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-tile:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-tile.is-active {
|
||||
border-color: var(--gn-settings-accent);
|
||||
box-shadow: 0 0 0 1px var(--gn-settings-accent-border);
|
||||
background: var(--gn-settings-card-bg);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview {
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gn-settings-line);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview.is-system {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark {
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-line {
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(1) {
|
||||
width: 58%;
|
||||
background: rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(2) {
|
||||
width: 78%;
|
||||
background: rgba(22, 163, 74, 0.38);
|
||||
}
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-light .gonavi-settings-mode-preview-line:nth-child(3) {
|
||||
width: 42%;
|
||||
background: rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(1) {
|
||||
width: 58%;
|
||||
background: rgba(248, 250, 252, 0.22);
|
||||
}
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(2) {
|
||||
width: 78%;
|
||||
background: rgba(74, 222, 128, 0.45);
|
||||
}
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-preview-pane.is-dark .gonavi-settings-mode-preview-line:nth-child(3) {
|
||||
width: 42%;
|
||||
background: rgba(248, 250, 252, 0.12);
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-mode-check {
|
||||
color: var(--gn-settings-accent);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Control-only stacked row: no duplicate label column */
|
||||
body .gonavi-theme-settings .gonavi-settings-row.is-control-only {
|
||||
grid-template-columns: 1fr;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
body .gonavi-theme-settings .gonavi-settings-row.is-control-only .gonavi-settings-label {
|
||||
display: none;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 216 KiB |
58
frontend/public/theme-settings-real-dom.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Real DOM Theme Settings Check</title>
|
||||
<link rel="stylesheet" href="./gonavi-theme-settings.extract.css" />
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; background: #eef2f6; padding: 24px; }
|
||||
.frame { width: 720px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px 20px 24px; border: 1px solid #e2e8f0; }
|
||||
h1 { margin: 0 0 4px; font-size: 16px; }
|
||||
.sub { margin: 0 0 12px; color: #64748b; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="frame">
|
||||
<h1>主题与外观</h1>
|
||||
<p class="sub">切换亮暗主题并调整界面观感。</p>
|
||||
<div class="gonavi-theme-settings">
|
||||
<div class="gonavi-settings-tabs" role="tablist">
|
||||
<button type="button" class="gonavi-settings-tab is-active" role="tab" aria-selected="true">
|
||||
<span class="gonavi-settings-tab-icon">▣</span><span>主题与界面</span>
|
||||
</button>
|
||||
<button type="button" class="gonavi-settings-tab" role="tab" aria-selected="false">
|
||||
<span class="gonavi-settings-tab-icon">◐</span><span>显示与字体</span>
|
||||
</button>
|
||||
<button type="button" class="gonavi-settings-tab" role="tab" aria-selected="false">
|
||||
<span class="gonavi-settings-tab-icon">▦</span><span>工作区</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gonavi-settings-inline-meta">更改将立即生效,无需保存。</div>
|
||||
|
||||
<section class="gonavi-settings-section" style="margin-top:16px">
|
||||
<div class="gonavi-settings-section-title">亮暗模式</div>
|
||||
<div class="gonavi-settings-mode-grid">
|
||||
<button type="button" class="gonavi-settings-mode-tile"><div class="gonavi-settings-mode-preview"><div class="gonavi-settings-mode-preview-pane is-light"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">亮色主题</span></div></button>
|
||||
<button type="button" class="gonavi-settings-mode-tile"><div class="gonavi-settings-mode-preview"><div class="gonavi-settings-mode-preview-pane is-dark"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">暗色主题</span></div></button>
|
||||
<button type="button" class="gonavi-settings-mode-tile is-active"><div class="gonavi-settings-mode-preview is-system"><div class="gonavi-settings-mode-preview-pane is-light"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div><div class="gonavi-settings-mode-preview-pane is-dark"><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span><span class="gonavi-settings-mode-preview-line"></span></div></div><div class="gonavi-settings-mode-meta"><span class="gonavi-settings-mode-label">跟随系统</span><span class="gonavi-settings-mode-check">✓</span></div></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="gonavi-settings-section" style="margin-top:20px">
|
||||
<div class="gonavi-settings-section-title">界面版本 <span class="gonavi-settings-beta-chip">Beta</span></div>
|
||||
<div class="gonavi-settings-ui-version-grid">
|
||||
<button type="button" class="gonavi-settings-ui-version-tile"><div class="gonavi-settings-ui-version-preview is-legacy"><div class="uv-side"><span class="uv-dot"></span><span class="uv-dot"></span><span class="uv-dot"></span></div><div class="uv-main"><div class="uv-bar is-accent"></div><div class="uv-cards"><div class="uv-card"></div><div class="uv-card"></div></div></div></div><div class="gonavi-settings-ui-version-meta"><span class="gonavi-settings-ui-version-title"><span>旧版 UI</span><span class="gonavi-settings-ui-version-badge">默认</span></span></div></button>
|
||||
<button type="button" class="gonavi-settings-ui-version-tile is-active"><div class="gonavi-settings-ui-version-preview is-v2"><div class="uv-side"><span class="uv-dot"></span><span class="uv-dot"></span><span class="uv-dot"></span></div><div class="uv-main"><div class="uv-bar is-accent"></div><div class="uv-cards"><div class="uv-card"></div><div class="uv-card"></div></div></div></div><div class="gonavi-settings-ui-version-meta"><span class="gonavi-settings-ui-version-title"><span>新版 UI</span><span class="gonavi-settings-ui-version-badge is-new">Beta</span></span><span class="gonavi-settings-mode-check">✓</span></div></button>
|
||||
</div>
|
||||
<div class="gonavi-settings-inline-choice-row">
|
||||
<span class="gonavi-settings-label">新版左侧搜索模式</span>
|
||||
<div class="gonavi-settings-pills">
|
||||
<button type="button" class="gonavi-settings-pill is-active">新版命令搜索</button>
|
||||
<button type="button" class="gonavi-settings-pill">旧版侧栏筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
386
frontend/public/theme-settings-visual.html
Normal file
@@ -0,0 +1,386 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GoNavi Theme Settings Visual</title>
|
||||
<style>
|
||||
:root {
|
||||
--fg: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--bg: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--accent: #16a34a;
|
||||
--accent-soft: #dcfce7;
|
||||
--accent-border: #86efac;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font);
|
||||
background: #e8edf3;
|
||||
color: var(--fg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.shell {
|
||||
width: min(920px, 100%);
|
||||
height: 640px;
|
||||
background: var(--panel);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15,23,42,.08);
|
||||
box-shadow: 0 20px 50px rgba(15,23,42,.10);
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.side {
|
||||
background: #fbfcfd;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.side .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.side .item.active {
|
||||
background: #ecfdf5;
|
||||
color: #166534;
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.header {
|
||||
padding: 18px 22px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px 22px 24px;
|
||||
}
|
||||
|
||||
/* underline tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 10px 8px 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.hint {
|
||||
margin: -8px 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
.chip.beta {
|
||||
background: #ecfdf5;
|
||||
color: #15803d;
|
||||
}
|
||||
.meta {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.card:hover { border-color: #cbd5e1; }
|
||||
.card.active {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.card .preview {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.preview.light {
|
||||
background: linear-gradient(180deg, #f8fafc, #eef2f7);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.preview.dark {
|
||||
background: linear-gradient(180deg, #0f172a, #111827);
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.preview.system {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.bar {
|
||||
height: 5px;
|
||||
border-radius: 99px;
|
||||
background: rgba(15,23,42,.12);
|
||||
}
|
||||
.preview.dark .bar { background: rgba(248,250,252,.18); }
|
||||
.bar.accent { background: rgba(22,163,74,.4); width: 72%; }
|
||||
.preview.dark .bar.accent { background: rgba(74,222,128,.5); width: 72%; }
|
||||
.card .label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.check { color: var(--accent); font-size: 12px; }
|
||||
|
||||
.ui-preview {
|
||||
height: 58px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
margin-bottom: 8px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.ui-preview.legacy { grid-template-columns: 34% 1fr; }
|
||||
.ui-preview.v2 { grid-template-columns: 16px 1fr; }
|
||||
.ui-side {
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 8px 6px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
align-content: start;
|
||||
}
|
||||
.ui-preview.v2 .ui-side {
|
||||
background: linear-gradient(180deg, #16a34a, #15803d);
|
||||
border: 0;
|
||||
place-items: center;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
.dot {
|
||||
width: 7px; height: 7px; border-radius: 99px;
|
||||
background: rgba(15,23,42,.16);
|
||||
}
|
||||
.ui-preview.v2 .dot { background: rgba(255,255,255,.9); }
|
||||
.ui-main { padding: 8px; display: grid; gap: 6px; }
|
||||
.ui-main .row { height: 8px; border-radius: 4px; background: rgba(15,23,42,.08); }
|
||||
.ui-main .row.a { width: 46%; background: rgba(22,163,74,.35); }
|
||||
.ui-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; height: 24px; }
|
||||
.ui-cards span {
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(15,23,42,.06);
|
||||
}
|
||||
|
||||
.row-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.row-inline .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.pills {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.pill {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pill.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell" id="fixture">
|
||||
<aside class="side">
|
||||
<div class="item active">偏好设置</div>
|
||||
<div class="item">服务配置</div>
|
||||
<div class="item">关于</div>
|
||||
</aside>
|
||||
<section class="main">
|
||||
<div class="header">
|
||||
<h1>主题与外观</h1>
|
||||
<p>切换亮暗主题并调整界面观感。</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="tabs">
|
||||
<button class="tab active" type="button">主题与界面</button>
|
||||
<button class="tab" type="button">显示与字体</button>
|
||||
<button class="tab" type="button">工作区</button>
|
||||
</div>
|
||||
<p class="hint">更改将立即生效,无需保存。</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">亮暗模式</div>
|
||||
<div class="grid-3">
|
||||
<button class="card" type="button">
|
||||
<div class="preview light">
|
||||
<div class="bar" style="width:55%"></div>
|
||||
<div class="bar accent"></div>
|
||||
<div class="bar" style="width:40%"></div>
|
||||
</div>
|
||||
<div class="label"><span>亮色主题</span></div>
|
||||
</button>
|
||||
<button class="card" type="button">
|
||||
<div class="preview dark">
|
||||
<div class="bar" style="width:55%"></div>
|
||||
<div class="bar accent"></div>
|
||||
<div class="bar" style="width:40%"></div>
|
||||
</div>
|
||||
<div class="label"><span>暗色主题</span></div>
|
||||
</button>
|
||||
<button class="card active" type="button">
|
||||
<div class="preview system">
|
||||
<div class="preview light" style="border:0;height:100%;border-radius:0;margin:0">
|
||||
<div class="bar" style="width:55%"></div>
|
||||
<div class="bar accent"></div>
|
||||
<div class="bar" style="width:40%"></div>
|
||||
</div>
|
||||
<div class="preview dark" style="border:0;height:100%;border-radius:0;margin:0">
|
||||
<div class="bar" style="width:55%"></div>
|
||||
<div class="bar accent"></div>
|
||||
<div class="bar" style="width:40%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label"><span>跟随系统</span><span class="check">✓</span></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
界面版本
|
||||
<span class="chip">NEW</span>
|
||||
<span class="chip beta">Beta</span>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<button class="card" type="button">
|
||||
<div class="ui-preview legacy">
|
||||
<div class="ui-side"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||
<div class="ui-main">
|
||||
<div class="row a"></div>
|
||||
<div class="ui-cards"><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label"><span>旧版 UI · 默认</span></div>
|
||||
</button>
|
||||
<button class="card active" type="button">
|
||||
<div class="ui-preview v2">
|
||||
<div class="ui-side"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>
|
||||
<div class="ui-main">
|
||||
<div class="row a"></div>
|
||||
<div class="ui-cards"><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label"><span>新版 UI · Beta</span><span class="check">✓</span></div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="meta">全平台可切换,立即生效;部分弹窗下次打开时应用新样式。</p>
|
||||
<div class="row-inline">
|
||||
<div class="name">左侧搜索</div>
|
||||
<div class="pills">
|
||||
<button class="pill active" type="button">命令搜索</button>
|
||||
<button class="pill" type="button">侧栏筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -10,6 +10,7 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const frontendDir = path.resolve(scriptDir, '..');
|
||||
const packageJsonPath = path.join(frontendDir, 'package.json');
|
||||
const packageLockPath = path.join(frontendDir, 'package-lock.json');
|
||||
const patchesDirPath = path.join(frontendDir, 'patches');
|
||||
const nodeModulesPath = path.join(frontendDir, 'node_modules');
|
||||
const npmHiddenLockPath = path.join(nodeModulesPath, '.package-lock.json');
|
||||
const installStatePath = path.join(nodeModulesPath, '.gonavi-install-state.json');
|
||||
@@ -39,9 +40,27 @@ const hashFile = (filePath) => {
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
const patchFiles = () => {
|
||||
if (!existsSync(patchesDirPath)) return [];
|
||||
return readdirSync(patchesDirPath)
|
||||
.filter((name) => name.endsWith('.patch'))
|
||||
.sort()
|
||||
.map((name) => path.join(patchesDirPath, name));
|
||||
};
|
||||
|
||||
const hashPatches = () => {
|
||||
const hash = createHash('sha256');
|
||||
for (const filePath of patchFiles()) {
|
||||
hash.update(path.basename(filePath));
|
||||
hash.update(readFileSync(filePath));
|
||||
}
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
const currentState = () => ({
|
||||
packageJson: hashFile(packageJsonPath),
|
||||
packageLock: existsSync(packageLockPath) ? hashFile(packageLockPath) : '',
|
||||
patches: hashPatches(),
|
||||
});
|
||||
|
||||
const readInstalledState = () => {
|
||||
@@ -60,7 +79,7 @@ const writeInstalledState = (state) => {
|
||||
const packageInputsAreOlderThanNpmLock = () => {
|
||||
if (!existsSync(npmHiddenLockPath)) return false;
|
||||
const markerTime = statSync(npmHiddenLockPath).mtimeMs;
|
||||
return [packageJsonPath, packageLockPath]
|
||||
return [packageJsonPath, packageLockPath, ...patchFiles()]
|
||||
.filter(existsSync)
|
||||
.every((filePath) => statSync(filePath).mtimeMs <= markerTime);
|
||||
};
|
||||
@@ -102,7 +121,8 @@ const forceInstall = process.env.GONAVI_FORCE_FRONTEND_INSTALL === '1';
|
||||
if (!forceInstall && existsSync(nodeModulesPath)) {
|
||||
if (
|
||||
installedState?.packageJson === state.packageJson &&
|
||||
installedState?.packageLock === state.packageLock
|
||||
installedState?.packageLock === state.packageLock &&
|
||||
installedState?.patches === state.patches
|
||||
) {
|
||||
console.log('Frontend dependencies are up to date; skipping npm install.');
|
||||
process.exit(0);
|
||||
|
||||
54
frontend/src/App.about-brand-lockup.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const brandIconsSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./brand/brandIcons.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const brandIconsDirectory = fileURLToPath(
|
||||
new globalThis.URL('../public/brand-icons/', import.meta.url),
|
||||
);
|
||||
const defaultTitlebarMark = fileURLToPath(
|
||||
new globalThis.URL('../public/brand-marks/02-database-search-transparent.png', import.meta.url),
|
||||
);
|
||||
|
||||
describe('about brand lockup', () => {
|
||||
it('uses a transparent lockup without a tile background on the about page', () => {
|
||||
expect(appSource).toContain('resolveBrandAboutSrc');
|
||||
expect(appSource).toContain('src={resolveBrandAboutSrc(brandIconId)}');
|
||||
|
||||
const aboutLogoStart = appSource.indexOf('src={resolveBrandAboutSrc(brandIconId)}');
|
||||
const aboutLogoSnippet = appSource.slice(aboutLogoStart, aboutLogoStart + 640);
|
||||
expect(aboutLogoSnippet).toContain("background: 'transparent'");
|
||||
expect(aboutLogoSnippet).toContain("boxShadow: 'none'");
|
||||
});
|
||||
|
||||
it('uses the transparent compact mark without a forced titlebar tile', () => {
|
||||
expect(appSource).toContain('src={resolveBrandTitlebarSrc(brandIconId)}');
|
||||
|
||||
const titlebarLogoStart = appSource.indexOf('src={resolveBrandTitlebarSrc(brandIconId)}');
|
||||
const titlebarLogoSnippet = appSource.slice(titlebarLogoStart, titlebarLogoStart + 640);
|
||||
expect(titlebarLogoSnippet).toContain("background: 'transparent'");
|
||||
expect(readFileSync(defaultTitlebarMark, 'base64')).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps one shared lossless WebP asset for every selectable mascot', () => {
|
||||
const iconAssetPaths = brandIconsSource.match(/iconPath: '\/brand-icons\/\d{2}-.+\.webp'/g) || [];
|
||||
expect(iconAssetPaths).toHaveLength(10);
|
||||
for (const declaration of iconAssetPaths) {
|
||||
const assetPath = declaration.match(/'([^']+)'/)?.[1];
|
||||
expect(assetPath).toBeTruthy();
|
||||
expect(readFileSync(`${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, 'utf8')).not.toBe('');
|
||||
}
|
||||
|
||||
const webpFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.webp'));
|
||||
expect(webpFiles).toHaveLength(10);
|
||||
const legacyPngFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.png'));
|
||||
expect(legacyPngFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,18 +10,37 @@ const aiPanelBoundarySource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const floatingAIWindowSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/FloatingAIChatWindow.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('AI panel lazy-load guard', () => {
|
||||
it('keeps AI panel failures scoped to the panel area with retry support', () => {
|
||||
expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';");
|
||||
expect(appSource).not.toContain("import AIChatPanel from './components/AIChatPanel';");
|
||||
expect(appSource).not.toContain("import { AISettingsContent } from './components/AISettingsModal';");
|
||||
expect(appSource).toContain("const createLazyAIChatPanel = () => React.lazy(() => import('./components/AIChatPanel'));");
|
||||
expect(appSource).toContain("const module = await import('./components/AISettingsModal');");
|
||||
expect(floatingAIWindowSource).not.toContain("import AIChatPanel from './AIChatPanel';");
|
||||
expect(floatingAIWindowSource).toContain("const createLazyAIChatPanel = () => React.lazy(() => import('./AIChatPanel'));");
|
||||
expect(floatingAIWindowSource).toContain('const LazyAIChatPanel = useMemo(createLazyAIChatPanel, [renderNonce]);');
|
||||
expect(floatingAIWindowSource).toContain('<React.Suspense');
|
||||
expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';");
|
||||
expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component');
|
||||
expect(appSource).toContain('<AIPanelErrorBoundary');
|
||||
expect(appSource).toContain('key={aiPanelRenderNonce}');
|
||||
expect(appSource).toContain('AI 面板加载失败');
|
||||
expect(appSource).toContain('重新加载');
|
||||
expect(appSource).toContain('key={`ai-settings-${aiSettingsRenderNonce}`}');
|
||||
expect(appSource).toContain("t('app.ai_panel.error.title')");
|
||||
expect(appSource).toContain("t('app.ai_panel.action.reload')");
|
||||
expect(appSource).toContain('setAiPanelRenderNonce((current) => current + 1)');
|
||||
expect(appSource).toContain('<AIChatPanel width={aiPanelRenderWidth}');
|
||||
expect(appSource).toContain('setAiSettingsRenderNonce((current) => current + 1)');
|
||||
expect(appSource).toContain('const LazyAIChatPanel = useMemo(createLazyAIChatPanel, [aiPanelRenderNonce]);');
|
||||
expect(appSource).toContain('const LazyAISettingsContent = useMemo(createLazyAISettingsContent, [aiSettingsRenderNonce]);');
|
||||
expect(appSource).toContain('<Button size="small" onClick={handleRetryAISettingsRender}>');
|
||||
expect(appSource).not.toContain('const LazyAISettingsContent = useMemo(createLazyAISettingsContent, [aiPanelRenderNonce]);');
|
||||
expect(appSource).toContain('<LazyAIChatPanel');
|
||||
expect(appSource).toContain('<LazyAISettingsContent');
|
||||
expect(appSource).toContain('<React.Suspense');
|
||||
expect(appSource).not.toContain('const loadAIChatPanelModule = async (retryNonce: number) => {');
|
||||
});
|
||||
});
|
||||
|
||||
70
frontend/src/App.close-tab-shortcut.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const appSource = readFileSync(new URL('./App.tsx', import.meta.url), 'utf8');
|
||||
const modalSource = readFileSync(
|
||||
new URL('./components/common/ResizableDraggableModal.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const floatingResultSource = readFileSync(
|
||||
new URL('./components/FloatingQueryResultWindows.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('App close-tab shortcut routing', () => {
|
||||
it('tracks only explicit workspace, result, and blocked interaction scopes', () => {
|
||||
expect(appSource).toContain("const closeShortcutScopeRef = useRef<CloseShortcutScope>('workspace');");
|
||||
expect(appSource).toContain('resolveCloseShortcutScopeFromTarget(event.target)');
|
||||
expect(appSource).toContain("document.addEventListener('pointerdown', handleExplicitCloseShortcutScope, true);");
|
||||
expect(appSource).toContain("document.addEventListener('focusin', handleExplicitCloseShortcutScope, true);");
|
||||
expect(appSource).toContain('data-gonavi-close-shortcut-scope="workspace"');
|
||||
});
|
||||
|
||||
it('gives shortcut recording priority over every global action', () => {
|
||||
const recorderGuardIndex = appSource.indexOf('if (capturingShortcutAction) {');
|
||||
const closeDecisionIndex = appSource.indexOf('const closeDecision = resolveCloseShortcutKeydownDecision({');
|
||||
expect(recorderGuardIndex).toBeGreaterThan(-1);
|
||||
expect(closeDecisionIndex).toBeGreaterThan(recorderGuardIndex);
|
||||
expect(appSource).toContain('setGlobalShortcutCaptureActive(Boolean(capturingShortcutAction));');
|
||||
});
|
||||
|
||||
it('uses a single close decision before dispatching exactly one scoped command', () => {
|
||||
expect(appSource).toContain('interactionBlocked: isCloseShortcutInteractionBlocked(event.target, document)');
|
||||
expect(appSource).toContain("if (closeDecision.kind === 'consume') {");
|
||||
expect(appSource).toContain('event.stopImmediatePropagation();');
|
||||
expect(appSource).toContain("if (closeShortcutScopeRef.current === 'workspace') {");
|
||||
expect(appSource).toContain('dispatchCloseActiveWorkspaceTab();');
|
||||
expect(appSource).toContain("} else if (closeShortcutScopeRef.current === 'result') {");
|
||||
expect(appSource).toContain('const targetTabId = resolveDockedActiveTabId(');
|
||||
expect(appSource).toContain('const outcome = dispatchCloseActiveResultTab(targetTabId);');
|
||||
});
|
||||
|
||||
it('enters blocked synchronously when the log tab hides the result area', () => {
|
||||
const dispatchIndex = appSource.indexOf('const outcome = dispatchCloseActiveResultTab(targetTabId);');
|
||||
const hiddenIndex = appSource.indexOf("if (outcome === 'hidden') {", dispatchIndex);
|
||||
const blockedIndex = appSource.indexOf("closeShortcutScopeRef.current = 'blocked';", hiddenIndex);
|
||||
expect(dispatchIndex).toBeGreaterThan(-1);
|
||||
expect(hiddenIndex).toBeGreaterThan(dispatchIndex);
|
||||
expect(blockedIndex).toBeGreaterThan(hiddenIndex);
|
||||
});
|
||||
|
||||
it('does not let the close router steal a migrated shortcut from its prior owner', () => {
|
||||
expect(appSource).toContain("const delegatedAction = closeDecision.kind === 'delegate'");
|
||||
expect(appSource).toContain('if (delegatedAction && action !== delegatedAction) {');
|
||||
expect(appSource).toContain("if (action === 'closeActiveTab') {");
|
||||
});
|
||||
});
|
||||
|
||||
describe('close shortcut interaction guards', () => {
|
||||
it('marks active reusable modals as background blockers', () => {
|
||||
expect(modalSource).toContain("data-gonavi-close-shortcut-guard={active ? 'true' : undefined}");
|
||||
expect(modalSource).toContain("data-gonavi-close-shortcut-blocks-background={active ? 'true' : undefined}");
|
||||
expect(modalSource).toContain('data-gonavi-close-shortcut-blocks-background="true"');
|
||||
});
|
||||
|
||||
it('marks detached result windows as blocked without globally blocking their existence', () => {
|
||||
expect(floatingResultSource).toContain('data-gonavi-close-shortcut-guard="true"');
|
||||
expect(floatingResultSource).toContain('data-gonavi-close-shortcut-scope="blocked"');
|
||||
expect(floatingResultSource).not.toContain('data-gonavi-close-shortcut-blocks-background="true"');
|
||||
});
|
||||
});
|
||||
1623
frontend/src/App.css
303
frontend/src/App.settings-center.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const appCssSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const aiSettingsModalSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/AISettingsModal.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('settings center layout', () => {
|
||||
it('hosts settings and tools in one split navigation shell', () => {
|
||||
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | ToolCenterGroupKey | 'about';");
|
||||
expect(appSource).toContain('type SettingsCenterPaneKey =');
|
||||
expect(appSource).toContain('| ToolCenterPaneKey');
|
||||
expect(appSource).toContain("const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');");
|
||||
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appSource).toContain("title: t('app.settings.group.preferences.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.services.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.config.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
|
||||
expect(appSource).toContain("title: t('app.settings.group.about.title')");
|
||||
expect(appSource).toContain('const combinedSettingsCenterGroups = [');
|
||||
expect(appSource).not.toContain('const [isToolsModalOpen');
|
||||
expect(appSource).not.toContain('{isToolsModalOpen &&');
|
||||
});
|
||||
|
||||
it('moves sidebar table metadata configuration into the settings center', () => {
|
||||
expect(appSource).toContain("key: 'sidebar-metadata'");
|
||||
expect(appSource).toContain("title: t('app.settings.sidebar_metadata.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.sidebar_metadata.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-metadata')");
|
||||
expect(appSource).toContain("setSidebarTableMetadataFieldSelected(");
|
||||
expect(appSource).toContain('DndContext');
|
||||
expect(appSource).toContain('SortableContext');
|
||||
expect(appSource).toContain('handleSidebarMetadataDragEnd');
|
||||
expect(appSource).toContain('sidebarTableMetadataFieldOrder');
|
||||
expect(appSource).toContain('data-sidebar-metadata-field={field}');
|
||||
expect(appSource).toContain("sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS");
|
||||
expect(appSource).toContain("t('sidebar.v2_table_group_menu.display_table_rows')");
|
||||
expect(appSource).not.toContain("setIsLanguageModalOpen(true)");
|
||||
});
|
||||
|
||||
it('adds persistent sidebar object visibility controls to preferences', () => {
|
||||
expect(appSource).toContain("key: 'sidebar-objects'");
|
||||
expect(appSource).toContain("title: t('app.settings.sidebar_objects.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.sidebar_objects.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'sidebar-objects')");
|
||||
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'sidebar-objects')");
|
||||
expect(appSource).toContain('renderSidebarObjectVisibilitySettingsPane();');
|
||||
expect(appSource).toContain('sidebarHiddenObjectGroups');
|
||||
expect(appSource).toContain('SIDEBAR_OBJECT_GROUP_KEYS.filter((key) => key !== \'tables\')');
|
||||
});
|
||||
|
||||
it('adds browser auth management into the services settings group', () => {
|
||||
expect(appSource).toContain("key: 'web-auth' as const");
|
||||
expect(appSource).toContain("title: t('app.settings.entry.web_auth.title')");
|
||||
expect(appSource).toContain("description: t('app.settings.entry.web_auth.description')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'web-auth')");
|
||||
expect(appSource).toContain("<WebAuthSettingsPanel");
|
||||
});
|
||||
|
||||
it('adds global proxy connection testing controls', () => {
|
||||
expect(appSource).toContain("const DEFAULT_GLOBAL_PROXY_TEST_URL = 'https://api.github.com/';");
|
||||
expect(appSource).toContain('const [proxyTestUrl, setProxyTestUrl]');
|
||||
expect(appSource).toContain('const [proxyTesting, setProxyTesting]');
|
||||
expect(appSource).toContain('const [proxyTestResult, setProxyTestResult]');
|
||||
expect(appSource).toContain('handleTestGlobalProxyDraft');
|
||||
expect(appSource).toContain('TestGlobalProxyConnection');
|
||||
expect(appSource).toContain('https://github.com/Syngnat/GoNavi/releases/latest');
|
||||
expect(appSource).toContain("t('app.proxy.test.action')");
|
||||
expect(appSource).toContain("t('app.proxy.test.target_placeholder')");
|
||||
});
|
||||
|
||||
it('adds close and back-to-settings actions to settings center detail panes', () => {
|
||||
expect(appSource).toContain('const handleBackFromSettingsCenterPane = useCallback(() => {');
|
||||
expect(appSource).toContain('const handleCancelSettingsCenterPane = useCallback(() => {');
|
||||
expect(appSource).toContain('onClick={handleCancelSettingsCenterPane}');
|
||||
expect(appSource).toContain("t('common.close')");
|
||||
expect(appSource).toContain('onClick={handleBackFromSettingsCenterPane}');
|
||||
expect(appSource).toContain("t('common.back_to_settings')");
|
||||
});
|
||||
|
||||
it('clears embedded tool transient state before switching settings groups or panes', () => {
|
||||
const cleanupStart = appSource.indexOf('const clearSettingsCenterTransientPaneState = useCallback(() => {');
|
||||
const cleanupSource = appSource.slice(
|
||||
cleanupStart,
|
||||
appSource.indexOf('const handleOpenToolsModal', cleanupStart),
|
||||
);
|
||||
|
||||
expect(cleanupStart).toBeGreaterThan(-1);
|
||||
expect(cleanupSource).toContain('setCapturingShortcutAction(null);');
|
||||
expect(cleanupSource).toContain("activeSettingsCenterPaneRef.current?.key === 'connection-package'");
|
||||
expect(cleanupSource).toContain('closeConnectionPackageDialog();');
|
||||
expect(cleanupSource).toContain("activeSettingsCenterPaneRef.current?.key === 'ai'");
|
||||
expect(cleanupSource).toContain('setFocusedAIProviderId(undefined);');
|
||||
expect(cleanupSource).toContain('setSecurityUpdateRepairSource(null);');
|
||||
expect(appSource).toContain('const handleOpenSettingsModal = useCallback');
|
||||
expect(appSource).toContain('const handleOpenToolCenterPane = useCallback');
|
||||
expect(appSource.match(/clearSettingsCenterTransientPaneState\(\);/g)?.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('routes every security-update detail and repair return through settings center', () => {
|
||||
const openDetailsStart = appSource.indexOf('const openSecurityUpdateSettings = useCallback(');
|
||||
const openDetailsSource = appSource.slice(
|
||||
openDetailsStart,
|
||||
appSource.indexOf('const handleOpenSecurityUpdateSettings', openDetailsStart),
|
||||
);
|
||||
|
||||
expect(openDetailsStart).toBeGreaterThan(-1);
|
||||
expect(openDetailsSource).toContain("setActiveSettingsCenterGroupKey('config');");
|
||||
expect(openDetailsSource).toContain("setActiveSettingsCenterPane({ key: 'security-update', group: 'config' });");
|
||||
expect(openDetailsSource).toContain('setIsSettingsModalOpen(true);');
|
||||
expect(appSource).toContain("const detailsWereOpen = isSettingsModalOpen && activeSettingsCenterPane?.key === 'security-update';");
|
||||
expect(appSource.match(/<SecurityUpdateSettingsModal/g)?.length).toBe(1);
|
||||
expect(appSource).toMatch(/<SecurityUpdateSettingsModal\r?\n\s+embedded/);
|
||||
expect(appSource).not.toContain('isSecurityUpdateSettingsOpen');
|
||||
expect(appSource).not.toContain('setIsSecurityUpdateSettingsOpen');
|
||||
});
|
||||
|
||||
it('uses a consistent close footer while keeping the theme instant-apply hint', () => {
|
||||
expect(appSource).toContain("t('common.back_to_settings')");
|
||||
expect(appSource).toContain("t('common.close')");
|
||||
expect(appSource).toContain("t('app.theme.instant_apply_hint')");
|
||||
});
|
||||
|
||||
it('gates the new theme layout to v2 and keeps legacy side nav for old UI', () => {
|
||||
expect(appSource).toContain('renderThemeSettingsContentV2');
|
||||
expect(appSource).toContain('renderThemeSettingsContentLegacy');
|
||||
expect(appSource).toContain('isV2Ui ? renderThemeSettingsContentV2() : renderThemeSettingsContentLegacy()');
|
||||
expect(aiSettingsModalSource).toContain("gridTemplateColumns: '168px minmax(0, 1fr)', gap: 0, padding: '10px 0'");
|
||||
expect(aiSettingsModalSource).toContain('className="ai-settings-body gonavi-ai-settings-flat"');
|
||||
expect(appSource).toContain('className="gonavi-theme-settings"');
|
||||
expect(appSource).toContain('ThemeSettingsSlider');
|
||||
expect(appSource).toContain("t('app.theme.custom.title')");
|
||||
expect(appSource).toContain('<CustomThemeManager />');
|
||||
expect(appSource).toContain('<CustomThemeManager legacyMode />');
|
||||
expect(appSource).toContain("value: 'workspace'");
|
||||
expect(appSource).toContain('gonavi-settings-tabs');
|
||||
expect(appSource).toContain('setThemeModalSection(item.value)');
|
||||
});
|
||||
|
||||
it('resolves custom-theme base mode synchronously and bridges its surfaces into Ant Design', () => {
|
||||
expect(appSource).toContain("const resolvedThemeMode = effectiveThemePreference === 'system'");
|
||||
expect(appSource).toContain("const darkMode = resolvedThemeMode === 'dark';");
|
||||
expect(appSource).toContain('const customThemeStyleContextKey = `${resolvedThemeMode}:${appearance.uiVersion}`;');
|
||||
expect(appSource).toContain('colorBgContainer: (isV2Ui ? v2AntBgContainer : undefined)');
|
||||
expect(appSource).toContain('colorBgElevated: (isV2Ui ? v2AntBgElevated : undefined)');
|
||||
expect(appSource).toContain('colorTextSecondary: v2AntTextSecondary');
|
||||
expect(appSource).toContain('rowHoverBg: (isV2Ui ? v2AntRowHoverBg : undefined)');
|
||||
});
|
||||
|
||||
it('opens theme, AI, and about entries inside settings center detail panes', () => {
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('preferences', 'theme')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('services', 'ai')");
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('about', 'about-go-navi')");
|
||||
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'theme')");
|
||||
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'ai')");
|
||||
expect(appSource).toContain('<LazyAISettingsContent');
|
||||
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'about-go-navi')");
|
||||
expect(appSource).toContain('renderSettingsCenterAboutPane()');
|
||||
});
|
||||
|
||||
it('opens AI settings from the chat panel via settings center instead of a standalone modal', () => {
|
||||
expect(appSource).toContain('const handleOpenAISettings = useCallback((providerId?: string) => {');
|
||||
expect(appSource).toContain("setActiveSettingsCenterPane({ key: 'ai', group: 'services' })");
|
||||
expect(appSource).toContain('setIsSettingsModalOpen(true)');
|
||||
expect(appSource).not.toContain('setIsAISettingsOpen(true)');
|
||||
expect(appSource).not.toContain('<AISettingsModal');
|
||||
});
|
||||
|
||||
it('keeps the settings center above the in-webview detached AI fallback', () => {
|
||||
const settingsModalStart = appSource.indexOf(
|
||||
"title={renderUtilityModalTitle(<SettingOutlined />, t('app.settings.title')",
|
||||
);
|
||||
const settingsModalSource = appSource.slice(settingsModalStart, settingsModalStart + 900);
|
||||
|
||||
expect(settingsModalStart).toBeGreaterThan(-1);
|
||||
expect(appSource).toContain('APP_FOREGROUND_MODAL_Z_INDEX,');
|
||||
expect(appSource).toContain('APP_NESTED_MODAL_Z_INDEX,');
|
||||
expect(appSource).toContain('const settingsCenterModalZIndex = Math.max(');
|
||||
expect(appSource).toContain('Number.isFinite(detachedAIChatZIndex) ? detachedAIChatZIndex + 1 : APP_FOREGROUND_MODAL_Z_INDEX');
|
||||
expect(appSource).toContain('const settingsChildModalZIndex = Math.max(');
|
||||
expect(appSource).toContain('settingsCenterModalZIndex + 100');
|
||||
expect(settingsModalSource).toContain('zIndex={settingsCenterModalZIndex}');
|
||||
});
|
||||
|
||||
it('opens the about group directly instead of showing a one-item list', () => {
|
||||
expect(appSource).toContain('const resolveSettingsCenterGroupInitialPane = (group: SettingsCenterGroupKey): SettingsCenterPaneState | null => (');
|
||||
expect(appSource).toContain("group === 'about' ? { key: 'about-go-navi', group: 'about' } : null");
|
||||
expect(appSource).toContain('setActiveSettingsCenterPane(resolveSettingsCenterGroupInitialPane(group));');
|
||||
expect(appSource).toContain('handleOpenSettingsModal(group.key);');
|
||||
});
|
||||
|
||||
it('routes silent update discovery to the settings center about pane via bridge', () => {
|
||||
expect(appSource).toContain('const updateCenterBridgeRef = useRef<{');
|
||||
expect(appSource).toContain('updateCenterBridgeRef,');
|
||||
expect(appSource).toContain('updateCenterBridgeRef.current = {');
|
||||
expect(appSource).toContain("handleOpenSettingsCenterPane('about', 'about-go-navi')");
|
||||
expect(appSource).toContain('prepareAboutSurface');
|
||||
expect(appSource).toContain('isSettingsAboutPaneOpen');
|
||||
});
|
||||
|
||||
it('renders the settings center about page as flat sections without nested cards', () => {
|
||||
const projectEntryStart = appSource.indexOf('const renderSettingsCenterAboutProjectEntry = ({');
|
||||
const aboutPaneStart = appSource.indexOf('const renderSettingsCenterAboutPane = () => {');
|
||||
const aboutPaneEnd = appSource.indexOf('const renderSettingsCenterAboutFooter = () => (', aboutPaneStart);
|
||||
const projectEntrySource = appSource.slice(projectEntryStart, aboutPaneStart);
|
||||
const aboutPaneSource = appSource.slice(aboutPaneStart, aboutPaneEnd);
|
||||
|
||||
expect(projectEntryStart).toBeGreaterThan(-1);
|
||||
expect(aboutPaneStart).toBeGreaterThan(projectEntryStart);
|
||||
expect(aboutPaneEnd).toBeGreaterThan(aboutPaneStart);
|
||||
expect(appSource).toContain('const renderSettingsCenterAboutPane = () => {');
|
||||
expect(appSource).toContain('const renderSettingsCenterAboutProjectEntry = ({');
|
||||
expect(appSource).toContain("activeSettingsCenterPane?.key === 'about-go-navi'");
|
||||
expect(appSource).toContain("padding: '0 4px 0 0'");
|
||||
expect(appSource).toContain("border: 'none'");
|
||||
expect(appSource).toContain("background: 'transparent'");
|
||||
expect(aboutPaneSource).toContain('className="gonavi-about-pane"');
|
||||
expect(aboutPaneSource).toContain('aria-labelledby="gonavi-about-version-heading"');
|
||||
expect(aboutPaneSource).toContain('aria-labelledby="gonavi-about-project-heading"');
|
||||
expect(aboutPaneSource).toContain("gridTemplateColumns: 'minmax(0, 1.15fr) minmax(260px, 0.85fr)'");
|
||||
expect(aboutPaneSource).not.toContain('cardBorder');
|
||||
expect(aboutPaneSource).not.toContain('cardBg');
|
||||
expect(aboutPaneSource).not.toContain('borderRadius');
|
||||
expect(projectEntrySource).toContain('className="gonavi-about-project-entry"');
|
||||
expect(projectEntrySource).toContain("border: 'none'");
|
||||
expect(projectEntrySource).toContain("background: 'transparent'");
|
||||
expect(projectEntrySource).not.toContain('borderRadius');
|
||||
expect(appCssSource).toContain('.gonavi-about-project-entry:hover:not(:disabled)');
|
||||
expect(appSource).toContain('width={92}');
|
||||
expect(appSource).toContain('height={92}');
|
||||
expect(appSource).toContain('width: 92');
|
||||
expect(appSource).toContain('height: 92');
|
||||
expect(appSource).toContain('const releaseTimeText = formatAboutReleaseTime(lastUpdateInfo?.releasePublishedAt);');
|
||||
expect(appSource).toContain("[t('app.about.version.release_time'), releaseTimeText]");
|
||||
expect(appSource).toContain("installMode === 'msi' || installMode === 'portable'");
|
||||
expect(appSource).toContain("[t('app.about.version.install_mode'), t(`app.about.install_mode.${installMode}`)]");
|
||||
expect(appSource).toContain("hasUpdate && packageType !== 'unknown'");
|
||||
expect(appSource).toContain("[t('app.about.version.package_type'), t(`app.about.package_type.${packageType}`)]");
|
||||
expect(appSource).toContain('className="gonavi-about-update-channel"');
|
||||
expect(appSource).toContain('<Segmented');
|
||||
expect(appSource).toContain("t('app.about.field.auto_check_updates')");
|
||||
expect(appSource).toContain("t('app.about.field.auto_check_interval')");
|
||||
expect(appSource).toContain("t('app.about.version_update.auto_check_hint')");
|
||||
expect(appSource).toContain("t('app.about.version_update.auto_check_disabled_hint')");
|
||||
expect(appSource).toContain('className="gonavi-about-auto-check-interval"');
|
||||
expect(appSource).toContain('checked={autoCheckForUpdates}');
|
||||
expect(appSource).toContain('setAutoCheckForUpdates(checked)');
|
||||
expect(appSource).toContain('setAutoCheckForUpdatesIntervalMinutes(Number(value))');
|
||||
expect(appSource).toContain('maxWidth: 360');
|
||||
expect(appSource).toContain("alignItems: 'start'");
|
||||
expect(appSource).toContain("overflowWrap: 'anywhere'");
|
||||
expect(appSource).toContain("t('app.about.version_update.title')");
|
||||
expect(appSource).toContain("t('app.about.project.github.title')");
|
||||
expect(appSource).toContain("t('app.about.project.issues.title')");
|
||||
expect(appSource).toContain("t('app.about.project.releases.title')");
|
||||
expect(appSource).toContain('const renderSettingsCenterAboutFooter = () => (');
|
||||
expect(appSource).toContain("t('app.about.last_checked_at', { time: aboutLastCheckedAt })");
|
||||
expect(appSource).toContain('renderAboutUpdateActions()');
|
||||
});
|
||||
|
||||
it('uses one flat detail shell across every settings page', () => {
|
||||
expect(appSource).toContain('const activeSettingsCenterDetailPanelStyle: React.CSSProperties = {');
|
||||
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain("padding: '0 4px 0 0'");
|
||||
expect(appSource).toContain("background: 'transparent'");
|
||||
expect(aiSettingsModalSource).toContain('className="ai-settings-body gonavi-ai-settings-flat"');
|
||||
expect(aiSettingsModalSource).toContain("gap: 0, padding: '10px 0'");
|
||||
expect(aiSettingsModalSource).toContain("padding: '0 6px 24px 22px'");
|
||||
});
|
||||
|
||||
it('keeps embedded split-pane settings stable at scroll boundaries', () => {
|
||||
expect(appSource).toContain('const isSettingsCenterContainedScrollPane =');
|
||||
expect(appSource).toContain("activeSettingsCenterPane?.key === 'theme' || activeSettingsCenterPane?.key === 'ai'");
|
||||
expect(appSource).toContain('const settingsCenterDetailBodyStyle: React.CSSProperties = isSettingsCenterContainedScrollPane');
|
||||
expect(appSource).toContain("overflowY: 'hidden'");
|
||||
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain("boxSizing: 'border-box'");
|
||||
expect(appSource).toContain("overscrollBehavior: 'contain'");
|
||||
expect(aiSettingsModalSource).toContain("boxSizing: 'border-box'");
|
||||
expect(aiSettingsModalSource).toContain("overscrollBehavior: 'contain'");
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,26 @@ const appCss = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const v2ThemeCss = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./v2-theme.css', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const linuxCJKFontBannerSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appUtilityStylesSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppUtilityStyles.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appSidebarResizeSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const sidebarLayoutSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./utils/sidebarLayout.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const getGlobalShortcutCaseBlock = (action: string) => {
|
||||
const caseToken = `case '${action}':`;
|
||||
@@ -29,12 +45,26 @@ const getGlobalShortcutCaseBlock = (action: string) => {
|
||||
return afterCase.slice(0, endIndex);
|
||||
};
|
||||
|
||||
describe('tool center menu entries', () => {
|
||||
describe('settings center tool entries', () => {
|
||||
it('opens data workflows as stable workspace tabs instead of settings-center modals', () => {
|
||||
expect(appSource).toContain("import { buildDataSyncWorkbenchTab } from './utils/dataSyncTab';");
|
||||
expect(appSource).toContain('const handleOpenDataSyncWorkbench = useCallback((entryMode: DataSyncEntryMode) => {');
|
||||
expect(appSource).toContain('addTab(buildDataSyncWorkbenchTab({ entryMode }));');
|
||||
expect(appSource).toContain("handleOpenDataSyncWorkbench('schemaCompare');");
|
||||
expect(appSource).toContain("handleOpenDataSyncWorkbench('dataCompare');");
|
||||
expect(appSource).toContain("handleOpenDataSyncWorkbench('sync');");
|
||||
expect(appSource).not.toContain("import DataSyncModal from './components/DataSyncModal';");
|
||||
expect(appSource).not.toContain('isSyncModalOpen');
|
||||
expect(appSource).not.toContain('syncModalEntryMode');
|
||||
});
|
||||
|
||||
it('exposes snippet management next to shortcut management', () => {
|
||||
expect(appSource).toContain("key: 'snippet-settings'");
|
||||
expect(appSource).toContain("title: t('app.tools.entry.snippets.title')");
|
||||
expect(appSource).toContain("description: t('app.tools.entry.snippets.description')");
|
||||
expect(appSource).toContain('setIsSnippetModalOpen(true)');
|
||||
expect(appSource).toContain("handleOpenToolCenterPane('workspace', 'snippet-settings')");
|
||||
expect(appSource).toContain('gonavi:open-snippet-settings');
|
||||
expect(appSource).not.toContain('isSnippetModalOpen');
|
||||
|
||||
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
|
||||
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
|
||||
@@ -42,56 +72,140 @@ describe('tool center menu entries', () => {
|
||||
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
|
||||
});
|
||||
|
||||
it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => {
|
||||
it('uses the settings center side navigation for every tool group', () => {
|
||||
expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';");
|
||||
expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');");
|
||||
expect(appSource).toContain("type SettingsCenterGroupKey = 'preferences' | 'services' | ToolCenterGroupKey | 'about';");
|
||||
expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);");
|
||||
expect(appSource).toContain("title: t('app.tools.group.config.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
|
||||
expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)");
|
||||
expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'");
|
||||
expect(appSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain('const combinedSettingsCenterGroups = [');
|
||||
expect(appSource).toContain('(group) => group.key === activeSettingsCenterGroupKey');
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("gridTemplateColumns: '204px minmax(0, 1fr)'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain('role="tablist" aria-orientation="vertical"');
|
||||
expect(appSource).toContain('role="tab"');
|
||||
expect(appSource).toContain('aria-selected={active}');
|
||||
expect(appSource).toContain('title={`${group.title} - ${group.description}`}');
|
||||
expect(appSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
|
||||
expect(appSource).toContain('setActiveToolCenterPane(null);');
|
||||
expect(appSource).toContain('group.items.length');
|
||||
expect(appUtilityStylesSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
|
||||
expect(appSource).toContain('setActiveSettingsCenterPane(null);');
|
||||
expect(appSource).not.toContain('{group.items.length}');
|
||||
expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {");
|
||||
expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain("const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {");
|
||||
expect(appSource).toContain("t('common.back_to_previous')");
|
||||
expect(appSource).toContain("t('common.back_to_settings')");
|
||||
expect(appSource).toContain("width={1080}");
|
||||
expect(appSource).toContain('centered');
|
||||
expect(appSource).not.toContain('const [isToolsModalOpen');
|
||||
});
|
||||
|
||||
it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => {
|
||||
expect(appSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
|
||||
expect(appSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
|
||||
expect(appSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
it('keeps the unified settings modal height fixed across group switches and scrolls the list area internally', () => {
|
||||
expect(appUtilityStylesSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
|
||||
expect(appUtilityStylesSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={activeSettingsCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={isActiveToolCenterPane ? toolCenterDetailBodyStyle : settingsCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appSource).toContain("overflowY: 'auto'");
|
||||
expect(appUtilityStylesSource).toContain("overflowY: 'auto'");
|
||||
expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'");
|
||||
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
|
||||
});
|
||||
|
||||
it('keeps browser-compatible connection transfer and mounted data-root entries available in the web runtime', () => {
|
||||
const toolGroupsStart = appSource.indexOf('const toolCenterGroups: SettingsCenterNavigationGroup[] = [');
|
||||
const configGroupStart = appSource.indexOf("key: 'config',", toolGroupsStart);
|
||||
const configGroupSource = appSource.slice(
|
||||
configGroupStart,
|
||||
appSource.indexOf("key: 'workflow',", configGroupStart),
|
||||
);
|
||||
|
||||
expect(toolGroupsStart).toBeGreaterThan(-1);
|
||||
expect(configGroupStart).toBeGreaterThan(toolGroupsStart);
|
||||
expect(appSource).toContain("accept=\".gonavi-conn,.json,.xml,.ncx\"");
|
||||
expect(appSource).toContain('ExportConnectionsPayload');
|
||||
expect(appSource).toContain('downloadBrowserTextFile');
|
||||
expect(appSource).toContain("__GONAVI_WEB_RUNTIME__?.buildType === 'web'");
|
||||
expect(appSource).toMatch(/if \(isWebRuntime\) \{\r?\n\s+return \(/);
|
||||
expect(configGroupSource).toContain("key: 'data-root'");
|
||||
expect(configGroupSource).toContain("handleOpenToolCenterPane('config', 'data-root')");
|
||||
expect(appSource).toContain('...toolCenterGroups,');
|
||||
});
|
||||
|
||||
it('lets the tool center detail header own embedded tool titles', () => {
|
||||
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
|
||||
const renderPaneSource = appSource.slice(
|
||||
renderPaneStart,
|
||||
appSource.indexOf('};\n\n return (', renderPaneStart),
|
||||
);
|
||||
const connectionPackageSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'connection-package')"),
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'data-root')"),
|
||||
);
|
||||
const dataRootSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'data-root')"),
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'security-update')"),
|
||||
);
|
||||
const securityUpdateSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'security-update')"),
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'drivers')"),
|
||||
);
|
||||
const driverSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'drivers')"),
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'snippet-settings')"),
|
||||
);
|
||||
const snippetSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'snippet-settings')"),
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')"),
|
||||
);
|
||||
const shortcutSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')"),
|
||||
renderPaneSource.indexOf('return null;', renderPaneSource.indexOf("if (activeSettingsCenterPane.key === 'shortcut-settings')")),
|
||||
);
|
||||
|
||||
expect(appSource).toContain('activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title');
|
||||
expect(connectionPackageSource).toContain('<ConnectionPackagePasswordModal');
|
||||
expect(connectionPackageSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(dataRootSource).toContain('title={null}');
|
||||
expect(dataRootSource).toContain('closable={false}');
|
||||
expect(dataRootSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(securityUpdateSource).toContain('<SecurityUpdateSettingsModal');
|
||||
expect(securityUpdateSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(renderPaneSource).not.toContain('<DataSyncModal');
|
||||
expect(driverSource).toContain('<DriverManagerModal');
|
||||
expect(driverSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(snippetSource).toContain('<SnippetSettingsModal');
|
||||
expect(snippetSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(shortcutSource).toContain('title={null}');
|
||||
expect(shortcutSource).toContain('closable={false}');
|
||||
expect(shortcutSource).not.toContain('renderUtilityModalTitle');
|
||||
});
|
||||
|
||||
it('does not render an extra top back button in tool detail headers', () => {
|
||||
const combinedGroupsIndex = appSource.indexOf('combinedSettingsCenterGroups.map');
|
||||
const detailHeaderSource = appSource.slice(
|
||||
appSource.indexOf('{activeSettingsCenterPane ? (', combinedGroupsIndex),
|
||||
appSource.indexOf('key={activeSettingsCenterPane.key}', combinedGroupsIndex),
|
||||
);
|
||||
|
||||
expect(detailHeaderSource).toContain('activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title');
|
||||
expect(detailHeaderSource).toContain('activeSettingsCenterPaneItem?.description ?? activeSettingsCenterGroup.description');
|
||||
expect(detailHeaderSource).not.toContain('<Button onClick={closeToolCenterPane}>');
|
||||
expect(detailHeaderSource).not.toContain("{t('common.back_to_settings')}");
|
||||
});
|
||||
|
||||
it('keeps the v2 AI entry in the sidebar and the legacy AI entry on the content edge', () => {
|
||||
expect(appSource).toContain('onToggleAI={toggleAIPanel}');
|
||||
expect(appSource).toContain('renderLegacyAIEdgeHandle');
|
||||
@@ -106,22 +220,37 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain('const handleOpenToolsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleOpenSettingsModal = useCallback(');
|
||||
expect(appSource).toContain('const handleToggleLogPanel = useCallback(');
|
||||
expect(appSource).toContain('new CustomEvent');
|
||||
expect(appSource).toContain("'gonavi:show-sql-execution-log'");
|
||||
expect(appSource).toContain("detail: { mode: 'open' }");
|
||||
expect(appSource).toContain('toggleAppLogPanel();');
|
||||
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
|
||||
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
|
||||
expect(appSource).toContain('theme={antdTheme}');
|
||||
expect(appSource).toContain('const sqlLogCount = useStore(state => state.sqlLogs.length);');
|
||||
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
|
||||
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
|
||||
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
|
||||
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
|
||||
expect(appSource).toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
|
||||
expect(appSource).toContain('onOpenAISettings={handleOpenAISettings}');
|
||||
expect(appSource).not.toContain('onOpenTools=');
|
||||
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
|
||||
expect(appSource).not.toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('theme={{');
|
||||
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
|
||||
});
|
||||
|
||||
it('renders the shared SQL log panel only for legacy layouts', () => {
|
||||
const logPanelIndex = appSource.indexOf('<LogPanel', appSource.indexOf('<Content'));
|
||||
const logPanelGuardIndex = appSource.lastIndexOf('{isLogPanelOpen && (', logPanelIndex);
|
||||
const legacyOnlyGuardIndex = appSource.lastIndexOf('{!isV2Ui && isLogPanelOpen && (', logPanelIndex);
|
||||
|
||||
expect(logPanelIndex).toBeGreaterThan(-1);
|
||||
expect(logPanelGuardIndex).toBe(-1);
|
||||
expect(legacyOnlyGuardIndex).toBeGreaterThan(-1);
|
||||
expect(appSource).toContain('onClose={handleCloseLogPanel}');
|
||||
expect(appSource).toContain('onResizeStart={handleLogResizeStart}');
|
||||
});
|
||||
|
||||
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
|
||||
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
|
||||
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
|
||||
@@ -152,15 +281,30 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain("darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'");
|
||||
});
|
||||
|
||||
it('keeps the green v2 accent as fallback while allowing custom CSS tokens to override it', () => {
|
||||
expect(appSource).toContain("const v2AntPrimaryColor = customThemeAntTokens.primary ?? (darkMode ? '#22c55e' : '#16a34a');");
|
||||
expect(appSource).toContain("const v2AntPrimaryContrastColor = customThemeAntTokens.primaryContrast ?? '#ffffff';");
|
||||
expect(appSource).toContain('extractCustomThemeAntTokens(activeCustomTheme.css)');
|
||||
expect(appSource).toContain('resolveAvailableCustomTheme(customThemes, activeCustomThemeId)');
|
||||
expect(appSource).toContain('colorTextLightSolid: isV2Ui ? v2AntPrimaryContrastColor');
|
||||
expect(appSource).toContain("colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff')");
|
||||
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedBg/);
|
||||
expect(appSource).toMatch(/background:\s*active\s*\?\s*overlayTheme\.selectedText/);
|
||||
expect(appSource).toContain("background: 'transparent'");
|
||||
expect(appSource).toMatch(/color:\s*active\s*\?\s*overlayTheme\.iconColor/);
|
||||
expect(appSource).toContain("background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)')");
|
||||
expect(appSource).toContain("color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff')");
|
||||
});
|
||||
|
||||
it('does not start sidebar resize from right-clicking the resize handle', () => {
|
||||
expect(appSource).toContain('if (e.button !== 0)');
|
||||
expect(appSidebarResizeSource).toContain('if (e.button !== 0)');
|
||||
expect(appSource).toContain('onContextMenu={(event) => {');
|
||||
expect(appSource).toContain('event.preventDefault();');
|
||||
expect(appSource).toContain('event.stopPropagation();');
|
||||
|
||||
const guardIndex = appSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
const guardIndex = appSidebarResizeSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSidebarResizeSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSidebarResizeSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
|
||||
expect(guardIndex).toBeGreaterThan(-1);
|
||||
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
|
||||
@@ -168,30 +312,45 @@ describe('tool center menu entries', () => {
|
||||
});
|
||||
|
||||
it('positions sidebar resize guide from the rendered sider edge', () => {
|
||||
expect(appSource).toContain('const siderRef = React.useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSidebarResizeSource).toContain('const siderRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSource).toContain('ref={siderRef}');
|
||||
expect(appSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
expect(appSidebarResizeSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSidebarResizeSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
});
|
||||
|
||||
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
|
||||
it('keeps sidebar resize bounds aligned across drag logic and sider CSS limits', () => {
|
||||
expect(sidebarLayoutSource).toContain('export const SIDEBAR_RESIZE_MAX_WIDTH = 960;');
|
||||
expect(sidebarLayoutSource).toContain('export const SIDEBAR_MIN_WORKBENCH_WIDTH = 360;');
|
||||
expect(appSidebarResizeSource).toContain('resolveSidebarResizeMaxWidth(window.innerWidth, minWidth)');
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
|
||||
expect(v2ThemeCss).toMatch(/body\[data-ui-version="v2"\]\s+\.gn-v2-app-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*min\(960px,\s*calc\(100vw - 360px\)\)\s*!important;/s);
|
||||
});
|
||||
|
||||
it('keeps connection modal warm-mounted while leaving the other heavyweight modals conditional', () => {
|
||||
it('keeps connection modal warm-mounted while leaving the remaining heavyweight modals conditional', () => {
|
||||
expect(appSource).toContain('const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);');
|
||||
expect(appSource).toContain('{isConnectionModalMounted && (');
|
||||
expect(appSource).toContain('{isToolsModalOpen && (');
|
||||
expect(appSource).toContain('{isSettingsModalOpen && (');
|
||||
expect(appSource).toContain('{isThemeModalOpen && (');
|
||||
expect(appSource).toContain('{isShortcutModalOpen && (');
|
||||
expect(appSource).toContain('{isAISettingsOpen && (');
|
||||
expect(appSource).not.toContain('{isToolsModalOpen && (');
|
||||
expect(appSource).not.toContain('{isShortcutModalOpen && (');
|
||||
expect(appSource).not.toContain('{isAISettingsOpen && (');
|
||||
expect(appSource).toContain('{isDriverModalOpen && (');
|
||||
expect(appSource).toContain('{isSyncModalOpen && (');
|
||||
expect(appSource).not.toContain('{isSyncModalOpen && (');
|
||||
});
|
||||
|
||||
it('recreates the hidden download progress portal above dialogs opened later', () => {
|
||||
const start = appSource.indexOf('title={updateDownloadProgress.version');
|
||||
const end = appSource.indexOf('</Modal>', start);
|
||||
const downloadProgressModalSource = appSource.slice(start, end);
|
||||
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
expect(downloadProgressModalSource).toContain('open={updateDownloadProgress.open}');
|
||||
expect(downloadProgressModalSource).toContain('destroyOnHidden');
|
||||
});
|
||||
|
||||
it('loads editable connection details before opening the edit modal so stored secrets can be shown', () => {
|
||||
@@ -205,8 +364,11 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain('setIsModalOpen(true);');
|
||||
});
|
||||
|
||||
it('loads editable AI provider details before opening the edit modal so stored api keys can be shown', () => {
|
||||
expect(appSource).toContain('<AISettingsModal');
|
||||
it('loads editable AI provider details inside settings-center AI pane content', () => {
|
||||
// 聊天/入口打开 AI 配置走设置中心 AISettingsContent,不再挂独立 AISettingsModal
|
||||
expect(appSource).toContain('<LazyAISettingsContent');
|
||||
expect(appSource).toContain("activeSettingsCenterPane.key === 'ai'");
|
||||
expect(appSource).not.toContain('<AISettingsModal');
|
||||
const modalSource = readFileSync(new URL('./components/AISettingsModal.tsx', import.meta.url), 'utf8');
|
||||
expect(modalSource).toContain("typeof Service?.AIGetEditableProvider === 'function'");
|
||||
expect(modalSource).toContain('await Service.AIGetEditableProvider(p.id)');
|
||||
@@ -216,10 +378,10 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
|
||||
});
|
||||
|
||||
it('keeps shortcut manager scrolling inside the modal body', () => {
|
||||
it('keeps shortcut manager scrolling inside the embedded settings pane', () => {
|
||||
expect(appSource).toContain('centered');
|
||||
expect(appSource).toContain("height: 'min(760px, calc(100vh - 80px))'");
|
||||
expect(appSource).toContain("maxHeight: 'calc(100vh - 80px)'");
|
||||
expect(appSource).toContain("if (activeSettingsCenterPane.key === 'shortcut-settings')");
|
||||
expect(appSource).toContain('embedded');
|
||||
expect(appSource).toContain("body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
|
||||
expect(appSource).toContain('data-gonavi-shortcut-modal-scroll="true"');
|
||||
expect(appSource).toContain("height: '100%'");
|
||||
@@ -239,10 +401,10 @@ describe('tool center menu entries', () => {
|
||||
['switchToNextTab', 'switchActiveTabByOffset(1);'],
|
||||
['switchToPreviousTab', 'switchActiveTabByOffset(-1);'],
|
||||
['newConnection', 'handleCreateConnection();'],
|
||||
['toggleAIPanel', 'toggleAIPanel();'],
|
||||
['toggleAIPanel', 'toggleOrFocusNativeAIChatFromMainWindow()'],
|
||||
['toggleLogPanel', 'handleToggleLogPanel();'],
|
||||
['toggleTheme', 'setTheme('],
|
||||
['openShortcutManager', 'setIsShortcutModalOpen(true);'],
|
||||
['toggleTheme', 'selectPresetTheme('],
|
||||
['openShortcutManager', "handleOpenToolCenterPane('workspace', 'shortcut-settings');"],
|
||||
['toggleMacFullscreen', 'handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });'],
|
||||
['resetWindowZoom', 'handleManualResetWindowZoom();'],
|
||||
]);
|
||||
@@ -268,16 +430,75 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain('该异常不一定表现为 viewport ratio drift');
|
||||
});
|
||||
|
||||
it('settles Windows cold-start window layout without requiring a taskbar restore', () => {
|
||||
expect(appSource).toContain("const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {");
|
||||
expect(appSource).toContain('markStartupWindowRestorePending(3200)');
|
||||
expect(appSource).toContain("applyStartupWindowChrome(1, 'maximised');");
|
||||
expect(appSource).toContain('const delayMs = attempt <= 1 ? 0 : applyRetryDelayMs');
|
||||
expect(appSource).toContain('markAppliedMaximisedOrFullscreen');
|
||||
expect(appSource).toContain('shouldPreferWindowsStartupMaximise(bounds, viewport)');
|
||||
expect(appSource).toContain('applyWindowsWorkAreaFillFallback');
|
||||
expect(appSource).toContain('resolveWorkAreaFillWindowBounds(readCurrentVisibleViewport())');
|
||||
expect(appSource).toContain('restoreNormalWindowBounds');
|
||||
expect(appSource).toContain("void fixWindowScaleIfNeeded('startup');");
|
||||
expect(appSource).toContain('const startupLayoutFixTimers = [220, 1000, 1900].map((delayMs) => (');
|
||||
expect(appSource).toContain('if (isStartupWindowRestorePending())');
|
||||
expect(appSource).toContain('clearStartupWindowRestorePending();');
|
||||
// 启动恢复顺序:开关最大化 → 记忆最大化 → 记忆尺寸
|
||||
expect(appSource).toContain('// 1) 「启动时最大化」开关优先(Windows 按 Maximize 处理)');
|
||||
expect(appSource).toContain('// 2) 记忆用户上次窗口态:最大化/全屏');
|
||||
expect(appSource).toContain('// 3) 普通窗口:恢复用户调整过的尺寸和位置');
|
||||
});
|
||||
|
||||
it('captures window state on startup and lifecycle events instead of waiting only for the polling interval', () => {
|
||||
expect(appSource).toContain('const scheduleWindowStateSave = (delayMs = 120) => {');
|
||||
expect(appSource).toContain('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
|
||||
expect(appSource).toContain('if (hydrated) {');
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(320);');
|
||||
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
|
||||
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
|
||||
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair();');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(260);');
|
||||
expect(appSource).toContain('const cleanupWindowActivityScheduler = installNativeWindowActivityScheduler({');
|
||||
expect(appSource).toContain('fallbackIntervalMs: WINDOW_STATE_FALLBACK_INTERVAL_MS,');
|
||||
expect(appSource).toContain('resize: handleWindowRuntimeChange,');
|
||||
expect(appSource).toContain('focus: handleWindowRuntimeChange,');
|
||||
expect(appSource).toContain('pageshow: handleWindowRuntimeChange,');
|
||||
expect(appSource).toContain('pagehide: handleWindowLifecycleFlush,');
|
||||
expect(appSource).toContain('beforeunload: handleWindowLifecycleFlush,');
|
||||
expect(appSource).toContain('cleanupWindowActivityScheduler();');
|
||||
});
|
||||
|
||||
it('keeps the resize minimise probe independent from DPR debounce and clears it on unmount', () => {
|
||||
const scaleEffectStart = appSource.indexOf('let minimisedCheckTimer: number | null = null;');
|
||||
const dprScheduleStart = appSource.indexOf('const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {', scaleEffectStart);
|
||||
const activationScheduleStart = appSource.indexOf('const scheduleActivationFix = () => {', dprScheduleStart);
|
||||
const resizeHandlerStart = appSource.indexOf('const handleWindowResize = () => {', activationScheduleStart);
|
||||
const startupFixStart = appSource.indexOf('// Windows 冷启动:', resizeHandlerStart);
|
||||
const schedulerStart = appSource.indexOf('fallbackIntervalMs: WINDOWS_SCALE_FALLBACK_INTERVAL_MS,', startupFixStart);
|
||||
const cleanupStart = appSource.indexOf('return () => {', schedulerStart);
|
||||
const cleanupEnd = appSource.indexOf('cleanupWindowActivityScheduler();', cleanupStart);
|
||||
|
||||
expect([scaleEffectStart, dprScheduleStart, activationScheduleStart, resizeHandlerStart, startupFixStart, schedulerStart, cleanupStart, cleanupEnd]
|
||||
.every((index) => index >= 0)).toBe(true);
|
||||
expect(appSource.slice(dprScheduleStart, activationScheduleStart)).not.toContain('minimisedCheckTimer');
|
||||
const resizeHandlerSource = appSource.slice(resizeHandlerStart, startupFixStart);
|
||||
const minimiseProbeIndex = resizeHandlerSource.indexOf('rememberMinimisedStateSoon();');
|
||||
const dprCheckIndex = resizeHandlerSource.indexOf("scheduleDevicePixelRatioCheck('resize');");
|
||||
expect(minimiseProbeIndex).toBeGreaterThan(-1);
|
||||
expect(dprCheckIndex).toBeGreaterThan(minimiseProbeIndex);
|
||||
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('window.clearTimeout(minimisedCheckTimer);');
|
||||
expect(appSource.slice(cleanupStart, cleanupEnd)).toContain('minimisedCheckTimer = null;');
|
||||
});
|
||||
|
||||
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
|
||||
expect(appSource).toContain('const readCurrentVisibleViewport = () => resolveWailsWindowVisibleViewport(');
|
||||
expect(appSource).toContain('{ useMonitorLocalOrigin: isMacLikePlatform() }');
|
||||
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
|
||||
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
|
||||
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
|
||||
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
|
||||
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
|
||||
});
|
||||
|
||||
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
|
||||
@@ -293,6 +514,11 @@ describe('tool center menu entries', () => {
|
||||
expect(appSource).toContain("window.removeEventListener('keydown', handleGlobalShortcut, true);");
|
||||
});
|
||||
|
||||
it('consumes repeated AI shortcut events without toggling the newly focused child again', () => {
|
||||
expect(appSource).toContain("if (event.repeat && matchedAction === 'toggleAIPanel') {");
|
||||
expect(appSource).toContain('event.stopImmediatePropagation();');
|
||||
});
|
||||
|
||||
it('skips the native mac titlebar bridge when the current runtime does not expose it', () => {
|
||||
expect(appSource).toContain("const backendApp = (window as any).go?.app?.App;");
|
||||
expect(appSource).toContain("if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {");
|
||||
@@ -317,14 +543,28 @@ describe('global appearance tokens', () => {
|
||||
expect(appSource).toContain("setProperty('--gn-font-size-mono'");
|
||||
expect(appSource).toContain("setProperty('--gn-data-table-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-sidebar-tree-font-size'");
|
||||
expect(appSource).toContain("setProperty('--gn-sidebar-rail-scale'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height'");
|
||||
expect(appSource).toContain("setProperty('--gn-control-height-sm'");
|
||||
expect(appSource).toContain('fontFamily: resolvedUiFontFamily');
|
||||
expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily');
|
||||
expect(appSource).toContain('const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);');
|
||||
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_title')");
|
||||
expect(appSource).toContain("t('app.theme.appearance.sidebar_rail_scale_hint')");
|
||||
expect(appSource).toContain('v2SidebarRailScale: sanitizeV2SidebarRailScale(value)');
|
||||
expect(appSource).toContain("t('app.theme.data_table.font_size')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
|
||||
expect(v2ThemeCss).toContain('--gn-sidebar-rail-scale');
|
||||
expect(v2ThemeCss).toContain('font-size: calc(var(--gn-font-size-sm, 12px) * var(--gn-sidebar-rail-scale, 1));');
|
||||
expect(v2ThemeCss).toContain('width: calc(38px * var(--gn-v2-rail-scale));');
|
||||
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
|
||||
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('ListInstalledFontFamilies()');
|
||||
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
|
||||
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
|
||||
@@ -340,8 +580,17 @@ describe('global appearance tokens', () => {
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Font Settings');
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')");
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.title')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.description')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.hint')");
|
||||
expect(appSource).toContain("t('app.theme.query_template.reset_default')");
|
||||
expect(appSource).toContain("const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;");
|
||||
expect(appSource).toContain("onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ newQuerySqlTemplate: null })}");
|
||||
expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体');
|
||||
expect(appSource).not.toContain(',然后重启 GoNavi。');
|
||||
expect(appSource).not.toContain('新建查询默认 SQL');
|
||||
expect(appSource).not.toContain('清空后新建查询将保持空白');
|
||||
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
|
||||
expect(appSource).toContain('matchFontFamilyOption');
|
||||
expect(appSource).toContain('showSearch');
|
||||
@@ -356,4 +605,15 @@ describe('global appearance tokens', () => {
|
||||
expect(appSource).toContain('dataTableFontSize: dataTableFontSizeFollowsGlobal');
|
||||
expect(appSource).toContain('sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal');
|
||||
});
|
||||
|
||||
it('scales settings-center typography and keeps action heights consistent', () => {
|
||||
expect(appCss).toContain('--gn-font-size: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1));');
|
||||
expect(appCss).toContain('--gn-font-size-sm: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.86);');
|
||||
expect(appCss).toContain('--gn-font-size-xs: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.76);');
|
||||
expect(appCss).toContain('--gn-font-size-mono: calc(var(--gonavi-font-size, 14px) * var(--gn-ui-scale, 1) * 0.92);');
|
||||
expect(appCss).toContain('--gn-settings-font-secondary: var(--gn-font-size-sm);');
|
||||
expect(appCss).toMatch(/\.gonavi-settings-center-modal \.ant-btn\s*\{[^}]*font-size:\s*var\(--gn-settings-font-caption\)/s);
|
||||
expect(appCss).toMatch(/\.gonavi-settings-center-modal \.gonavi-ai-provider-add\.ant-btn\s*\{[^}]*height:\s*var\(--gn-control-height, 32px\) !important;/s);
|
||||
expect(appCss).toMatch(/\.gonavi-theme-settings \.gonavi-settings-section-hint\s*\{[^}]*font-size:\s*var\(--gn-settings-font-secondary/s);
|
||||
});
|
||||
});
|
||||
|
||||
7265
frontend/src/App.tsx
@@ -13,40 +13,73 @@ describe('UI version switch placement', () => {
|
||||
expect(appSource).toContain("import './v2-theme.css';");
|
||||
});
|
||||
|
||||
it('keeps the UI version switch in theme mode and outside macOS-only settings', () => {
|
||||
it('keeps light/dark first with compact previews and UI version preview tiles', () => {
|
||||
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
|
||||
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
|
||||
const customThemeIndex = appSource.indexOf("t('app.theme.custom.title')", lightThemeIndex);
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(") : themeModalSection === 'appearance' ? (", themeBranchIndex);
|
||||
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
|
||||
|
||||
expect(themeBranchIndex).toBeGreaterThan(-1);
|
||||
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
|
||||
expect(lightThemeIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(customThemeIndex).toBeGreaterThan(lightThemeIndex);
|
||||
expect(customThemeIndex).toBeLessThan(uiVersionIndex);
|
||||
expect(uiVersionIndex).toBeGreaterThan(lightThemeIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
|
||||
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')");
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
|
||||
expect(appSource).toContain('renderUiVersionPreview');
|
||||
expect(appSource).toContain('gonavi-settings-ui-version-grid');
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key })}");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.beta_warning')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.platform_hint')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
|
||||
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
|
||||
expect(appSource).toContain("appearance.uiVersion === 'v2' ? (");
|
||||
});
|
||||
|
||||
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')");
|
||||
const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex);
|
||||
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
|
||||
it('uses compact previews only in v2 theme settings layout', () => {
|
||||
expect(appSource).toContain('renderThemeSettingsContentV2');
|
||||
expect(appSource).toContain('renderThemeSettingsContentLegacy');
|
||||
expect(appSource).toContain('isV2Ui ? renderThemeSettingsContentV2() : renderThemeSettingsContentLegacy()');
|
||||
expect(appSource).toContain('className="gonavi-theme-settings"');
|
||||
expect(appSource).toContain('gonavi-settings-mode-grid');
|
||||
expect(appSource).toContain('gonavi-settings-mode-tile');
|
||||
expect(appSource).toContain('renderThemeModePreview');
|
||||
expect(appSource).toContain("preview: 'light' as const");
|
||||
expect(appSource).toContain('ThemeSettingsSlider');
|
||||
expect(appSource).toContain("unit=\"percent\"");
|
||||
expect(appSource).toContain('gonavi-settings-tabs');
|
||||
expect(appSource).toContain('gonavi-settings-tab');
|
||||
expect(appSource).toContain('gonavi-settings-pill');
|
||||
expect(appSource).toContain('<CustomThemeManager />');
|
||||
expect(appSource).toContain('<CustomThemeManager legacyMode />');
|
||||
// 旧版布局仍保留侧栏导航
|
||||
expect(appSource).toContain("gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0'");
|
||||
});
|
||||
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')");
|
||||
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')");
|
||||
expect(uiVersionBlock).toContain('CheckOutlined');
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(uiVersionBlock).toContain('<Segmented');
|
||||
it('keeps theme and UI version radio tiles on one tab stop with complete keyboard navigation', () => {
|
||||
const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? (");
|
||||
const appearanceBranchIndex = appSource.indexOf(") : themeModalSection === 'appearance' ? (", themeBranchIndex);
|
||||
const themeSource = appSource.slice(themeBranchIndex, appearanceBranchIndex);
|
||||
|
||||
expect(themeSource).toContain(']).map((item, itemIndex, themeItems) => {');
|
||||
expect(themeSource).toContain('tabIndex={themePreference === item.key ? 0 : -1}');
|
||||
expect(themeSource).toContain('selectPresetTheme(themeItems[nextIndex].key);');
|
||||
expect(themeSource).toContain(']).map((item, itemIndex, uiVersionItems) => {');
|
||||
expect(themeSource).toContain('tabIndex={active ? 0 : -1}');
|
||||
expect(themeSource).toContain('setAppearance({ uiVersion: uiVersionItems[nextIndex].key });');
|
||||
expect(themeSource.match(/\['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'\]/g)).toHaveLength(2);
|
||||
expect(themeSource.match(/querySelectorAll<HTMLElement>\('\[role="radio"\]'\)/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('isolates workspace settings and remembers the active section', () => {
|
||||
expect(appSource).toContain("value: 'workspace'");
|
||||
expect(appSource).toContain("t('app.theme.nav.workspace.title')");
|
||||
expect(appSource).toContain("setThemeModalSection('workspace')");
|
||||
expect(appSource).toContain("themeModalSection !== 'workspace'");
|
||||
expect(appSource).toContain('gonavi.themeSettingsSection');
|
||||
});
|
||||
|
||||
it('localizes the v2 sidebar search mode copy', () => {
|
||||
|
||||
46
frontend/src/App.update-quit-guard.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('restart-to-update unsaved SQL guard', () => {
|
||||
it('runs the confirmed update action through every application quit path', () => {
|
||||
expect(appSource).toContain('type ApplicationQuitConfirmedAction = () => Promise<boolean>;');
|
||||
expect(appSource).toContain('const handleApplicationQuitRequest = useCallback(async (confirmedAction?: ApplicationQuitConfirmedAction) => {');
|
||||
expect(appSource).toContain('const runConfirmedAction = async (): Promise<boolean> => {');
|
||||
const runnerStart = appSource.indexOf('const runConfirmedAction = async (): Promise<boolean> => {');
|
||||
const runnerEnd = appSource.indexOf('\n let targets;', runnerStart);
|
||||
const runnerSource = appSource.slice(runnerStart, runnerEnd);
|
||||
expect(runnerSource).toContain('accepted = await confirmedAction();');
|
||||
expect(runnerSource).toContain('await forceQuitApplication();\n accepted = true;');
|
||||
expect(runnerSource).toContain('} catch (error) {\n resetApplicationQuitRequest();');
|
||||
expect(runnerSource).toContain('if (!accepted) {\n resetApplicationQuitRequest();');
|
||||
expect(runnerSource).toContain('return accepted;');
|
||||
expect(appSource).toContain('if (targets.length === 0) {\n await runConfirmedAction();');
|
||||
expect(appSource).toContain('void runConfirmedAction();');
|
||||
const saveIndex = appSource.indexOf('await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);');
|
||||
const actionAfterSaveIndex = appSource.indexOf('await runConfirmedAction();', saveIndex);
|
||||
expect(saveIndex).toBeGreaterThan(-1);
|
||||
expect(actionAfterSaveIndex).toBeGreaterThan(saveIndex);
|
||||
});
|
||||
|
||||
it('confirms closing every Windows instance before entering the unsaved SQL guard', () => {
|
||||
expect(appSource).toContain('const handleInstallUpdateRequest = useCallback(async () => {');
|
||||
expect(appSource).toContain("if (installMode === 'portable' || installMode === 'msi') {");
|
||||
expect(appSource).toContain("title: t('app.about.update_install_confirm.close_instances_title')");
|
||||
expect(appSource).toContain("content: t('app.about.update_install_confirm.close_instances_content')");
|
||||
expect(appSource).toContain("okText: t('app.about.update_install_confirm.close_instances_ok')");
|
||||
expect(appSource).toContain("cancelText: t('common.cancel')");
|
||||
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(true));');
|
||||
expect(appSource).toContain('await handleApplicationQuitRequest(() => handleInstallFromProgress(false));');
|
||||
expect(appSource.match(/void handleInstallUpdateRequest\(\);/g)).toHaveLength(2);
|
||||
expect(appSource).not.toContain('onClick={handleInstallFromProgress}');
|
||||
expect(appSource).toContain("updateInstallAction === 'install-and-restart'");
|
||||
expect(appSource).toContain("updateInstallAction === 'launch-installer'");
|
||||
expect(appSource.match(/\{updateInstallActionLabel\}/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
47
frontend/src/bootstrap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { loadCatalog } from './i18n/catalog';
|
||||
import { resolveLanguage } from './i18n/resolveLanguage';
|
||||
import { isNativeDetachedWindowRoute } from './utils/nativeDetachedWindowRoute';
|
||||
|
||||
type DetachedBootstrapRuntime = {
|
||||
loadBootstrap?: () => Promise<{
|
||||
payload?: { storeState?: { languagePreference?: string } };
|
||||
}>;
|
||||
};
|
||||
|
||||
const readBrowserLanguages = (): string[] => {
|
||||
if (typeof navigator === 'undefined') return [];
|
||||
if (Array.isArray(navigator.languages) && navigator.languages.length > 0) {
|
||||
return [...navigator.languages];
|
||||
}
|
||||
return navigator.language ? [navigator.language] : [];
|
||||
};
|
||||
|
||||
const readDetachedLanguagePreference = async (): Promise<string | undefined> => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const runtime = (window as typeof window & {
|
||||
__GONAVI_DETACHED__?: DetachedBootstrapRuntime;
|
||||
}).__GONAVI_DETACHED__;
|
||||
if (typeof runtime?.loadBootstrap !== 'function') return undefined;
|
||||
try {
|
||||
const bootstrap = await runtime.loadBootstrap();
|
||||
return bootstrap.payload?.storeState?.languagePreference;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const loadEntry = async (): Promise<void> => {
|
||||
const detached = isNativeDetachedWindowRoute();
|
||||
const preference = detached ? await readDetachedLanguagePreference() : undefined;
|
||||
const language = resolveLanguage(preference, readBrowserLanguages());
|
||||
await loadCatalog(language);
|
||||
if (detached) {
|
||||
await import('./nativeDetachedMain');
|
||||
} else {
|
||||
await import('./main');
|
||||
}
|
||||
};
|
||||
|
||||
void loadEntry().catch((error) => {
|
||||
console.error('[GoNavi] Failed to load frontend entry', error);
|
||||
});
|
||||