diff --git a/docs/commands.md b/docs/commands.md index cf1d6f6..6e836e8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -44,6 +44,7 @@ See [TUI Guide](tui.md) for complete behavior and screen-specific shortcuts. Login to LeetCode with browser cookies. Notes: + - `leetcode login` lets you choose site (`leetcode.com` or `leetcode.cn`) before cookie input. - Default credential backend is system keychain. - Set `LEETCODECLI_CREDENTIAL_BACKEND=file` with `LEETCODECLI_MASTER_KEY` for encrypted file mode. @@ -58,6 +59,7 @@ Notes: Clear stored credentials. Note: + - In env auth mode (`LEETCODE_SESSION` + `LEETCODE_CSRF_TOKEN`), unset env vars in your shell to log out. **Usage**: `leetcode logout` @@ -925,6 +927,20 @@ leetcode changelog --breaking --- +## Star the Project + +### `leetcode star` + +Open the GitHub repository to star the project. + +**Usage**: `leetcode star` + +Stars help other developers discover leetcode-cli and encourage contributions. + +> **Note**: The CLI also shows a non-intrusive star prompt after accepted submissions at milestones (10th, 20th, 30th) and then every 40 submissions. The prompt resets every 30 days and can be permanently dismissed. + +--- + ## Global Options These work with any command: diff --git a/docs/config.md b/docs/config.md index 4688083..d4dc8f8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -97,14 +97,14 @@ Config is stored per-workspace in `~/.leetcode/workspaces//config.json`. Settings are now stored per-workspace for isolation: -| Data | Storage Location | Scope | -| ----------- | ------------------------------------------- | ------------- | -| Config | `~/.leetcode/workspaces//config.json` | Per-workspace | -| Timer | `~/.leetcode/workspaces//timer.json` | Per-workspace | -| Collab | `~/.leetcode/workspaces//collab.json` | Per-workspace | -| Snapshots | `~/.leetcode/workspaces//snapshots/` | Per-workspace | -| Credentials | Keychain (default) or `~/.leetcode/credentials.v2.enc.json` (file backend) | Shared | -| Bookmarks | `~/.leetcode/bookmarks.json` | Shared | +| Data | Storage Location | Scope | +| ----------- | -------------------------------------------------------------------------- | ------------- | +| Config | `~/.leetcode/workspaces//config.json` | Per-workspace | +| Timer | `~/.leetcode/workspaces//timer.json` | Per-workspace | +| Collab | `~/.leetcode/workspaces//collab.json` | Per-workspace | +| Snapshots | `~/.leetcode/workspaces//snapshots/` | Per-workspace | +| Credentials | Keychain (default) or `~/.leetcode/credentials.v2.enc.json` (file backend) | Shared | +| Bookmarks | `~/.leetcode/bookmarks.json` | Shared | Use `leetcode workspace current` to see which workspace is active. diff --git a/docs/releases.md b/docs/releases.md index 8bfd2c2..1be0d18 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,30 @@ # Release Notes +## v3.2.0 + +> **Release Date**: 2026-06-28 +> **Focus**: Community Engagement + Security + +### ⭐ Star Prompt & Command + +- New `leetcode star` command β€” opens the GitHub repo directly. +- Non-intrusive star prompt after accepted submissions: + - **Milestones**: Shows at 10th, 20th, and 30th accepted submission (one-time each). + - **Recurring**: After all milestones, shows every 40 accepted submissions. + - **30-day reset**: Recurring counter resets if 30 days pass without reaching the threshold. + - **Dismissible**: Choose "Don't ask again" to permanently disable. + - Falls back to a passive one-liner in non-TTY environments. + +### πŸ”’ Security + +- Fixed high-severity dependency vulnerabilities (PR [#12](https://github.com/night-slayer18/leetcode-cli/pull/12)). + +### 🧹 Code Quality + +- Applied consistent formatting across the entire codebase (line wrapping, argument alignment). + +--- + ## v3.1.0 > **Release Date**: 2026-05-01 diff --git a/docs/tui.md b/docs/tui.md index 80fc388..b02f6ca 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -83,4 +83,3 @@ If your terminal still looks wrong after an interrupted session, run: ```bash reset ``` - diff --git a/package-lock.json b/package-lock.json index 531c759..015d164 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@night-slayer18/leetcode-cli", - "version": "3.1.0", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { "@supabase/supabase-js": "^2.90.1", @@ -49,9 +49,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -59,9 +59,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -69,13 +69,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -85,14 +85,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -118,6 +118,40 @@ "node": ">=0.1.90" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1266,6 +1300,317 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -1721,6 +2066,17 @@ "node": ">=20.0.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2052,29 +2408,29 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2083,31 +2439,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2116,7 +2472,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2128,26 +2484,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -2155,13 +2511,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2170,9 +2527,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -2180,14 +2537,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2300,15 +2658,15 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", - "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", - "js-tokens": "^9.0.1" + "js-tokens": "^10.0.0" } }, "node_modules/atomically": { @@ -2792,6 +3150,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2990,9 +3355,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -3381,9 +3746,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3951,17 +4316,27 @@ } }, "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4031,6 +4406,279 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4135,14 +4783,14 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, @@ -4291,9 +4939,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -4652,9 +5300,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4672,7 +5320,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4949,6 +5597,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -5204,9 +5886,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -5449,14 +6131,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5466,9 +6148,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -5676,18 +6358,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -5703,9 +6384,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -5718,13 +6400,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -5751,31 +6436,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -5791,12 +6476,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -5817,6 +6505,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -5825,6 +6519,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5894,9 +6591,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index a69628a..5678001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@night-slayer18/leetcode-cli", - "version": "3.1.0", + "version": "3.2.0", "description": "A modern LeetCode CLI built with TypeScript", "type": "module", "main": "dist/index.js", diff --git a/src/__tests__/api/client-cn.test.ts b/src/__tests__/api/client-cn.test.ts index 1beb63d..979762c 100644 --- a/src/__tests__/api/client-cn.test.ts +++ b/src/__tests__/api/client-cn.test.ts @@ -6,21 +6,21 @@ describe('LeetCodeClient cn getProblemById', () => { const client = new LeetCodeClient('leetcode.cn'); const getProblemsSpy = vi.spyOn(client, 'getProblems').mockResolvedValueOnce({ - total: 1, - problems: [ - { - questionId: '1', - questionFrontendId: '1', - title: 'δΈ€ζ•°δΉ‹ε’Œ', - titleSlug: 'two-sum', - difficulty: 'Easy', - isPaidOnly: false, - acRate: 52.3, - topicTags: [], - status: 'ac', - }, - ], - }); + total: 1, + problems: [ + { + questionId: '1', + questionFrontendId: '1', + title: 'δΈ€ζ•°δΉ‹ε’Œ', + titleSlug: 'two-sum', + difficulty: 'Easy', + isPaidOnly: false, + acRate: 52.3, + topicTags: [], + status: 'ac', + }, + ], + }); const getProblemSpy = vi.spyOn(client, 'getProblem').mockResolvedValue({ questionId: '1', diff --git a/src/__tests__/api/cn-adapter.test.ts b/src/__tests__/api/cn-adapter.test.ts index 0f6f87f..8d37bcd 100644 --- a/src/__tests__/api/cn-adapter.test.ts +++ b/src/__tests__/api/cn-adapter.test.ts @@ -123,7 +123,9 @@ describe('cn adapters', () => { acRate: '52.3', status: 'AC', topicTags: [{ name: 'Array', slug: 'array', translatedName: 'ζ•°η»„' }], - codeSnippets: [{ lang: 'TypeScript', langSlug: 'typescript', code: 'function twoSum() {}' }], + codeSnippets: [ + { lang: 'TypeScript', langSlug: 'typescript', code: 'function twoSum() {}' }, + ], sampleTestCase: '[2,7,11,15]\n9', exampleTestcases: '[2,7,11,15]\n9', hints: ['Use a hash map.'], diff --git a/src/__tests__/commands/solve.test.ts b/src/__tests__/commands/solve.test.ts index 8bdd425..a3ecd38 100644 --- a/src/__tests__/commands/solve.test.ts +++ b/src/__tests__/commands/solve.test.ts @@ -101,7 +101,11 @@ import { pickCommand, batchPickCommand } from '../../commands/pick.js'; import { testCommand } from '../../commands/test.js'; import { submitCommand } from '../../commands/submit.js'; import { leetcodeClient } from '../../api/client.js'; -import { findSolutionFile, findFileByName, getLangSlugFromExtension } from '../../utils/fileUtils.js'; +import { + findSolutionFile, + findFileByName, + getLangSlugFromExtension, +} from '../../utils/fileUtils.js'; import { writeFile } from 'fs/promises'; import { existsSync } from 'fs'; @@ -175,7 +179,9 @@ describe('Solve Commands', () => { it('should not create a file when problem id lookup fails', async () => { vi.mocked(existsSync).mockReturnValue(false); - vi.mocked(leetcodeClient.getProblemById).mockRejectedValueOnce(new Error('Problem #1 not found')); + vi.mocked(leetcodeClient.getProblemById).mockRejectedValueOnce( + new Error('Problem #1 not found') + ); const result = await pickCommand('1', { open: false }); diff --git a/src/__tests__/storage/credentials.test.ts b/src/__tests__/storage/credentials.test.ts index 605a4ab..f92fa0e 100644 --- a/src/__tests__/storage/credentials.test.ts +++ b/src/__tests__/storage/credentials.test.ts @@ -88,7 +88,9 @@ describe('credentials storage resolver', () => { it('should use keychain backend by default', async () => { const { credentials } = await loadCredentialsModule(); - keytarMock.getPassword.mockResolvedValue(JSON.stringify({ session: 'kc-session', csrfToken: 'kc-csrf' })); + keytarMock.getPassword.mockResolvedValue( + JSON.stringify({ session: 'kc-session', csrfToken: 'kc-csrf' }) + ); await expect(credentials.get()).resolves.toEqual({ session: 'kc-session', diff --git a/src/__tests__/tui/auth-effects.test.ts b/src/__tests__/tui/auth-effects.test.ts index 9229b0c..3cff473 100644 --- a/src/__tests__/tui/auth-effects.test.ts +++ b/src/__tests__/tui/auth-effects.test.ts @@ -175,7 +175,10 @@ describe('TUI auth effects', () => { await flushAsync(); - expect(dispatched).toContainEqual({ type: 'GLOBAL_ERROR', error: 'System keychain is unavailable.' }); + expect(dispatched).toContainEqual({ + type: 'GLOBAL_ERROR', + error: 'System keychain is unavailable.', + }); expect(dispatched).toContainEqual({ type: 'AUTH_CHECK_COMPLETE', user: null }); }); diff --git a/src/__tests__/tui/login-screen.test.ts b/src/__tests__/tui/login-screen.test.ts index c6e7cb5..cb4dbc9 100644 --- a/src/__tests__/tui/login-screen.test.ts +++ b/src/__tests__/tui/login-screen.test.ts @@ -30,7 +30,10 @@ describe('TUI login screen', () => { const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); const [cnModel] = update({ type: 'LOGIN_SWITCH_SITE' }, siteModel); const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, cnModel); - const [sessionModel] = update({ type: 'LOGIN_SESSION_INPUT', value: 'session-token' }, inputModel); + const [sessionModel] = update( + { type: 'LOGIN_SESSION_INPUT', value: 'session-token' }, + inputModel + ); const [readyModel] = update({ type: 'LOGIN_CSRF_INPUT', value: 'csrf-token' }, sessionModel); const [, cmd] = update({ type: 'LOGIN_SUBMIT' }, readyModel); diff --git a/src/__tests__/tui/problem-screen.test.ts b/src/__tests__/tui/problem-screen.test.ts index 956ee69..58cfd23 100644 --- a/src/__tests__/tui/problem-screen.test.ts +++ b/src/__tests__/tui/problem-screen.test.ts @@ -196,15 +196,30 @@ describe('TUI Problem Screen', () => { } expect(hintModel.activeHintIndex).toBe(0); - const [snapshotsOpen] = ProblemScreen.update({ type: 'PROBLEM_SHOW_SNAPSHOTS' }, hintModel, 32, 120); + const [snapshotsOpen] = ProblemScreen.update( + { type: 'PROBLEM_SHOW_SNAPSHOTS' }, + hintModel, + 32, + 120 + ); let snapshotsModel = snapshotsOpen; for (let i = 0; i < 10; i++) { - [snapshotsModel] = ProblemScreen.update({ type: 'PROBLEM_SNAPSHOT_DOWN' }, snapshotsModel, 32, 120); + [snapshotsModel] = ProblemScreen.update( + { type: 'PROBLEM_SNAPSHOT_DOWN' }, + snapshotsModel, + 32, + 120 + ); } expect(snapshotsModel.snapshotCursor).toBe(mockSnapshots.length - 1); for (let i = 0; i < 10; i++) { - [snapshotsModel] = ProblemScreen.update({ type: 'PROBLEM_SNAPSHOT_UP' }, snapshotsModel, 32, 120); + [snapshotsModel] = ProblemScreen.update( + { type: 'PROBLEM_SNAPSHOT_UP' }, + snapshotsModel, + 32, + 120 + ); } expect(snapshotsModel.snapshotCursor).toBe(0); }); diff --git a/src/__tests__/utils/languages.test.ts b/src/__tests__/utils/languages.test.ts index 9888d83..c7ab0d7 100644 --- a/src/__tests__/utils/languages.test.ts +++ b/src/__tests__/utils/languages.test.ts @@ -20,9 +20,7 @@ describe('languages utility', () => { it('should resolve sql dialect from snippets with mysql fallback', () => { expect(resolveSqlDialectLangSlug()).toBe('mysql'); - const mssql = resolveSqlDialectLangSlug([ - { lang: 'MS SQL Server', langSlug: 'mssql' }, - ]); + const mssql = resolveSqlDialectLangSlug([{ lang: 'MS SQL Server', langSlug: 'mssql' }]); expect(mssql).toBe('mssql'); }); diff --git a/src/__tests__/utils/star-prompt.test.ts b/src/__tests__/utils/star-prompt.test.ts new file mode 100644 index 0000000..c23d62f --- /dev/null +++ b/src/__tests__/utils/star-prompt.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../storage/star-prompt.js', () => { + let state = { + totalAccepted: 0, + shownMilestones: [] as number[], + submissionsSinceLastPrompt: 0, + windowStartedAt: null as number | null, + lastShownAt: null as number | null, + dismissed: false, + }; + + const RESET_MS = 30 * 24 * 60 * 60 * 1000; + + return { + STAR_PROMPT_MILESTONES: [10, 20, 30], + STAR_PROMPT_RECURRING_INTERVAL: 40, + STAR_PROMPT_RESET_DAYS: 30, + starPromptStorage: { + getState: vi.fn(() => ({ ...state, shownMilestones: [...state.shownMilestones] })), + incrementSubmissionCount: vi.fn(() => { + const now = Date.now(); + state.totalAccepted += 1; + if (!state.windowStartedAt || now - state.windowStartedAt >= RESET_MS) { + state.submissionsSinceLastPrompt = 0; + state.windowStartedAt = now; + } + state.submissionsSinceLastPrompt += 1; + }), + markMilestoneShown: vi.fn((milestone: number) => { + state.lastShownAt = Date.now(); + if (!state.shownMilestones.includes(milestone)) { + state.shownMilestones.push(milestone); + } + }), + markRecurringShown: vi.fn(() => { + state.lastShownAt = Date.now(); + state.submissionsSinceLastPrompt = 0; + state.windowStartedAt = Date.now(); + }), + dismissPermanently: vi.fn(() => { + state.dismissed = true; + state.lastShownAt = Date.now(); + }), + resetForTests: vi.fn(() => { + state = { + totalAccepted: 0, + shownMilestones: [], + submissionsSinceLastPrompt: 0, + windowStartedAt: null, + lastShownAt: null, + dismissed: false, + }; + }), + }, + }; +}); + +import { starPromptStorage } from '../../storage/star-prompt.js'; +import { shouldShowStarPrompt } from '../../utils/star-prompt.js'; + +const THIRTY_ONE_DAYS_MS = 31 * 24 * 60 * 60 * 1000; + +describe('star prompt', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.stubEnv('VITEST', ''); + vi.stubEnv('CI', ''); + starPromptStorage.resetForTests(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + // --- Milestone phase --- + + it('does not show before reaching the first milestone (10)', () => { + for (let i = 0; i < 9; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + }); + + it('shows at the 10th submission milestone', () => { + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(true); + }); + + it('shows at 20th and 30th milestones', () => { + // Reach 10, mark shown + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(10); + + // 11–19: no prompt + for (let i = 0; i < 9; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + + // 20th: prompt + starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(true); + starPromptStorage.markMilestoneShown(20); + + // 21–29: no prompt + for (let i = 0; i < 9; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + + // 30th: prompt + starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(true); + }); + + it('does not re-show a milestone already shown', () => { + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(10); + expect(shouldShowStarPrompt('accepted')).toBe(false); + }); + + // --- Recurring phase (post-milestones) --- + + it('switches to every-40 after all milestones exhausted', () => { + // Burn through milestones + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(10); + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(20); + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(30); + starPromptStorage.markRecurringShown(); // reset recurring counter + + // 39 more: no prompt + for (let i = 0; i < 39; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + + // 40th: prompt + starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(true); + }); + + // --- 30-day window reset --- + + it('resets recurring counter after 30 days', () => { + // Exhaust milestones + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(10); + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(20); + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + starPromptStorage.markMilestoneShown(30); + starPromptStorage.markRecurringShown(); + + // Do 20 submissions (< 40) + for (let i = 0; i < 20; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + + // Advance 31 days β€” counter resets + vi.advanceTimersByTime(THIRTY_ONE_DAYS_MS); + + // Next submission starts fresh window at 1 + starPromptStorage.incrementSubmissionCount(); + expect(starPromptStorage.getState().submissionsSinceLastPrompt).toBe(1); + expect(shouldShowStarPrompt('accepted')).toBe(false); + }); + + // --- Dismissal --- + + it('respects permanent dismissal', () => { + starPromptStorage.dismissPermanently(); + for (let i = 0; i < 10; i++) starPromptStorage.incrementSubmissionCount(); + expect(shouldShowStarPrompt('accepted')).toBe(false); + }); +}); diff --git a/src/api/adapters/cn.ts b/src/api/adapters/cn.ts index 8d05893..78cab7c 100644 --- a/src/api/adapters/cn.ts +++ b/src/api/adapters/cn.ts @@ -173,7 +173,9 @@ function toProblemFromListEntry(question: CnProblemListItem): Problem { }; } -export function normalizeCnDailyChallenge(input: { todayRecord?: CnDailyRecord[] }): DailyChallenge { +export function normalizeCnDailyChallenge(input: { + todayRecord?: CnDailyRecord[]; +}): DailyChallenge { const record = input.todayRecord?.[0]; if (!record || !record.question) { throw new Error('No daily challenge found for leetcode.cn'); @@ -193,7 +195,9 @@ export function normalizeCnProblemList(input: { }): { total: number; problems: Problem[] } { return { total: input.problemsetQuestionList.total, - problems: input.problemsetQuestionList.questions.map((question) => toProblemFromListEntry(question)), + problems: input.problemsetQuestionList.questions.map((question) => + toProblemFromListEntry(question) + ), }; } @@ -253,7 +257,8 @@ export function normalizeCnUserProfile( countMap.set(key, value); } - const all = (countMap.get('Easy') ?? 0) + (countMap.get('Medium') ?? 0) + (countMap.get('Hard') ?? 0); + const all = + (countMap.get('Easy') ?? 0) + (countMap.get('Medium') ?? 0) + (countMap.get('Hard') ?? 0); countMap.set('All', Math.max(countMap.get('All') ?? 0, all)); return { diff --git a/src/api/client.ts b/src/api/client.ts index 583b1e2..b655130 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -68,7 +68,9 @@ const OPERATION_LABEL: Record = { }; function isSchemaMismatchError(message: string): boolean { - return /(cannot query field|unknown argument|unknown type|did you mean|validation error)/i.test(message); + return /(cannot query field|unknown argument|unknown type|did you mean|validation error)/i.test( + message + ); } export class LeetCodeClient { @@ -227,7 +229,11 @@ export class LeetCodeClient { } if (this.site === 'leetcode.cn') { - const data = await this.graphql('PROBLEM_LIST', this.queries.PROBLEM_LIST_QUERY, variables); + const data = await this.graphql( + 'PROBLEM_LIST', + this.queries.PROBLEM_LIST_QUERY, + variables + ); const validated = CnProblemListSchema.parse(data); return normalizeCnProblemList(validated); } @@ -246,16 +252,24 @@ export class LeetCodeClient { async getProblem(titleSlug: string): Promise { if (this.site === 'leetcode.cn') { - const data = await this.graphql('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { - titleSlug, - }); + const data = await this.graphql( + 'PROBLEM_DETAIL', + this.queries.PROBLEM_DETAIL_QUERY, + { + titleSlug, + } + ); const validated = CnProblemDetailSchema.parse(data); return normalizeCnProblemDetail(validated); } - const data = await this.graphql<{ question: ProblemDetail }>('PROBLEM_DETAIL', this.queries.PROBLEM_DETAIL_QUERY, { - titleSlug, - }); + const data = await this.graphql<{ question: ProblemDetail }>( + 'PROBLEM_DETAIL', + this.queries.PROBLEM_DETAIL_QUERY, + { + titleSlug, + } + ); const validated = ProblemDetailSchema.parse(data.question); return validated as ProblemDetail; @@ -294,7 +308,10 @@ export class LeetCodeClient { async getDailyChallenge(): Promise { if (this.site === 'leetcode.cn') { - const data = await this.graphql('DAILY_CHALLENGE', this.queries.DAILY_CHALLENGE_QUERY); + const data = await this.graphql( + 'DAILY_CHALLENGE', + this.queries.DAILY_CHALLENGE_QUERY + ); const validated = CnDailyChallengeSchema.parse(data); return normalizeCnDailyChallenge(validated); } @@ -376,7 +393,9 @@ export class LeetCodeClient { advanced: Array<{ tagName: string; tagSlug: string; problemsSolved: number }>; }> { if (this.site === 'leetcode.cn') { - const data = await this.graphql('SKILL_STATS', this.queries.SKILL_STATS_QUERY, { username }); + const data = await this.graphql('SKILL_STATS', this.queries.SKILL_STATS_QUERY, { + username, + }); const validated = CnSkillStatsSchema.parse(data); return normalizeCnSkillStats(validated); } @@ -401,7 +420,11 @@ export class LeetCodeClient { ): Promise { const data = await this.graphql<{ questionSubmissionList: { submissions: Submission[] }; - }>('SUBMISSION_LIST', this.queries.SUBMISSION_LIST_QUERY, { questionSlug: slug, limit, offset }); + }>('SUBMISSION_LIST', this.queries.SUBMISSION_LIST_QUERY, { + questionSlug: slug, + limit, + offset, + }); const validated = z.array(SubmissionSchema).parse(data.questionSubmissionList.submissions); return validated; diff --git a/src/commands/config.ts b/src/commands/config.ts index 1bb5e24..fba3eb1 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -78,15 +78,19 @@ export async function configCommand(options: ConfigOptions): Promise { default: false, }, ]); - + if (!confirm.proceed) { console.log(chalk.gray('Change aborted. Staying on ' + currentSite)); return; } } - + await credentials.clear(); - console.log(chalk.yellow(`⚠️ Logged out β€” run "leetcode login" to authenticate with ${normalizedSite}.`)); + console.log( + chalk.yellow( + `⚠️ Logged out β€” run "leetcode login" to authenticate with ${normalizedSite}.` + ) + ); } config.setSite(normalizedSite); @@ -159,12 +163,14 @@ export async function configInteractiveCommand(): Promise { default: false, }, ]); - + if (confirm.proceed) { config.setSite(answers.site); await credentials.clear(); console.log(); - console.log(chalk.yellow(`⚠️ Logged out β€” run "leetcode login" to authenticate with ${answers.site}.`)); + console.log( + chalk.yellow(`⚠️ Logged out β€” run "leetcode login" to authenticate with ${answers.site}.`) + ); } else { console.log(); console.log(chalk.gray('Site change aborted. Staying on ' + currentSite)); diff --git a/src/commands/login.ts b/src/commands/login.ts index 1fa2191..c2704de 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -57,7 +57,9 @@ export async function loginCommand(): Promise { const envCreds = await credentials.get(); if (!envCreds) { - console.log(chalk.yellow('Environment credential mode is active but credentials are unavailable.')); + console.log( + chalk.yellow('Environment credential mode is active but credentials are unavailable.') + ); return; } @@ -73,7 +75,9 @@ export async function loginCommand(): Promise { } spinner.succeed(`Logged in as ${chalk.green(username)}`); - console.log(chalk.gray('Environment credential mode is read-only. Nothing was written to disk.')); + console.log( + chalk.gray('Environment credential mode is read-only. Nothing was written to disk.') + ); return; } catch (error) { spinner.fail('Authentication failed'); diff --git a/src/commands/star.ts b/src/commands/star.ts new file mode 100644 index 0000000..a7f0569 --- /dev/null +++ b/src/commands/star.ts @@ -0,0 +1,11 @@ +import chalk from 'chalk'; +import { GITHUB_REPO_URL, openGitHubRepo } from '../utils/star-prompt.js'; + +export async function starCommand(): Promise { + console.log(); + console.log(chalk.yellow('⭐ Thanks for supporting leetcode-cli!')); + console.log(chalk.gray(`Opening ${GITHUB_REPO_URL}`)); + console.log(); + + await openGitHubRepo(); +} diff --git a/src/commands/submit.ts b/src/commands/submit.ts index 3b080c0..eeed74f 100644 --- a/src/commands/submit.ts +++ b/src/commands/submit.ts @@ -9,6 +9,7 @@ import { requireAuth } from '../utils/auth.js'; import { config } from '../storage/config.js'; import { timerStorage } from '../storage/timer.js'; import { displaySubmissionResult } from '../utils/display.js'; +import { maybeShowStarPrompt } from '../utils/star-prompt.js'; import { findSolutionFile, findFileByName, getLangSlugFromExtension } from '../utils/fileUtils.js'; import { isProblemId, isFileName, isPathInsideWorkDir } from '../utils/validation.js'; @@ -106,6 +107,8 @@ export async function submitCommand(fileOrId: string): Promise { // Record timer if active and submission was accepted if (result.status_msg === 'Accepted') { + await maybeShowStarPrompt('accepted'); + const activeTimer = timerStorage.getActiveTimer(); if (activeTimer && activeTimer.problemId === problemId) { const timerResult = timerStorage.stopTimer(); diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index b799765..dd51833 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -34,7 +34,9 @@ export async function workspaceListCommand(): Promise { const marker = ws === active ? chalk.green('β–Έ ') : ' '; const name = ws === active ? chalk.green.bold(ws) : ws; console.log(`${marker}${name}`); - console.log(` ${chalk.gray(config.workDir)} ${chalk.gray(`(${config.site ?? 'leetcode.com'})`)}`); + console.log( + ` ${chalk.gray(config.workDir)} ${chalk.gray(`(${config.site ?? 'leetcode.com'})`)}` + ); } console.log(); } diff --git a/src/index.ts b/src/index.ts index 0628314..5022398 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ import { } from './commands/workspace.js'; import { updateCommand, checkForUpdatesOnStartup } from './commands/update.js'; import { changelogCommand } from './commands/changelog.js'; +import { starCommand } from './commands/star.js'; import { getSupportedLanguagesLabel } from './utils/languages.js'; import { launchTUI as launchNewTUI } from './tui/index.js'; @@ -67,7 +68,7 @@ program .name('leetcode') .usage('[command] [options]') .description(chalk.bold.cyan('πŸ”₯ A modern LeetCode CLI built with TypeScript')) - .version('3.1.0', '-v, --version', 'Output the version number') + .version('3.2.0', '-v, --version', 'Output the version number') .helpOption('-h, --help', 'Display help for command') .addHelpText( 'after', @@ -614,6 +615,18 @@ ${chalk.yellow('Examples:')} ) .action((version, options) => changelogCommand(version, options)); +program + .command('star') + .description('Open the GitHub repo to star the project') + .addHelpText( + 'after', + ` +${chalk.yellow('Why star?')} + Stars help other developers discover leetcode-cli and encourage contributions. +` + ) + .action(starCommand); + program.showHelpAfterError('(add --help for additional information)'); // Check for updates on startup (non-blocking) diff --git a/src/storage/config.ts b/src/storage/config.ts index a577226..2c22b1a 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -59,7 +59,9 @@ export const config = { }, getSite(): LeetCodeSite { - return normalizeLeetCodeSiteInput(workspaceStorage.getConfig().site ?? '') ?? DEFAULT_LEETCODE_SITE; + return ( + normalizeLeetCodeSiteInput(workspaceStorage.getConfig().site ?? '') ?? DEFAULT_LEETCODE_SITE + ); }, getPath(): string { diff --git a/src/storage/credentials.ts b/src/storage/credentials.ts index 74f7630..3ac5cfb 100644 --- a/src/storage/credentials.ts +++ b/src/storage/credentials.ts @@ -105,7 +105,12 @@ function lockFile(path: string): void { } function deriveKey(masterKey: string, salt: Buffer): Buffer { - return scryptSync(masterKey, salt, 32, { N: 32768, r: 8, p: 1, maxmem: 128 * 1024 * 1024 }) as Buffer; + return scryptSync(masterKey, salt, 32, { + N: 32768, + r: 8, + p: 1, + maxmem: 128 * 1024 * 1024, + }) as Buffer; } function encryptCredentials(creds: LeetCodeCredentials, masterKey: string): EncryptedCredentialsV2 { @@ -142,7 +147,9 @@ function decryptCredentials( const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); - const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString( + 'utf8' + ); const parsed = JSON.parse(plaintext); return isValidCredentials(parsed) ? parsed : null; } catch { @@ -160,7 +167,8 @@ function parseKeychainSecret(secret: string): LeetCodeCredentials | null { } function classifyKeychainError(error: unknown): CredentialStatusReason { - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); + const message = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const unavailablePatterns = [ 'not available', 'unsupported', @@ -297,9 +305,10 @@ async function readFromKeychain( } } -function readFromEncryptedFile( - envPartial: boolean -): { status: CredentialStoreStatus; creds: LeetCodeCredentials | null } { +function readFromEncryptedFile(envPartial: boolean): { + status: CredentialStoreStatus; + creds: LeetCodeCredentials | null; +} { const masterKey = process.env['LEETCODECLI_MASTER_KEY']; const path = ENCRYPTED_CREDENTIALS_FILE; @@ -506,7 +515,12 @@ export const credentials = { try { await keytar.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, JSON.stringify(creds)); - return successResult('keychain', 'keychain', null, 'Credentials saved to the system keychain.'); + return successResult( + 'keychain', + 'keychain', + null, + 'Credentials saved to the system keychain.' + ); } catch (error) { const reason = classifyKeychainError(error); return failureResult('keychain', reason, null, explainReason(reason)); @@ -548,7 +562,12 @@ export const credentials = { try { await keytar.deletePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); - return successResult('keychain', 'keychain', null, 'Credentials removed from the system keychain.'); + return successResult( + 'keychain', + 'keychain', + null, + 'Credentials removed from the system keychain.' + ); } catch (error) { const reason = classifyKeychainError(error); return failureResult('keychain', reason, null, explainReason(reason)); diff --git a/src/storage/snapshots.ts b/src/storage/snapshots.ts index 799406a..88fc7d4 100644 --- a/src/storage/snapshots.ts +++ b/src/storage/snapshots.ts @@ -86,7 +86,9 @@ export const snapshotStorage = { // Generate name if not provided const snapshotName = (name ?? `snapshot-${nextId}`).trim(); if (!isValidSnapshotName(snapshotName)) { - return { error: 'Invalid snapshot name. Use 1-64 characters: letters, numbers, spaces, "-" or "_".' }; + return { + error: 'Invalid snapshot name. Use 1-64 characters: letters, numbers, spaces, "-" or "_".', + }; } // Check for duplicate name diff --git a/src/storage/star-prompt.ts b/src/storage/star-prompt.ts new file mode 100644 index 0000000..58aa791 --- /dev/null +++ b/src/storage/star-prompt.ts @@ -0,0 +1,116 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +/** Show the prompt at these exact submission counts (one-time each, lifetime). */ +export const STAR_PROMPT_MILESTONES = [10, 20, 30] as const; + +/** After all milestones are exhausted, show every N submissions. */ +export const STAR_PROMPT_RECURRING_INTERVAL = 40; + +/** Reset the recurring counter if 30 days pass without reaching the interval. */ +export const STAR_PROMPT_RESET_DAYS = 30; + +interface StarPromptState { + /** Total accepted submissions tracked for milestone checks. */ + totalAccepted: number; + /** Milestones that have already been shown (persists across windows). */ + shownMilestones: number[]; + /** Submissions counted since last prompt in the recurring (post-milestone) phase. */ + submissionsSinceLastPrompt: number; + /** Epoch ms when the recurring counter window started. */ + windowStartedAt: number | null; + /** Epoch ms when the prompt was last shown. */ + lastShownAt: number | null; + /** True if the user chose "Don't ask again". */ + dismissed: boolean; +} + +const LEETCODE_DIR = join(homedir(), '.leetcode'); +const STATE_FILE = join(LEETCODE_DIR, 'star-prompt.json'); + +const DEFAULT_STATE: StarPromptState = { + totalAccepted: 0, + shownMilestones: [], + submissionsSinceLastPrompt: 0, + windowStartedAt: null, + lastShownAt: null, + dismissed: false, +}; + +function ensureDir(): void { + if (!existsSync(LEETCODE_DIR)) { + mkdirSync(LEETCODE_DIR, { recursive: true }); + } +} + +function loadState(): StarPromptState { + if (existsSync(STATE_FILE)) { + try { + return { ...DEFAULT_STATE, ...JSON.parse(readFileSync(STATE_FILE, 'utf-8')) }; + } catch { + return { ...DEFAULT_STATE }; + } + } + return { ...DEFAULT_STATE }; +} + +function saveState(state: StarPromptState): void { + ensureDir(); + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + '\n'); +} + +export const starPromptStorage = { + getState(): StarPromptState { + return loadState(); + }, + + /** + * Increment submission counters. + * Bumps totalAccepted (for milestones) and submissionsSinceLastPrompt (for recurring). + * If the 30-day window has elapsed, resets the recurring counter first. + */ + incrementSubmissionCount(): void { + const state = loadState(); + const now = Date.now(); + const resetMs = STAR_PROMPT_RESET_DAYS * 24 * 60 * 60 * 1000; + + state.totalAccepted += 1; + + if (!state.windowStartedAt || now - state.windowStartedAt >= resetMs) { + state.submissionsSinceLastPrompt = 0; + state.windowStartedAt = now; + } + + state.submissionsSinceLastPrompt += 1; + saveState(state); + }, + + markMilestoneShown(milestone: number): void { + const state = loadState(); + state.lastShownAt = Date.now(); + if (!state.shownMilestones.includes(milestone)) { + state.shownMilestones.push(milestone); + } + saveState(state); + }, + + markRecurringShown(): void { + const state = loadState(); + state.lastShownAt = Date.now(); + state.submissionsSinceLastPrompt = 0; + state.windowStartedAt = Date.now(); + saveState(state); + }, + + dismissPermanently(): void { + const state = loadState(); + state.dismissed = true; + state.lastShownAt = Date.now(); + saveState(state); + }, + + resetForTests(): void { + saveState({ ...DEFAULT_STATE }); + }, +}; diff --git a/src/tui/screens/changelog/index.ts b/src/tui/screens/changelog/index.ts index 844c857..3e7cf08 100644 --- a/src/tui/screens/changelog/index.ts +++ b/src/tui/screens/changelog/index.ts @@ -36,7 +36,10 @@ export function update( case 'CHANGELOG_FETCH_SUCCESS': const entries = parseReleases(msg.content); - return [{ ...model, loading: false, entries: entries, error: null, scrollOffset: 0 }, Cmd.none()]; + return [ + { ...model, loading: false, entries: entries, error: null, scrollOffset: 0 }, + Cmd.none(), + ]; case 'CHANGELOG_FETCH_ERROR': return [{ ...model, loading: false, error: msg.error }, Cmd.none()]; diff --git a/src/tui/screens/config/index.ts b/src/tui/screens/config/index.ts index b5a6299..73650da 100644 --- a/src/tui/screens/config/index.ts +++ b/src/tui/screens/config/index.ts @@ -185,7 +185,7 @@ export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenM showSiteConfirm: true, pendingSite: model.draftValue, }, - Cmd.none() + Cmd.none(), ]; } @@ -206,7 +206,7 @@ export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenM case 'CONFIG_SITE_CONFIRM': { if (!model.showSiteConfirm || !model.pendingSite) return [model, Cmd.none()]; - + const option = getSelectedOption(model); const newOptions = [...model.options]; newOptions[model.selectedOption] = { ...option, value: model.pendingSite }; @@ -219,16 +219,13 @@ export function update(msg: ConfigMsg, model: ConfigScreenModel): [ConfigScreenM pendingSite: null, draftValue: model.pendingSite, }, - Cmd.batch( - Cmd.saveConfig(option.id, model.pendingSite), - Cmd.logout() - ), + Cmd.batch(Cmd.saveConfig(option.id, model.pendingSite), Cmd.logout()), ]; } case 'CONFIG_SITE_CANCEL': { if (!model.showSiteConfirm) return [model, Cmd.none()]; - + const option = getSelectedOption(model); return [ { diff --git a/src/tui/screens/config/view.ts b/src/tui/screens/config/view.ts index dce9bf4..5854af1 100644 --- a/src/tui/screens/config/view.ts +++ b/src/tui/screens/config/view.ts @@ -24,12 +24,22 @@ export function view(model: ConfigScreenModel, width: number, height: number): s const safeHeight = Math.max(8, height); const lines: string[] = []; - lines.push(...renderScreenTitle(`${icons.gear} Configuration`, 'Workspace-local CLI settings', safeWidth)); + lines.push( + ...renderScreenTitle(`${icons.gear} Configuration`, 'Workspace-local CLI settings', safeWidth) + ); lines.push(chalk.hex(colors.textMuted)(borders.horizontal.repeat(safeWidth))); const bodyHeight = Math.max(3, safeHeight - lines.length - 3); if (safeWidth >= 90) { - lines.push(...splitPane(renderOptionList(model, safeWidth), renderOptionDetails(model, safeWidth), safeWidth, bodyHeight, 0.4)); + lines.push( + ...splitPane( + renderOptionList(model, safeWidth), + renderOptionDetails(model, safeWidth), + safeWidth, + bodyHeight, + 0.4 + ) + ); } else { const topHeight = Math.max(3, Math.floor(bodyHeight * 0.4)); const bottomHeight = Math.max(3, bodyHeight - topHeight - 1); @@ -78,7 +88,9 @@ function renderOptionDetails(model: ConfigScreenModel, width: number): string[] lines.push(renderSectionHeader('Editor', paneWidth)); lines.push(''); lines.push(chalk.hex(colors.primary).bold(option.label)); - lines.push(...wrapLines([chalk.hex(colors.textMuted)(option.description)], Math.max(12, paneWidth - 2))); + lines.push( + ...wrapLines([chalk.hex(colors.textMuted)(option.description)], Math.max(12, paneWidth - 2)) + ); lines.push(''); const rawValue = model.isEditing ? `${model.draftValue}β–ˆ` : model.draftValue; diff --git a/src/tui/screens/list/view.ts b/src/tui/screens/list/view.ts index d715e31..b059834 100644 --- a/src/tui/screens/list/view.ts +++ b/src/tui/screens/list/view.ts @@ -102,7 +102,8 @@ function renderSearchBar(model: ListScreenModel, width: number): string { const searchBox = chalk.bgHex(colors.bgHighlight).hex(colors.textBright)( ` ${buffer}${cursor} `.padEnd(boxWidth) ); - const hint = width > 60 ? chalk.hex(colors.textMuted)(' (Enter to search, Esc to cancel)') : ''; + const hint = + width > 60 ? chalk.hex(colors.textMuted)(' (Enter to search, Esc to cancel)') : ''; return prefix + searchBox + hint; } @@ -179,18 +180,17 @@ function renderFilters(model: ListScreenModel, width: number): string { function renderTableHeader(width: number): string { const cols = getTableColumns(width); - const titleWidth = - Math.max( - 8, - width - - cols.selector - - cols.status - - cols.id - - cols.difficulty - - cols.acceptance - - cols.premium - + const titleWidth = Math.max( + 8, + width - + cols.selector - + cols.status - + cols.id - + cols.difficulty - + cols.acceptance - + cols.premium - 6 - ); + ); const sections = [ ' ' + padEnd('', cols.selector) + padEnd('', cols.status) + padEnd('ID', cols.id), @@ -211,18 +211,17 @@ function renderTableHeader(width: number): string { function renderProblemRow(problem: Problem, isSelected: boolean, width: number): string { const cols = getTableColumns(width); - const titleWidth = - Math.max( - 8, - width - - cols.selector - - cols.status - - cols.id - - cols.difficulty - - cols.acceptance - - cols.premium - + const titleWidth = Math.max( + 8, + width - + cols.selector - + cols.status - + cols.id - + cols.difficulty - + cols.acceptance - + cols.premium - 6 - ); + ); const selector = isSelected ? chalk.hex(colors.primary).bold('β–Ά ') : ' '; @@ -245,7 +244,8 @@ function renderProblemRow(problem: Problem, isSelected: boolean, width: number): : problem.difficulty === 'Medium' ? colors.warning : colors.error; - const diff = cols.difficulty > 0 ? chalk.hex(diffColor)(padEnd(problem.difficulty, cols.difficulty)) : ''; + const diff = + cols.difficulty > 0 ? chalk.hex(diffColor)(padEnd(problem.difficulty, cols.difficulty)) : ''; const acc = cols.acceptance > 0 ? chalk.hex(colors.textMuted)(padStart(`${Math.round(problem.acRate)}%`, cols.acceptance)) @@ -289,7 +289,8 @@ function renderListFooter(model: ListScreenModel, width: number): string { keyHint('b', 'Bookmarks'), keyHint('↡', 'Open'), ]; - const rightPart = width < 70 ? `${keyHint('j/k', 'Move')} ${keyHint('↡', 'Open')}` : hints.join(' '); + const rightPart = + width < 70 ? `${keyHint('j/k', 'Move')} ${keyHint('↡', 'Open')}` : hints.join(' '); const padding = width - stripAnsi(leftPart + moreInfo).length - stripAnsi(rightPart).length; return leftPart + moreInfo + (padding > 0 ? ' '.repeat(padding) : ' ') + rightPart; diff --git a/src/tui/screens/login/index.ts b/src/tui/screens/login/index.ts index 000ddcd..39c67e9 100644 --- a/src/tui/screens/login/index.ts +++ b/src/tui/screens/login/index.ts @@ -5,7 +5,10 @@ import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../util export { view } from './view.js'; function getConfiguredSite() { - return normalizeLeetCodeSiteInput(config.getSite?.() ?? config.getConfig?.().site ?? '') ?? DEFAULT_LEETCODE_SITE; + return ( + normalizeLeetCodeSiteInput(config.getSite?.() ?? config.getConfig?.().site ?? '') ?? + DEFAULT_LEETCODE_SITE + ); } export function init(): [LoginScreenModel, Command] { diff --git a/src/tui/screens/login/view.ts b/src/tui/screens/login/view.ts index 4a865e9..6dad132 100644 --- a/src/tui/screens/login/view.ts +++ b/src/tui/screens/login/view.ts @@ -34,7 +34,10 @@ export function view(model: LoginScreenModel, width: number, height: number): st '', '1. Continue to choose your LeetCode site', '2. We will show the correct cookie domain on the next screen', - '3. Copy the values of ' + chalk.bold.cyan('LEETCODE_SESSION') + ' and ' + chalk.bold.cyan('csrftoken'), + '3. Copy the values of ' + + chalk.bold.cyan('LEETCODE_SESSION') + + ' and ' + + chalk.bold.cyan('csrftoken'), '', 'Default storage: system keychain.', 'Use LEETCODECLI_CREDENTIAL_BACKEND=file + LEETCODECLI_MASTER_KEY for encrypted file mode.', @@ -116,7 +119,9 @@ export function view(model: LoginScreenModel, width: number, height: number): st ); boxLines.push(''); - boxLines.push(chalk.gray(`Cookie source: https://${domain} β†’ DevTools β†’ Application β†’ Cookies β†’ ${domain}`)); + boxLines.push( + chalk.gray(`Cookie source: https://${domain} β†’ DevTools β†’ Application β†’ Cookies β†’ ${domain}`) + ); boxLines.push(''); if (model.error) { boxLines.push(center(chalk.red(model.error), contentWidth - 4)); diff --git a/src/tui/screens/problem/index.ts b/src/tui/screens/problem/index.ts index b71ab64..5b23451 100644 --- a/src/tui/screens/problem/index.ts +++ b/src/tui/screens/problem/index.ts @@ -93,7 +93,10 @@ export function update( return [{ ...model, scrollOffset: Math.max(0, model.scrollOffset - 1) }, Cmd.none()]; case 'PROBLEM_SCROLL_DOWN': - return [{ ...model, scrollOffset: Math.min(bodyMaxScroll, model.scrollOffset + 1) }, Cmd.none()]; + return [ + { ...model, scrollOffset: Math.min(bodyMaxScroll, model.scrollOffset + 1) }, + Cmd.none(), + ]; case 'PROBLEM_PAGE_UP': { const page = Math.max(3, Math.floor(getBodyHeight(terminalHeight, model.drawerMode) * 0.8)); @@ -279,7 +282,10 @@ export function update( } case 'PROBLEM_HINT_SCROLL_UP': - return [{ ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, Cmd.none()]; + return [ + { ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, + Cmd.none(), + ]; case 'PROBLEM_HINT_SCROLL_DOWN': return [ @@ -359,7 +365,10 @@ export function update( return [closeDrawer(model), Cmd.none()]; case 'PROBLEM_SUBMISSIONS_SCROLL_UP': - return [{ ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, Cmd.none()]; + return [ + { ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, + Cmd.none(), + ]; case 'PROBLEM_SUBMISSIONS_SCROLL_DOWN': return [ @@ -428,7 +437,10 @@ export function update( return [closeDrawer(model), Cmd.none()]; case 'PROBLEM_NOTE_SCROLL_UP': - return [{ ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, Cmd.none()]; + return [ + { ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, + Cmd.none(), + ]; case 'PROBLEM_NOTE_SCROLL_DOWN': return [ @@ -463,7 +475,10 @@ export function update( return [closeDrawer(model), Cmd.none()]; case 'PROBLEM_DIFF_SCROLL_UP': - return [{ ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, Cmd.none()]; + return [ + { ...model, drawerScrollOffset: Math.max(0, model.drawerScrollOffset - 1) }, + Cmd.none(), + ]; case 'PROBLEM_DIFF_SCROLL_DOWN': return [ @@ -556,16 +571,23 @@ function getDrawerRows(model: ProblemScreenModel, drawerWidth: number): string[] } case 'note': - return wrapLines(formatNotePreview(model.noteContent || 'No notes found. Press e to edit.'), contentWidth); + return wrapLines( + formatNotePreview(model.noteContent || 'No notes found. Press e to edit.'), + contentWidth + ); case 'diff': - return (model.diffContent || 'No diff available.').split('\n').map((line) => sanitizeText(line)); + return (model.diffContent || 'No diff available.') + .split('\n') + .map((line) => sanitizeText(line)); case 'testResult': { const result = model.testResult; if (!result) return ['No test result available.']; - if (result.compile_error) return ['Compile Error', ...wrapLines([result.compile_error], contentWidth)]; - if (result.runtime_error) return ['Runtime Error', ...wrapLines([result.runtime_error], contentWidth)]; + if (result.compile_error) + return ['Compile Error', ...wrapLines([result.compile_error], contentWidth)]; + if (result.runtime_error) + return ['Runtime Error', ...wrapLines([result.runtime_error], contentWidth)]; if (result.correct_answer) return ['All test cases passed']; return ['Wrong answer']; } diff --git a/src/tui/screens/problem/layout.ts b/src/tui/screens/problem/layout.ts index 3386327..446d6cf 100644 --- a/src/tui/screens/problem/layout.ts +++ b/src/tui/screens/problem/layout.ts @@ -44,4 +44,3 @@ export function computeProblemViewport( return { bodyHeight, drawerHeight }; } - diff --git a/src/tui/screens/problem/view.ts b/src/tui/screens/problem/view.ts index d139131..76605f6 100644 --- a/src/tui/screens/problem/view.ts +++ b/src/tui/screens/problem/view.ts @@ -128,10 +128,7 @@ function renderDrawer(model: ProblemScreenModel, width: number, height: number): const visible = rows.slice(offset, offset + available); const lines: string[] = [ - renderSectionHeader( - model.focusRegion === 'drawer' ? `${title} β€’ Focused` : title, - width - ), + renderSectionHeader(model.focusRegion === 'drawer' ? `${title} β€’ Focused` : title, width), ]; lines.push(...visible.map((line) => truncate(line, width))); @@ -161,17 +158,32 @@ function renderSnapshotsDrawer(model: ProblemScreenModel, width: number, height: const name = truncate(snap.name, 20).padEnd(20); const lang = truncate(snap.language, 10).padEnd(10); const row = `${pointer} ${String(snap.id).padStart(3)} ${name} ${lang} ${String(snap.lines).padStart(4)}L`; - lines.push(selected ? chalk.bgHex(colors.bgHighlight)(truncate(row, width).padEnd(Math.max(0, width - 1))) : row); + lines.push( + selected + ? chalk.bgHex(colors.bgHighlight)(truncate(row, width).padEnd(Math.max(0, width - 1))) + : row + ); } } while (lines.length < height - 1) lines.push(''); - lines.push(chalk.hex(colors.textMuted)(truncate(`${keyHint('j/k', 'Move')} ${keyHint('d/r', 'Diff/Restore')} ${keyHint('V/Esc', 'Close')}`, width))); + lines.push( + chalk.hex(colors.textMuted)( + truncate( + `${keyHint('j/k', 'Move')} ${keyHint('d/r', 'Diff/Restore')} ${keyHint('V/Esc', 'Close')}`, + width + ) + ) + ); return lines.slice(0, height); } -function getDrawerRows(model: ProblemScreenModel, mode: ProblemDrawerMode, width: number): string[] { +function getDrawerRows( + model: ProblemScreenModel, + mode: ProblemDrawerMode, + width: number +): string[] { const contentWidth = Math.max(10, width - 2); switch (mode) { @@ -180,7 +192,9 @@ function getDrawerRows(model: ProblemScreenModel, mode: ProblemDrawerMode, width const index = model.activeHintIndex ?? 0; const raw = hints[index] ?? 'No hints available'; const clean = sanitize(raw); - const header = chalk.hex(colors.primary).bold(`Hint ${index + 1}/${Math.max(1, hints.length)}`); + const header = chalk + .hex(colors.primary) + .bold(`Hint ${index + 1}/${Math.max(1, hints.length)}`); return [header, ...wrapLines([clean || 'No hint content'], contentWidth)]; } @@ -206,7 +220,10 @@ function getDrawerRows(model: ProblemScreenModel, mode: ProblemDrawerMode, width } case 'note': - return wrapLines(formatNotePreview(model.noteContent || 'No notes found. Press e to edit.'), contentWidth); + return wrapLines( + formatNotePreview(model.noteContent || 'No notes found. Press e to edit.'), + contentWidth + ); case 'diff': { const content = model.diffContent || 'No diff available.'; @@ -255,14 +272,13 @@ function getDrawerRows(model: ProblemScreenModel, mode: ProblemDrawerMode, width model.successMessage || model.error || (model.isRunning ? 'Working...' : 'Ready'); - const color = - model.error - ? colors.error - : model.successMessage - ? colors.success - : model.isRunning - ? colors.primary - : colors.textMuted; + const color = model.error + ? colors.error + : model.successMessage + ? colors.success + : model.isRunning + ? colors.primary + : colors.textMuted; return wrapLines([message], contentWidth).map((line) => chalk.hex(color)(line)); } @@ -382,7 +398,12 @@ function formatNotePreview(content: string): string[] { return lines.length > 0 ? lines : ['No notes found. Press e to edit.']; } -function renderCenterMessage(message: string, width: number, height: number, color: string): string { +function renderCenterMessage( + message: string, + width: number, + height: number, + color: string +): string { const lines: string[] = []; const top = Math.max(0, Math.floor(height / 2) - 1); for (let i = 0; i < top; i++) lines.push(''); diff --git a/src/tui/screens/workspace/index.ts b/src/tui/screens/workspace/index.ts index 0df0549..61461f9 100644 --- a/src/tui/screens/workspace/index.ts +++ b/src/tui/screens/workspace/index.ts @@ -12,7 +12,9 @@ type EditableWorkspaceConfig = { const EDIT_FIELDS: Array = ['lang', 'workDir', 'editor', 'syncRepo']; -function toEditableConfig(config: ReturnType): EditableWorkspaceConfig { +function toEditableConfig( + config: ReturnType +): EditableWorkspaceConfig { return { lang: config.lang ?? '', workDir: config.workDir ?? '', @@ -92,20 +94,15 @@ export function update( switch (msg.type) { case 'WORKSPACE_UP': if (model.isEditing) return [model, Cmd.none()]; - return [ - syncSelection(model, model.workspaces, model.cursor - 1), - Cmd.none(), - ]; + return [syncSelection(model, model.workspaces, model.cursor - 1), Cmd.none()]; case 'WORKSPACE_DOWN': if (model.isEditing) return [model, Cmd.none()]; - return [ - syncSelection(model, model.workspaces, model.cursor + 1), - Cmd.none(), - ]; + return [syncSelection(model, model.workspaces, model.cursor + 1), Cmd.none()]; case 'WORKSPACE_SELECT': { - if (model.isEditing || model.showCreateInput || model.showDeleteConfirm) return [model, Cmd.none()]; + if (model.isEditing || model.showCreateInput || model.showDeleteConfirm) + return [model, Cmd.none()]; const target = model.workspaces[model.cursor]; if (!target) return [model, Cmd.none()]; if (target === model.activeWorkspace) { @@ -122,7 +119,10 @@ export function update( } case 'WORKSPACE_FOCUS_LIST': - return [{ ...model, paneFocus: 'list', isEditing: false, isDirty: false, error: null }, Cmd.none()]; + return [ + { ...model, paneFocus: 'list', isEditing: false, isDirty: false, error: null }, + Cmd.none(), + ]; case 'WORKSPACE_FOCUS_EDITOR': return [{ ...model, paneFocus: 'editor', error: null }, Cmd.none()]; @@ -176,7 +176,8 @@ export function update( ]; case 'WORKSPACE_EDIT_SAVE': { - if (!model.isEditing || !model.selectedWorkspace || !model.draftConfig) return [model, Cmd.none()]; + if (!model.isEditing || !model.selectedWorkspace || !model.draftConfig) + return [model, Cmd.none()]; const nextDraft = { lang: model.draftConfig.lang.trim(), workDir: model.draftConfig.workDir.trim(), @@ -230,11 +231,17 @@ export function update( case 'WORKSPACE_CREATE_INPUT': if (!model.showCreateInput) return [model, Cmd.none()]; - return [{ ...model, newWorkspaceName: model.newWorkspaceName + msg.char, error: null }, Cmd.none()]; + return [ + { ...model, newWorkspaceName: model.newWorkspaceName + msg.char, error: null }, + Cmd.none(), + ]; case 'WORKSPACE_CREATE_BACKSPACE': if (!model.showCreateInput) return [model, Cmd.none()]; - return [{ ...model, newWorkspaceName: model.newWorkspaceName.slice(0, -1), error: null }, Cmd.none()]; + return [ + { ...model, newWorkspaceName: model.newWorkspaceName.slice(0, -1), error: null }, + Cmd.none(), + ]; case 'WORKSPACE_CREATE_CANCEL': return [{ ...model, showCreateInput: false, newWorkspaceName: '', error: null }, Cmd.none()]; diff --git a/src/tui/screens/workspace/view.ts b/src/tui/screens/workspace/view.ts index 1f59254..eb65258 100644 --- a/src/tui/screens/workspace/view.ts +++ b/src/tui/screens/workspace/view.ts @@ -31,7 +31,15 @@ export function view(model: WorkspaceScreenModel, width: number, height: number) const bodyHeight = Math.max(3, safeHeight - lines.length - 3); if (safeWidth >= 90) { - lines.push(...splitPane(renderWorkspaceList(model, safeWidth), renderWorkspaceEditor(model, safeWidth), safeWidth, bodyHeight, 0.43)); + lines.push( + ...splitPane( + renderWorkspaceList(model, safeWidth), + renderWorkspaceEditor(model, safeWidth), + safeWidth, + bodyHeight, + 0.43 + ) + ); } else { const topHeight = Math.max(3, Math.floor(bodyHeight * 0.45)); const bottomHeight = Math.max(3, bodyHeight - topHeight - 1); @@ -122,7 +130,9 @@ function renderWorkspaceEditor(model: WorkspaceScreenModel, width: number): stri lines.push(''); lines.push( chalk.hex(colors.textMuted)( - model.isEditing ? 'Editing field: Enter=Save Esc=Cancel' : 'Press Enter to edit selected field' + model.isEditing + ? 'Editing field: Enter=Save Esc=Cancel' + : 'Press Enter to edit selected field' ) ); return lines; @@ -156,7 +166,10 @@ function renderFooter(model: WorkspaceScreenModel, width: number): string[] { [ { key: '↑/↓', label: model.paneFocus === 'list' ? 'Select workspace' : 'Select field' }, { key: 'Tab/h/l', label: 'Switch pane' }, - { key: 'Enter', label: model.paneFocus === 'list' ? 'Switch/Edit' : model.isEditing ? 'Save' : 'Edit' }, + { + key: 'Enter', + label: model.paneFocus === 'list' ? 'Switch/Edit' : model.isEditing ? 'Save' : 'Edit', + }, { key: 'c', label: 'Create' }, { key: 'd', label: 'Delete' }, { key: 'Esc', label: 'Back/Cancel' }, diff --git a/src/tui/types.ts b/src/tui/types.ts index a0068ba..c9ffe28 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -445,7 +445,12 @@ export type Command = | { readonly type: 'CMD_SWITCH_WORKSPACE'; readonly name: string } | { readonly type: 'CMD_FETCH_CHANGELOG' } | { readonly type: 'CMD_LOGOUT' } - | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string; readonly site: LeetCodeSite }; + | { + readonly type: 'CMD_LOGIN'; + readonly session: string; + readonly csrf: string; + readonly site: LeetCodeSite; + }; export const Cmd = { none: (): Command => ({ type: 'CMD_NONE' }), diff --git a/src/tui/update.ts b/src/tui/update.ts index 2145957..44dfe30 100644 --- a/src/tui/update.ts +++ b/src/tui/update.ts @@ -673,19 +673,25 @@ function handleProblemKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command else if (key.name === 'd') problemMsg = { type: 'PROBLEM_DIFF_SNAPSHOT' }; else if (key.name === 'r') problemMsg = { type: 'PROBLEM_RESTORE_SNAPSHOT' }; } else if (problemModel.drawerMode === 'hint') { - if (key.name === 'j' || key.name === 'down') problemMsg = { type: 'PROBLEM_HINT_SCROLL_DOWN' }; - else if (key.name === 'k' || key.name === 'up') problemMsg = { type: 'PROBLEM_HINT_SCROLL_UP' }; + if (key.name === 'j' || key.name === 'down') + problemMsg = { type: 'PROBLEM_HINT_SCROLL_DOWN' }; + else if (key.name === 'k' || key.name === 'up') + problemMsg = { type: 'PROBLEM_HINT_SCROLL_UP' }; } else if (problemModel.drawerMode === 'submissions') { if (key.name === 'j' || key.name === 'down') problemMsg = { type: 'PROBLEM_SUBMISSIONS_SCROLL_DOWN' }; else if (key.name === 'k' || key.name === 'up') problemMsg = { type: 'PROBLEM_SUBMISSIONS_SCROLL_UP' }; } else if (problemModel.drawerMode === 'note') { - if (key.name === 'j' || key.name === 'down') problemMsg = { type: 'PROBLEM_NOTE_SCROLL_DOWN' }; - else if (key.name === 'k' || key.name === 'up') problemMsg = { type: 'PROBLEM_NOTE_SCROLL_UP' }; + if (key.name === 'j' || key.name === 'down') + problemMsg = { type: 'PROBLEM_NOTE_SCROLL_DOWN' }; + else if (key.name === 'k' || key.name === 'up') + problemMsg = { type: 'PROBLEM_NOTE_SCROLL_UP' }; } else if (problemModel.drawerMode === 'diff') { - if (key.name === 'j' || key.name === 'down') problemMsg = { type: 'PROBLEM_DIFF_SCROLL_DOWN' }; - else if (key.name === 'k' || key.name === 'up') problemMsg = { type: 'PROBLEM_DIFF_SCROLL_UP' }; + if (key.name === 'j' || key.name === 'down') + problemMsg = { type: 'PROBLEM_DIFF_SCROLL_DOWN' }; + else if (key.name === 'k' || key.name === 'up') + problemMsg = { type: 'PROBLEM_DIFF_SCROLL_UP' }; } } @@ -723,9 +729,7 @@ function handleProblemKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command return [model, Cmd.none()]; } -function mapProblemGlobalAction( - keyName: string -): import('./types.js').ProblemMsg | null { +function mapProblemGlobalAction(keyName: string): import('./types.js').ProblemMsg | null { if (keyName === 'p') return { type: 'PROBLEM_PICK' }; if (keyName === 't') return { type: 'PROBLEM_TEST' }; if (keyName === 's') return { type: 'PROBLEM_SUBMIT' }; @@ -787,7 +791,7 @@ function handleGenericKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command } else if (key.name === 'left') { configMsg = { type: 'CONFIG_FOCUS_LIST' }; } else if (key.name === 'right') { - configMsg = { type: 'CONFIG_FOCUS_EDITOR' }; + configMsg = { type: 'CONFIG_FOCUS_EDITOR' }; } else { if (key.name === 'j' || key.name === 'down') configMsg = { type: 'CONFIG_OPTION_DOWN' }; else if (key.name === 'k' || key.name === 'up') configMsg = { type: 'CONFIG_OPTION_UP' }; @@ -915,7 +919,8 @@ function handleWorkspaceKeyPress(model: AppModel, msg: AppMsg): [AppModel, Comma } else { if (key.name === 'j' || key.name === 'down') wsMsg = { type: 'WORKSPACE_FIELD_DOWN' }; else if (key.name === 'k' || key.name === 'up') wsMsg = { type: 'WORKSPACE_FIELD_UP' }; - else if (key.name === 'return' || key.name === 'enter') wsMsg = { type: 'WORKSPACE_EDIT_START' }; + else if (key.name === 'return' || key.name === 'enter') + wsMsg = { type: 'WORKSPACE_EDIT_START' }; } } @@ -976,7 +981,13 @@ function handleLoginKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command] } else if (loginModel.step === 'site') { if (key.name === 'escape') { return updateLogin(model, { type: 'LOGIN_BACK' }); - } else if (key.name === 'tab' || key.name === 'down' || key.name === 'up' || key.name === 'left' || key.name === 'right') { + } else if ( + key.name === 'tab' || + key.name === 'down' || + key.name === 'up' || + key.name === 'left' || + key.name === 'right' + ) { return updateLogin(model, { type: 'LOGIN_SWITCH_SITE' }); } else if (key.name === 'return' || key.name === 'enter') { return updateLogin(model, { type: 'LOGIN_SUBMIT' }); diff --git a/src/utils/star-prompt.ts b/src/utils/star-prompt.ts new file mode 100644 index 0000000..154e7b3 --- /dev/null +++ b/src/utils/star-prompt.ts @@ -0,0 +1,117 @@ +import chalk from 'chalk'; +import open from 'open'; +import inquirer from 'inquirer'; +import { + STAR_PROMPT_MILESTONES, + STAR_PROMPT_RECURRING_INTERVAL, + starPromptStorage, +} from '../storage/star-prompt.js'; + +export const GITHUB_REPO_URL = 'https://github.com/night-slayer18/leetcode-cli'; + +export type StarPromptTrigger = 'accepted'; + +function shouldSkipPrompt(): boolean { + if (process.env.VITEST || process.env.CI) return true; + if (starPromptStorage.getState().dismissed) return true; + return false; +} + +/** + * Check whether a milestone prompt should fire. + * Returns the milestone number, or null. + */ +function getPendingMilestone(totalAccepted: number, shownMilestones: number[]): number | null { + if (STAR_PROMPT_MILESTONES.includes(totalAccepted as (typeof STAR_PROMPT_MILESTONES)[number])) { + if (!shownMilestones.includes(totalAccepted)) { + return totalAccepted; + } + } + return null; +} + +/** + * Check whether all milestones are exhausted and the recurring interval has been reached. + */ +function isRecurringReady( + shownMilestones: number[], + submissionsSinceLastPrompt: number +): boolean { + const allMilestonesShown = STAR_PROMPT_MILESTONES.every((m) => shownMilestones.includes(m)); + if (!allMilestonesShown) return false; + return submissionsSinceLastPrompt >= STAR_PROMPT_RECURRING_INTERVAL; +} + +export function shouldShowStarPrompt(trigger: StarPromptTrigger): boolean { + if (trigger !== 'accepted' || shouldSkipPrompt()) return false; + + const state = starPromptStorage.getState(); + + if (getPendingMilestone(state.totalAccepted, state.shownMilestones)) return true; + if (isRecurringReady(state.shownMilestones, state.submissionsSinceLastPrompt)) return true; + + return false; +} + +export async function openGitHubRepo(): Promise { + await open(GITHUB_REPO_URL, { wait: false }); +} + +function displayPassivePrompt(): void { + console.log(); + console.log( + chalk.yellow('⭐ Enjoying leetcode-cli?') + + chalk.gray(' Star the repo to help others discover it: ') + + chalk.cyan.underline(GITHUB_REPO_URL) + ); + console.log(); +} + +export async function maybeShowStarPrompt(trigger: StarPromptTrigger): Promise { + if (trigger === 'accepted') { + starPromptStorage.incrementSubmissionCount(); + } + + if (!shouldShowStarPrompt(trigger)) return; + + const state = starPromptStorage.getState(); + const milestone = getPendingMilestone(state.totalAccepted, state.shownMilestones); + + if (milestone) { + starPromptStorage.markMilestoneShown(milestone); + } else { + starPromptStorage.markRecurringShown(); + } + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + displayPassivePrompt(); + return; + } + + console.log(); + const { choice } = await inquirer.prompt<{ choice: 'star' | 'later' | 'dismiss' }>([ + { + type: 'list', + name: 'choice', + message: chalk.yellow( + 'Enjoying leetcode-cli? A GitHub star helps new contributors find the project.' + ), + choices: [ + { name: 'Open GitHub to star', value: 'star' }, + { name: 'Maybe later', value: 'later' }, + { name: "Don't ask again", value: 'dismiss' }, + ], + }, + ]); + + if (choice === 'star') { + console.log(chalk.gray(`Opening ${GITHUB_REPO_URL}`)); + await openGitHubRepo(); + console.log(chalk.green('Thanks for supporting the project!')); + } else if (choice === 'dismiss') { + starPromptStorage.dismissPermanently(); + console.log(chalk.gray("Got it β€” we won't ask again.")); + } + + console.log(); +} diff --git a/src/utils/templates.ts b/src/utils/templates.ts index b264af1..c3be9be 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -36,10 +36,7 @@ export function getCodeTemplate( return snippets[0] ?? null; } -export function getPremiumPlaceholderCode( - language: SupportedLanguage, - title: string -): string { +export function getPremiumPlaceholderCode(language: SupportedLanguage, title: string): string { const commentStyle = getCommentStyle(language); return `${commentStyle.single} Premium Problem - ${title}\n${commentStyle.single} Solution stub not available - visit LeetCode to view`; } diff --git a/tsconfig.json b/tsconfig.json index dd44445..ff49920 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "declarationMap": true, "sourceMap": true, "jsx": "react-jsx", - "jsxImportSource": "react" + "jsxImportSource": "react", + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]