diff --git a/capture/output/nuxt-dev-install-module.svg b/capture/output/nuxt-dev-install-module.svg index b9abff96e..e20fd6398 100644 --- a/capture/output/nuxt-dev-install-module.svg +++ b/capture/output/nuxt-dev-install-module.svg @@ -16,47 +16,44 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} nuxt dev (module auto-install) - -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0s STARTING starting Nuxt...rrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 0.1s STARTING setting up modulesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.3s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.4s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 0.5s STARTING generating typesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 0.7s STARTING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.9s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.0s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.1s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.3s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.4s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.5s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -❯ Do you want to install @nuxt/scripts package?● Yes /○No -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠹ installing with npm · 0.0s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠸ installing with npm · 0.1s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠼ installing with npm · 0.2s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠴ installing with npm · 0.4s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠦ installing with npm · 0.5s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠧ installing with npm · 0.6s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠇ installing with npm · 0.7s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠏ installing with npm · 0.8s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠋ installing with npm · 1.0s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠙ installing with npm · 1.1s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠹ installing with npm · 1.2s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠸ installing with npm · 1.3s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠼ installing with npm · 1.4s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠴ installing with npm · 1.6s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠦ installing with npm · 1.7s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠧ installing with npm · 1.8s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠇ installing with npm · 1.9s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/⠏ installing with npm · 2.0s READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/✔ 1 request 482ms median ⚠ 1 warning READY ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.1s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 0.2s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.2s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.4s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.5s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.6s BUILDING ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/✔ 1 request 482ms median READY ✔dependencies installed · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.77sLocalhttp://localhost:3000/✔ 1 request 482ms median READY watching for changes · GET /scripts · 200 · 482msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING preparing your apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING starting Nuxt...oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 96ms STARTING setting up modulesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 201ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 301ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 439ms STARTING generating typesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 567ms STARTING bundling appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 687ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 807ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 927ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.04s STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +❯ Do you want to install @nuxt/scripts package?● Yes /○No +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠏ installing with npm · 0.0ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠋ installing with npm · 121ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠙ installing with npm · 242ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠹ installing with npm · 363ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠸ installing with npm · 483ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠼ installing with npm · 605ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠴ installing with npm · 725ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠦ installing with npm · 846ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠧ installing with npm · 967ms READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠇ installing with npm · 1.08s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠏ installing with npm · 1.20s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠋ installing with npm · 1.32s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠙ installing with npm · 1.44s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠹ installing with npm · 1.57s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠸ installing with npm · 1.69s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠼ installing with npm · 1.81s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠴ installing with npm · 1.93s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/⠦ installing with npm · 2.05s READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/✔ 1 request 406ms median ⚠ 1 warning READY ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 36ms BUILDING ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 195ms BUILDING ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 243ms BUILDING ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 368ms BUILDING ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 488ms BUILDING ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/✔ 1 request 406ms median READY ✔dependencies installed · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +✔ Do you want to install @nuxt/scripts package?Yes⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/✔ 1 request 406ms median READY watching for changes · GET /scripts · 200 · 406msrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit diff --git a/capture/output/nuxt-dev-install-module.txt b/capture/output/nuxt-dev-install-module.txt index 196202885..d35bf3619 100644 --- a/capture/output/nuxt-dev-install-module.txt +++ b/capture/output/nuxt-dev-install-module.txt @@ -1,4 +1,4 @@ -styles: eba055abc05015cd +styles: 676a790aac9bd9d9 Local http://localhost:3000/ Local http://localhost:3000/ ⠋ waiting for requests @@ -23,6 +23,7 @@ styles: eba055abc05015cd STARTING preparing your app STARTING setting up modules STARTING starting Nuxt... + o open · i info · l logs · n network · p routes · ? help · q quit r restart · o open · i info · l logs · n network · p routes · ? help · q quit ⣠⣦⣠⡀ Nuxt x.y.z ⣠⣦⣠⡀ Nuxt x.y.z ready in 42 ms diff --git a/capture/output/nuxt-dev-restart.svg b/capture/output/nuxt-dev-restart.svg index 149cf764c..64a89d3c6 100644 --- a/capture/output/nuxt-dev-restart.svg +++ b/capture/output/nuxt-dev-restart.svg @@ -16,31 +16,29 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} nuxt dev (restart on config change) - -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0s STARTING starting Nuxt...rrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 0.1s STARTING setting up modulesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.3s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.4s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 0.5s STARTING generating typesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 0.7s STARTING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.9s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.0s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.1s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.2s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.4s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2ready in 1.67sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━0% · 0.0s BUILDING nuxt.config.ts changed. Reloading Nuxt...rrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 0.1s BUILDING generating typesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.2s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.4s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.5s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.6s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2ready in 1.67sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 0.1s BUILDING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.1s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.3s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.4s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 0.5s BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2ready in 1.67sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING preparing your appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING starting Nuxt...oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 94ms STARTING setting up modulesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 200ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 304ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 424ms STARTING generating typesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 576ms STARTING bundling appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 695ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 815ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 941ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.06s STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━0% · 0.0ms BUILDING nuxt.config.ts changed. Reloading Nuxt...rrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 189ms BUILDING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 242ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 373ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 493ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 617ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 66ms BUILDING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 120ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 245ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 365ms BUILDING compiling changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.42sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit diff --git a/capture/output/nuxt-dev-restart.txt b/capture/output/nuxt-dev-restart.txt index aa38b25ed..573b639b9 100644 --- a/capture/output/nuxt-dev-restart.txt +++ b/capture/output/nuxt-dev-restart.txt @@ -1,4 +1,4 @@ -styles: cbc7a29b1cebf5be +styles: 1dd116504606b5d1 Local http://localhost:3000/ Local http://localhost:3000/ ⠋ waiting for requests @@ -23,6 +23,7 @@ styles: cbc7a29b1cebf5be STARTING preparing your app STARTING setting up modules STARTING starting Nuxt... + o open · i info · l logs · n network · p routes · ? help · q quit r restart · o open · i info · l logs · n network · p routes · ? help · q quit ⣠⣦⣠⡀ Nuxt x.y.z ⣠⣦⣠⡀ Nuxt x.y.z ready in 42 ms diff --git a/capture/output/nuxt-dev-static.svg b/capture/output/nuxt-dev-static.svg index 545491495..5785254eb 100644 --- a/capture/output/nuxt-dev-static.svg +++ b/capture/output/nuxt-dev-static.svg @@ -16,6 +16,6 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} nuxt dev (ready) -⣠⣦⣠⡀Nuxt4.5.2ready in 2.09sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.47sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit diff --git a/capture/output/nuxt-dev-static.txt b/capture/output/nuxt-dev-static.txt index e9afddcd7..1e474efdd 100644 --- a/capture/output/nuxt-dev-static.txt +++ b/capture/output/nuxt-dev-static.txt @@ -1,4 +1,4 @@ -styles: 1a9af3048c1f3c2b +styles: bd638746e7494b36 Local http://localhost:3000/ Local http://localhost:3000/ ⠋ waiting for requests @@ -16,6 +16,7 @@ styles: 1a9af3048c1f3c2b STARTING preparing your app STARTING setting up modules STARTING starting Nuxt... + o open · i info · l logs · n network · p routes · ? help · q quit r restart · o open · i info · l logs · n network · p routes · ? help · q quit ⣠⣦⣠⡀ Nuxt x.y.z ⣠⣦⣠⡀ Nuxt x.y.z ready in 42 ms diff --git a/capture/output/nuxt-dev.svg b/capture/output/nuxt-dev.svg index b7da9c376..63ad1b8fe 100644 --- a/capture/output/nuxt-dev.svg +++ b/capture/output/nuxt-dev.svg @@ -16,20 +16,17 @@ svg{--bg:#ffffff;--fg:#24292f;--chrome:#f6f8fa;--dot:#d0d7de} nuxt dev - -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0s STARTING starting Nuxt...rrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 0.2s STARTING setting up modulesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.3s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 0.5s STARTING preparing apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 0.7s STARTING generating typesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 0.8s STARTING generating typesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 0.9s STARTING bundling apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.1s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.2s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.3s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.4s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.5s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.7s STARTING building serverrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit -⣠⣦⣠⡀Nuxt4.5.2ready in 2.09sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING preparing your apprrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/⠋━━━━━━━━━━━━━━━━━━━━0% · 0.0ms STARTING starting Nuxt...oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━17% · 103ms STARTING setting up modulesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 229ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━33% · 337ms STARTING preparing appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━50% · 478ms STARTING generating typesoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━67% · 607ms STARTING bundling appoopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 730ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 850ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 971ms STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2Localhttp://localhost:3000/━━━━━━━━━━━━━━━━━━━━83% · 1.09s STARTING building serveroopen · iinfo · llogs · nnetwork · proutes · ?help · qquit +⣠⣦⣠⡀Nuxt4.5.2ready in 1.49sLocalhttp://localhost:3000/waiting for requests READY watching for changesrrestart · oopen · iinfo · llogs · nnetwork · proutes · ?help · qquit diff --git a/capture/output/nuxt-dev.txt b/capture/output/nuxt-dev.txt index e9afddcd7..1e474efdd 100644 --- a/capture/output/nuxt-dev.txt +++ b/capture/output/nuxt-dev.txt @@ -1,4 +1,4 @@ -styles: 1a9af3048c1f3c2b +styles: bd638746e7494b36 Local http://localhost:3000/ Local http://localhost:3000/ ⠋ waiting for requests @@ -16,6 +16,7 @@ styles: 1a9af3048c1f3c2b STARTING preparing your app STARTING setting up modules STARTING starting Nuxt... + o open · i info · l logs · n network · p routes · ? help · q quit r restart · o open · i info · l logs · n network · p routes · ? help · q quit ⣠⣦⣠⡀ Nuxt x.y.z ⣠⣦⣠⡀ Nuxt x.y.z ready in 42 ms diff --git a/packages/nuxt-cli/src/dev/shortcuts.ts b/packages/nuxt-cli/src/dev/shortcuts.ts index c359c638d..57005f57d 100644 --- a/packages/nuxt-cli/src/dev/shortcuts.ts +++ b/packages/nuxt-cli/src/dev/shortcuts.ts @@ -9,12 +9,13 @@ import { isCI, isTest } from 'std-env' import { guardReplayedInput, restoreRawMode, withDirectStdout } from '../utils/console' import { copyURL, openBrowser, printQRCode } from './listen' +import { isShutdownAdopted } from './shutdown' export type { ShortcutContext } from './shortcut-context' interface ActionContext extends ShortcutContext { - /** Stop reading shortcuts, so a quitting server does not keep stdin open. */ closeInput: () => void + open: () => void } interface Shortcut { @@ -31,11 +32,19 @@ const shortcuts: Shortcut[] = [ isAvailable: context => !!context.restart, action: context => context.restart?.(), }, + { + keys: ['restart-clear', 'restart --clear'], + description: 'restart with a cleared cache', + isAvailable: context => !!context.restart && !!context.clearCaches, + action: async (context) => { + await context.clearCaches?.() + await context.restart?.() + }, + }, { keys: ['o', 'open'], - description: 'open in browser', - isAvailable: context => !!context.listener, - action: context => context.listener && openBrowser(context.listener.url), + description: 'open in browser (press again while starting to cancel)', + action: context => context.open(), }, { keys: ['u', 'urls'], @@ -50,7 +59,7 @@ const shortcuts: Shortcut[] = [ action: context => context.listener && printQRCode(resolveShareableURL(context.listener), { showURL: true }), }, { - keys: ['copy'], + keys: ['y', 'copy'], description: 'copy the server URL to the clipboard', isAvailable: context => !!context.listener, action: context => context.listener && copyURL(resolveShareableURL(context.listener)), @@ -85,6 +94,10 @@ function resolveShareableURL(listener: Listener): string { async function quit(context: ActionContext): Promise { context.closeInput() + if (isShutdownAdopted()) { + process.emit('SIGINT' as any) + return + } try { await context.close() } @@ -100,17 +113,9 @@ function printHelp(context: ActionContext): void { ` ${styleText('dim', 'press')} ${styleText('bold', `${keys[0]} + enter`)} ${styleText('dim', `to ${description}`)}`, ) // eslint-disable-next-line no-console - console.log(`\n${lines.join('\n')}\n`) + console.log(`\n${lines.join('\n')}\n ${styleText('bold', 'Ctrl-C')} ${styleText('dim', 'to quit; press again during cleanup to force exit')}\n`) } -/** - * Without a readable stdin there are no shortcuts to offer, so point at the way - * to talk to the server instead: a caller driving the CLI without a keyboard - * (an agent, a wrapper script) otherwise has no indication that one exists. - * - * `/` is suggested rather than an API route because it is the one path every - * project serves. - */ function printRequestHint(): void { // eslint-disable-next-line no-console console.log(`\n ${styleText('dim', 'run')} ${styleText('bold', 'nuxt curl /')} ${styleText('dim', 'to send a request to this server')}\n`) @@ -120,23 +125,23 @@ function availableShortcuts(context: ShortcutContext): Shortcut[] { return shortcuts.filter(shortcut => shortcut.isAvailable?.(context) !== false) } -/** - * Bind ` + enter` shortcuts to stdin. - * - * No output stream is passed to readline, so it stays in non-terminal mode and - * does not intercept Ctrl-C or put stdin into raw mode. - */ +/** Bind line-based commands without raw mode or intercepting Ctrl-C. */ export function setupShortcuts(context: ShortcutContext): void { if (!process.stdin.isTTY || isCI || isTest) { - // A hint written into a redirected log is read by nobody and answered by - // nobody, so it is only offered while stdout is still a terminal. if (process.stdout.isTTY && !isCI && !isTest) { context.onReady(() => printRequestHint()) } return } + let armedOpen = false + let ready = false context.onReady(() => { + ready = true + if (armedOpen && context.listener) { + armedOpen = false + openBrowser(context.listener.url) + } // eslint-disable-next-line no-console console.log(`\n ${styleText('dim', 'press')} ${styleText('bold', 'h + enter')} ${styleText('dim', 'to see available shortcuts')}\n`) }) @@ -155,7 +160,16 @@ export function setupShortcuts(context: ShortcutContext): void { return } try { - await shortcut.action({ ...context, closeInput: () => rl.close() }) + await shortcut.action({ ...context, closeInput: () => rl.close(), open: () => { + if (ready && context.listener) { + openBrowser(context.listener.url) + } + else { + armedOpen = !armedOpen + // eslint-disable-next-line no-console + console.log(armedOpen ? 'Browser will open when the server is ready.' : 'Browser opening cancelled.') + } + } }) } catch (error) { console.error(error) diff --git a/packages/nuxt-cli/src/dev/shutdown.ts b/packages/nuxt-cli/src/dev/shutdown.ts index 66a7c66ee..3b705b420 100644 --- a/packages/nuxt-cli/src/dev/shutdown.ts +++ b/packages/nuxt-cli/src/dev/shutdown.ts @@ -46,7 +46,7 @@ const SHUTDOWN_NOTICE_MS = 1500 export function handleShutdownSignals(close: () => Promise): void { adoptShutdown() let closing = false - for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const signal of ['SIGINT', 'SIGTERM', 'SIGQUIT'] as const) { process.on(signal, () => { if (closing) { process.exit(130) diff --git a/packages/nuxt-cli/src/dev/tui/help-overlay.ts b/packages/nuxt-cli/src/dev/tui/help-overlay.ts index 480446ee7..ba82274eb 100644 --- a/packages/nuxt-cli/src/dev/tui/help-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/help-overlay.ts @@ -8,18 +8,23 @@ import { formatHints, ScreenOverlay } from './screen' export interface HelpEntry { keys: string[] - /** The key that does the same thing when held with control. */ ctrl?: string description: string } const VIEW_ENTRIES: HelpEntry[] = [ - { keys: ['y', 'enter'], description: 'copy the selected line' }, + { keys: ['↑ / k', '↓ / j'], description: 'select an entry' }, + { keys: ['Page Up', 'Page Down'], description: 'scroll a screenful' }, + { keys: ['Home', 'g'], description: 'go to the beginning' }, + { keys: ['End', 'G'], description: 'follow the latest entries' }, + { keys: ['y'], description: 'copy the selected entry' }, + { keys: ['enter'], description: 'open details, otherwise copy' }, { keys: ['Y'], description: 'copy the whole view' }, - { keys: ['/'], description: 'search' }, + { keys: ['/'], description: 'search (enter applies, esc cancels)' }, + { keys: ['esc', 'q'], description: 'close or go back; opening key also closes' }, ] -/** The keyboard shortcuts, as a view rather than a wall of log output. */ +/** Keyboard shortcut reference. */ export class HelpOverlay extends ScreenOverlay { #entries: () => HelpEntry[] @@ -28,6 +33,15 @@ export class HelpOverlay extends ScreenOverlay { this.#entries = entries } + open(): void { + if (this.isOpen) { + return + } + super.open() + this.select(0) + this.repaint() + } + protected get closeKeys(): readonly string[] { return ['h', '?'] } @@ -43,18 +57,20 @@ export class HelpOverlay extends ScreenOverlay { lines: [`${styleText('bold', formatKeys(entry).padEnd(width))} ${styleText(MUTED, entry.description)}`], }) return [ - ...entries.map(row), + { lines: [styleText('bold', 'global')] }, + ...entries.filter(entry => entry.ctrl).map(row), + { lines: ['', styleText('bold', 'main panel')] }, + ...entries.filter(entry => !entry.ctrl).map(row), { lines: ['', styleText('bold', 'in a view')] }, ...VIEW_ENTRIES.map(row), ] } protected renderHints(columns: number): string { - return formatHints([['q', 'close']], columns) + return formatHints([['↑/↓', 'select'], ['PgUp/PgDn', 'scroll'], ['esc/q', 'close']], columns) } } -/** Keys as a user presses them, with shifted letters spelled out. */ function formatKeys({ keys, ctrl }: HelpEntry): string { const named = keys.map(key => /^[A-Z]$/.test(key) ? `shift-${key.toLowerCase()}` : key) return [...named, ...ctrl ? [`ctrl-${ctrl}`] : []].join(' / ') diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index bf296ff6a..5db8bfa37 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -41,22 +41,16 @@ import { beginDevUI } from './session' export { beginDevUI } from './session' export type { DevUIController } -/** The controller driving each session, so a second caller joins it. */ const attached = new WeakMap() -/** How often the traffic ticker may repaint, so bursts cannot strobe the panel. */ const TICKER_REPAINT_MS = 250 -/** Frames the mark skips per painted one while waiting for the first render. */ const WARMUP_FRAME_RATIO = 4 -/** How long the mark keeps traffic colour after a request. */ const ACTIVITY_MS = 700 -/** How long passing feedback stays on the panel before it is dropped. */ const NOTICE_MS = 4000 -/** Statuses that mean a load is in flight, so the server is not up yet. */ const LOADING_STATUSES = new Set(['starting', 'building', 'restarting']) interface UIShortcut { @@ -65,16 +59,11 @@ interface UIShortcut { hint?: string /** Higher survives longer as the hint line narrows. */ priority?: number - /** The key that does the same thing when held with control. */ ctrl?: string - /** - * Match this exact key sequence rather than the key name, for shifted - * letters that would otherwise be indistinguishable from their lowercase - * shortcut. - */ + isAvailable?: () => boolean + /** Exact sequence, including case. */ sequence?: string description: string - /** Whether the shortcut is waiting on the server before it can act. */ isArmed?: () => boolean action: () => void } @@ -117,7 +106,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) surface.screenMode = 'split-footer' } const cwd = options.cwd || process.cwd() - /** Once the history has been read the counts have served their purpose. */ function acknowledgeLogs(): void { update({ warnings: 0, errors: 0 }) } @@ -136,7 +124,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) let shortcuts: UIShortcut[] = [] let qrCode: string | undefined let armedOpen = false - const helpOverlay = new HelpOverlay(() => shortcuts, write, release) + let ready = false + const helpOverlay = new HelpOverlay(() => shortcuts.filter(shortcut => shortcut.isAvailable?.() !== false), write, release) const infoOverlay = new InfoOverlay( () => describeSession(context, cwd, requests, sessionStart, state.update, state.updateLink), write, @@ -150,14 +139,8 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) let animation: NodeJS.Timeout | undefined let animationInterval = LOGO_FRAME_MS let activityTimer: NodeJS.Timeout | undefined - /** Report currently named on the status line. */ let reported: string | undefined let noticeTimer: NodeJS.Timeout | undefined - /** - * The load in flight raised an error, so the server never came up. Held apart - * from the counts, which are cumulative and so cannot say whether anything is - * wrong *now*. - */ let loadFailed = false function update(patch: Partial): void { @@ -173,8 +156,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) update({}) } - // Progress writes the panel state itself; going through `update` is what arms - // the animation for whatever it has just put there. session.onProgressChange(refresh) function advanceFrame(): void { @@ -187,17 +168,9 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) } - /** - * Animate the mark only while something is in flight, on screen. A request - * being rendered counts: the server is not loading, but it is the only thing - * happening, and a still panel in front of a slow page reads as a hung one. - */ function syncAnimation(): void { const busy = (state.status !== 'ready' && state.status !== 'error') || !!state.task || !!state.rendering const working = busy && !openOverlay() - // Waiting on a render is measured in seconds, sometimes tens of them, which - // is too long to spend a build's frame rate on: the panel only has to look - // alive. const interval = state.status === 'warming' || state.rendering ? LOGO_FRAME_MS * WARMUP_FRAME_RATIO : LOGO_FRAME_MS if (working && animation && interval !== animationInterval) { clearInterval(animation) @@ -214,16 +187,13 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) } } - /** A message held on the status line until the user has seen it. */ interface HeldNotice { text: string tone: 'info' | 'warn' - /** Replaces the status badge, for something that is waiting on the user. */ label?: string resolve: () => void } - /** Notices not yet acknowledged, oldest first; the line shows the newest. */ const heldNotices: HeldNotice[] = [] function clearNotice(): void { @@ -241,14 +211,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) clearNotice() } - /** - * Hold a message on the status line until the user acknowledges it with a - * keypress or the caller lets it go. - * - * The single path for anything that must not scroll away unnoticed, whether - * it was reported through the terminal host or recovered from a box a tool - * printed without knowing about the host. - */ function holdNotice(notice: { text: string, tone: 'info' | 'warn', label?: string }) { let resolve!: () => void const dismissed = new Promise((settle) => { @@ -261,7 +223,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return { dismiss: () => dismissHeld(held), dismissed } } - /** Show `text` for a moment. Nothing a notice reports outlives the moment. */ function showNotice(text: string, tone: 'info' | 'warn' | 'success'): void { clearTimeout(noticeTimer) update({ notice: { text: text.split('\n')[0]!.trim(), tone } }) @@ -275,48 +236,33 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) const repaintTicker = createTickerRepainter(refresh) - /** Drop the session's history: log events, the request table and the counts. */ function clearHistory(): void { events.clear() requests.clear() loadFailed = false - // With the history gone, the error badge would point at nothing. update({ failures: 0, ...state.status === 'error' ? { status: 'ready' as DevStatus, note: undefined } : {} }) } events.onClear(() => acknowledgeLogs()) events.onEvent((event, merged) => { - // A merge is another report of something already counted, not news. if (merged) { return } if (isBoxedNotice(event)) { - // The box itself is written above the panel, where its URL can be read and - // copied; the badge is what stops it scrolling away unnoticed. holdNotice({ text: firstSentence(event.message), tone: 'warn', label: 'ACTION' }) return } if (event.level <= 0) { - // An error raised while a load is in flight is that load failing, and - // nothing else reports that it has. One raised by a server already up - // belongs to the page it was serving. loadFailed ||= LOADING_STATUSES.has(state.status) update({ errors: (state.errors ?? 0) + 1, status: 'error', - // The phase note describes work that is no longer happening, so the - // badge's own description takes the line back. ...state.status === 'error' ? {} : { note: undefined }, }) } else if (event.level === 1) { - // A warning about what the CLI could not do says nothing about the app, so - // it is shown once rather than counted against the build. if (event.source === 'cli') { - // One raised while starting up describes the session itself, not a - // moment in it: how the server is exposed, what could not be set up. - // Those are worth holding until someone has looked at the panel. if (state.readyMs === undefined) { holdNotice({ text: firstSentence(event.message), tone: 'warn', label: 'WARNING' }) } @@ -331,15 +277,14 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) context.onReady(() => { + ready = true if (armedOpen && context.listener) { armedOpen = false openBrowser(context.listener.url) syncHints() } - // Whether anything is still being waited for is progress's to say: a ready - // listener only knows the socket is up, and a server nobody has asked for a - // page yet is not warming up, it is idle. const warming = state.status === 'warming' + syncHints() update({ status: warming ? 'warming' : 'ready', note: warming ? state.note : undefined, @@ -361,12 +306,12 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) update({ update: latest, updateLink: notes ? terminalLink(label, notes) : label }) }) - const quit = () => { + const quit = (signal: 'SIGINT' | 'SIGQUIT' = 'SIGINT') => { session.teardown({ keep: true }) if (!isShutdownAdopted()) { - process.exit(130) + process.exit(signal === 'SIGQUIT' ? 131 : 130) } - process.emit('SIGINT' as any) + process.emit(signal as any) } const settleRestart = () => update({ status: loadFailed ? 'error' : 'ready', note: undefined }) @@ -386,29 +331,22 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) await context.restart() } catch (error) { - // A restart that throws must not take the session with it: both call - // sites dispatch it from a keypress, where nothing is awaiting. showNotice(`could not restart: ${error instanceof Error ? error.message : error}`, 'warn') } finally { - // A restart that got through, or whose load failed, has already reported - // itself through `setStatus`; only one nothing spoke for is left to settle. if (state.status === 'restarting') { settleRestart() } } } - // Dispatch and the help view are both driven from here, so they cannot drift. shortcuts = [ - { keys: ['r'], ctrl: 'r', hint: 'restart', priority: 80, description: 'restart the dev server', action: () => void restart() }, - { keys: ['R'], sequence: 'R', description: 'restart with a cleared cache', action: () => void restart({ clearCache: true }) }, + { keys: ['r'], isAvailable: () => !!context.restart, hint: 'restart', priority: 80, description: 'restart the dev server', action: () => void restart() }, + { keys: ['R'], sequence: 'R', isAvailable: () => !!context.restart && !!context.clearCaches, description: 'restart with a cleared cache', action: () => void restart({ clearCache: true }) }, { keys: ['o'], hint: 'open', priority: 40, description: 'open in browser', isArmed: () => armedOpen, action: () => open() }, { keys: ['y'], description: 'copy the server URL to the clipboard', action: () => void copyURL(context, showNotice) }, - { keys: ['c'], ctrl: 'l', description: 'clear logs, requests and the console', action: () => { - clearHistory() - clearConsole(surface) - } }, + { keys: ['c'], description: 'clear the console without deleting history', action: () => clearConsole(surface) }, + { keys: ['x'], description: 'delete log and request history', action: clearHistory }, { keys: ['e'], description: 'open the logs at the last error', action: () => { surface.screenMode = 'alternate-screen' overlay.openAtLastError() @@ -418,14 +356,32 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) { keys: ['n'], hint: 'network', priority: 60, description: 'browse served requests', action: () => openView(trafficOverlay) }, { keys: ['p'], hint: 'routes', priority: 30, description: 'browse pages and server routes', action: () => openView(routeOverlay) }, { keys: ['?', 'h'], hint: 'help', priority: 100, description: 'show this help', action: () => openView(helpOverlay) }, - // A quit is only confirmed while something is in flight; when idle it takes - // effect at once, as in every other tool. - { keys: ['q'], ctrl: 'd', hint: 'quit', priority: 90, description: 'quit', action: () => state.status === 'ready' ? quit() : update({ confirmQuit: true }) }, + { keys: ['q'], hint: 'quit', priority: 90, description: 'quit', action: () => quit() }, + { keys: [], ctrl: 'c', description: 'quit (press again during cleanup to force exit)', action: () => quit() }, + { keys: [], ctrl: 'd', description: 'quit', action: () => quit() }, + { keys: [], ctrl: 'l', description: 'clear the console or redraw the current view', action: () => { + const active = openOverlay() + if (active) { + active.repaint() + } + else { + clearConsole(surface) + } + } }, + { keys: [], ctrl: 'r', isAvailable: () => !!context.restart, description: 'restart the dev server', action: () => { + openOverlay()?.close() + void restart() + } }, + ...process.platform === 'win32' + ? [] + : [ + { keys: [], ctrl: 'z', description: 'suspend (resume with fg)', action: () => suspend() }, + { keys: [], ctrl: '\\', description: 'quit with SIGQUIT', action: () => quit('SIGQUIT') }, + ], ] - /** Open the app, or arm the shortcut so a starting server opens once it is up. */ function open(): void { - if (context.listener) { + if (ready && context.listener) { openBrowser(context.listener.url) return } @@ -436,7 +392,7 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) function syncHints(): void { update({ hints: shortcuts - .filter((shortcut): shortcut is UIShortcut & { hint: string, priority: number } => !!shortcut.hint) + .filter((shortcut): shortcut is UIShortcut & { hint: string, priority: number } => !!shortcut.hint && shortcut.isAvailable?.() !== false) .map(({ keys, hint, priority, isArmed }) => ({ key: keys[0]!, label: hint, @@ -456,79 +412,93 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) const onKey = (key: Key) => { const active = openOverlay() - // Any keypress is proof the panel is being watched, so a held notice has - // done its work. The key still does whatever it would have done. for (const held of [...heldNotices]) { dismissHeld(held) } - if (key.ctrl && key.name === 'c') { - active?.close() - return quit() + if (key.meta) { + return } - - // Works inside every view too: the views render from the same history. - if (key.ctrl && key.name === 'l') { - clearHistory() - if (active) { - return active.repaint() + if (key.ctrl) { + const shortcut = shortcuts.find(shortcut => shortcut.ctrl === key.name) + if (shortcut?.isAvailable?.() === false) { + return showNotice('shortcut is not available yet', 'info') } - return clearConsole(surface) + return shortcut?.action() } - if (active) { return active.handleKey(key) } - if (state.confirmQuit && !key.ctrl) { - return key.name === 'y' ? quit() : update({ confirmQuit: false }) - } - - // Punctuation keys arrive with a sequence and no name, so both are matched. const shortcut = shortcuts.find(({ sequence }) => sequence && sequence === key.sequence) - ?? shortcuts.find(({ keys, ctrl, sequence }) => !sequence && (key.ctrl - ? ctrl === key.name - : (!!key.name && keys.includes(key.name)) || (!!key.sequence && keys.includes(key.sequence)))) + ?? shortcuts.find(({ keys, sequence }) => !sequence && ( + (!!key.name && !key.shift && keys.includes(key.name)) || (!!key.sequence && keys.includes(key.sequence)) + )) + if (shortcut?.isAvailable?.() === false) { + return showNotice('shortcut is not available yet', 'info') + } void shortcut?.action() } let detach = attachKeys(onKey) - /** Set at teardown, so a borrow that outlives the session cannot restore it. */ let torn = false + let resumeTerminal: (() => void) | undefined + let terminalHolds = 0 + let resumeSurface: (() => void) | undefined + + function holdTerminal(): () => void { + if (terminalHolds++ === 0) { + openOverlay()?.close() + detach() + resumeSurface = surface.suspend() + } + return () => { + if (--terminalHolds === 0) { + resumeSurface?.() + resumeSurface = undefined + if (!torn) { + detach = attachKeys(onKey, { ignoreBufferedInput: true }) + render() + } + } + } + } + + function suspend(): void { + if (process.platform === 'win32' || torn || resumeTerminal) { + return + } + resumeTerminal = holdTerminal() + process.kill(process.pid, 'SIGSTOP') + } + + function onContinue(): void { + if (!resumeTerminal) { + return + } + resumeTerminal() + resumeTerminal = undefined + } + + if (process.platform !== 'win32') { + process.on('SIGTSTP', suspend) + process.on('SIGCONT', onContinue) + } - /** - * Give borrowed work the terminal for as long as it needs it. - * - * The panel and the shortcuts both have to let go: the panel because it - * paints over the rows the work draws on, and the shortcuts because they - * hold stdin in raw mode and would answer the keystrokes meant for it. - */ async function lendTerminal(work: () => Promise): Promise { - openOverlay()?.close() - detach() - const resume = surface.suspend() + const resume = holdTerminal() try { return await work() } finally { resume() - // A session torn down mid-borrow (Ctrl-C answered a prompt) has already - // detached and given the terminal back; re-attaching would put stdin - // into raw mode with nothing listening and keep the process alive. - if (!torn) { - detach = attachKeys(onKey, { ignoreBufferedInput: true }) - render() - } } } - /** Settles when the terminal is free again, so borrowers take turns. */ let terminalQueue: Promise = Promise.resolve() - /** Set inside a borrow, so a nested borrow can be told from a rival one. */ const borrowScope = new AsyncLocalStorage() - /** Work reported through the host, most recent last; the panel shows the last. */ const tasks: Array<{ label: string, startedAt: number }> = [] function syncTasks(): void { @@ -538,10 +508,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) const releaseHost = registerTerminalHost({ version: 1, withTerminal: (work: () => Promise): Promise => { - // A borrower that asks again mid-borrow already has the terminal: kit - // lends it to a prompt whose consola implementation routes back through - // here, and queueing that behind itself would deadlock. A rival caller - // is not in the scope and still waits its turn. if (borrowScope.getStore()) { return work() } @@ -550,9 +516,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return result }, notify: (notification) => { - // The full text goes into scrollback where it can be read and copied, - // and into the history; the held notice is what stops it scrolling away - // unnoticed. surfaceText(renderNotification(notification)) events.push({ time: Date.now(), @@ -584,8 +547,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) if (!message) { return } - // The outcome goes two places: feedback on the panel now, and the - // history, which is where the line a spinner leaves behind survives. showNotice(message, outcome === 'failure' ? 'warn' : 'success') events.push({ time: Date.now(), @@ -600,13 +561,14 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }) session.onTeardown(() => { torn = true + process.off('SIGTSTP', suspend) + process.off('SIGCONT', onContinue) + onContinue() clearInterval(animation) clearTimeout(activityTimer) clearTimeout(noticeTimer) animation = undefined releaseHost() - // Nothing can be acknowledged on a torn-down panel, and a caller may be - // awaiting the dismissal. for (const held of [...heldNotices]) { dismissHeld(held) } @@ -620,15 +582,11 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) interactive: true, settleRestart, setStatus: (status, note) => { - // The counts only describe what is currently wrong, so a successful load - // supersedes earlier build errors. if (status === 'ready') { loadFailed = false update({ status, note: undefined, progress: undefined, phaseStartedAt: undefined, phaseElapsedMs: undefined, errors: 0, warnings: 0, failures: 0 }) return } - // Entering a working state restarts the clock, and drops any progress - // fraction: only a full load reports one, and a stale bar would lie. const restarted = state.status === 'ready' || state.status === 'error' update({ status, note, ...restarted ? { loadStartedAt: Date.now(), elapsedMs: 0, progress: undefined, phaseStartedAt: undefined, phaseElapsedMs: undefined } : {} }) }, @@ -649,17 +607,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return } requests.push(batch.map(request => ({ time: Date.now(), ...request }))) - // The bundler's own probes 503 while a restart is in flight const app = batch.filter(request => !request.internal) const failed = app.filter(request => request.status >= 500) - // Nuxt answers a failed render with its error page rather than logging it, - // so the response status is the only signal that something is wrong. const failing = (app.at(-1)?.status ?? 0) >= 500 - // A page that failed is answered by the next one that does not, but only - // a load that gets through clears a failed load. const recovered = app.length > 0 && !failing && state.status === 'error' && !loadFailed - // A request that failed because the load did is a symptom of it, and the - // load error is reported in the logs rather than against the request. const failureNote = failing && !loadFailed ? 'a request failed · press n to trace it' : undefined update({ active: true, @@ -674,8 +625,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) }, pushSpans: spans => requests.pushSpans(spans), pushReport: (report) => { - // Set before the event, which would otherwise paint the badge's standing - // description in between. reported = report.id update({ status: 'error', note: `${report.message} · press l to read it` }) events.push({ @@ -711,10 +660,6 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) return controller } -/** - * Repaint at most once per {@link TICKER_REPAINT_MS}, with a trailing repaint so - * the last request of a burst is always the one left on screen. - */ function createTickerRepainter(render: () => void): () => void { let last = 0 let timer: NodeJS.Timeout | undefined @@ -754,7 +699,6 @@ async function copyURL(context: ShortcutContext, notify: (text: string, tone: 'i } } -/** The URL block, in the order a user is most likely to want them. */ function describeURLs(context: ShortcutContext): PanelURL[] { const { listener } = context if (!listener) { @@ -767,7 +711,6 @@ function describeURLs(context: ShortcutContext): PanelURL[] { return urls } -/** The sections shown by the info view, gathered when it is opened. */ function describeSession( context: ShortcutContext, cwd: string, @@ -812,25 +755,21 @@ function describeSession( ] } -/** The gist of a message, for a status line that has one line to say it in. */ function firstSentence(message: string): string { return normaliseMessage(message).split('. ')[0]! } -/** A notification as it belongs in scrollback: legible, copyable, unboxed. */ function renderNotification({ title, message, level }: TerminalNotification): string { const mark = level === 'warn' ? styleText(['yellow', 'bold'], '\u26A0') : styleText('cyan', '\u2139') const head = title ? `${mark} ${styleText('bold', title)}\n` : '' return `${head}${message}` } -/** A version, linked to its release notes where the terminal supports it. */ function linkVersion(version: string): string { const notes = releaseNotesUrl('nuxt', version) return notes ? terminalLink(version, notes) : version } -/** A QR code for whichever URL another device could reach, if any. */ async function resolveQRCode(context: ShortcutContext): Promise { const url = context.listener?.qrURL || context.listener?.getURLs().find(({ type }) => type !== 'local')?.url @@ -841,7 +780,6 @@ async function resolveQRCode(context: ShortcutContext): Promise { if (!current) { return undefined @@ -855,7 +793,6 @@ async function resolveUpdate(current?: string): Promise { return update?.latest } catch { - // An unreachable registry must never disturb the session. return undefined } } diff --git a/packages/nuxt-cli/src/dev/tui/keys.ts b/packages/nuxt-cli/src/dev/tui/keys.ts index c40138458..6b247d129 100644 --- a/packages/nuxt-cli/src/dev/tui/keys.ts +++ b/packages/nuxt-cli/src/dev/tui/keys.ts @@ -8,20 +8,14 @@ import { filterTerminalReplies } from './terminal-replies' export interface Key { name?: string ctrl?: boolean + meta?: boolean + shift?: boolean sequence?: string } /** - * Put stdin into raw mode and deliver single keypresses. - * - * Raw mode means the terminal no longer turns Ctrl-C into `SIGINT`, so the - * handler receives it as a key and is responsible for shutdown. Replies from - * the terminal are dropped, and keys wait until the background query has - * finished with stdin. - * - * Pass `ignoreBufferedInput` when taking stdin back after something else held - * it, such as a prompt: what the terminal buffered meanwhile was typed at that, - * not at the panel. + * Deliver raw keypresses, excluding terminal replies. The handler owns Ctrl-C. + * Set `ignoreBufferedInput` when returning from a prompt. */ export function attachKeys(onKey: (key: Key) => void, { ignoreBufferedInput = false }: { ignoreBufferedInput?: boolean } = {}): () => void { let release: (() => void) | undefined @@ -61,7 +55,6 @@ export function attachKeys(onKey: (key: Key) => void, { ignoreBufferedInput = fa } } - // Synchronous when nothing holds stdin, so a caller can deliver a key at once. const held = whenStdinReleased() if (held) { void held.then(attach) diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index 698cd7d55..2433f67b0 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -57,7 +57,7 @@ export class LogOverlay extends ScreenOverlay { return this.#setLevel('warn') case 'e': return this.#setLevel('error') - case 'c': + case 't': return this.#toggleSource('cli') case 'b': return this.#toggleSource('build') @@ -65,6 +65,7 @@ export class LogOverlay extends ScreenOverlay { return this.#toggleSource('runtime') case 'x': this.#events.clear() + this.resetSelection() this.resetScroll() return true default: @@ -79,19 +80,18 @@ export class LogOverlay extends ScreenOverlay { const level = this.#level === 'all' ? 'all levels' : `${this.#level}+ only` const hidden = this.#events.recent(SCAN_LIMIT).length - this.#matching().length const hiddenNote = hidden > 0 ? ` · ${styleText(MUTED, `${hidden} hidden`)}` : '' - return ` ${styleText('bold', 'logs')} · ${sources} · ${level}${hiddenNote}${this.renderPosition()}${this.renderSearch()}` + return ` ${styleText('bold', 'logs')}${this.renderPosition()} · ${sources} · ${level}${hiddenNote}${this.renderSearch()}` } protected renderEntries(columns: number): OverlayEntry[] { const events = this.#matching() - // Sized to the locale's own time format rather than the widest possible one. - const timeWidth = Math.max(0, ...events.map(event => formatTime(event.time).length)) let heading: string | undefined return events.map((event) => { const repeated = event.requestId !== undefined && event.requestId === heading heading = event.requestId return { - lines: formatEvent(event, columns, timeWidth, repeated), + key: event, + lines: formatEvent(event, columns, repeated), copy: [formatTime(event.time), event.request, event.message].filter(Boolean).join(' '), } }) @@ -100,13 +100,14 @@ export class LogOverlay extends ScreenOverlay { protected renderHints(columns: number): string { return formatHints([ ['↑/↓', 'select'], - ['g/G', 'top/bottom'], + ['Home/End', 'top/live'], + ['PgUp/PgDn', 'scroll'], ['e', 'errors'], ['w', 'warnings'], ['a', 'all'], - ['c/b/r', 'cli/build/runtime'], + ['t/b/r', 'cli/build/runtime'], ['/', 'search'], - ['x', 'clear'], + ['x', 'delete logs'], ['y', 'copy'], ['Y', 'copy all'], ['q', 'close'], @@ -126,6 +127,7 @@ export class LogOverlay extends ScreenOverlay { #setLevel(level: LevelFilter): boolean { this.#level = this.#level === level ? 'all' : level + this.resetSelection() this.resetScroll() return true } @@ -136,6 +138,7 @@ export class LogOverlay extends ScreenOverlay { if (Object.values(this.#sources).every(shown => !shown)) { this.#sources[source] = true } + this.resetSelection() this.resetScroll() return true } @@ -145,17 +148,12 @@ export function formatTime(time: number): string { return new Date(time).toLocaleTimeString() } -/** - * A log as one or more rows. - * - * A log emitted for a request is headed by that request, sharing the line with - * the timestamp, and its message sits underneath at the usual message column. - * The heading repeats whenever the request changes, keyed on the individual - * request so two calls to the same path stay separate. - */ -export function formatEvent(event: DevLogEvent, columns: number, timeWidth: number, sameRequest = false): string[] { - const messageColumn = timeWidth + 1 - const time = styleText(MUTED, formatTime(event.time).padStart(timeWidth)) +/** Format log rows with timestamps and request headings. */ +export function formatEvent(event: DevLogEvent, columns: number, sameRequest = false): string[] { + const messageColumn = 9 + const date = new Date(event.time) + const clock = [date.getHours(), date.getMinutes(), date.getSeconds()].map(part => String(part).padStart(2, '0')).join(':') + const time = styleText(MUTED, clock) const tag = event.tag ? styleText(MUTED, `[${event.tag}] `) : '' const indent = ' '.repeat(messageColumn) diff --git a/packages/nuxt-cli/src/dev/tui/panel.ts b/packages/nuxt-cli/src/dev/tui/panel.ts index a1851bf24..dc8c1909f 100644 --- a/packages/nuxt-cli/src/dev/tui/panel.ts +++ b/packages/nuxt-cli/src/dev/tui/panel.ts @@ -112,7 +112,6 @@ export interface PanelState { notice?: { text: string, tone: 'info' | 'warn' | 'success', label?: string } /** Long-running work reported through the terminal host, while it runs. */ task?: { label: string, startedAt: number } - confirmQuit?: boolean /** Shortcut hints, dropped lowest-priority first when the line is full. */ hints?: PanelHint[] /** The keys are not being listened for yet, so the hints render greyed out. */ @@ -314,13 +313,6 @@ function renderNotice(state: PanelState): string { } function renderStatus(state: PanelState, columns: number): string { - if (state.confirmQuit) { - return truncate( - ` ${styleText(['bgYellow', 'black', 'bold'], ' QUIT? ')} ${styleText(MUTED, 'press')} ${styleText('bold', 'y')} ${styleText(MUTED, 'to confirm,')} ${styleText('bold', 'esc')} ${styleText(MUTED, 'to stay')}`, - columns, - ) - } - if (state.notice?.label) { return truncate( ` ${styleText(['bgYellow', 'black', 'bold'], ` ${state.notice.label} `)} ${styleText(MUTED, decapitalise(state.notice.text))}`, @@ -393,9 +385,6 @@ function renderTicker(state: PanelState, room: number): string { * go, only help is left: the rest of the keys are listed there anyway. */ function renderHints(state: PanelState, columns: number): string { - if (state.confirmQuit) { - return '' - } const remaining = [ // Whatever else has to go, the way to read the error stays on screen. ...state.errors ? [{ key: 'e', label: 'last error', priority: Number.POSITIVE_INFINITY }] : [], diff --git a/packages/nuxt-cli/src/dev/tui/request-overlay.ts b/packages/nuxt-cli/src/dev/tui/request-overlay.ts index 5638e5054..d34903cc6 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -55,15 +55,15 @@ export class RequestOverlay extends ScreenOverlay { return ['n'] } - handleKey(key: Key): void { - // Inside a trace, the ways out lead back to the table, not out of the view. + protected handleNavigationKey(key: Key): boolean { if (this.#detail && (key.name === 'escape' || key.name === 'q' || key.name === 'backspace' || key.name === 'n')) { this.#detail = undefined this.resetSelection() this.resetScroll() - return this.repaint() + this.repaint() + return true } - super.handleKey(key) + return false } protected activate(index: number): boolean { @@ -142,7 +142,8 @@ export class RequestOverlay extends ScreenOverlay { return formatHints([ ['↑/↓', 'select'], ['enter', 'trace'], - ['g/G', 'top/bottom'], + ['Home/End', 'top/live'], + ['PgUp/PgDn', 'scroll'], ['e', 'errors'], ['s', `slow >${SLOW_MS}ms`], ['a', 'all'], @@ -170,9 +171,9 @@ export class RequestOverlay extends ScreenOverlay { if (!events.length) { return [...summary, { lines: [styleText(MUTED, request.id === undefined ? 'this request predates log attribution' : 'no logs were captured for this request')] }] } - const timeWidth = Math.max(0, ...events.map(event => formatTime(event.time).length)) return [...summary, ...events.map(event => ({ - lines: formatEvent(event, columns, timeWidth, true), + key: event, + lines: formatEvent(event, columns, true), copy: [formatTime(event.time), event.message].filter(Boolean).join(' '), }))] } diff --git a/packages/nuxt-cli/src/dev/tui/screen.ts b/packages/nuxt-cli/src/dev/tui/screen.ts index 0284ad306..aaf0c8ba8 100644 --- a/packages/nuxt-cli/src/dev/tui/screen.ts +++ b/packages/nuxt-cli/src/dev/tui/screen.ts @@ -10,19 +10,18 @@ import { writeClipboard } from '../listen' const RENDER_DELAY_MS = 50 -/** How long a copy confirmation stays in the hint line. */ const NOTICE_MS = 2000 -/** Most characters copying a whole view puts on the clipboard, keeping the newest entries. */ const COPY_ALL_MAX_CHARS = 60_000 -/** Marks the selected entry; the same width is reserved on every row. */ const SELECTED_GUTTER = '▎ ' const GUTTER = ' ' const GUTTER_WIDTH = 2 export interface OverlayEntry { lines: string[] + /** Stable entry identity. */ + key?: object | string /** Plain text put on the clipboard when this entry is copied. */ copy?: string } @@ -30,26 +29,24 @@ export interface OverlayEntry { const ENTER_ALT = '\u001B[?1049h\u001B[?25l' const LEAVE_ALT = '\u001B[?25h\u001B[?1049l' -/** - * A full-screen view in the alternate buffer. - * - * Entering and leaving never disturbs the real scrollback, so a view can own the - * whole terminal for as long as it is open. Subclasses supply the content and - * any keys of their own; scrolling, throttled repaints, search, copying and the - * buffer switch are handled here. - */ +/** Alternate-screen view with navigation, search and copying. */ export abstract class ScreenOverlay { #write: (chunk: string) => void #onClose: () => void #subscribe: (listener: () => void) => () => void #open = false #offset = 0 + #following = true + #top = 0 + #anchor?: { key?: object | string, index: number, line: number } + #revealSelection = false #renderTimer?: NodeJS.Timeout #unsubscribe?: () => void #onResize = () => this.#scheduleRender() #query = '' #searching = false #selected?: number + #selectedKey?: object | string #notice?: { text: string, until: number } constructor(options: { @@ -102,8 +99,8 @@ export abstract class ScreenOverlay { return } this.#open = true - this.#offset = 0 - this.#selected = undefined + this.resetScroll() + this.resetSelection() this.#write(ENTER_ALT) this.#unsubscribe = this.#subscribe(() => this.#scheduleRender()) process.stdout.on('resize', this.#onResize) @@ -116,8 +113,6 @@ export abstract class ScreenOverlay { } this.#open = false clearTimeout(this.#renderTimer) - // A cleared timer that stays set would make every later repaint a no-op, - // and the views are reopened for the life of the session. this.#renderTimer = undefined process.stdout.off('resize', this.#onResize) this.#unsubscribe?.() @@ -125,7 +120,14 @@ export abstract class ScreenOverlay { this.#onClose() } + protected handleNavigationKey(_key: Key): boolean { + return false + } + handleKey(key: Key): void { + if (key.ctrl || key.meta) { + return + } if (this.#searching) { return this.#handleSearchKey(key) } @@ -134,6 +136,10 @@ export abstract class ScreenOverlay { return this.render() } + if (this.handleNavigationKey(key)) { + return + } + const page = Math.max(1, this.bodyRows() - 1) switch (key.name) { case 'q': @@ -148,18 +154,25 @@ export abstract class ScreenOverlay { this.#move(1) break case 'pageup': - this.#move(-page) + this.#page(-page) break case 'pagedown': - this.#move(page) + this.#page(page) + break + case 'home': + this.select(0) + break + case 'end': + this.resetSelection() + this.resetScroll() break case 'g': if (key.sequence === 'G') { - this.#selected = undefined - this.#offset = 0 + this.resetSelection() + this.resetScroll() } else { - this.#selected = 0 + this.select(0) } break case 'return': @@ -187,9 +200,8 @@ export abstract class ScreenOverlay { return Math.max(1, (process.stdout.rows || 24) - 3) } - /** The scroll position for a title line, or an empty string at the tail. */ protected renderPosition(): string { - return this.#offset > 0 ? ` \u00B7 ${paint('warning', `scrolled \u2191${this.#offset}`)}` : '' + return this.#following ? '' : ` · ${paint('warning', `follow paused${this.#offset > 0 ? ` · ↓${this.#offset} rows below` : ''}`)}` } /** The active search text, lowercased. Empty when nothing is being searched. */ @@ -217,28 +229,34 @@ export abstract class ScreenOverlay { else if (key.name === 'backspace') { this.#query = this.#query.slice(0, -1) } - else if (key.sequence && key.sequence.length === 1 && !key.ctrl && key.sequence >= ' ') { + else if (key.sequence && key.sequence.length === 1 && key.sequence >= ' ') { this.#query += key.sequence } else { return } + this.resetSelection() this.resetScroll() this.render() } protected resetScroll(): void { this.#offset = 0 + this.#following = true + this.#anchor = undefined + this.#revealSelection = false } - /** Drop the selection, for views that swap their entry list wholesale. */ protected resetSelection(): void { this.#selected = undefined + this.#selectedKey = undefined } /** Move the selection to `index`; rendering scrolls it into view. */ protected select(index: number): void { this.#selected = index + this.#following = false + this.#revealSelection = true } protected render(): void { @@ -246,9 +264,14 @@ export abstract class ScreenOverlay { const bodyRows = this.bodyRows() const entries = this.#entries() + if (!this.#revealSelection && this.#selectedKey !== undefined) { + const index = entries.findIndex(entry => entry.key === this.#selectedKey) + this.#selected = index < 0 ? undefined : index + } if (this.#selected !== undefined) { this.#selected = entries.length ? Math.min(this.#selected, entries.length - 1) : undefined } + this.#selectedKey = this.#selected === undefined ? undefined : entries[this.#selected]?.key const rows: string[] = [] let selectedRows: { start: number, end: number } | undefined @@ -262,17 +285,29 @@ export abstract class ScreenOverlay { } } - const maxOffset = Math.max(0, rows.length - bodyRows) - this.#offset = Math.min(this.#offset, maxOffset) - let end = rows.length - this.#offset - // Keep the selection on screen, scrolling the least needed to do so. - if (selectedRows) { - end = Math.max(end, selectedRows.end) - end = Math.min(end, Math.max(selectedRows.start + bodyRows, bodyRows)) - end = Math.min(end, rows.length) - this.#offset = rows.length - end + const maxTop = Math.max(0, rows.length - bodyRows) + let top = this.#following ? maxTop : this.#top + if (!this.#following && this.#anchor) { + const anchor = this.#anchor + const index = anchor.key === undefined ? anchor.index : entries.findIndex(entry => entry.key === anchor.key) + top = index < 0 + ? 0 + : entries.slice(0, index).reduce((total, entry) => total + entry.lines.length, 0) + + Math.min(anchor.line, Math.max(0, (entries[index]?.lines.length ?? 1) - 1)) + } + if (selectedRows && this.#revealSelection) { + if (selectedRows.end - selectedRows.start > bodyRows || selectedRows.start < top) { + top = selectedRows.start + } + else if (selectedRows.end > top + bodyRows) { + top = selectedRows.end - bodyRows + } } - const visible = rows.slice(Math.max(0, end - bodyRows), end) + this.#revealSelection = false + this.#top = Math.min(Math.max(0, top), maxTop) + this.#offset = Math.max(0, rows.length - this.#top - bodyRows) + this.#anchor = this.#rowAnchor(entries, this.#top) + const visible = rows.slice(this.#top, this.#top + bodyRows) const frame = [ truncate(this.renderTitle(columns), columns), @@ -288,14 +323,12 @@ export abstract class ScreenOverlay { if (this.#notice && this.#notice.until > Date.now()) { return styleText('green', this.#notice.text) } - // Typing captures every key, so the way out has to be on screen. const columns = process.stdout.columns || 80 return this.#searching ? formatHints([['enter', 'apply'], ['esc', 'cancel'], ['⌫', 'delete']], columns) : this.renderHints(columns) } - /** Views lay out inside the gutter, so their own truncation stays exact. */ #entries(): OverlayEntry[] { return this.renderEntries((process.stdout.columns || 80) - GUTTER_WIDTH) } @@ -306,24 +339,32 @@ export abstract class ScreenOverlay { return } if (this.#selected === undefined) { - this.#selected = delta > 0 ? this.#firstVisible(entries) : entries.length - 1 + const first = this.#rowAnchor(entries, this.#top) + this.select(delta > 0 ? Math.min((first?.index ?? 0) + (first?.line ? 1 : 0), entries.length - 1) : entries.length - 1) return } - this.#selected = Math.min(Math.max(this.#selected + delta, 0), entries.length - 1) + this.select(Math.min(Math.max(this.#selected + delta, 0), entries.length - 1)) } - /** The first entry that starts inside the rows currently on screen. */ - #firstVisible(entries: OverlayEntry[]): number { + #page(delta: number): void { + const entries = this.#entries() const rows = entries.reduce((total, entry) => total + entry.lines.length, 0) - const top = Math.max(0, rows - this.#offset - this.bodyRows()) - let row = 0 + this.#following = false + this.#top = Math.min(Math.max(0, this.#top + delta), Math.max(0, rows - this.bodyRows())) + this.#anchor = this.#rowAnchor(entries, this.#top) + this.#selected = this.#rowAnchor(entries, delta < 0 ? this.#top : Math.min(rows - 1, this.#top + this.bodyRows() - 1))?.index + this.#selectedKey = this.#selected === undefined ? undefined : entries[this.#selected]?.key + this.#revealSelection = false + } + + #rowAnchor(entries: OverlayEntry[], row: number): { key?: object | string, index: number, line: number } | undefined { + let start = 0 for (const [index, entry] of entries.entries()) { - if (row >= top) { - return index + if (row < start + entry.lines.length) { + return { key: entry.key, index, line: Math.max(0, row - start) } } - row += entry.lines.length + start += entry.lines.length } - return entries.length - 1 } async #copySelected(): Promise { @@ -388,11 +429,7 @@ export abstract class ScreenOverlay { } } -/** - * `key description` pairs joined the way every view's hint line is, dropped - * from the right until they fit. The first and last are kept: moving around - * and getting out matter more than any filter. - */ +/** Format hints to fit, preserving the first and last. */ export function formatHints(hints: Array<[key: string, description: string]>, columns = Number.POSITIVE_INFINITY): string { const remaining = [...hints] const render = () => remaining diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index 78b229e55..20b4435ca 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -508,6 +508,7 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = ['exit', onSignal], ['SIGINT', onInterrupt(130)], ['SIGTERM', onInterrupt(143)], + ['SIGQUIT', onInterrupt(131)], ['SIGHUP', onHangup], // Raw mode and a pinned panel would otherwise outlive the crash: node // prints the error and exits without unwinding through `exit` first. diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index 43c221f23..fd5738b1a 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -203,11 +203,6 @@ describe('dev tui panel', () => { expect(lines).toHaveLength(renderPanel({ ...READY, requests: 188, medianMs: 11 }, 100, 30).length) }) - it('keeps a notice out of the way of a quit confirmation', () => { - const text = renderPanelText({ ...READY, confirmQuit: true, notice: { text: 'copied', tone: 'success' } }, 100, 30) - expect(text).not.toContain('copied') - }) - it('keeps the status line and hints when the panel is squeezed', () => { const lines = renderPanel({ ...READY, notice: { text: 'copied', tone: 'success' } }, 60, 3).map(strip) expect(lines).toHaveLength(2) @@ -291,13 +286,6 @@ describe('dev tui panel', () => { expect(confirmed.find(line => line.includes('localhost'))).not.toContain('\u280B') }) - it('replaces the hints with a confirmation prompt', () => { - const text = renderPanelText({ ...READY, confirmQuit: true }, 100, 30) - expect(text).toContain('QUIT?') - expect(text).toContain('press y to confirm') - expect(text).not.toContain('q quit') - }) - it('substitutes plain characters when the terminal cannot render glyphs', () => { const text = renderPanelText({ ...READY, ascii: true, warnings: 1, errors: 2, requests: 3 }, 100, 30) expect(text).not.toMatch(/[⣠⣦⡀✔⚠✖]/) @@ -912,7 +900,7 @@ describe('log overlay', () => { it('should leave a message that carries its own colours alone', () => { const coloured = `${'\u001B[31m'}✖${'\u001B[39m'} ParseError` - const lines = formatEvent(event({ message: coloured, rendered: coloured, level: 0, type: 'error', styled: true }), 80, 8) + const lines = formatEvent(event({ message: coloured, rendered: coloured, level: 0, type: 'error', styled: true }), 80) expect(lines.join('\n')).toContain(coloured) }) @@ -938,8 +926,7 @@ describe('log overlay', () => { it('truncates by visible width and carries styling across the cut', () => { const styled = event({ message: `\u001B[32m➜\u001B[39m DevTools: press Shift + A in your browser to enable the DevTools`, level: 2 }) - const timeWidth = new Date(0).toLocaleTimeString().length - const [line] = formatEvent(styled as any, 40, timeWidth) + const [line] = formatEvent(styled as any, 40) expect(strip(line!).length).toBeLessThanOrEqual(40) // The cut must not swallow the reset, or everything after stays green. expect(line!.endsWith('\u001B[0m')).toBe(true) @@ -1012,6 +999,24 @@ describe('log overlay', () => { expect(process.stdout.listenerCount('resize')).toBe(before) }) + it('ignores modified view shortcuts', () => { + const events = new DevEventLog() + events.push(event({ source: 'runtime', message: 'runtime log' })) + const { overlay, lastFrame } = create(events) + overlay.open() + const before = lastFrame() + for (const key of [ + { name: 'r', ctrl: true }, + { name: 'x', ctrl: true }, + { name: 'q', meta: true }, + ]) { + overlay.handleKey(key) + expect(lastFrame()).toBe(before) + expect(overlay.isOpen).toBe(true) + } + overlay.close() + }) + it('clears the history from the view', () => { const events = new DevEventLog() events.push(event({ message: 'old news' })) @@ -1046,7 +1051,7 @@ describe('log overlay', () => { const { overlay, lastFrame } = create(events) overlay.open() - overlay.handleKey({ name: 'c' }) + overlay.handleKey({ name: 't' }) expect(strip(lastFrame())).not.toContain('from cli') expect(strip(lastFrame())).toContain('from runtime') expect(strip(lastFrame())).toContain('from build') @@ -1184,13 +1189,185 @@ describe('log overlay', () => { overlay.handleKey({ name: 'g' }) expect(strip(lastFrame())).toContain('line 0') - expect(strip(lastFrame())).toContain('scrolled') + expect(strip(lastFrame())).toContain('paused') overlay.handleKey({ sequence: 'G', name: 'g' }) - expect(strip(lastFrame())).not.toContain('scrolled') + expect(strip(lastFrame())).not.toContain('paused') expect(lastFrame()).not.toContain('\u258E') }) + it('pages through an entry taller than the viewport', () => { + const events = new DevEventLog() + const rows = (process.stdout.rows || 24) - 3 + events.push(event({ message: Array.from({ length: rows * 3 }, (_, i) => `row ${i}`).join('\n') })) + const { overlay, lastFrame } = create(events) + const body = () => strip(lastFrame()).split('\n').slice(2, 2 + rows) + overlay.open() + overlay.handleKey({ name: 'home' }) + expect(body()[0]).toContain('row 0') + overlay.handleKey({ name: 'pagedown' }) + expect(body()[0]).toContain(`row ${rows - 1}`) + overlay.handleKey({ name: 'pagedown' }) + expect(body()[0]).toContain(`row ${2 * (rows - 1)}`) + overlay.handleKey({ name: 'pageup' }) + expect(body()[0]).toContain(`row ${rows - 1}`) + overlay.handleKey({ name: 'end' }) + expect(body().at(-1)).toContain(`row ${rows * 3 - 1}`) + expect(strip(lastFrame())).not.toContain('paused') + overlay.close() + }) + + it('pages by rows across entries of different heights', () => { + const events = new DevEventLog() + const rows = (process.stdout.rows || 24) - 3 + events.push(event({ message: 'short' })) + events.push(event({ message: Array.from({ length: rows * 2 }, (_, i) => `stack ${i}`).join('\n') })) + events.push(event({ message: 'last' })) + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'home' }) + overlay.handleKey({ name: 'pagedown' }) + expect(strip(lastFrame()).split('\n')[2]).toContain(`stack ${rows - 2}`) + overlay.handleKey({ name: 'pageup' }) + expect(strip(lastFrame()).split('\n')[2]).toContain('short') + overlay.close() + }) + + it('keeps the viewport still while paused and resumes following with End', async () => { + const events = new DevEventLog() + const rows = (process.stdout.rows || 24) - 3 + for (let i = 0; i < rows * 2; i++) { + events.push(event({ message: `entry ${i}` })) + } + const { overlay, lastFrame } = create(events) + const body = () => strip(lastFrame()).split('\n').slice(2, 2 + rows).join('\n') + overlay.open() + overlay.handleKey({ name: 'pageup' }) + const before = body() + events.push(event({ message: 'new arrival\nsecond row' })) + await vi.waitFor(() => expect(strip(lastFrame())).toContain(`↓${rows + 1} rows below`)) + expect(body()).toBe(before) + overlay.handleKey({ name: 'end' }) + expect(body()).toContain('new arrival') + expect(strip(lastFrame())).not.toContain('paused') + events.push(event({ message: 'following again' })) + await vi.waitFor(() => expect(body()).toContain('following again')) + overlay.close() + }) + + it('shows incoming logs in spare rows without moving the selection', async () => { + const events = new DevEventLog() + events.push(event({ message: 'selected entry' })) + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'up' }) + const selected = strip(lastFrame()).split('\n')[2] + events.push(event({ message: 'incoming entry' })) + await vi.waitFor(() => expect(strip(lastFrame())).toContain('incoming entry')) + expect(strip(lastFrame()).split('\n')[2]).toBe(selected) + expect(strip(lastFrame())).toContain('follow paused') + overlay.close() + }) + + it('keeps the viewport anchored when old events leave the history', async () => { + const rows = (process.stdout.rows || 24) - 3 + const events = new DevEventLog(rows * 3) + for (let i = 0; i < rows * 3; i++) { + events.push(event({ message: `entry ${i}` })) + } + const { overlay, lastFrame } = create(events) + const body = () => strip(lastFrame()).split('\n').slice(2, 2 + rows).join('\n') + overlay.open() + overlay.handleKey({ name: 'pageup' }) + const before = body() + events.push(event({ message: 'new arrival' })) + await vi.waitFor(() => expect(strip(lastFrame())).toContain(`↓${rows} rows below`)) + expect(body()).toBe(before) + overlay.close() + }) + + it('keeps a paged position inside a large entry when the terminal height changes', async () => { + const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'rows') + const events = new DevEventLog() + events.push(event({ message: Array.from({ length: 40 }, (_, i) => `row ${i}`).join('\n') })) + const { overlay, lastFrame } = create(events) + try { + Object.defineProperty(process.stdout, 'rows', { value: 8, configurable: true }) + overlay.open() + overlay.handleKey({ name: 'home' }) + overlay.handleKey({ name: 'pagedown' }) + expect(strip(lastFrame()).split('\n')[2]).toContain('row 4') + Object.defineProperty(process.stdout, 'rows', { value: 12, configurable: true }) + process.stdout.emit('resize') + await vi.waitFor(() => expect(strip(lastFrame()).split('\n')).toHaveLength(12)) + expect(strip(lastFrame()).split('\n')[2]).toContain('row 4') + } + finally { + overlay.close() + if (descriptor) { + Object.defineProperty(process.stdout, 'rows', descriptor) + } + else { + Reflect.deleteProperty(process.stdout, 'rows') + } + } + }) + + it('copies a complete entry after paging into its middle', async () => { + const events = new DevEventLog() + const rows = (process.stdout.rows || 24) - 3 + const time = new Date(2024, 0, 1, 13, 2, 3).getTime() + const message = Array.from({ length: rows * 3 }, (_, i) => `row ${i}`).join('\n') + events.push(event({ time, message })) + const { overlay } = create(events) + overlay.open() + overlay.handleKey({ name: 'home' }) + overlay.handleKey({ name: 'pagedown' }) + overlay.handleKey({ name: 'y' }) + await vi.waitFor(() => expect(copied).toHaveLength(1)) + expect(copied[0]).toBe(`${new Date(time).toLocaleTimeString()} ${message}`) + overlay.close() + }) + + it('resumes following and clears the selection when filters change', () => { + const events = new DevEventLog() + events.push(event({ message: 'info' })) + events.push(event({ message: 'error', level: 0 })) + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'home' }) + expect(strip(lastFrame())).toContain('paused') + overlay.handleKey({ name: 'e' }) + expect(strip(lastFrame())).not.toContain('paused') + expect(lastFrame()).not.toContain('▎') + expect(strip(lastFrame())).toContain('error') + overlay.close() + }) + + it('uses Home and End as aliases for g and G', () => { + const events = new DevEventLog() + for (let i = 0; i < 100; i++) { + events.push(event({ message: `entry ${i}` })) + } + const { overlay, lastFrame } = create(events) + overlay.open() + overlay.handleKey({ name: 'home' }) + const home = lastFrame() + overlay.handleKey({ name: 'end' }) + const end = lastFrame() + overlay.handleKey({ name: 'g', sequence: 'g' }) + expect(lastFrame()).toBe(home) + overlay.handleKey({ name: 'g', sequence: 'G' }) + expect(lastFrame()).toBe(end) + overlay.close() + }) + + it('uses a compact local 24-hour timestamp in the gutter', () => { + const time = new Date(2024, 0, 1, 13, 2, 3).getTime() + const [line] = formatEvent(event({ time, message: 'message' }), 80) + expect(strip(line!)).toBe('13:02:03 message') + }) + it('starts at the top when entering the list with the down arrow', () => { const events = new DevEventLog() for (let i = 0; i < 10; i++) { @@ -1532,6 +1709,26 @@ describe('request overlay', () => { expect(lastFrame()).toContain('traffic') }) + it('keeps trace navigation keys inside search', () => { + const { log, overlay, lastFrame } = create() + log.push([{ time: 0, method: 'GET', url: '/quiet', status: 200, duration: 2 }]) + overlay.open() + overlay.handleKey({ name: 'down' }) + overlay.handleKey({ name: 'return' }) + overlay.handleKey({ sequence: '/' }) + overlay.handleKey({ name: 'q', sequence: 'q' }) + overlay.handleKey({ name: 'n', sequence: 'n' }) + expect(lastFrame()).toContain('search qn') + overlay.handleKey({ name: 'backspace' }) + expect(lastFrame()).toContain('search q') + overlay.handleKey({ name: 'escape' }) + expect(lastFrame()).toContain('trace') + expect(lastFrame()).not.toContain('search') + overlay.handleKey({ name: 'escape' }) + expect(lastFrame()).toContain('traffic') + overlay.close() + }) + it('draws the spans timed for a request on a timeline', () => { const { log, overlay, lastFrame } = create({ events: new DevEventLog() }) log.push([{ id: 'r1', time: 0, start: 1000, method: 'GET', url: '/', status: 200, duration: 100 }]) @@ -1591,7 +1788,8 @@ describe('help overlay', () => { let closed = 0 const overlay = new HelpOverlay( () => [ - { keys: ['r'], ctrl: 'r', description: 'restart the dev server' }, + { keys: ['r'], description: 'restart the dev server' }, + { keys: [], ctrl: 'r', description: 'restart the dev server' }, { keys: ['R'], description: 'restart with a cleared cache' }, { keys: ['h', '?'], description: 'show this help' }, ], @@ -1606,12 +1804,36 @@ describe('help overlay', () => { it('lists shortcuts with their aliases instead of logging them', () => { const { overlay, lastFrame } = create() overlay.open() - expect(lastFrame()).toContain('r / ctrl-r') + expect(lastFrame()).toContain('ctrl-r') + expect(lastFrame()).toContain('global') + expect(lastFrame()).toContain('main panel') + expect(lastFrame().indexOf('ctrl-r')).toBeLessThan(lastFrame().indexOf('main panel')) + expect(lastFrame().indexOf('shift-r')).toBeGreaterThan(lastFrame().indexOf('main panel')) expect(lastFrame()).toContain('shift-r') expect(lastFrame()).toContain('h / ?') expect(lastFrame()).toContain('restart the dev server') }) + it('opens at the beginning in a short terminal', () => { + const rows = Object.getOwnPropertyDescriptor(process.stdout, 'rows') + const { overlay, lastFrame } = create() + try { + Object.defineProperty(process.stdout, 'rows', { value: 8, configurable: true }) + overlay.open() + expect(lastFrame().split('\n')[2]).toContain('global') + expect(lastFrame()).toContain('ctrl-r') + } + finally { + overlay.close() + if (rows) { + Object.defineProperty(process.stdout, 'rows', rows) + } + else { + Reflect.deleteProperty(process.stdout, 'rows') + } + } + }) + it('lists the keys available inside a view', () => { const { overlay, lastFrame } = create() overlay.open() @@ -2725,6 +2947,165 @@ async function withPanel(run: (ui: ReturnType, settle: () => } } +describe('panel shortcut routing', () => { + it.each(['ready', 'starting', 'building', 'restarting', 'warming', 'error'] as const)('quits without confirmation while %s', async (status) => { + const interrupt = vi.fn() + process.on('SIGINT', interrupt) + try { + await withPanel(async (ui) => { + ui.setStatus(status) + process.stdin.emit('keypress', 'q', { name: 'q', sequence: 'q' }) + expect(interrupt).toHaveBeenCalledOnce() + expect(process.stdin.listenerCount('keypress')).toBe(0) + }) + } + finally { + process.off('SIGINT', interrupt) + } + }) + + it('quits with Ctrl-D from search inside a view', async () => { + const interrupt = vi.fn() + process.on('SIGINT', interrupt) + try { + await withPanel(async (_ui, _settle) => { + process.stdin.emit('keypress', 'l', { name: 'l' }) + process.stdin.emit('keypress', '/', { sequence: '/' }) + process.stdin.emit('keypress', '', { name: 'd', ctrl: true }) + expect(interrupt).toHaveBeenCalledOnce() + expect(process.stdin.listenerCount('keypress')).toBe(0) + }) + } + finally { + process.off('SIGINT', interrupt) + } + }) + + it.runIf(process.platform !== 'win32')('forwards Ctrl-backslash as SIGQUIT', async () => { + const quit = vi.fn() + process.on('SIGQUIT', quit) + try { + await withPanel(async () => { + process.stdin.emit('keypress', '', { name: '\\', ctrl: true }) + expect(quit).toHaveBeenCalledOnce() + expect(process.stdin.listenerCount('keypress')).toBe(0) + }) + } + finally { + process.off('SIGQUIT', quit) + } + }) + + it('hides unavailable restart shortcuts and reports their use', async () => { + await withPanel(async (_ui, _settle, session) => { + expect(session.state.hints?.some(hint => hint.key === 'r')).toBe(false) + process.stdin.emit('keypress', 'r', { name: 'r', sequence: 'r' }) + expect(session.state.notice?.text).toContain('not available yet') + }) + }) + + it('keeps the active view open when Ctrl-R is unavailable', async () => { + await withPanel(async (_ui, _settle, session) => { + process.stdin.emit('keypress', 'l', { name: 'l' }) + expect(session.surface.screenMode).toBe('alternate-screen') + process.stdin.emit('keypress', '', { name: 'r', ctrl: true }) + expect(session.state.notice?.text).toContain('not available yet') + expect(session.surface.screenMode).toBe('alternate-screen') + process.stdin.emit('keypress', '', { name: 'escape' }) + expect(session.surface.screenMode).toBe('split-footer') + }) + }) + + it('clears caches only for Shift-R', async () => { + const clearCaches = vi.fn(async () => []) + const restart = vi.fn() + await withPanel(async () => { + process.stdin.emit('keypress', 'r', { name: 'r', sequence: 'r' }) + expect(restart).toHaveBeenCalledOnce() + expect(clearCaches).not.toHaveBeenCalled() + process.stdin.emit('keypress', 'R', { name: 'r', sequence: 'R', shift: true }) + await vi.waitFor(() => expect(restart).toHaveBeenCalledTimes(2)) + expect(clearCaches).toHaveBeenCalledOnce() + }, { restart, clearCaches }) + }) + + it('ignores Alt-Q and restarts globally with Ctrl-R', async () => { + const restart = vi.fn() + await withPanel(async (_ui, _settle) => { + process.stdin.emit('keypress', '', { name: 'q', meta: true }) + expect(process.stdin.listenerCount('keypress')).toBeGreaterThan(0) + expect(restart).not.toHaveBeenCalled() + process.stdin.emit('keypress', 'l', { name: 'l' }) + process.stdin.emit('keypress', '', { name: 'r', ctrl: true }) + expect(restart).toHaveBeenCalledOnce() + }, { restart }) + }) + + it('preserves history when clearing the display and deletes it with x', async () => { + await withPanel(async (ui, _settle, session) => { + ui.pushServerLog({ level: 3, logType: 'info', message: 'retained log' }) + ui.pushRequests([{ method: 'GET', url: '/retained', status: 200, duration: 1 }]) + process.stdin.emit('keypress', 'c', { name: 'c' }) + process.stdin.emit('keypress', '', { name: 'l', ctrl: true }) + expect(session.events.recent(10).some(event => event.message === 'retained log')).toBe(true) + expect(session.state.requests).toBe(1) + process.stdin.emit('keypress', 'l', { name: 'l' }) + process.stdin.emit('keypress', '', { name: 'l', ctrl: true }) + expect(session.events.recent(10).some(event => event.message === 'retained log')).toBe(true) + process.stdin.emit('keypress', '', { name: 'escape' }) + process.stdin.emit('keypress', 'x', { name: 'x' }) + expect(session.events.recent(10)).toEqual([]) + expect(session.state.requests).toBeUndefined() + }) + }) + + it.runIf(process.platform !== 'win32')('keeps borrowed input exclusive across suspend and resume', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await withPanel(async (_ui, _settle, session) => { + let finish!: () => void + const borrowed = useTerminalHost()!.withTerminal(() => new Promise((resolve) => { + finish = resolve + })) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + process.emit('SIGTSTP') + process.emit('SIGCONT') + expect(process.stdin.listenerCount('keypress')).toBe(0) + finish() + await borrowed + await new Promise(resolve => setImmediate(resolve)) + expect(process.stdin.listenerCount('keypress')).toBe(1) + session.teardown() + expect(process.stdin.listenerCount('keypress')).toBe(0) + }) + } + finally { + kill.mockRestore() + } + }) + + it.runIf(process.platform !== 'win32')('restores keyboard input after suspension', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + const restart = vi.fn() + await withPanel(async () => { + process.stdin.emit('keypress', 'l', { name: 'l' }) + process.stdin.emit('keypress', '', { name: 'z', ctrl: true }) + expect(kill).toHaveBeenCalledWith(process.pid, 'SIGSTOP') + expect(process.stdin.listenerCount('keypress')).toBe(0) + process.emit('SIGCONT') + await new Promise(resolve => setImmediate(resolve)) + expect(process.stdin.listenerCount('keypress')).toBe(1) + process.stdin.emit('keypress', 'r', { name: 'r', sequence: 'r' }) + expect(restart).toHaveBeenCalledOnce() + }, { restart }) + } + finally { + kill.mockRestore() + } + }) +}) + describe('request failures on the panel', () => { it('should not report the bundler\'s own failed probes as failed requests', async () => { await withPanel(async (ui, settle) => { @@ -3027,6 +3408,24 @@ describe('request failures on the panel', () => { }) }) + it('should cancel opening after a listener binds but before readiness', async () => { + opened.length = 0 + const { context: deferred, attach } = deferShortcutContext() + let ready!: () => void + await withPanel(async () => { + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + attach({ + listener: listener as never, + close: async () => {}, + onReady: (callback) => { ready = () => callback(listener.url) }, + }) + process.stdin.emit('keypress', 'o', { name: 'o', sequence: 'o' }) + expect(opened).toEqual([]) + ready() + expect(opened).toEqual([]) + }, { context: deferred }) + }) + it('should open at once when the server is already up', async () => { await withStartingPanel(async (ready) => { ready() diff --git a/packages/nuxt-cli/test/unit/shortcuts.spec.ts b/packages/nuxt-cli/test/unit/shortcuts.spec.ts index cc1842402..3b10c5508 100644 --- a/packages/nuxt-cli/test/unit/shortcuts.spec.ts +++ b/packages/nuxt-cli/test/unit/shortcuts.spec.ts @@ -175,6 +175,50 @@ describe('setupShortcuts', () => { await vi.waitFor(() => expect(copyURL).toHaveBeenCalledWith('http://192.168.1.20:3000/')) }) + it('should copy the URL with y', async () => { + const { press } = setup() + await press('y') + expect(copyURL).toHaveBeenCalledWith('http://localhost:3000/') + }) + + it.each(['restart-clear', 'restart --clear'])('should clear caches before %s', async (command) => { + const order: string[] = [] + const { press } = setup({ + clearCaches: async () => { + order.push('clear') + return [] + }, + restart: () => { order.push('restart') }, + }) + await press(command) + expect(order).toEqual(['clear', 'restart']) + }) + + it('should queue opening the browser until ready', async () => { + let ready!: (address: string) => void + const { context, listener, press } = setup({ listener: undefined, onReady: (callback) => { + ready = callback + } }) + await press('o') + expect(openBrowser).not.toHaveBeenCalled() + context.listener = listener + ready(listener.url) + expect(openBrowser).toHaveBeenCalledWith(listener.url) + }) + + it('should cancel a queued browser opening', async () => { + let ready!: (address: string) => void + const { context, listener, press } = setup({ listener: undefined, onReady: (callback) => { + ready = callback + } }) + await press('o') + context.listener = listener + await press('o') + expect(openBrowser).not.toHaveBeenCalled() + ready(listener.url) + expect(openBrowser).not.toHaveBeenCalled() + }) + it('should restart when a restart handler is available', async () => { const restart = vi.fn() const { press } = setup({ restart }) diff --git a/packages/nuxt-cli/test/unit/terminal-output.spec.ts b/packages/nuxt-cli/test/unit/terminal-output.spec.ts index fb9d49c3e..091e087b0 100644 --- a/packages/nuxt-cli/test/unit/terminal-output.spec.ts +++ b/packages/nuxt-cli/test/unit/terminal-output.spec.ts @@ -192,13 +192,14 @@ describe('dev server terminal output', () => { const renderer = await render(() => press('h')) expect(screen(renderer)).toMatchInlineSnapshot(` - " press o + enter to open in browser + " press o + enter to open in browser (press again while starting to cancel) press u + enter to show server URLs press qr + enter to show a QR code for the server URL - press copy + enter to copy the server URL to the clipboard + press y + enter to copy the server URL to the clipboard press c + enter to clear the console press q + enter to quit - press h + enter to show this help" + press h + enter to show this help + Ctrl-C to quit; press again during cleanup to force exit" `) })