diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md index 2e91ad7..a03f78c 100644 --- a/IMPROVEMENT_PLAN.md +++ b/IMPROVEMENT_PLAN.md @@ -833,6 +833,15 @@ The phase is iterative: every iteration is its own branch, tests (TDD, e2e for s 5. ✅ **v0.9 — appearance** (branch `phase-10-v09`): UI settings on the server (`/api/v1/settings`), the accent color, density, fonts, the environment frame, a theme editor with import and export, `prefers-contrast`, Stylelint on colors, the component showcase, visual snapshots of themes. — *The interface language is kept in the settings too. Stylelint pulls in `braces` with an open advisory and no fixed version (a dev dependency only): to be kept in mind for `npm audit` in v1.0.* 6. ✅ **v1.0 — readiness** (the criteria below; branch `phase-10-v10`): integrations and instructions (Laravel, Symfony, WordPress, Bitrix), freezing format v2 and API v1, CI — CodeQL, `govulncheck`, `npm audit`, attestations, SBOM, fuzz, `paths` filters; release notes, issue/PR templates, publishing `client-php` 2.0.0 and the releases. — *The freeze: `spec/frozen/v1.0`, `make spec-compat` (oasdiff) and a test of v2 logs. Left to the owner: publishing `client-php` 2.0.0 (a repository for the split, a deploy key, Packagist), publishing the draft releases, protecting `main`, turning on private vulnerability reporting (`SECURITY.md` and the issue templates link to it). What is postponed is in `ROADMAP.md`.* +### 8.2o After 1.0 + +Work after the 1.0 release, each part on its own branch and through a pull request (the user merges). + +1. ✅ **v1.0.1 — hotfix**: the library of a release reported `2.0.0-dev`; `scripts/release-check.sh` refuses a stable tag with a pre-release library. +2. ✅ **CI and builds**: the fatal error test of the library is skipped where PHP itself exits with 1 on running out of memory (setup-php builds of PHP 7.4–8.1 from October 2026); `make desktop-deps` checks the C compiler, GTK and WebKitGTK and prints how to install them on the system (apt, dnf, pacman, zypper, apk; GTK3 where WebKitGTK 6.0 is missing). +3. ✅ **Documents in two languages**: every document is a pair `X.md` (English) / `X.ru.md` (Russian) with a language switcher; this plan is translated, `IMPROVEMENT_PLAN.ru.md` is the Russian original; the repository has an English description, topics and badges. +4. ✅ **UI redesign** (branch `feat/ui-redesign`): a sidebar with the projects, servers and tools that folds to a rail of icons; two columns below 1360px, one below 900px; Lucide icons; shared controls (`ui/src/styles/controls.css`) and new theme tokens with checked contrast; help in the app (`ui/src/lib/help.ts`: how it works, the library, projects, servers, pushing logs, reading requests, keys) linked from the dialogs and empty states; first steps in an empty viewer; CLI runs named by their script (`argv` in the feed API, compatible with 1.0). — *Found along the way: the port field of a new server and the fields of a new project ran out of the dialog (an e2e test now checks every field at 1440, 900 and 420px); a tall dialog did not fit the window.* + ### 8.3 Dependencies and timing ``` diff --git a/IMPROVEMENT_PLAN.ru.md b/IMPROVEMENT_PLAN.ru.md index c3ea9ca..9222ed8 100644 --- a/IMPROVEMENT_PLAN.ru.md +++ b/IMPROVEMENT_PLAN.ru.md @@ -833,6 +833,15 @@ Server (local | ssh | http) — тип подключения, парамет 5. ✅ **v0.9 — оформление** (ветка `phase-10-v09`): настройки UI на сервере (`/api/v1/settings`), акцентный цвет, плотность, шрифты, рамка окружения, редактор тем с импортом и экспортом, `prefers-contrast`, Stylelint на цвета, витрина компонентов, визуальные снапшоты тем. — *Язык интерфейса тоже хранится в настройках. Stylelint тянет `braces` с открытым advisory без исправленной версии (только dev-зависимость): учесть при `npm audit` в v1.0.* 6. ✅ **v1.0 — готовность** (критерии ниже; ветка `phase-10-v10`): интеграции и инструкции (Laravel, Symfony, WordPress, Bitrix), заморозка формата v2 и API v1, CI — CodeQL, `govulncheck`, `npm audit`, attestations, SBOM, fuzz, фильтры `paths`; release notes, шаблоны issue/PR, публикация `client-php` 2.0.0 и релизов. — *Заморозка: `spec/frozen/v1.0`, `make spec-compat` (oasdiff) и тест логов v2. Осталось владельцу: публикация `client-php` 2.0.0 (репозиторий для split, deploy key, Packagist), публикация черновиков релизов, защита `main`, включить private vulnerability reporting (на него ссылаются `SECURITY.md` и шаблоны issue). Отложенное — в `ROADMAP.md`.* +### 8.2o После 1.0 + +Работа после релиза 1.0: каждая часть — своя ветка и pull request (сливает пользователь). + +1. ✅ **v1.0.1 — исправление**: библиотека в релизе называла себя `2.0.0-dev`; `scripts/release-check.sh` не даёт выпустить стабильный тег с предварительной версией библиотеки. +2. ✅ **CI и сборка**: тест фатальной ошибки библиотеки пропускается там, где сам PHP при нехватке памяти выходит с кодом 1 (сборки setup-php для PHP 7.4–8.1 октября 2026); `make desktop-deps` проверяет компилятор C, GTK и WebKitGTK и подсказывает, как их поставить в этой системе (apt, dnf, pacman, zypper, apk; GTK3, где нет WebKitGTK 6.0). +3. ✅ **Документы на двух языках**: каждый документ — пара `X.md` (английский) / `X.ru.md` (русский) с переключателем языка; план переведён, `IMPROVEMENT_PLAN.ru.md` — русский оригинал; у репозитория английское описание, топики и бейджи. +4. ✅ **Редизайн интерфейса** (ветка `feat/ui-redesign`): боковая панель с проектами, серверами и инструментами, сворачивается в полоску иконок; две колонки уже 1360px, одна — уже 900px; иконки Lucide; общие элементы управления (`ui/src/styles/controls.css`) и новые токены темы с проверкой контраста; справка в приложении (`ui/src/lib/help.ts`: как это работает, библиотека, проекты, серверы, отправка логов, чтение запросов, клавиши) со ссылками из диалогов и пустых состояний; первые шаги в пустом просмотрщике; CLI-запросы называются по скрипту (`argv` в API ленты, совместимо с 1.0). — *Найдено по ходу: поле порта нового сервера и поля нового проекта вылезали за диалог (теперь e2e проверяет каждое поле на 1440, 900 и 420px); высокий диалог не помещался в окно.* + ### 8.3 Зависимости и сроки ``` diff --git a/README.md b/README.md index 5b240a5..6f7dfaa 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ The one-liners work from the first published release that carries these files (v Other flags: `--addr` (default `127.0.0.1:8820`; another address needs a token), `--token` (better `$WAPP_DEBUGGER_TOKEN`, out of the process list), `--db` (the database, by default in the user config directory; `secrets.json` with tokens of HTTP servers lives next to it), `--scan-interval` (fallback rescan of local directories, 5 s). `wapp-debugger serve --help` lists them all. -The UI speaks English and Russian and has light, dark and high-contrast themes (switches in the header; "System" follows the color scheme and the contrast setting of the OS). **Appearance** in the header sets an accent color over any theme, the density of lists, the font size, the font of code (one installed on your computer) and a frame around the window in the color of stage and prod. The **theme editor** there makes themes of your own: colors over a built-in theme with a live preview and a contrast check, shared as `.theme.json` files (Export, Import). The viewer keeps these settings, so every browser and the desktop app look the same. Search looks for words in messages and their context or for a part of the URL. +The window has a sidebar with the projects and servers (it folds to a rail of icons) and three panes: the feed, the request and the message. **Help** (?) at the bottom of the sidebar explains how the viewer works and how to connect the library, add a project or a server and push logs; with no projects yet the feed shows the first steps. + +The UI speaks English and Russian and has light, dark and high-contrast themes (switches at the bottom of the sidebar; "System" follows the color scheme and the contrast setting of the OS). **Appearance** next to them sets an accent color over any theme, the density of lists, the font size, the font of code (one installed on your computer) and a frame around the window in the color of stage and prod. The **theme editor** there makes themes of your own: colors over a built-in theme with a live preview and a contrast check, shared as `.theme.json` files (Export, Import). The viewer keeps these settings, so every browser and the desktop app look the same. Search looks for words in messages and their context or for a part of the URL. ## Projects on other servers @@ -150,13 +152,13 @@ The same viewer in a window, without a network port. Installers are attached to - Windows: SmartScreen → **More info** → **Run anyway**; - Linux: `sudo apt install ./wapp-debugger-desktop_*.deb`, or `chmod +x` the AppImage. -What the desktop adds to the browser version (**Settings** in the header): +What the desktop adds to the browser version (**Settings** at the bottom of the sidebar): - an icon in the tray (the menu bar on macOS) with the number of new requests with errors and a menu of the last ones; closing the window keeps the app there; - system notifications about new requests with errors — or warnings too — in all or chosen projects; a burst comes as one notice; a click opens the request; - links `wapp-debugger://open?project=3&request=42&message=1001` open the request in the app (from a chat, a ticket, a script); the installers register the scheme, a second start hands the link to the running window; - start at login; the window comes back where it was; -- a notice in the header when a newer version is out (GitHub Releases once a day; can be switched off). +- a notice at the top of the sidebar when a newer version is out (GitHub Releases once a day; can be switched off). From source: `make desktop-build` (needs a C compiler and, on Linux, GTK4 + WebKitGTK 6.0; `make desktop-deps` checks them and prints how to install what is missing on your system), then `dist/wapp-debugger-desktop [--logs-dir …]`; `--logs-dir` is optional here too. `make desktop-package` builds the installers of the current system; more in [desktop/README.md](desktop/README.md). diff --git a/README.ru.md b/README.ru.md index 1718ba4..29a7c5b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -87,7 +87,9 @@ wapp-debugger serve --logs-dir /var/www/app/tmp/logs # откройте http:/ Другие флаги: `--addr` (по умолчанию `127.0.0.1:8820`; другой адрес требует токен), `--token` (лучше `$WAPP_DEBUGGER_TOKEN`, чтобы токена не было в списке процессов), `--db` (база, по умолчанию в папке конфигов пользователя; рядом с ней — `secrets.json` с токенами HTTP-серверов), `--scan-interval` (резервное пересканирование локальных папок, 5 с). Полный список — `wapp-debugger serve --help`. -Интерфейс на английском и русском, темы — светлая, тёмная и высококонтрастная (переключатели в шапке; «Системная» следует цветовой схеме и настройке контраста ОС). **Оформление** в шапке задаёт акцентный цвет поверх любой темы, плотность списков, размер шрифта, шрифт кода (установленный на компьютере) и рамку окна цвета окружения на stage и prod. Там же **редактор тем**: свои цвета поверх встроенной темы с живым предпросмотром и проверкой контраста, обмен темами файлами `.theme.json` («Экспорт», «Импорт»). Настройки хранит просмотрщик, поэтому во всех браузерах и в десктоп-версии они одинаковые. Поиск ищет слова в сообщениях и их контексте или часть URL. +Слева — панель проектов и серверов (сворачивается в полоску иконок), справа — три панели: лента, запрос и сообщение. **Справка** (?) внизу панели объясняет, как устроен просмотрщик и как подключить библиотеку, добавить проект или сервер и отправлять логи; пока проектов нет, лента показывает первые шаги. + +Интерфейс на английском и русском, темы — светлая, тёмная и высококонтрастная (переключатели внизу боковой панели; «Системная» следует цветовой схеме и настройке контраста ОС). **Оформление** рядом с ними задаёт акцентный цвет поверх любой темы, плотность списков, размер шрифта, шрифт кода (установленный на компьютере) и рамку окна цвета окружения на stage и prod. Там же **редактор тем**: свои цвета поверх встроенной темы с живым предпросмотром и проверкой контраста, обмен темами файлами `.theme.json` («Экспорт», «Импорт»). Настройки хранит просмотрщик, поэтому во всех браузерах и в десктоп-версии они одинаковые. Поиск ищет слова в сообщениях и их контексте или часть URL. ## Проекты на других серверах @@ -149,13 +151,13 @@ wapp-debugger serve --logs-dir /var/www/app/tmp/logs # откройте http:/ - Windows: SmartScreen → **Подробнее** → **Выполнить в любом случае**; - Linux: `sudo apt install ./wapp-debugger-desktop_*.deb` или `chmod +x` для AppImage. -Что десктоп добавляет к браузерной версии (**«Настройки»** в шапке): +Что десктоп добавляет к браузерной версии (**«Настройки»** внизу боковой панели): - значок в трее (в строке меню на macOS) с числом новых запросов с ошибками и меню последних; закрытое окно оставляет приложение там; - системные уведомления о новых запросах с ошибками — или и с предупреждениями — во всех или выбранных проектах; всплеск приходит одним уведомлением; клик открывает запрос; - ссылки `wapp-debugger://open?project=3&request=42&message=1001` открывают запрос в приложении (из чата, задачи, скрипта); установщики регистрируют схему, повторный запуск передаёт ссылку уже открытому окну; - запуск при входе в систему; окно открывается там, где было; -- сообщение в шапке о новой версии (GitHub Releases раз в сутки; отключается). +- сообщение вверху боковой панели о новой версии (GitHub Releases раз в сутки; отключается). Из исходников: `make desktop-build` (нужен компилятор C, а в Linux ещё GTK4 + WebKitGTK 6.0; `make desktop-deps` проверяет их и подсказывает, как поставить недостающее в вашей системе), затем `dist/wapp-debugger-desktop [--logs-dir …]`; `--logs-dir` и здесь необязателен. `make desktop-package` собирает установщики текущей системы; подробнее — в [desktop/README.ru.md](desktop/README.ru.md). diff --git a/ROADMAP.md b/ROADMAP.md index 5e56ebc..e40b94f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,11 +23,11 @@ What 1.0 has and what comes after it. The detailed working plan is **Viewer** -- Panels that resize and collapse; servers in the tree of projects; a narrow layout. +- Panels that resize (the sidebar already folds and the layout narrows; servers group their projects). - A page of project settings with path mapping (server or container ↔ local copy) and its check. - A status line: the live stream, each server, files not imported yet, disk space. -- Onboarding for an empty viewer; a raw JSON tab of a message; an overview and an errors tab of a - request; saved filters and a date range. +- A raw JSON tab of a message; an overview and an errors tab of a request; saved filters and a + date range. (First steps for an empty viewer and help in the app are done.) **Sources** diff --git a/ROADMAP.ru.md b/ROADMAP.ru.md index 1a314ce..bac991c 100644 --- a/ROADMAP.ru.md +++ b/ROADMAP.ru.md @@ -23,11 +23,11 @@ **Просмотрщик** -- Панели, которые меняют размер и сворачиваются; серверы в дереве проектов; узкая раскладка. +- Панели, которые меняют размер (боковая панель уже сворачивается, узкая раскладка есть; проекты сгруппированы по серверам). - Страница настроек проекта с маппингом путей (сервер или контейнер ↔ локальная копия) и его проверкой. - Строка состояния: live-поток, каждый сервер, ещё не импортированные файлы, место на диске. -- Онбординг для пустого просмотрщика; вкладка сырого JSON у сообщения; вкладки обзора и ошибок у - запроса; сохранённые фильтры и диапазон дат. +- Вкладка сырого JSON у сообщения; вкладки обзора и ошибок у запроса; сохранённые фильтры и + диапазон дат. (Первые шаги в пустом просмотрщике и справка в приложении сделаны.) **Источники** diff --git a/docs/screenshot.png b/docs/screenshot.png index 7fca31c..24e26c5 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/server/internal/api/details.go b/server/internal/api/details.go index 5d3d8a7..6867e45 100644 --- a/server/internal/api/details.go +++ b/server/internal/api/details.go @@ -40,10 +40,7 @@ func (s *server) GetRequest(ctx context.Context, request GetRequestRequestObject Lang: &lang, Ip: optional(detail.IP), ServerIp: optional(detail.ServerIP), ServerName: optional(detail.ServerName), Hostname: optional(detail.Hostname), Pid: detail.PID, ProcessUser: optional(detail.ProcessUser), AppUser: optional(detail.AppUser), SessionId: optional(detail.SessionID), LibraryVersion: optional(detail.LibraryVersion), PhpVersion: optional(detail.PHPVersion), - EndedAt: detail.EndedAt, MemoryPeakBytes: detail.MemoryPeakBytes, Children: &children, - } - if detail.Argv != nil { - response.Argv = &detail.Argv + EndedAt: detail.EndedAt, MemoryPeakBytes: detail.MemoryPeakBytes, Children: &children, Argv: base.Argv, } return response, nil } diff --git a/server/internal/api/openapi.gen.go b/server/internal/api/openapi.gen.go index 9d3058a..b3f54f1 100644 --- a/server/internal/api/openapi.gen.go +++ b/server/internal/api/openapi.gen.go @@ -702,9 +702,11 @@ type ProjectPatch struct { // // Example: {"duration_ms":40.2,"error_count":0,"exit_code":null,"finished":true,"format_version":2,"id":42,"is_ajax":false,"message_count":6,"method":"GET","parent_request_key":null,"pinned":false,"project_id":1,"received_at":1792400000310,"request_key":"01JAZ7Q5X0K4M8T2V6N9P3R5S7","sapi":"web","server_id":1,"started_at":1792400000000,"status_code":200,"url":"https://shop.example.test/catalog/item?id=42","warning_count":2} type Request struct { - DurationMs *float64 `json:"duration_ms,omitempty"` - ErrorCount int `json:"error_count"` - ExitCode *int `json:"exit_code,omitempty"` + // Argv The command line of a CLI run; absent for a web request + Argv *[]string `json:"argv,omitempty"` + DurationMs *float64 `json:"duration_ms,omitempty"` + ErrorCount int `json:"error_count"` + ExitCode *int `json:"exit_code,omitempty"` // Finished false means the request is still running or the process was killed Finished bool `json:"finished"` @@ -738,8 +740,10 @@ type RequestFormatVersion int // RequestDetail defines model for RequestDetail. type RequestDetail struct { - AppUser *string `json:"app_user,omitempty"` - Argv *[]string `json:"argv,omitempty"` + AppUser *string `json:"app_user,omitempty"` + + // Argv The command line of a CLI run; absent for a web request + Argv *[]string `json:"argv,omitempty"` // Children Child requests (AJAX, subprocesses) by parent_request_key Children *[]Request `json:"children,omitempty"` @@ -5311,129 +5315,130 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/server/internal/api/server.go b/server/internal/api/server.go index 7a038ee..8ce92cf 100644 --- a/server/internal/api/server.go +++ b/server/internal/api/server.go @@ -190,9 +190,17 @@ func RequestDTO(r store.RequestRow) Request { ReceivedAt: r.ReceivedAt, Pinned: r.Pinned, Note: optional(r.Note), + Argv: argvOf(r.Argv), } } +func argvOf(argv []string) *[]string { + if argv == nil { + return nil + } + return &argv +} + func optional(value string) *string { if value == "" { return nil diff --git a/server/internal/api/server_test.go b/server/internal/api/server_test.go index bfee55e..6e20527 100644 --- a/server/internal/api/server_test.go +++ b/server/internal/api/server_test.go @@ -110,6 +110,34 @@ func TestListRequestsPaginatesNewestFirst(t *testing.T) { } } +// A CLI run in the feed carries its command line, so the feed can name it +func TestListRequestsCarriesTheCommandLineOfCLIRuns(t *testing.T) { + e := newTestEnv(t) + _, body := get(t, e.server.URL+"/api/v1/requests?sapi=cli&limit=50") + var page struct { + Items []struct { + RequestKey string `json:"request_key"` + Argv *[]string `json:"argv"` + } `json:"items"` + } + if err := json.Unmarshal([]byte(body), &page); err != nil { + t.Fatal(err) + } + found := false + for _, item := range page.Items { + if item.Argv != nil && strings.Join(*item.Argv, " ") == "bin/console queue:work --queue=emails" { + found = true + } + } + if !found { + t.Fatalf("no argv of the CLI fixture in the feed: %s", body) + } + _, web := get(t, e.server.URL+"/api/v1/requests?sapi=web&limit=50") + if strings.Contains(web, `"argv"`) { + t.Fatalf("a web request has no argv: %s", web) + } +} + func assertProblem(t *testing.T, response *http.Response, body string, status int) { t.Helper() var problem Problem diff --git a/server/internal/store/details.go b/server/internal/store/details.go index ddeb844..96b7fe1 100644 --- a/server/internal/store/details.go +++ b/server/internal/store/details.go @@ -16,7 +16,6 @@ import ( type RequestDetail struct { RequestRow Lang string - Argv []string IP string ServerIP string ServerName string @@ -39,20 +38,15 @@ func (s *Store) GetRequestDetail(ctx context.Context, id int64) (RequestDetail, return RequestDetail{}, found, err } detail := RequestDetail{RequestRow: row} - var argv, ip, serverIP, serverName, hostname, processUser, appUser, sessionID, libraryVersion, phpVersion sql.NullString + var ip, serverIP, serverName, hostname, processUser, appUser, sessionID, libraryVersion, phpVersion sql.NullString err = s.db.QueryRowContext(ctx, ` - SELECT lang, argv, ip, server_ip, server_name, hostname, pid, process_user, app_user, session_id, + SELECT lang, ip, server_ip, server_name, hostname, pid, process_user, app_user, session_id, library_version, php_version, ended_at, memory_peak_bytes - FROM requests WHERE id = ?`, id).Scan(&detail.Lang, &argv, &ip, &serverIP, &serverName, &hostname, &detail.PID, + FROM requests WHERE id = ?`, id).Scan(&detail.Lang, &ip, &serverIP, &serverName, &hostname, &detail.PID, &processUser, &appUser, &sessionID, &libraryVersion, &phpVersion, &detail.EndedAt, &detail.MemoryPeakBytes) if err != nil { return RequestDetail{}, false, err } - if argv.Valid { - if err := json.Unmarshal([]byte(argv.String), &detail.Argv); err != nil { - return RequestDetail{}, false, err - } - } detail.IP, detail.ServerIP, detail.ServerName, detail.Hostname = ip.String, serverIP.String, serverName.String, hostname.String detail.ProcessUser, detail.AppUser, detail.SessionID = processUser.String, appUser.String, sessionID.String detail.LibraryVersion, detail.PHPVersion = libraryVersion.String, phpVersion.String diff --git a/server/internal/store/details_test.go b/server/internal/store/details_test.go index ad451c7..4658530 100644 --- a/server/internal/store/details_test.go +++ b/server/internal/store/details_test.go @@ -161,3 +161,30 @@ func TestProjectLinksOnAnSSHServerCarryTheHost(t *testing.T) { t.Fatalf("local project has a remote host: %+v", links) } } + +// The feed names a CLI run by its command line: the row carries argv, a web request has none +func TestRequestRowsCarryTheCommandLine(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + project, _ := s.EnsureLocalProject(ctx, "shop", "/r", "/r/logs") + ids := importV2(t, s, project.ProjectID, "web-request.jsonl", "cli-exception.jsonl") + + page, err := s.ListRequests(ctx, RequestFilter{ProjectID: project.ProjectID, Limit: 10}) + if err != nil { + t.Fatal(err) + } + argv := map[int64][]string{} + for _, row := range page.Items { + argv[row.ID] = row.Argv + } + if got := strings.Join(argv[ids["cli-exception.jsonl"]], " "); got != "bin/console queue:work --queue=emails" { + t.Fatalf("cli argv = %q", got) + } + if argv[ids["web-request.jsonl"]] != nil { + t.Fatalf("web argv = %q", argv[ids["web-request.jsonl"]]) + } + row, _, _ := s.GetRequest(ctx, ids["cli-exception.jsonl"]) + if len(row.Argv) != 3 { + t.Fatalf("GetRequest argv = %q", row.Argv) + } +} diff --git a/server/internal/store/requests.go b/server/internal/store/requests.go index fc64c60..4aaaedc 100644 --- a/server/internal/store/requests.go +++ b/server/internal/store/requests.go @@ -321,6 +321,8 @@ type RequestRow struct { ReceivedAt int64 Pinned bool Note string + // Argv is the command line of a CLI run; nil for a web request. + Argv []string } // RequestFilter holds feed parameters; zero values do not filter. @@ -448,7 +450,7 @@ func (s *Store) ListRequests(ctx context.Context, filter RequestFilter) (Request const selectRequestRows = ` SELECT r.id, r.project_id, p.server_id, r.request_key, COALESCE(r.parent_request_key, ''), COALESCE(f.format_version, 2), r.sapi, COALESCE(r.method, ''), COALESCE(r.url, ''), r.is_ajax, r.status_code, r.exit_code, r.started_at, r.duration_ms, - r.finished, r.message_count, r.error_count, r.warning_count, r.received_at, r.pinned, COALESCE(r.note, '') + r.finished, r.message_count, r.error_count, r.warning_count, r.received_at, r.pinned, COALESCE(r.note, ''), r.argv FROM requests r JOIN projects p ON p.id = r.project_id LEFT JOIN log_files f ON f.id = r.log_file_id` @@ -459,9 +461,13 @@ type rowScanner interface { func scanRequestRow(row rowScanner) (RequestRow, error) { var r RequestRow + var argv sql.NullString err := row.Scan(&r.ID, &r.ProjectID, &r.ServerID, &r.Key, &r.ParentKey, &r.FormatVersion, &r.Sapi, &r.Method, &r.URL, &r.IsAjax, &r.StatusCode, &r.ExitCode, &r.StartedAt, &r.DurationMs, &r.Finished, &r.MessageCount, &r.ErrorCount, - &r.WarningCount, &r.ReceivedAt, &r.Pinned, &r.Note) + &r.WarningCount, &r.ReceivedAt, &r.Pinned, &r.Note, &argv) + if err == nil && argv.Valid { + err = json.Unmarshal([]byte(argv.String), &r.Argv) + } return r, err } diff --git a/spec/openapi.yaml b/spec/openapi.yaml index a64002c..25a3daf 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -1105,6 +1105,11 @@ components: sapi: { $ref: '#/components/schemas/Sapi' } method: { type: string, nullable: true } url: { type: string, nullable: true } + argv: + type: array + nullable: true + description: The command line of a CLI run; absent for a web request + items: { type: string } is_ajax: { type: boolean } status_code: { type: integer, nullable: true } exit_code: { type: integer, nullable: true } @@ -1145,10 +1150,6 @@ components: - type: object properties: lang: { type: string, enum: [php, js] } - argv: - type: array - nullable: true - items: { type: string } ip: { type: string, nullable: true } server_ip: { type: string, nullable: true } server_name: { type: string, nullable: true } diff --git a/ui/e2e/__screenshots__/appearance.png b/ui/e2e/__screenshots__/appearance.png index 552c828..d8b2a59 100644 Binary files a/ui/e2e/__screenshots__/appearance.png and b/ui/e2e/__screenshots__/appearance.png differ diff --git a/ui/e2e/__screenshots__/help-library.png b/ui/e2e/__screenshots__/help-library.png new file mode 100644 index 0000000..2f59d6e Binary files /dev/null and b/ui/e2e/__screenshots__/help-library.png differ diff --git a/ui/e2e/__screenshots__/help-overview.png b/ui/e2e/__screenshots__/help-overview.png new file mode 100644 index 0000000..567ba4a Binary files /dev/null and b/ui/e2e/__screenshots__/help-overview.png differ diff --git a/ui/e2e/__screenshots__/showcase.png b/ui/e2e/__screenshots__/showcase.png index 0bd391d..c6bb113 100644 Binary files a/ui/e2e/__screenshots__/showcase.png and b/ui/e2e/__screenshots__/showcase.png differ diff --git a/ui/e2e/__screenshots__/theme-editor.png b/ui/e2e/__screenshots__/theme-editor.png index 04967d7..3224507 100644 Binary files a/ui/e2e/__screenshots__/theme-editor.png and b/ui/e2e/__screenshots__/theme-editor.png differ diff --git a/ui/e2e/__screenshots__/viewer-dark.png b/ui/e2e/__screenshots__/viewer-dark.png index b679c47..d4057d1 100644 Binary files a/ui/e2e/__screenshots__/viewer-dark.png and b/ui/e2e/__screenshots__/viewer-dark.png differ diff --git a/ui/e2e/__screenshots__/viewer-high-contrast.png b/ui/e2e/__screenshots__/viewer-high-contrast.png index a9b10d5..796b16e 100644 Binary files a/ui/e2e/__screenshots__/viewer-high-contrast.png and b/ui/e2e/__screenshots__/viewer-high-contrast.png differ diff --git a/ui/e2e/__screenshots__/viewer-light.png b/ui/e2e/__screenshots__/viewer-light.png index 88211fc..cce5d05 100644 Binary files a/ui/e2e/__screenshots__/viewer-light.png and b/ui/e2e/__screenshots__/viewer-light.png differ diff --git a/ui/e2e/__screenshots__/viewer-narrow.png b/ui/e2e/__screenshots__/viewer-narrow.png new file mode 100644 index 0000000..dded519 Binary files /dev/null and b/ui/e2e/__screenshots__/viewer-narrow.png differ diff --git a/ui/e2e/viewer.spec.ts b/ui/e2e/viewer.spec.ts index 1029b1f..b1e3487 100644 --- a/ui/e2e/viewer.spec.ts +++ b/ui/e2e/viewer.spec.ts @@ -217,10 +217,47 @@ test('a Laravel log comes in as a request of its own', async ({ page }) => { .setInputFiles( fileURLToPath(new URL('../../spec/fixtures/thirdparty/laravel.log', import.meta.url)), ); - await expect(page.getByRole('heading', { level: 2, name: 'CLI' })).toBeVisible(); - await expect(page.getByText('laravel.log', { exact: true })).toBeVisible(); + // The request is named after the file it came from + await expect(page.getByRole('heading', { level: 2, name: 'laravel.log' })).toBeVisible(); const messages = page.getByRole('list', { name: 'Messages' }); await expect(messages).toContainText('User logged in'); await expect(messages).toContainText('SQLSTATE[42S02]: Base table or view not found'); await page.screenshot({ path: test.info().outputPath('laravel.png') }); }); + +// Every field of a form stays inside its dialog, whatever the width of the window +async function expectFieldsInside(page: Page, name: string) { + const dialog = page.getByRole('dialog', { name }); + const box = await dialog.boundingBox(); + if (!box) { + throw new Error(`no box of the dialog ${name}`); + } + for (const field of await dialog + .locator('input:visible, textarea:visible, select:visible') + .all()) { + const html = await field.evaluate((element) => element.outerHTML); + const inner = await field.boundingBox(); + if (!inner) { + throw new Error(`no box of ${html}`); + } + expect(inner.x, html).toBeGreaterThanOrEqual(box.x); + expect(inner.x + inner.width, html).toBeLessThanOrEqual(box.x + box.width); + } +} + +for (const width of [1440, 900, 420]) { + test(`the fields of the server and project dialogs fit at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: 'Servers' }).first().click(); + await page.getByRole('button', { name: 'Add server' }).click(); + await expectFieldsInside(page, 'New server'); + await page.getByRole('radio', { name: 'HTTP' }).click(); + await expectFieldsInside(page, 'New server'); + await page.keyboard.press('Escape'); + await page.keyboard.press('Escape'); + + await page.getByRole('button', { name: 'Add project' }).first().click(); + await expectFieldsInside(page, 'New project'); + }); +} diff --git a/ui/e2e/visual.spec.ts b/ui/e2e/visual.spec.ts index 482cae3..59ee2f1 100644 --- a/ui/e2e/visual.spec.ts +++ b/ui/e2e/visual.spec.ts @@ -38,3 +38,26 @@ test('the theme editor', async ({ page, request }) => { await page.getByRole('button', { name: 'Theme editor…' }).click(); await expect(page).toHaveScreenshot('theme-editor.png', { mask: mask(page) }); }); + +test('the help', async ({ page, request }) => { + await resetSettings(request, { theme: 'light' }); + await page.goto('/'); + await page.getByRole('button', { name: 'Help' }).click(); + const help = page.getByRole('dialog', { name: 'Help' }); + await expect(help).toHaveScreenshot('help-overview.png'); + await help.getByRole('button', { name: /Connect the library/ }).click(); + await expect(help).toHaveScreenshot('help-library.png'); +}); + +test('the viewer in a narrower window', async ({ page, request }) => { + await resetSettings(request, { theme: 'light' }); + await page.setViewportSize({ width: 1100, height: 800 }); + await page.goto('/'); + await page + .getByRole('region', { name: 'Requests' }) + .getByRole('button', { name: /\/catalog\/item\?id=42/ }) + .click(); + await page.getByRole('button', { name: /Loading the item/ }).click(); + await expect(page.getByRole('region', { name: 'Message' })).toContainText('item_id'); + await expect(page).toHaveScreenshot('viewer-narrow.png', { mask: mask(page) }); +}); diff --git a/ui/package-lock.json b/ui/package-lock.json index 292a4a1..d784863 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,6 +8,7 @@ "name": "wapp-debugger-ui", "version": "0.0.0", "dependencies": { + "@lucide/svelte": "1.56.0", "highlight.js": "11.12.0" }, "devDependencies": { @@ -590,7 +591,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -601,7 +601,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -612,7 +611,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -622,14 +620,12 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -660,6 +656,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@lucide/svelte": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.56.0.tgz", + "integrity": "sha512-YzbcmneHmPog3wAdXSl7oORgJhFc6klu8rRLMmm5uXwPQ8B0740c7dcNd3oJ72nCPvlbCW1XEmsWa4I0SDIogw==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1003,7 +1008,6 @@ "version": "1.0.13", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1178,7 +1182,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1348,7 +1351,7 @@ "version": "8.71.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1470,7 +1473,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1570,7 +1572,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1676,7 +1677,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1875,7 +1875,6 @@ "version": "5.9.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", - "dev": true, "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -2159,7 +2158,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/espree": { @@ -2197,7 +2195,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2803,7 +2800,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -3231,7 +3227,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -4313,7 +4308,6 @@ "version": "5.57.2", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", @@ -4444,7 +4438,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -4454,7 +4447,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -5096,7 +5088,6 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", - "dev": true, "license": "MIT" } } diff --git a/ui/package.json b/ui/package.json index 87da836..06cd0c7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -35,6 +35,7 @@ "vitest": "^5.0.5" }, "dependencies": { + "@lucide/svelte": "1.56.0", "highlight.js": "11.12.0" } } diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 4cf2282..9111002 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -9,12 +9,26 @@ import RequestTabs from './components/RequestTabs.svelte'; import RequestList from './components/RequestList.svelte'; import CommandPalette, { type PaletteCommand } from './components/CommandPalette.svelte'; + import HelpDialog from './components/HelpDialog.svelte'; import SettingsDialog from './components/SettingsDialog.svelte'; import ShortcutsDialog from './components/ShortcutsDialog.svelte'; import Showcase from './components/Showcase.svelte'; import ThemeEditor from './components/ThemeEditor.svelte'; import Toaster from './components/Toaster.svelte'; - import { emitCommand } from './lib/commands'; + import { emitCommand, onCommand } from './lib/commands'; + import type { HelpTopic } from './lib/help'; + import { + Bug, + Command, + Help, + List, + MessageSquare, + Palette, + PanelLeftClose, + PanelLeftOpen, + Settings, + Sparkles, + } from './lib/icons'; import { themePreferences } from './lib/theme'; import ThemeSwitch from './components/ThemeSwitch.svelte'; import { createHttpTransport, type Transport, type UpdateInfo } from './lib/api/client'; @@ -88,7 +102,44 @@ }); let paletteOpen = $state(false); - let helpOpen = $state(false); + let shortcutsOpen = $state(false); + let guideOpen = $state(false); + let guideTopic = $state('overview'); + + function openGuide(topic: HelpTopic = 'overview') { + guideTopic = topic; + guideOpen = true; + } + + $effect(() => + onCommand((command) => { + if (command.type === 'help') { + openGuide(command.topic); + } + }), + ); + + // The sidebar folds to a rail of icons; the choice stays in this browser, a narrow window starts + // folded + const sidebarKey = 'wapp-debugger.sidebar'; + function storedSidebar(): boolean | undefined { + try { + const value = localStorage.getItem(sidebarKey); + return value === null ? undefined : value === 'collapsed'; + } catch { + return undefined; + } + } + let collapsed = $state(storedSidebar() ?? window.innerWidth < 900); + + function toggleSidebar() { + collapsed = !collapsed; + try { + localStorage.setItem(sidebarKey, collapsed ? 'collapsed' : 'expanded'); + } catch { + // Private windows may refuse storage: the sidebar still folds for now + } + } let commands = $state([]); // Commands of the palette: projects and servers as they are now, then the actions @@ -162,7 +213,8 @@ ...(desktop ? [{ id: 'settings', label: i18n.t('settings.title'), run: () => (settingsOpen = true) }] : []), - { id: 'help', label: i18n.t('keys.title'), hint: '?', run: () => (helpOpen = true) }, + { id: 'guide', label: i18n.t('help.title'), run: () => openGuide() }, + { id: 'help', label: i18n.t('keys.title'), hint: '?', run: () => (shortcutsOpen = true) }, ]; paletteOpen = true; } @@ -259,7 +311,7 @@ emitCommand({ type: 'focus-filter' }); break; case '?': - helpOpen = true; + shortcutsOpen = true; break; default: return; @@ -299,43 +351,47 @@ {:else}
-
-

{i18n.t('app.title')}

- {#if version} - {version} - {/if} +
-
-
+ + + +
+
navigate({ ...scope, requestId })} />
- {:else} + {:else if route.requestId !== undefined} + {@const requestId = route.requestId}
- {#if route.requestId !== undefined} - {@const requestId = route.requestId} - navigate({ ...scope, requestId: id })} - oncompare={(id) => navigate({ ...scope, requestId, compareId: id })} - /> - - navigate({ ...scope, requestId: route.requestId, messageId: id })} - onselectrequest={(id) => navigate({ ...scope, requestId: id })} - /> - {:else} -

{i18n.t('detail.empty')}

- {/if} + navigate({ ...scope, requestId: id })} + oncompare={(id) => navigate({ ...scope, requestId, compareId: id })} + /> + + navigate({ ...scope, requestId: route.requestId, messageId: id })} + onselectrequest={(id) => navigate({ ...scope, requestId: id })} + />
{#if route.messageId !== undefined} - {:else if route.requestId !== undefined} -

{i18n.t('message.empty')}

+ {:else} +
+ +

{i18n.t('message.empty')}

+

{i18n.t('message.emptyText')}

+
{/if}
+ {:else} +
+
+ +

{i18n.t('detail.emptyTitle')}

+

{i18n.t('detail.emptyText')}

+
+
{/if}
@@ -408,7 +516,16 @@ onfilter={(text) => emitCommand({ type: 'filter', text })} onclose={() => (paletteOpen = false)} /> - (helpOpen = false)} /> + (shortcutsOpen = false)} /> + (guideOpen = false)} + onshortcuts={() => { + guideOpen = false; + shortcutsOpen = true; + }} +/> - .settings { - padding: 2px var(--space-3); - border: 1px solid var(--border); - border-radius: 999px; - background: var(--surface); - color: var(--text); - font: inherit; - font-size: var(--font-size-small); - cursor: pointer; - } - - .update { - padding: 0 var(--space-2); - border-radius: var(--radius); - background: var(--level-warning-soft); - color: var(--level-warning); - font-size: var(--font-size-small); - } - - .settings:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; - } - .app { display: grid; - grid-template-rows: auto 1fr; + grid-template-columns: auto minmax(0, 1fr); height: 100vh; } @@ -475,34 +568,126 @@ --env-frame: var(--env-prod); } - .header { + .sidebar { display: flex; + flex-direction: column; gap: var(--space-3); + width: var(--sidebar-width); + min-height: 0; + padding: var(--space-3) 0; + border-right: 1px solid var(--border); + background: var(--bg); + } + + .sidebar[data-collapsed='true'] { + width: var(--sidebar-rail); align-items: center; - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--border); - background: var(--surface); + } + + .brand { + display: flex; + gap: var(--space-3); + align-items: center; + padding: 0 var(--space-3) 0 var(--space-4); + } + + [data-collapsed='true'] .brand { + flex-direction: column; + gap: var(--space-2); + padding: 0; + } + + .logo { + display: grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-contrast); + } + + .brand-text { + display: grid; + flex: 1; + min-width: 0; } .title { margin: 0; - font-size: 15px; - font-weight: 600; + font-size: 14px; + font-weight: 700; } .version { + overflow: hidden; color: var(--text-muted); font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .update { + display: flex; + gap: var(--space-2); + align-items: center; + margin: 0 var(--space-3); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius); + background: var(--level-warning-soft); + color: var(--level-warning); font-size: var(--font-size-small); + text-decoration: none; } - .spacer { + [data-collapsed='true'] .update { + margin: 0; + padding: var(--space-2); + } + + .nav { flex: 1; + min-height: 0; + overflow: auto; + } + + [data-collapsed='true'] .nav { + width: 100%; + } + + .footer { + display: grid; + gap: var(--space-3); + padding: var(--space-3) var(--space-3) 0; + border-top: 1px solid var(--border); + } + + [data-collapsed='true'] .footer { + padding: var(--space-3) 0 0; + } + + .tools { + display: flex; + gap: var(--space-1); + } + + [data-collapsed='true'] .tools { + flex-direction: column; + } + + .switches { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: space-between; } .panes { display: grid; - grid-template-columns: minmax(320px, 1.1fr) minmax(320px, 1fr) minmax(320px, 1fr); + grid-template-columns: minmax(340px, 1.15fr) minmax(320px, 1fr) minmax(320px, 1fr); + min-width: 0; min-height: 0; background: var(--surface); } @@ -517,28 +702,55 @@ border-right: 0; } - /* The comparison takes the places of the request and the message */ + /* The comparison or the empty state take the places of the request and the message */ .pane.wide { grid-column: span 2; } - .hint { - padding: var(--space-6) var(--space-4); - color: var(--text-muted); + .feed { + display: flex; + flex-direction: column; } - @media (max-width: 1100px) { + /* Narrower: the feed on the left, the request over its message on the right */ + @media (max-width: 1360px) { .panes { - grid-template-columns: 1fr; - grid-auto-rows: minmax(240px, auto); + grid-template-columns: minmax(300px, 0.9fr) minmax(0, 1.1fr); + grid-template-rows: minmax(0, 1.2fr) minmax(0, 1fr); + } + + .feed { + grid-row: span 2; } .pane.wide { + grid-row: span 2; grid-column: auto; } - .pane { + .pane:not(.feed) { border-right: 0; + } + + .pane:not(.feed, .wide):nth-child(2) { + border-bottom: 1px solid var(--border); + } + } + + @media (max-width: 900px) { + .panes { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: none; + grid-auto-rows: minmax(280px, auto); + overflow: auto; + } + + .feed, + .pane.wide { + grid-row: auto; + } + + .pane { border-bottom: 1px solid var(--border); } } diff --git a/ui/src/App.test.ts b/ui/src/App.test.ts index 907a92d..d58bda4 100644 --- a/ui/src/App.test.ts +++ b/ui/src/App.test.ts @@ -35,6 +35,7 @@ describe('App', () => { beforeEach(() => { i18n.set('en'); window.location.hash = ''; + localStorage.removeItem('wapp-debugger.sidebar'); }); it('shows the product name, the switches and an empty feed', async () => { @@ -43,10 +44,56 @@ describe('App', () => { expect(screen.getByRole('heading', { name: 'wapp-debugger', level: 1 })).toBeInTheDocument(); expect(screen.getByRole('radiogroup', { name: 'Theme' })).toBeInTheDocument(); expect(screen.getByRole('radiogroup', { name: 'Language' })).toBeInTheDocument(); - expect(await screen.findByText('No requests yet')).toBeInTheDocument(); + // With no projects yet the feed says how to start + expect(await screen.findByRole('heading', { name: 'Start debugging' })).toBeInTheDocument(); expect(await screen.findByText('v0.0.0-test')).toBeInTheDocument(); }); + it('opens the help from the sidebar and from the first steps', async () => { + const { transport } = fakeTransport(); + render(App, { transport }); + await fireEvent.click(screen.getByRole('button', { name: 'Help' })); + const help = screen.getByRole('dialog', { name: 'Help' }); + expect(within(help).getByRole('heading', { name: 'How it works' })).toBeInTheDocument(); + await fireEvent.click(within(help).getByRole('button', { name: 'Close' })); + + const start = await screen.findByRole('region', { name: 'Start debugging' }); + await fireEvent.click(within(start).getByRole('button', { name: /Connect the library/ })); + expect( + within(screen.getByRole('dialog', { name: 'Help' })).getByRole('heading', { + name: 'Connect the library', + }), + ).toBeInTheDocument(); + }); + + it('adds the first project from the first steps', async () => { + const { transport } = fakeTransport(); + render(App, { transport }); + const start = await screen.findByRole('region', { name: 'Start debugging' }); + await fireEvent.click(within(start).getByRole('button', { name: 'Add project' })); + expect(screen.getByRole('dialog', { name: 'New project' })).toBeInTheDocument(); + }); + + it('collapses the sidebar to a rail and remembers it', async () => { + const { transport } = fakeTransport(); + const { unmount } = render(App, { transport, store: freshStore() }); + await fireEvent.click(screen.getByRole('button', { name: 'Collapse the sidebar' })); + expect(screen.getByRole('button', { name: 'Expand the sidebar' })).toBeInTheDocument(); + expect(screen.getByRole('complementary')).toHaveAttribute('data-collapsed', 'true'); + unmount(); + render(App, { transport }); + expect(screen.getByRole('button', { name: 'Expand the sidebar' })).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: 'Expand the sidebar' })); + expect(screen.getByRole('complementary')).toHaveAttribute('data-collapsed', 'false'); + }); + + it('opens the commands from the sidebar', async () => { + const { transport } = fakeTransport(); + render(App, { transport }); + await fireEvent.click(screen.getByRole('button', { name: 'Commands' })); + expect(await screen.findByRole('dialog', { name: 'Commands' })).toBeInTheDocument(); + }); + it('offers the settings of the desktop app only in the desktop app', async () => { const { transport } = fakeTransport(); render(App, { transport }); diff --git a/ui/src/components/AppearanceDialog.svelte b/ui/src/components/AppearanceDialog.svelte index d62a785..50129a7 100644 --- a/ui/src/components/AppearanceDialog.svelte +++ b/ui/src/components/AppearanceDialog.svelte @@ -10,6 +10,7 @@ import { themePreferences } from '../lib/theme'; import { builtInTokens } from '../lib/themeTokens'; import ColorField from './ColorField.svelte'; + import { Palette } from '../lib/icons'; import Dialog from './Dialog.svelte'; import SegmentedControl from './SegmentedControl.svelte'; import Select from './Select.svelte'; @@ -76,7 +77,7 @@ } - +
diff --git a/ui/src/components/CommandPalette.svelte b/ui/src/components/CommandPalette.svelte index 5f7ff33..77273b3 100644 --- a/ui/src/components/CommandPalette.svelte +++ b/ui/src/components/CommandPalette.svelte @@ -2,6 +2,7 @@ // Ctrl+K: type to find a command, arrows to choose, Enter to run. Typed text that is no command // can still filter the feed. import { i18n } from '../lib/i18n/locale.svelte'; + import { Command } from '../lib/icons'; import Dialog from './Dialog.svelte'; export interface PaletteCommand { @@ -67,7 +68,7 @@ } - + {i18n.t('config.exportLabel')} - {i18n.t('config.download')} + {i18n.t('config.download')} {:else}

{i18n.t('config.importHint')}

-