From 2677364d0ed90760a869bd7b25b142e714b749c8 Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Fri, 20 Mar 2026 18:23:16 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mac-window):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=87=E6=8D=A2=20macOS=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=8E=A7=E5=88=B6=E4=B8=8E=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=85=A8=E5=B1=8F=E8=A1=8C=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 500 +++++++++++++++++- frontend/package.json | 8 +- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 144 +++-- .../src/components/dataGridLayout.test.ts | 93 +--- .../src/components/redisViewerTree.test.ts | 134 ++--- .../redisViewerWorkbenchTheme.test.ts | 70 +-- frontend/src/store.ts | 15 +- frontend/src/utils/appearance.test.ts | 23 + frontend/src/utils/macWindow.test.ts | 47 ++ frontend/src/utils/macWindow.ts | 42 ++ .../src/utils/overlayWorkbenchTheme.test.ts | 42 +- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/app.go | 6 + internal/app/window_style_darwin.go | 70 +++ internal/app/window_style_logic.go | 32 ++ internal/app/window_style_logic_test.go | 37 ++ internal/app/window_style_stub.go | 5 + 19 files changed, 1013 insertions(+), 263 deletions(-) create mode 100644 frontend/src/utils/appearance.test.ts create mode 100644 frontend/src/utils/macWindow.test.ts create mode 100644 frontend/src/utils/macWindow.ts create mode 100644 internal/app/window_style_darwin.go create mode 100644 internal/app/window_style_logic.go create mode 100644 internal/app/window_style_logic_test.go create mode 100644 internal/app/window_style_stub.go 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} - > -
+ {useNativeMacWindowControls ? ( +
+ ) : ( +
e.stopPropagation()} + style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any} + > +
+ )}
@@ -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) {}