diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 40c2497..8eb0dc7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -30,7 +30,8 @@
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
- "vite": "^5.0.8"
+ "vite": "^5.0.8",
+ "vitest": "^3.2.4"
}
},
"node_modules/@ant-design/colors": {
@@ -1513,6 +1514,24 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1594,6 +1613,121 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/antd": {
"version": "5.29.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1665,6 +1799,16 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -1709,6 +1853,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001766",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
@@ -1730,6 +1884,33 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -1803,6 +1984,16 @@
}
}
},
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
@@ -1826,6 +2017,13 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1875,6 +2073,44 @@
"node": ">=6"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1953,6 +2189,13 @@
"loose-envify": "cli.js"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1963,6 +2206,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
@@ -2057,6 +2310,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2064,6 +2334,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -2892,6 +3175,13 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2915,18 +3205,52 @@
"sql-formatter": "bin/sql-formatter-cli.cjs"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/string-convert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
"integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
"license": "MIT"
},
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -2942,6 +3266,67 @@
"node": ">=12.22"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
@@ -3081,6 +3466,119 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "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",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 2690060..52dbb4a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@@ -31,6 +32,7 @@
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
- "vite": "^5.0.8"
+ "vite": "^5.0.8",
+ "vitest": "^3.2.4"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/package.json.md5 b/frontend/package.json.md5
index 0f8f4fe..4df5c44 100755
--- a/frontend/package.json.md5
+++ b/frontend/package.json.md5
@@ -1 +1 @@
-5b8157374dae5f9340e31b2d0bd2c00e
\ No newline at end of file
+594ebbc6a946fc76ba11bee7b3f53282
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 31cf7c5..5d89559 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
+import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import {
SHORTCUT_ACTION_META,
@@ -24,7 +25,7 @@ import {
isShortcutMatch,
normalizeShortcutCombo,
} from './utils/shortcuts';
-import { ConfigureGlobalProxy, SetWindowTranslucency } from '../wailsjs/go/app/App';
+import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import './App.css';
const { Sider, Content } = Layout;
@@ -722,6 +723,19 @@ function App() {
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
const isWindowsRuntime = runtimePlatform === 'windows'
|| (runtimePlatform === '' && isWindowsPlatform());
+ const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
+
+ useEffect(() => {
+ if (!isStoreHydrated || !isMacRuntime) {
+ return;
+ }
+
+ try {
+ void SetMacNativeWindowControls(useNativeMacWindowControls).catch(() => undefined);
+ } catch (e) {
+ console.warn('Wails API: SetMacNativeWindowControls unavailable', e);
+ }
+ }, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
@@ -1169,6 +1183,10 @@ function App() {
await WindowUnfullscreen();
return;
}
+ if (useNativeMacWindowControls && isMacRuntime) {
+ await WindowFullscreen();
+ return;
+ }
await WindowToggleMaximise();
} catch (_) {
// ignore
@@ -1180,6 +1198,9 @@ function App() {
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
+ if (useNativeMacWindowControls) {
+ return;
+ }
void handleTitleBarWindowToggle();
};
@@ -1330,8 +1351,34 @@ function App() {
};
}, []);
+ useEffect(() => {
+ if (!isMacRuntime || !useNativeMacWindowControls) {
+ return;
+ }
+
+ const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
+ if (!shouldSuppressMacNativeEscapeExit(isMacRuntime, useNativeMacWindowControls, useStore.getState().windowState === 'fullscreen', event)) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ window.addEventListener('keydown', handleMacNativeEscapeCapture, true);
+ return () => {
+ window.removeEventListener('keydown', handleMacNativeEscapeCapture, true);
+ };
+ }, [isMacRuntime, useNativeMacWindowControls]);
+
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
+ if (shouldHandleMacNativeFullscreenShortcut(isMacRuntime, useNativeMacWindowControls, event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ void handleTitleBarWindowToggle();
+ return;
+ }
+
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
const binding = shortcutOptions[action];
if (!binding?.enabled) {
@@ -1376,7 +1423,7 @@ function App() {
return () => {
window.removeEventListener('keydown', handleGlobalShortcut);
};
- }, [handleNewQuery, shortcutOptions, themeMode, setTheme]);
+ }, [handleNewQuery, handleTitleBarWindowToggle, isMacRuntime, shortcutOptions, themeMode, setTheme, useNativeMacWindowControls]);
useEffect(() => {
if (!capturingShortcutAction) {
@@ -1523,40 +1570,45 @@ function App() {
userSelect: 'none',
WebkitAppRegion: 'drag', // Wails drag region
'--wails-draggable': 'drag',
- paddingLeft: Math.max(12, Math.round(16 * effectiveUiScale)),
+ paddingLeft: getMacNativeTitlebarPaddingLeft(effectiveUiScale, useNativeMacWindowControls),
+ paddingRight: getMacNativeTitlebarPaddingRight(effectiveUiScale, useNativeMacWindowControls),
fontSize: tokenFontSize
} as any}
>
-
+
{/* Logo can be added here if available */}
GoNavi
-
e.stopPropagation()}
- style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
- >
- }
- style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
- onClick={WindowMinimise}
- />
- }
- style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
- onClick={() => { void handleTitleBarWindowToggle(); }}
- />
- }
- danger
- className="titlebar-close-btn"
- style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
- onClick={Quit}
- />
-
+ {useNativeMacWindowControls ? (
+
+ ) : (
+
e.stopPropagation()}
+ style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
+ >
+ }
+ style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
+ onClick={WindowMinimise}
+ />
+ }
+ style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
+ onClick={() => { void handleTitleBarWindowToggle(); }}
+ />
+ }
+ danger
+ className="titlebar-close-btn"
+ style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
+ onClick={Quit}
+ />
+
+ )}
@@ -2008,6 +2060,24 @@ function App() {
+ {isMacRuntime ? (
+
+
macOS 窗口控制
+
+
+
使用 macOS 原生窗口控制
+
启用后显示左上角红黄绿按钮,并优先使用 macOS 原生全屏行为。
+
+
setAppearance({ useNativeMacWindowControls: checked })}
+ />
+
+
+ * 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认
+
+
+ ) : null}
启动窗口
@@ -2020,13 +2090,13 @@ function App() {
diff --git a/frontend/src/components/dataGridLayout.test.ts b/frontend/src/components/dataGridLayout.test.ts
index e52b23c..476e863 100644
--- a/frontend/src/components/dataGridLayout.test.ts
+++ b/frontend/src/components/dataGridLayout.test.ts
@@ -1,69 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
import { calculateTableBodyBottomPadding, calculateVirtualTableScrollX } from './dataGridLayout';
-const assertEqual = (actual: unknown, expected: unknown, message: string) => {
- if (actual !== expected) {
- throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
- }
-};
+describe('dataGridLayout helpers', () => {
+ it('returns zero bottom padding without horizontal overflow', () => {
+ expect(calculateTableBodyBottomPadding({
+ hasHorizontalOverflow: false,
+ floatingScrollbarHeight: 10,
+ floatingScrollbarGap: 6,
+ })).toBe(0);
+ });
-assertEqual(
- calculateTableBodyBottomPadding({
- hasHorizontalOverflow: false,
- floatingScrollbarHeight: 10,
- floatingScrollbarGap: 6,
- }),
- 0,
- '无横向滚动条时不应增加底部间距'
-);
+ it('adds safe area when horizontal overflow exists', () => {
+ expect(calculateTableBodyBottomPadding({
+ hasHorizontalOverflow: true,
+ floatingScrollbarHeight: 10,
+ floatingScrollbarGap: 6,
+ })).toBe(28);
+ expect(calculateTableBodyBottomPadding({
+ hasHorizontalOverflow: true,
+ floatingScrollbarHeight: 14,
+ floatingScrollbarGap: 4,
+ })).toBe(30);
+ });
-assertEqual(
- calculateTableBodyBottomPadding({
- hasHorizontalOverflow: true,
- floatingScrollbarHeight: 10,
- floatingScrollbarGap: 6,
- }),
- 28,
- '默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
-);
-
-assertEqual(
- calculateTableBodyBottomPadding({
- hasHorizontalOverflow: true,
- floatingScrollbarHeight: 14,
- floatingScrollbarGap: 4,
- }),
- 30,
- '较粗滚动条场景下应同步放大底部安全区'
-);
-
-assertEqual(
- calculateVirtualTableScrollX({
- totalWidth: 646,
- tableViewportWidth: 1200,
- isMacLike: false,
- }),
- 1200,
- '列总宽小于视口时应按视口宽度返回 scroll.x,避免 header/body 走两套宽度'
-);
-
-assertEqual(
- calculateVirtualTableScrollX({
- totalWidth: 646,
- tableViewportWidth: 0,
- isMacLike: false,
- }),
- 646,
- '未拿到视口宽度时应退回列宽总和'
-);
-
-assertEqual(
- calculateVirtualTableScrollX({
- totalWidth: 1200,
- tableViewportWidth: 800,
- isMacLike: true,
- }),
- 1202,
- 'macOS 横向溢出时仍需额外预留 2px 以稳定滚动轨道'
-);
-
-console.log('dataGridLayout tests passed');
+ it('keeps scroll width aligned with viewport or content width', () => {
+ expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 1200, isMacLike: false })).toBe(1200);
+ expect(calculateVirtualTableScrollX({ totalWidth: 646, tableViewportWidth: 0, isMacLike: false })).toBe(646);
+ expect(calculateVirtualTableScrollX({ totalWidth: 1200, tableViewportWidth: 800, isMacLike: true })).toBe(1202);
+ });
+});
diff --git a/frontend/src/components/redisViewerTree.test.ts b/frontend/src/components/redisViewerTree.test.ts
index 0db9242..7a558cb 100644
--- a/frontend/src/components/redisViewerTree.test.ts
+++ b/frontend/src/components/redisViewerTree.test.ts
@@ -1,3 +1,5 @@
+import { describe, expect, it } from 'vitest';
+
import type { RedisKeyInfo } from '../types';
import {
applyRenamedRedisKeyState,
@@ -7,20 +9,6 @@ import {
isGroupFullyChecked,
} from './redisViewerTree';
-const assert = (condition: unknown, message: string) => {
- if (!condition) {
- throw new Error(message);
- }
-};
-
-const assertEqual = (actual: unknown, expected: unknown, message: string) => {
- const actualText = JSON.stringify(actual);
- const expectedText = JSON.stringify(expected);
- if (actualText !== expectedText) {
- throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
- }
-};
-
const sampleKeys: RedisKeyInfo[] = [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: -1 },
@@ -28,78 +16,64 @@ const sampleKeys: RedisKeyInfo[] = [
{ key: 'misc', type: 'set', ttl: -1 },
];
-const tree = buildRedisKeyTree(sampleKeys, true);
-const appGroup = tree.treeData.find((node) => node.key === 'group:app');
-const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
+describe('redisViewerTree helpers', () => {
+ it('builds grouped redis key tree and group selection state', () => {
+ const tree = buildRedisKeyTree(sampleKeys, true);
+ const appGroup = tree.treeData.find((node) => node.key === 'group:app');
+ const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
-assert(appGroup, '应生成 group:app 节点');
-assert(userGroup, '应生成 group:app:user 节点');
-assertEqual(
- appGroup?.descendantRawKeys,
- ['app:order:1', 'app:user:1', 'app:user:2'],
- 'app 分组应收集全部后代 key'
-);
+ expect(appGroup).toBeTruthy();
+ expect(userGroup).toBeTruthy();
+ expect(appGroup?.descendantRawKeys).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
-const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
-assertEqual(
- selectedAfterGroupCheck,
- ['app:order:1', 'app:user:1', 'app:user:2'],
- '勾选分组应递归选中全部后代 key'
-);
+ const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
+ expect(selectedAfterGroupCheck).toEqual(['app:order:1', 'app:user:1', 'app:user:2']);
-const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
-assertEqual(
- checkedState.checked,
- ['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
- '全部后代已选中时,父分组和叶子都应进入 checked'
-);
-assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
-assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
+ const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
+ expect(checkedState.checked).toEqual(['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app']);
+ expect(checkedState.halfChecked).toEqual([]);
+ expect(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck)).toBe(true);
-const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
-assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
-assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
+ const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
+ expect(selectedAfterGroupUncheck).toEqual([]);
+ expect(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck)).toBe(false);
+ });
-const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
-assertEqual(
- partialState.halfChecked,
- ['group:app:user', 'group:app'],
- '仅部分后代选中时,相关分组应进入 halfChecked'
-);
-assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
+ it('marks parent groups as half checked for partial selection', () => {
+ const tree = buildRedisKeyTree(sampleKeys, true);
+ const appGroup = tree.treeData.find((node) => node.key === 'group:app');
+ const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
-const renamedState = applyRenamedRedisKeyState(
- {
- keys: sampleKeys,
- selectedKey: 'app:user:2',
- selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
- },
- 'app:user:2',
- 'app:user:200'
-);
+ expect(partialState.halfChecked).toEqual(['group:app:user', 'group:app']);
+ expect(isGroupFullyChecked(appGroup!, ['app:user:1'])).toBe(false);
+ });
-assertEqual(
- renamedState.keys.map((item) => item.key),
- ['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
- '重命名后 keys 列表应替换旧 key'
-);
-assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
-assertEqual(
- renamedState.selectedKeys,
- ['app:user:1', 'app:user:200', 'misc'],
- '批量选中集合中的旧 key 应映射为新 key'
-);
+ it('updates selected keys consistently after rename', () => {
+ const renamedState = applyRenamedRedisKeyState(
+ {
+ keys: sampleKeys,
+ selectedKey: 'app:user:2',
+ selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
+ },
+ 'app:user:2',
+ 'app:user:200'
+ );
-const unrelatedRenameState = applyRenamedRedisKeyState(
- {
- keys: sampleKeys,
- selectedKey: 'misc',
- selectedKeys: ['app:user:1'],
- },
- 'app:order:1',
- 'app:order:9'
-);
-assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
-assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
+ expect(renamedState.keys.map((item) => item.key)).toEqual(['app:user:1', 'app:user:200', 'app:order:1', 'misc']);
+ expect(renamedState.selectedKey).toBe('app:user:200');
+ expect(renamedState.selectedKeys).toEqual(['app:user:1', 'app:user:200', 'misc']);
-console.log('redisViewerTree tests passed');
+ const unrelatedRenameState = applyRenamedRedisKeyState(
+ {
+ keys: sampleKeys,
+ selectedKey: 'misc',
+ selectedKeys: ['app:user:1'],
+ },
+ 'app:order:1',
+ 'app:order:9'
+ );
+
+ expect(unrelatedRenameState.selectedKey).toBe('misc');
+ expect(unrelatedRenameState.selectedKeys).toEqual(['app:user:1']);
+ });
+});
diff --git a/frontend/src/components/redisViewerWorkbenchTheme.test.ts b/frontend/src/components/redisViewerWorkbenchTheme.test.ts
index f53d144..8a64063 100644
--- a/frontend/src/components/redisViewerWorkbenchTheme.test.ts
+++ b/frontend/src/components/redisViewerWorkbenchTheme.test.ts
@@ -1,50 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
-const assertEqual = (actual: unknown, expected: unknown, message: string) => {
- if (actual !== expected) {
- throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
- }
-};
+describe('buildRedisWorkbenchTheme', () => {
+ it('builds dark redis workbench theme', () => {
+ const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14 });
+ expect(darkTheme.isDark).toBe(true);
+ expect(darkTheme.panelBg).toMatch(/^rgba\(/);
+ expect(darkTheme.toolbarPrimaryBg).toMatch(/^linear-gradient\(/);
+ expect(darkTheme.actionDangerBg).not.toBe(darkTheme.actionSecondaryBg);
+ expect(darkTheme.treeSelectedBg).not.toBe(darkTheme.treeHoverBg);
+ expect(darkTheme.appBg).toMatch(/rgba\(15, 15, 17,/);
+ expect(darkTheme.panelBg).toMatch(/rgba\(24, 24, 28,/);
+ expect(darkTheme.panelBgStrong).toMatch(/rgba\(31, 31, 36,/);
+ expect(darkTheme.backdropFilter).toBe('blur(14px)');
+ });
-const assertNotEqual = (actual: unknown, expected: unknown, message: string) => {
- if (actual === expected) {
- throw new Error(`${message}\nactual: ${String(actual)}\nnotExpected: ${String(expected)}`);
- }
-};
-
-const assertMatch = (value: string, pattern: RegExp, message: string) => {
- if (!pattern.test(value)) {
- throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
- }
-};
-
-const darkTheme = buildRedisWorkbenchTheme({
- darkMode: true,
- opacity: 0.72,
- blur: 14,
+ it('builds light redis workbench theme', () => {
+ const lightTheme = buildRedisWorkbenchTheme({ darkMode: false, opacity: 1, blur: 0 });
+ expect(lightTheme.isDark).toBe(false);
+ expect(lightTheme.panelBg).toMatch(/^rgba\(/);
+ expect(lightTheme.contentEmptyBg).toMatch(/^linear-gradient\(/);
+ expect(lightTheme.textPrimary).not.toBe(lightTheme.textSecondary);
+ expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg);
+ expect(lightTheme.backdropFilter).toBe('none');
+ });
});
-
-assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
-assertMatch(darkTheme.panelBg, /^rgba\(/, 'dark 主题面板背景应为 rgba');
-assertMatch(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/, '工具栏主按钮应使用渐变背景');
-assertNotEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg, '危险态按钮背景不应与普通按钮相同');
-assertNotEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg, '树节点选中态与悬浮态不应相同');
-assertMatch(darkTheme.appBg, /rgba\(15, 15, 17,/, 'dark 背景应保持中性黑基底');
-assertMatch(darkTheme.panelBg, /rgba\(24, 24, 28,/, 'dark 面板背景应保持中性黑灰');
-assertMatch(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/, 'dark 强面板背景应保持中性黑灰');
-assertEqual(darkTheme.backdropFilter, 'blur(14px)', 'blur 参数应映射为 backdropFilter');
-
-const lightTheme = buildRedisWorkbenchTheme({
- darkMode: false,
- opacity: 1,
- blur: 0,
-});
-
-assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
-assertMatch(lightTheme.panelBg, /^rgba\(/, 'light 主题面板背景应为 rgba');
-assertMatch(lightTheme.contentEmptyBg, /^linear-gradient\(/, 'light 空状态背景应为渐变');
-assertNotEqual(lightTheme.textPrimary, lightTheme.textSecondary, '主次文本颜色应区分');
-assertNotEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg, '状态 tag 应区分普通与弱化样式');
-assertEqual(lightTheme.backdropFilter, 'none', 'blur=0 时 backdropFilter 应为 none');
-
-console.log('redisViewerWorkbenchTheme tests passed');
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index e081769..6a87182 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -10,7 +10,7 @@ import {
sanitizeShortcutOptions,
} from './utils/shortcuts';
-const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0 };
+const DEFAULT_APPEARANCE = { enabled: true, opacity: 1.0, blur: 0, useNativeMacWindowControls: false };
const DEFAULT_UI_SCALE = 1.0;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
@@ -25,7 +25,7 @@ const MAX_HOST_ENTRY_LENGTH = 512;
const MAX_HOST_ENTRIES = 64;
const DEFAULT_TIMEOUT_SECONDS = 30;
const MAX_TIMEOUT_SECONDS = 3600;
-const PERSIST_VERSION = 6;
+const PERSIST_VERSION = 7;
const DEFAULT_CONNECTION_TYPE = 'mysql';
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
enabled: false,
@@ -405,7 +405,7 @@ interface AppState {
activeContext: { connectionId: string; dbName: string } | null;
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
- appearance: { enabled: boolean; opacity: number; blur: number };
+ appearance: { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean };
uiScale: number;
fontSize: number;
startupFullscreen: boolean;
@@ -450,7 +450,7 @@ interface AppState {
deleteQuery: (id: string) => void;
setTheme: (theme: 'light' | 'dark') => void;
- setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void;
+ setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }>) => void;
setUiScale: (scale: number) => void;
setFontSize: (size: number) => void;
setStartupFullscreen: (enabled: boolean) => void;
@@ -561,9 +561,9 @@ const sanitizeTableHiddenColumns = (value: unknown): Record =>
};
const sanitizeAppearance = (
- appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined,
+ appearance: Partial<{ enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean }> | undefined,
version: number
-): { enabled: boolean; opacity: number; blur: number } => {
+): { enabled: boolean; opacity: number; blur: number; useNativeMacWindowControls: boolean } => {
if (!appearance || typeof appearance !== 'object') {
return { ...DEFAULT_APPEARANCE };
}
@@ -571,6 +571,9 @@ const sanitizeAppearance = (
enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled,
opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity,
blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur,
+ useNativeMacWindowControls: typeof appearance.useNativeMacWindowControls === 'boolean'
+ ? appearance.useNativeMacWindowControls
+ : DEFAULT_APPEARANCE.useNativeMacWindowControls,
};
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
return { ...DEFAULT_APPEARANCE };
diff --git a/frontend/src/utils/appearance.test.ts b/frontend/src/utils/appearance.test.ts
new file mode 100644
index 0000000..89f19f0
--- /dev/null
+++ b/frontend/src/utils/appearance.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
+
+describe('appearance helpers', () => {
+ it('falls back to opaque non-blurred appearance when disabled', () => {
+ expect(resolveAppearanceValues({ enabled: false, opacity: 0.3, blur: 12 })).toEqual({ opacity: 1, blur: 0 });
+ });
+
+ it('preserves configured values when appearance is enabled', () => {
+ expect(resolveAppearanceValues({ enabled: true, opacity: 0.72, blur: 9 })).toEqual({ opacity: 0.72, blur: 9 });
+ });
+
+ it('caps opacity at full opacity upper bound', () => {
+ expect(normalizeOpacityForPlatform(2)).toBe(1);
+ });
+
+ it('never returns negative blur and formats blur filter correctly', () => {
+ expect(normalizeBlurForPlatform(-4)).toBe(0);
+ expect(blurToFilter(0)).toBeUndefined();
+ expect(blurToFilter(8)).toBe('blur(8px)');
+ });
+});
diff --git a/frontend/src/utils/macWindow.test.ts b/frontend/src/utils/macWindow.test.ts
new file mode 100644
index 0000000..5e1d0d0
--- /dev/null
+++ b/frontend/src/utils/macWindow.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ getMacNativeTitlebarPaddingLeft,
+ getMacNativeTitlebarPaddingRight,
+ shouldHandleMacNativeFullscreenShortcut,
+ shouldSuppressMacNativeEscapeExit,
+} from './macWindow';
+
+describe('macWindow helpers', () => {
+ it('uses compact padding when native controls are disabled', () => {
+ expect(getMacNativeTitlebarPaddingLeft(1, false)).toBe(16);
+ expect(getMacNativeTitlebarPaddingRight(1, false)).toBe(0);
+ });
+
+ it('reserves traffic-light safe area when native controls are enabled', () => {
+ expect(getMacNativeTitlebarPaddingLeft(1, true)).toBe(96);
+ expect(getMacNativeTitlebarPaddingRight(1, true)).toBe(16);
+ });
+
+ it('keeps minimum safe area under small ui scales', () => {
+ expect(getMacNativeTitlebarPaddingLeft(0.5, true)).toBe(88);
+ expect(getMacNativeTitlebarPaddingRight(0.5, true)).toBe(12);
+ });
+
+ it('matches Control+Command+F only for mac native mode', () => {
+ expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(true);
+ expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'F' })).toBe(true);
+ });
+
+ it('rejects conflicting modifiers and non-target keys', () => {
+ expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: true, key: 'f' })).toBe(false);
+ expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: false, altKey: false, key: 'f' })).toBe(false);
+ expect(shouldHandleMacNativeFullscreenShortcut(false, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
+ expect(shouldHandleMacNativeFullscreenShortcut(true, false, { ctrlKey: true, metaKey: true, altKey: false, key: 'f' })).toBe(false);
+ expect(shouldHandleMacNativeFullscreenShortcut(true, true, { ctrlKey: true, metaKey: true, altKey: false, key: 'g' })).toBe(false);
+ });
+
+ it('suppresses Escape only in mac native fullscreen mode', () => {
+ expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: false })).toBe(true);
+ expect(shouldSuppressMacNativeEscapeExit(true, true, false, { key: 'Escape', defaultPrevented: false })).toBe(false);
+ expect(shouldSuppressMacNativeEscapeExit(true, false, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
+ expect(shouldSuppressMacNativeEscapeExit(false, true, true, { key: 'Escape', defaultPrevented: false })).toBe(false);
+ expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Enter', defaultPrevented: false })).toBe(false);
+ expect(shouldSuppressMacNativeEscapeExit(true, true, true, { key: 'Escape', defaultPrevented: true })).toBe(false);
+ });
+});
diff --git a/frontend/src/utils/macWindow.ts b/frontend/src/utils/macWindow.ts
new file mode 100644
index 0000000..bb156f8
--- /dev/null
+++ b/frontend/src/utils/macWindow.ts
@@ -0,0 +1,42 @@
+export const getMacNativeTitlebarPaddingLeft = (uiScale: number, enabled: boolean): number => {
+ if (!enabled) {
+ return Math.max(12, Math.round(16 * uiScale));
+ }
+ return Math.max(88, Math.round(96 * uiScale));
+};
+
+export const getMacNativeTitlebarPaddingRight = (uiScale: number, enabled: boolean): number => {
+ if (!enabled) {
+ return 0;
+ }
+ return Math.max(12, Math.round(16 * uiScale));
+};
+
+export const shouldHandleMacNativeFullscreenShortcut = (
+ isMacRuntime: boolean,
+ useNativeMacWindowControls: boolean,
+ event: Pick,
+): boolean => {
+ if (!isMacRuntime || !useNativeMacWindowControls) {
+ return false;
+ }
+ if (!event.ctrlKey || !event.metaKey || event.altKey) {
+ return false;
+ }
+ return String(event.key || '').toLowerCase() === 'f';
+};
+
+export const shouldSuppressMacNativeEscapeExit = (
+ isMacRuntime: boolean,
+ useNativeMacWindowControls: boolean,
+ isFullscreen: boolean,
+ event: Pick,
+): boolean => {
+ if (!isMacRuntime || !useNativeMacWindowControls || !isFullscreen) {
+ return false;
+ }
+ if (event.defaultPrevented) {
+ return false;
+ }
+ return String(event.key || '') === 'Escape';
+};
diff --git a/frontend/src/utils/overlayWorkbenchTheme.test.ts b/frontend/src/utils/overlayWorkbenchTheme.test.ts
index 2979180..0a26027 100644
--- a/frontend/src/utils/overlayWorkbenchTheme.test.ts
+++ b/frontend/src/utils/overlayWorkbenchTheme.test.ts
@@ -1,27 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
-const assertEqual = (actual: unknown, expected: unknown, message: string) => {
- if (actual !== expected) {
- throw new Error(`${message}\nactual: ${String(actual)}\nexpected: ${String(expected)}`);
- }
-};
+describe('buildOverlayWorkbenchTheme', () => {
+ it('builds dark theme tokens', () => {
+ const darkTheme = buildOverlayWorkbenchTheme(true);
+ expect(darkTheme.isDark).toBe(true);
+ expect(darkTheme.shellBg).toMatch(/rgba\(15, 15, 17,/);
+ expect(darkTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
+ expect(darkTheme.iconColor).toBe('#ffd666');
+ });
-const assertMatch = (value: string, pattern: RegExp, message: string) => {
- if (!pattern.test(value)) {
- throw new Error(`${message}\nactual: ${value}\npattern: ${String(pattern)}`);
- }
-};
-
-const darkTheme = buildOverlayWorkbenchTheme(true);
-assertEqual(darkTheme.isDark, true, 'dark 主题标记应为 true');
-assertMatch(darkTheme.shellBg, /rgba\(15, 15, 17,/, 'dark 弹层背景应保持中性黑');
-assertMatch(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/, 'dark section 背景透明度应匹配');
-assertEqual(darkTheme.iconColor, '#ffd666', 'dark 图标色应为金色强调');
-
-const lightTheme = buildOverlayWorkbenchTheme(false);
-assertEqual(lightTheme.isDark, false, 'light 主题标记应为 false');
-assertMatch(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/, 'light 弹层背景透明度应匹配');
-assertMatch(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/, 'light section 背景透明度应匹配');
-assertEqual(lightTheme.iconColor, '#1677ff', 'light 图标色应为蓝色强调');
-
-console.log('overlayWorkbenchTheme tests passed');
+ it('builds light theme tokens', () => {
+ const lightTheme = buildOverlayWorkbenchTheme(false);
+ expect(lightTheme.isDark).toBe(false);
+ expect(lightTheme.shellBg).toMatch(/rgba\(255,255,255,0\.98\)/);
+ expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
+ expect(lightTheme.iconColor).toBe('#1677ff');
+ });
+});
diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts
index 5b288e4..d6fb6a4 100755
--- a/frontend/wailsjs/go/app/App.d.ts
+++ b/frontend/wailsjs/go/app/App.d.ts
@@ -193,6 +193,8 @@ export function SelectDriverPackageFile(arg1:string):Promise;
+export function SetMacNativeWindowControls(arg1:boolean):Promise;
+
export function SetWindowTranslucency(arg1:number,arg2:number):Promise;
export function TestConnection(arg1:connection.ConnectionConfig):Promise;
diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js
index dfee01f..495177e 100755
--- a/frontend/wailsjs/go/app/App.js
+++ b/frontend/wailsjs/go/app/App.js
@@ -378,6 +378,10 @@ export function SelectSSHKeyFile(arg1) {
return window['go']['app']['App']['SelectSSHKeyFile'](arg1);
}
+export function SetMacNativeWindowControls(arg1) {
+ return window['go']['app']['App']['SetMacNativeWindowControls'](arg1);
+}
+
export function SetWindowTranslucency(arg1, arg2) {
return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2);
}
diff --git a/internal/app/app.go b/internal/app/app.go
index 4a0aff9..98238c9 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -68,6 +68,12 @@ func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
setMacWindowTranslucency(opacity, blur)
}
+// SetMacNativeWindowControls toggles macOS native traffic-light window controls.
+// On non-macOS platforms this is a no-op.
+func (a *App) SetMacNativeWindowControls(enabled bool) {
+ setMacNativeWindowControls(enabled)
+}
+
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
logger.Infof("应用开始关闭,准备释放资源")
diff --git a/internal/app/window_style_darwin.go b/internal/app/window_style_darwin.go
new file mode 100644
index 0000000..5c1ea2b
--- /dev/null
+++ b/internal/app/window_style_darwin.go
@@ -0,0 +1,70 @@
+//go:build darwin
+
+package app
+
+/*
+#cgo CFLAGS: -x objective-c -fblocks
+#cgo LDFLAGS: -framework Cocoa
+#import
+#import
+
+static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) {
+ if (window == nil) {
+ return;
+ }
+ for (NSWindowButton buttonType = NSWindowCloseButton; buttonType <= NSWindowZoomButton; buttonType++) {
+ NSButton *button = [window standardWindowButton:buttonType];
+ if (button != nil) {
+ [button setHidden:!visible];
+ [button setEnabled:visible];
+ }
+ }
+}
+
+static void gonaviApplyMacWindowStyle(BOOL enabled) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ for (NSWindow *window in [NSApp windows]) {
+ if (window == nil) {
+ continue;
+ }
+
+ NSUInteger styleMask = [window styleMask];
+ styleMask |= NSWindowStyleMaskClosable;
+ styleMask |= NSWindowStyleMaskMiniaturizable;
+ styleMask |= NSWindowStyleMaskResizable;
+
+ if (enabled) {
+ styleMask |= NSWindowStyleMaskTitled;
+ styleMask |= NSWindowStyleMaskFullSizeContentView;
+ [window setStyleMask:styleMask];
+ [window setTitleVisibility:NSWindowTitleHidden];
+ [window setTitlebarAppearsTransparent:YES];
+ [window setMovableByWindowBackground:YES];
+ [window setCollectionBehavior:[window collectionBehavior] | NSWindowCollectionBehaviorFullScreenPrimary];
+ gonaviSetWindowButtonsVisible(window, YES);
+ } else {
+ styleMask &= ~NSWindowStyleMaskTitled;
+ styleMask &= ~NSWindowStyleMaskFullSizeContentView;
+ [window setStyleMask:styleMask];
+ [window setTitleVisibility:NSWindowTitleVisible];
+ [window setTitlebarAppearsTransparent:NO];
+ [window setMovableByWindowBackground:YES];
+ gonaviSetWindowButtonsVisible(window, NO);
+ }
+
+ [[window contentView] setNeedsDisplay:YES];
+ [window invalidateShadow];
+ }
+ });
+}
+*/
+import "C"
+
+func setMacNativeWindowControls(enabled bool) {
+ state := resolveMacNativeWindowControlState(enabled)
+ flag := C.BOOL(false)
+ if state.ShowNativeButtons {
+ flag = C.BOOL(true)
+ }
+ C.gonaviApplyMacWindowStyle(flag)
+}
diff --git a/internal/app/window_style_logic.go b/internal/app/window_style_logic.go
new file mode 100644
index 0000000..79e33cb
--- /dev/null
+++ b/internal/app/window_style_logic.go
@@ -0,0 +1,32 @@
+package app
+
+type macNativeWindowControlState struct {
+ ShowNativeButtons bool
+ UseTitledWindow bool
+ UseFullSizeContent bool
+ HideWindowTitle bool
+ TransparentTitlebar bool
+ AllowNativeFullscreen bool
+}
+
+func resolveMacNativeWindowControlState(enabled bool) macNativeWindowControlState {
+ if enabled {
+ return macNativeWindowControlState{
+ ShowNativeButtons: true,
+ UseTitledWindow: true,
+ UseFullSizeContent: true,
+ HideWindowTitle: true,
+ TransparentTitlebar: true,
+ AllowNativeFullscreen: true,
+ }
+ }
+
+ return macNativeWindowControlState{
+ ShowNativeButtons: false,
+ UseTitledWindow: false,
+ UseFullSizeContent: false,
+ HideWindowTitle: false,
+ TransparentTitlebar: false,
+ AllowNativeFullscreen: false,
+ }
+}
diff --git a/internal/app/window_style_logic_test.go b/internal/app/window_style_logic_test.go
new file mode 100644
index 0000000..430c6e3
--- /dev/null
+++ b/internal/app/window_style_logic_test.go
@@ -0,0 +1,37 @@
+package app
+
+import "testing"
+
+func TestResolveMacNativeWindowControlStateEnabled(t *testing.T) {
+ state := resolveMacNativeWindowControlState(true)
+
+ if !state.ShowNativeButtons {
+ t.Fatal("expected native buttons to be visible when enabled")
+ }
+ if !state.UseTitledWindow || !state.UseFullSizeContent {
+ t.Fatal("expected enabled state to request titled full-size content window")
+ }
+ if !state.HideWindowTitle || !state.TransparentTitlebar {
+ t.Fatal("expected enabled state to hide title and use transparent titlebar")
+ }
+ if !state.AllowNativeFullscreen {
+ t.Fatal("expected enabled state to allow native fullscreen")
+ }
+}
+
+func TestResolveMacNativeWindowControlStateDisabled(t *testing.T) {
+ state := resolveMacNativeWindowControlState(false)
+
+ if state.ShowNativeButtons {
+ t.Fatal("expected native buttons to be hidden when disabled")
+ }
+ if state.UseTitledWindow || state.UseFullSizeContent {
+ t.Fatal("expected disabled state to avoid titled/full-size content window")
+ }
+ if state.HideWindowTitle || state.TransparentTitlebar {
+ t.Fatal("expected disabled state to keep title visibility and opaque titlebar")
+ }
+ if state.AllowNativeFullscreen {
+ t.Fatal("expected disabled state to avoid native fullscreen behavior")
+ }
+}
diff --git a/internal/app/window_style_stub.go b/internal/app/window_style_stub.go
new file mode 100644
index 0000000..8c78d11
--- /dev/null
+++ b/internal/app/window_style_stub.go
@@ -0,0 +1,5 @@
+//go:build !darwin
+
+package app
+
+func setMacNativeWindowControls(enabled bool) {}