Initial SFERA platform baseline

This commit is contained in:
2026-05-16 19:03:49 +03:00
commit 3b845c8fce
282 changed files with 55045 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# SFERA Web
Next.js frontend for the SFERA 1C semantic workspace.
## Run On This NAS Workspace
Use the mapped `R:` drive. It points to `\\nas\MST` and avoids Windows tooling issues with UNC current directories.
```bat
cd /d R:\codex\SFERA\frontend\sfera-web
npm run dev
```
Do not run `npm` from the UNC path `\\nas\MST\...`. Windows starts `cmd.exe` in `C:\Windows` for UNC current directories, so package scripts cannot find `package.json`.
The app resolves the API endpoint in this order:
1. `SFERA_API_URL`
2. `NEXT_PUBLIC_SFERA_API_URL`
3. the current panel host with API port `8000`
Examples:
- `http://192.168.200.60:3000` -> `http://192.168.200.60:8000`
- `http://docker-test.cin.su:3000` -> `http://docker-test.cin.su:8000`
- `http://127.0.0.1:3001` -> `http://localhost:8000`
If the stand moves to another API port, set:
```text
SFERA_API_URL=http://api-host:8080
```
You can also override only the derived port:
```text
SFERA_API_PORT=8080
```
## Checks
```bat
cd /d R:\codex\SFERA\frontend\sfera-web
node_modules\.bin\tsc.cmd -p tsconfig.json --noEmit
node_modules\.bin\next.cmd build
npm run smoke:editor
npm run smoke:editor:runtime
npm run smoke:project-setup
```
`npm run smoke:editor` checks the running stand URL. By default it uses `http://192.168.200.60:3000` and verifies that the overview stays a dashboard while editor modes open directly:
- `mode=module`
- `mode=form`
- `mode=events`
- `mode=learning`
Use `SFERA_WEB_URL` to point the smoke check at another host or port. The HTML smoke retries each page briefly while a freshly started stand settles.
`npm run smoke:editor:runtime` opens the running editor in an installed Chrome/Edge through `playwright-core`. It catches browser runtime failures such as Monaco `Runtime TypeError`, verifies that Monaco renders in `mode=module`, opens `mode=versions`, clicks a version row, checks full version payload details, and exercises metadata draft preview/apply through the API.
`npm run smoke:project-setup` checks the new Project Setup / Import Center flow. It prepares the `default` project through the web API proxy, verifies that the indexed Project IDE Shell opens, and exercises metadata draft preview/save to workspace history.
By default the runtime smoke uses the installed Chrome/Edge paths on this Windows stand. Override when needed:
```text
SFERA_BROWSER_PATH=C:\Path\To\chrome.exe
SFERA_WEB_URL=http://docker-test.cin.su:3000
```
Use `npm run smoke:editor:all` to run both the HTML smoke and browser runtime smoke. Browser-side editor actions require API CORS for the panel origin; the API allows LAN panel origins, localhost, and `docker-test.cin.su` by default. The Project Setup smoke uses the Next.js `/api/sfera/...` proxy and therefore also verifies frontend-to-backend routing.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "sfera-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 0.0.0.0 --port 3000",
"build": "next build",
"start": "next start --hostname 0.0.0.0 --port 3000",
"lint": "next lint",
"smoke:editor": "node scripts/smoke-editor-modes.mjs",
"smoke:editor:all": "npm run smoke:editor && npm run smoke:editor:runtime",
"smoke:editor:runtime": "node scripts/smoke-editor-runtime.mjs",
"smoke:project-setup": "node scripts/smoke-project-setup.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"monaco-editor": "^0.55.1",
"next": "^15.0.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"eslint-config-next": "^15.0.4",
"playwright-core": "^1.59.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
export default config;
@@ -0,0 +1,25 @@
The MIT License (MIT)
=====================
Copyright © 2026 Andrew Ponomarev
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,9 @@
1C-like metadata icons
======================
Source: MetadataViewer1C by Andrew Ponomarev
Repository: https://github.com/asweetand-a11y/MetadataViewer1C
License: MIT, see LICENSE.MetadataViewer1C.md
These SVGs are used as a visually similar open-source icon set for SFERA's 1C metadata tree.
They are not extracted from the 1C:Enterprise Configurator executable or distribution.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M20.95 31.95 35.4 17.5l-2.15-2.15-12.3 12.3L15 21.7l-2.15 2.15ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 440 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24.5 21.7h3v-4.2h4.2v-3h-4.2v-4.2h-3v4.2h-4.2v3h4.2Zm-4.2 7.8h11.4v-3H20.3ZM13 40q-1.2 0-2.1-.9-.9-.9-.9-2.1V5q0-1.2.9-2.1.9-.9 2.1-.9h17.4L42 13.6V37q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h26V14.9L28.9 5H13v32Zm-6 9q-1.2 0-2.1-.9Q4 44.2 4 43V12.05h3V43h24.9v3Zm6-9V5v32Z"/>
</svg>

After

Width:  |  Height:  |  Size: 506 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M32 22q.85 0 1.425-.575Q34 20.85 34 20q0-.85-.575-1.425Q32.85 18 32 18q-.85 0-1.425.575Q30 19.15 30 20q0 .85.575 1.425Q31.15 22 32 22Zm-16-5h10v-3H16ZM9 42q-1.7-5.7-3.35-11.375Q4 24.95 4 19q0-4.6 3.2-7.8T15 8h10q1.45-1.9 3.525-2.95Q30.6 4 33 4q1.25 0 2.125.875T36 7q0 .3-.075.6t-.175.55q-.2.55-.375 1.125T35.1 10.45L39.65 15H44v13.95l-5.65 1.85L35 42H24v-4h-4v4Zm2.25-3H17v-4h10v4h5.75l3.15-10.5 5.1-1.75V18h-2.6L32 11.6q.05-1.25.325-2.425Q32.6 8 32.9 6.8q-1.9.5-3.6 1.475-1.7.975-2.6 2.725H15q-3.3 0-5.65 2.35Q7 15.7 7 19q0 5.15 1.45 10.075Q9.9 34 11.25 39ZM24 22.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M8 25.5v-3h32v3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M31 44v-4.75L15.5 31.5H6v-11h8.75l6.25-7V4h11v11h-8.3L17 22.5v6.45l14 6.95V33h11v11Zm-7-32h5V7h-5ZM9 28.5h5v-5H9ZM34 41h5v-5h-5ZM26.5 9.5ZM11.5 26Zm25 12.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M15.7 36.6h2.5v-4.4h4.4v-2.5h-4.4v-4.4h-2.5v4.4h-4.4v2.5h4.4Zm10.75-1.75H36.5V32.4H26.45Zm0-5.35H36.5V27H26.45Zm1.85-8.15 3.05-3.05 3.05 3.05 1.8-1.8-3.05-3.05 3.05-3.05-1.8-1.8-3.05 3.05-3.05-3.05-1.8 1.8 3.05 3.05-3.05 3.05Zm-16.25-3.6h9.8v-2.5h-9.8ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M6 42V6h36v36Zm3-25h30V9H9Zm11 11h8v-8h-8Zm0 11h8v-8h-8ZM9 28h8v-8H9Zm22 0h8v-8h-8ZM9 39h8v-8H9Zm22 0h8v-8h-8Z"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M14.5 40V13H4V8h26v5H19.5v27Zm18 0V23H26v-5h18v5h-6.5v17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="m24 41.5-18-14 2.5-1.85L24 37.7l15.5-12.05L42 27.5Zm0-7.6-18-14 18-14 18 14Zm0-15.05Zm0 11.25 13.1-10.2L24 9.7 10.9 19.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M15 33.7q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm0-8.2q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm0-8.2q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm6.6 16.4h12.2v-3H21.6Zm0-8.2h12.2v-3H21.6Zm0-8.2h12.2v-3H21.6ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M15.35 16.6q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm8.65 0q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45Zm8.65 0q.6 0 1.05-.45.45-.45.45-1.05 0-.6-.45-1.05-.45-.45-1.05-.45-.6 0-1.05.45-.45.45-.45 1.05 0 .6.45 1.05.45.45 1.05.45ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 753 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M19.15 32.5 32.5 24l-13.35-8.5ZM24 44q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q4.15 0 7.8 1.575 3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm0-3q7.1 0 12.05-4.975Q41 31.05 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41Zm0-17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M13.4 35.5q-3.1 0-5.25-2.15T6 28.1q0-3.1 2.15-5.25t5.25-2.15q3.1 0 5.25 2.15t2.15 5.25q0 3.1-2.15 5.25T13.4 35.5Zm0-3q1.85 0 3.125-1.275T17.8 28.1q0-1.85-1.275-3.125T13.4 23.7q-1.85 0-3.125 1.275T9 28.1q0 1.85 1.275 3.125T13.4 32.5Zm8.45-16.7q1.9 0 3.15-1.25t1.25-3.15q0-1.9-1.25-3.15T21.85 7q-1.9 0-3.15 1.25t-1.25 3.15q0 1.9 1.25 3.15t3.15 1.25Zm0 3q-3.1 0-5.25-2.15t-2.15-5.25q0-3.1 2.15-5.25T21.85 4q3.1 0 5.25 2.15t2.15 5.25q0 3.1-2.15 5.25t-5.25 2.15ZM34.6 39q1.9 0 3.15-1.25T39 34.6q0-1.9-1.25-3.15T34.6 30.2q-1.9 0-3.15 1.25T30.2 34.6q0 1.9 1.25 3.15T34.6 39Zm0 3q-3.1 0-5.25-2.15T27.2 34.6q0-3.1 2.15-5.25t5.25-2.15q3.1 0 5.25 2.15T42 34.6q0 3.1-2.15 5.25T34.6 42ZM21.85 11.4ZM13.4 28.1Zm21.2 6.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 950 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M13.85 34.05H27.6v-3H13.85Zm0-8.55h20.3v-3h-20.3Zm0-8.55h20.3v-3h-20.3ZM9 42q-1.2 0-2.1-.9Q6 40.2 6 39V9q0-1.2.9-2.1Q7.8 6 9 6h30q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h30V9H9v30ZM9 9v30V9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M6 6h16.5v16.5H6Zm3 2.95v9.4ZM25.5 6H42v16.5H25.5Zm4.15 2.95v9.4ZM6 25.5h16.5V42H6Zm3 4.05V39Zm23.25-4.05h3v6.75H42v3h-6.75V42h-3v-6.75H25.5v-3h6.75ZM28.5 9v10.5H39V9ZM9 9v10.5h10.5V9Zm0 19.5V39h10.5V28.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="m14 42-2.15-2.15 4.3-4.3q-5.4.35-9.775-3.525T2 22q0-5.85 4.075-9.925Q10.15 8 16 8h6v3h-6q-4.55 0-7.775 3.225Q5 17.45 5 22q0 4.7 3.275 7.7t8.025 2.9l-4.45-4.45L14 26l8 8Zm12-2V26h18v14Zm0-18V8h18v14Zm3-3h12v-8H29Z"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M7 36q-1.2 0-2.1-.9Q4 34.2 4 33V15q0-1.15.9-2.075Q5.8 12 7 12h34q1.2 0 2.1.925.9.925.9 2.075v18q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h34V15h-6.5v9h-3v-9h-6v9h-3v-9h-6v9h-3v-9H7v18Zm6.5-9h3Zm9 0h3Zm9 0h3ZM24 24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style><path class="icon-canvas-transparent" d="M16 16H0V0h16v16z" id="canvas"/><path class="icon-vs-out" d="M15 16H2V0h8.621L15 4.379V16z" id="outline"/><path class="icon-vs-fg" d="M13 14H4V2h5v4h4v8zm-3-9V2.207L12.793 5H10z" id="iconFg"/><path class="icon-vs-bg" d="M3 1v14h11V4.793L10.207 1H3zm10 13H4V2h5v4h4v8zm-3-9V2.207L12.793 5H10z" id="iconBg"/></svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M28.05 31V20h3.75l3.25 3.25V31Zm-3 3h13V22l-5-5h-8Zm-18 6q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 -960 960 960">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M120-80v-60h100v-30h-60v-60h60v-30H120v-60h160v90l-30 30 30 30v90H120Zm0-280v-150h100v-30H120v-60h160v150H180v30h100v60H120Zm60-280v-180h-60v-60h120v240h-60Zm189 431v-60h471v60H369Zm0-243v-60h471v60H369Zm0-243v-60h471v60H369Z"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 42v-3.55l10.8-10.8 3.55 3.55L27.55 42ZM6 31.5v-3h15v3Zm34.5-2.45-3.55-3.55 1.45-1.45q.4-.4 1.05-.4t1.05.4l1.45 1.45q.4.4.4 1.05t-.4 1.05ZM6 23.25v-3h23.5v3ZM6 15v-3h23.5v3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M11 44q-1.2 0-2.1-.9Q8 42.2 8 41V7q0-1.2.9-2.1Q9.8 4 11 4h17l12 12v7.8h-3V18H26V7H11v34h15v3Zm0-3V7v34Zm26.8-11.15 1.4 1.4-8.2 8.2V42h2.55l8.2-8.2 1.4 1.4-8.8 8.8H29v-5.35Zm5.35 5.35-5.35-5.35 3.05-3.05q.45-.45 1.05-.45.6 0 1.05.45l3.25 3.25q.45.45.45 1.05 0 .6-.45 1.05Z"/>
</svg>

After

Width:  |  Height:  |  Size: 515 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M28.5 40v-3h6q1.05 0 1.775-.725Q37 35.55 37 34.5v-5q0-1.85 1.125-3.3 1.125-1.45 2.875-2v-.4q-1.75-.5-2.875-1.975T37 18.5v-5q0-1.05-.725-1.775Q35.55 11 34.5 11h-6V8h6q2.3 0 3.9 1.6t1.6 3.9v5q0 1.05.725 1.775Q41.45 21 42.5 21H44v6h-1.5q-1.05 0-1.775.725Q40 28.45 40 29.5v5q0 2.3-1.6 3.9T34.5 40Zm-15 0q-2.3 0-3.9-1.6T8 34.5v-5q0-1.05-.725-1.775Q6.55 27 5.5 27H4v-6h1.5q1.05 0 1.775-.725Q8 19.55 8 18.5v-5q0-2.3 1.6-3.9T13.5 8h6v3h-6q-1.05 0-1.775.725Q11 12.45 11 13.5v5q0 1.85-1.125 3.325T7 23.8v.4q1.75.55 2.875 2T11 29.5v5q0 1.05.725 1.775Q12.45 37 13.5 37h6v3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M19.95 42 22 27.9h-7.3q-.55 0-.8-.5t0-.95L26.15 6h2.05l-2.05 14.05h7.2q.55 0 .825.5.275.5.025.95L22 42Z"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M8.35 40v-3h6.5l-.75-.6q-3.2-2.55-4.65-5.55-1.45-3-1.45-6.7 0-5.3 3.125-9.525Q14.25 10.4 19.35 8.8v3.1q-3.75 1.45-6.05 4.825T11 24.15q0 3.15 1.175 5.475 1.175 2.325 3.175 4.025l1.5 1.05v-6.2h3V40Zm20.35-.75V36.1q3.8-1.45 6.05-4.825T37 23.85q0-2.4-1.175-4.875T32.75 14.6l-1.45-1.3v6.2h-3V8h11.5v3h-6.55l.75.7q3 2.8 4.5 6t1.5 6.15q0 5.3-3.1 9.55-3.1 4.25-8.2 5.85Z"/>
</svg>

After

Width:  |  Height:  |  Size: 606 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="m24.5 28.85 11.4-11.4-2.1-2.05-9.25 9.3-4.85-4.85-2.1 2.1ZM13 38q-1.2 0-2.1-.9-.9-.9-.9-2.1V7q0-1.2.9-2.1.9-.9 2.1-.9h28q1.2 0 2.1.9.9.9.9 2.1v28q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h28V7H13v28Zm-6 9q-1.2 0-2.1-.9Q4 42.2 4 41V10h3v31h31v3Zm6-37v28V7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 22q-8.05 0-13.025-2.45T6 14q0-3.15 4.975-5.575Q15.95 6 24 6t13.025 2.425Q42 10.85 42 14q0 3.1-4.975 5.55Q32.05 22 24 22Zm0 10q-7.3 0-12.65-2.2Q6 27.6 6 24.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 27 24 27q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 21.5 42 19.5v5q0 3.1-5.35 5.3Q31.3 32 24 32Zm0 10q-7.3 0-12.65-2.2Q6 37.6 6 34.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 37 24 37q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 31.5 42 29.5v5q0 3.1-5.35 5.3Q31.3 42 24 42Z"/>
</svg>

After

Width:  |  Height:  |  Size: 780 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M22 40q-.85 0-1.425-.575Q20 38.85 20 38V26L8.05 10.75q-.7-.85-.2-1.8Q8.35 8 9.4 8h29.2q1.05 0 1.55.95t-.2 1.8L28 26v12q0 .85-.575 1.425Q26.85 40 26 40Zm2-13.8L36 11H12Zm0 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M7.05 40q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M8 38v-3h19.3v3Zm0-8.3v-3h32v3Zm0-8.35v-3h32v3ZM8 13v-3h32v3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 44q-4.2 0-7.85-1.575Q12.5 40.85 9.8 38.15q-2.7-2.7-4.25-6.375Q4 28.1 4 23.9t1.55-7.825Q7.1 12.45 9.8 9.75t6.35-4.225Q19.8 4 24 4q4.2 0 7.85 1.525Q35.5 7.05 38.2 9.75q2.7 2.7 4.25 6.325Q44 19.7 44 23.9t-1.55 7.875Q40.9 35.45 38.2 38.15t-6.35 4.275Q28.2 44 24 44Zm0-2.9q1.75-1.8 2.925-4.125Q28.1 34.65 28.85 31.45H19.2q.7 3 1.875 5.4Q22.25 39.25 24 41.1Zm-4.25-.6q-1.25-1.9-2.15-4.1-.9-2.2-1.5-4.95H8.6Q10.5 35 13 37.025q2.5 2.025 6.75 3.475Zm8.55-.05q3.6-1.15 6.475-3.45 2.875-2.3 4.625-5.55h-7.45q-.65 2.7-1.525 4.9-.875 2.2-2.125 4.1Zm-20.7-12h7.95q-.15-1.35-.175-2.425-.025-1.075-.025-2.125 0-1.25.05-2.225.05-.975.2-2.175h-8q-.35 1.2-.475 2.15T7 23.9q0 1.3.125 2.325.125 1.025.475 2.225Zm11.05 0H29.4q.2-1.55.25-2.525.05-.975.05-2.025 0-1-.05-1.925T29.4 19.5H18.65q-.2 1.55-.25 2.475-.05.925-.05 1.925 0 1.05.05 2.025.05.975.25 2.525Zm13.75 0h8q.35-1.2.475-2.225Q41 25.2 41 23.9q0-1.3-.125-2.25T40.4 19.5h-7.95q.15 1.75.2 2.675.05.925.05 1.725 0 1.1-.075 2.075-.075.975-.225 2.475Zm-.5-11.95h7.5q-1.65-3.45-4.525-5.75Q32 8.45 28.25 7.5q1.25 1.85 2.125 4t1.525 5Zm-12.7 0h9.7q-.55-2.65-1.85-5.125T24 7q-1.6 1.35-2.7 3.55-1.1 2.2-2.1 5.95Zm-10.6 0h7.55q.55-2.7 1.4-4.825.85-2.125 2.15-4.125-3.75.95-6.55 3.2T8.6 16.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M4 39.5v-31h40v31Zm3-21.65h6.25V11.5H7Zm9.25 0h6.25V11.5h-6.25Zm9.25 0h6.25V11.5H25.5Zm9.25 0H41V11.5h-6.25Zm0 9.35H41v-6.35h-6.25Zm-9.25 0h6.25v-6.35H25.5Zm-9.25 0h6.25v-6.35h-6.25Zm-3-6.35H7v6.35h6.25Zm21.5 15.65H41v-6.3h-6.25Zm-9.25 0h6.25v-6.3H25.5Zm-9.25 0h6.25v-6.3h-6.25ZM7 36.5h6.25v-6.3H7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M12.55 40q-4.4 0-7.475-3.075Q2 33.85 2 29.45q0-3.9 2.5-6.85 2.5-2.95 6.35-3.55 1-4.85 4.7-7.925T24.1 8.05q5.6 0 9.45 4.075Q37.4 16.2 37.4 21.9v1.2q3.6-.1 6.1 2.325Q46 27.85 46 31.55q0 3.45-2.5 5.95T37.55 40Zm0-3h25q2.25 0 3.85-1.6t1.6-3.85q0-2.25-1.6-3.85t-3.85-1.6H34.4v-4.2q0-4.55-3.05-7.7-3.05-3.15-7.45-3.15t-7.475 3.15q-3.075 3.15-3.075 7.7h-.95q-3.1 0-5.25 2.175T5 29.45q0 3.15 2.2 5.35Q9.4 37 12.55 37ZM33 15V4h11v11Zm5-5h5V5h-5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 680 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M34 36q-4.95 0-8.475-3.525Q22 28.95 22 24q0-4.95 3.525-8.475Q29.05 12 34 12q4.95 0 8.475 3.525Q46 19.05 46 24q0 4.95-3.525 8.475Q38.95 36 34 36Z"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm40-80h480L570-480 450-320l-90-120-120 160Zm-40 80v-560 560Z"/>
</svg>

After

Width:  |  Height:  |  Size: 472 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M8 40V18h7v22Zm12.5 0V8h7v32ZM33 40V26h7v14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="m22.4 39.65-11-6.45q-.7-.4-1.075-1.1-.375-.7-.375-1.5V17.75q0-.8.375-1.5t1.075-1.1L22.45 8.6q.7-.4 1.55-.4.85 0 1.55.4l11.05 6.55q.7.4 1.075 1.1.375.7.375 1.5V30.6q0 .8-.4 1.5t-1.1 1.1L25.4 39.65q-.7.4-1.5.4t-1.5-.4Zm.1-3.45V25L13 19.55V30.5Zm3 0 9.55-5.7V19.55L25.5 25ZM2 11.45V7.6q0-2.35 1.625-3.975T7.6 2h3.85v3H7.6q-1.1 0-1.85.75T5 7.6v3.85ZM7.6 46q-2.35 0-3.975-1.625T2 40.4v-3.85h3v3.85q0 1.1.75 1.85T7.6 43h3.85v3Zm28.95-.2v-3h3.85q1.1 0 1.85-.75T43 40.2v-3.85h3v3.85q0 2.35-1.625 3.975T40.4 45.8ZM43 11.45V7.6q0-1.1-.75-1.85T40.4 5h-3.85V2h3.85q2.35 0 3.975 1.625T46 7.6v3.85ZM24 22.3l9.5-5.5-9.5-5.45-9.5 5.45Zm0 1.25Zm0-1.25Zm1.5 2.7Zm-3 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 894 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M14 27.4q-1.4 0-2.4-1t-1-2.4q0-1.4 1-2.4t2.4-1q1.4 0 2.4 1t1 2.4q0 1.4-1 2.4t-2.4 1Zm0 8.6q-5 0-8.5-3.5T2 24q0-5 3.5-8.5T14 12q3.6 0 6.3 1.7 2.7 1.7 4.25 5.15h17.8L48 24.5l-8.35 7.65-4.4-3.2-4.4 3.2-3.75-3h-2.55q-1.25 3-3.925 4.925Q17.95 36 14 36Zm0-3q2.9 0 5.35-1.925 2.45-1.925 3.15-4.925h5.7l2.7 2.25 4.4-3.15 4.1 3.1 4.25-3.95-2.55-2.55H22.5q-.6-2.8-3-4.825Q17.1 15 14 15q-3.75 0-6.375 2.625T5 24q0 3.75 2.625 6.375T14 33Z"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 38q5.85 0 9.925-4.075Q38 29.85 38 24q0-5.85-4.075-9.925Q29.85 10 24 10v14l-9.55 10q1.85 1.95 4.35 2.975Q21.3 38 24 38Zm0 6q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q4.15 0 7.8 1.575 3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm0-3q7.1 0 12.05-4.975Q41 31.05 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41Zm0-17Z"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 -960 960 960">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M120-280v-60h560v60H120Zm80-170v-60h560v60H200Zm80-170v-60h560v60H280Z"/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 29.25 18.75 24 24 18.75 29.25 24Zm-4.25-14.7L15.6 10.4 24 2l8.4 8.4-4.15 4.15L24 10.3ZM10.4 32.4 2 24l8.4-8.4 4.15 4.15L10.3 24l4.25 4.25Zm27.2 0-4.15-4.15L37.7 24l-4.25-4.25 4.15-4.15L46 24ZM24 46l-8.4-8.4 4.15-4.15L24 37.7l4.25-4.25 4.15 4.15Z"/>
</svg>

After

Width:  |  Height:  |  Size: 493 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M603-160h56v-83l102-24q21-5 43.5-11.5T827-299q0-8-8-14.5T792-326q-38-11-58-34t-18-49q2-21 19.5-37.5T785-474q28-10 41.5-22.5T840-526q0-20-17-30.5t-37-3.5q-6 2-12.5 3t-12.5 1q-26 0-43.5-20T700-625q0-26 13.5-47.5T727-720q0-17-14.5-28.5T677-760q-29 0-40.5 12.5T625-700q0 17 1.5 33t1.5 33q0 45-16 66t-50 21q-11 0-23-3t-23-3q-6 0-9.5 2.5T503-543q0 10 12 20t23 19q26 23 38 44t12 42q0 27-19.5 46T521-353q-11 0-21-2t-21-3q-28-2-43 5t-15 21q0 14 13 23t38 13l131 21v115Zm-450 40-73-33 200-440 73 33-200 440Zm370 40v-128l-57-8q-55-8-89.5-39.5T342-329q0-48 39.5-80.5T476-439q5 0 8.5.5t7.5 1.5q-8-8-15-14.5T464-465q-20-22-29-40.5t-9-37.5q0-38 21.5-62.5T504-630q7 0 19 .5t26 2.5q2-20 0-41t-2-41q0-63 34-97t97-34q56 0 93.5 32t36.5 78q0 23-8 45t-18 43q5-1 10.5-1.5t10.5-.5q48 0 82.5 34.5T920-525q0 43-30.5 77.5T818-402q7 2 13 4t12 5q29 14 47 39.5t18 55.5q0 38-33.5 66.5T779-188l-40 10v98H523ZM138-537q-29-29-43.5-65.5T80-679q0-84 58-142.5T280-880q40 0 76.5 15t65.5 44l-56 56q-17-17-39.5-26t-46.5-9q-50 0-85 35t-35 85q0 24 9 46.5t26 39.5l-57 57Zm493 128Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M8 46V35h5v-5.5H8v-11h5V13H8V2h13v11h-5v5.5h5v4h8v-4h13v11H29v-4h-8v4h-5V35h5v11Zm3-3h7v-5h-7Zm0-16.5h7v-5h-7Zm21 0h7v-5h-7ZM11 10h7V5h-7Zm3.5-2.5Zm0 16.5Zm21 0Zm-21 16.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M7 37h9.35V11H7v26Zm12.35 0h9.3V11h-9.3v26Zm12.3 0H41V11h-9.35v26ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M11.1 37.3 4 30.2l2.1-2.1 5 4.95 8.95-8.95 2.1 2.15Zm0-16L4 14.2l2.1-2.1 5 4.95 8.95-8.95 2.1 2.15ZM26 33.5v-3h18v3Zm0-16v-3h18v3Z"/>
</svg>

After

Width:  |  Height:  |  Size: 374 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M13 33h13v-3H13Zm19 0h3V15h-3Zm-19-7.5h13v-3H13Zm0-7.5h13v-3H13ZM6.6 42q-1.2 0-2.1-.9-.9-.9-.9-2.1V9q0-1.2.9-2.1.9-.9 2.1-.9h34.8q1.2 0 2.1.9.9.9.9 2.1v30q0 1.2-.9 2.1-.9.9-2.1.9Zm0-3h34.8V9H6.6v30Zm0 0V9v30Z"/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 44q-4.2 0-7.85-1.575Q12.5 40.85 9.8 38.15q-2.7-2.7-4.25-6.375Q4 28.1 4 23.9t1.55-7.825Q7.1 12.45 9.8 9.75t6.35-4.225Q19.8 4 24 4q1.15 0 2.275.125T28.5 4.5v10.4q-.7-2.25-1.85-4.275Q25.5 8.6 24 7q-1.6 1.35-2.7 3.55-1.1 2.2-2.1 5.95h9.3v3h-9.85q-.2 1.55-.25 2.475-.05.925-.05 1.925 0 1.05.05 2.025.05.975.25 2.525H29.4q.2-1.55.25-2.525.05-.975.05-2.025 0-1-.05-1.925T29.4 19.5h3.05q.15 1.75.2 2.675.05.925.05 1.725 0 1.1-.075 2.075-.075.975-.225 2.475h8q.35-1.2.475-2.225Q41 25.2 41 23.9q0-1.3-.125-2.25T40.4 19.5h3.15q.25 1.05.35 2.15.1 1.1.1 2.25 0 4.2-1.55 7.875T38.2 38.15q-2.7 2.7-6.35 4.275Q28.2 44 24 44ZM7.6 28.45h7.95q-.15-1.35-.175-2.425-.025-1.075-.025-2.125 0-1.25.05-2.225.05-.975.2-2.175h-8q-.35 1.2-.475 2.15T7 23.9q0 1.3.125 2.325.125 1.025.475 2.225ZM19.75 40.5q-1.25-1.9-2.15-4.1-.9-2.2-1.5-4.95H8.6Q10.5 35 13 37.025q2.5 2.025 6.75 3.475ZM8.6 16.5h7.55q.55-2.7 1.4-4.825.85-2.125 2.15-4.125-3.75.95-6.55 3.2T8.6 16.5ZM24 41.1q1.75-1.8 2.925-4.125Q28.1 34.65 28.85 31.45H19.2q.7 3 1.875 5.4Q22.25 39.25 24 41.1Zm4.3-.65q3.6-1.15 6.475-3.45 2.875-2.3 4.625-5.55h-7.45q-.65 2.7-1.525 4.9-.875 2.2-2.125 4.1ZM33 15V4h11v11Zm5-5h5V5h-5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M12.55 40q-4.4 0-7.475-3.075Q2 33.85 2 29.45q0-3.9 2.5-6.85 2.5-2.95 6.35-3.55 1-4.85 4.7-7.925T24.1 8.05q5.6 0 9.45 4.075Q37.4 16.2 37.4 21.9v1.2q3.6-.1 6.1 2.325Q46 27.85 46 31.55q0 3.45-2.5 5.95T37.55 40Zm0-3h25q2.25 0 3.85-1.6t1.6-3.85q0-2.25-1.6-3.85t-3.85-1.6H34.4v-4.2q0-4.55-3.05-7.7-3.05-3.15-7.45-3.15t-7.475 3.15q-3.075 3.15-3.075 7.7h-.95q-3.1 0-5.25 2.175T5 29.45q0 3.15 2.2 5.35Q9.4 37 12.55 37ZM24 24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48">
<style>.icon-canvas-transparent{opacity:0;fill:#2d2d30}.icon-vs-out{fill:#2d2d30}.icon-vs-bg{fill:#c5c5c5}.icon-vs-fg{fill:#2b282e}</style>
<path class="icon-vs-fg" d="M24 44q-3.3 0-6.525-1.2-3.225-1.2-5.775-3.15-2.55-1.95-4.125-4.4Q6 32.8 6 30.25v-5l6.75 5.05-2.9 2.9q1.55 2.9 5.3 5.175T22.5 40.9V21.5H16v-3h6.5v-3.7q-1.9-.7-2.95-2.1-1.05-1.4-1.05-3.2 0-2.3 1.625-3.9T24 4q2.3 0 3.9 1.6t1.6 3.9q0 1.8-1.05 3.2-1.05 1.4-2.95 2.1v3.7H32v3h-6.5v19.4q3.6-.25 7.35-2.525 3.75-2.275 5.3-5.175l-2.9-2.9L42 25.25v5q0 2.55-1.575 5t-4.125 4.4q-2.55 1.95-5.775 3.15Q27.3 44 24 44Zm0-32q1.05 0 1.775-.75.725-.75.725-1.75 0-1.05-.725-1.775Q25.05 7 24 7q-1 0-1.75.725T21.5 9.5q0 1 .75 1.75T24 12Z"/>
</svg>

After

Width:  |  Height:  |  Size: 759 B

@@ -0,0 +1,165 @@
const baseUrl = process.env.SFERA_WEB_URL ?? "http://192.168.200.60:3000";
const projectId = process.env.SFERA_PROJECT_ID ?? "demo";
const routine = encodeURIComponent(process.env.SFERA_ROUTINE ?? "Проведение");
const attempts = Number(process.env.SFERA_SMOKE_ATTEMPTS ?? "5");
const checks = [
{
name: "root opens IDE workspace",
url: `${baseUrl}/?lang=ru&project=${projectId}`,
mustInclude: [
"SFERA",
"data-top-project-bar",
"data-top-bar-logo",
"data-top-bar-selector=\"workspace\"",
"data-top-bar-selector=\"project\"",
"data-top-bar-selector=\"environment\"",
"data-top-bar-selector=\"active-task\"",
"data-top-bar-action=\"project-settings\"",
"data-top-bar-action=\"create-project\"",
"data-top-bar-badge=\"api-status\"",
"data-top-bar-badge=\"agent-status\"",
"data-top-bar-language",
"data-top-bar-button=\"profile\"",
"Ctrl+K",
"data-status-bar",
"data-status-item=\"current-user\""
],
mustNotInclude: ["Открыть в редакторе"]
},
{
name: "root opens IDE workspace (en)",
url: `${baseUrl}/?lang=en&project=${projectId}`,
mustInclude: [
"SFERA",
"data-top-project-bar",
"data-top-bar-logo",
"data-top-bar-selector=\"workspace\"",
"data-top-bar-selector=\"project\"",
"data-top-bar-selector=\"environment\"",
"data-top-bar-selector=\"active-task\"",
"data-top-bar-action=\"project-settings\"",
"data-top-bar-action=\"create-project\"",
"data-top-bar-badge=\"api-status\"",
"data-top-bar-badge=\"agent-status\"",
"data-top-bar-language",
"data-top-bar-button=\"profile\"",
"Ctrl+K",
"data-status-bar",
"data-status-item=\"current-user\""
],
mustNotInclude: ["Open in editor"]
},
{
name: "project settings route",
url: `${baseUrl}/project-settings`,
mustInclude: [
"Project Settings",
"Import Center",
"REFERENCE_CONFIGURATION",
"Reference config",
"data-import-action=\"REFERENCE_CONFIGURATION:import\"",
"data-import-action=\"XML_DUMP:check\"",
"data-settings-section=\"docker-runtime-adapter\"",
"data-settings-section=\"its-documentation-access\"",
"Пользователи и доступ",
"Интеграции задач",
"Docker/runtime adapter",
"ITS/documentation access",
"Audit",
"Backup/restore",
"SFERA_ITS_URL",
"SFERA_ITS_USERNAME",
"SFERA_ITS_PASSWORD",
"https://its.1c.ru/db/v838doc#browse:13:-1:7",
".env.local",
"&lt;set locally&gt;"
],
mustNotInclude: ["Открыть в редакторе"]
},
{
name: "project settings route (en)",
url: `${baseUrl}/project-settings?lang=en`,
mustInclude: [
"Project Settings",
"Import Center",
"REFERENCE_CONFIGURATION",
"Reference config",
"data-import-action=\"REFERENCE_CONFIGURATION:import\"",
"data-import-action=\"XML_DUMP:check\"",
"data-settings-section=\"task-session-policy\"",
"data-settings-section=\"docker-runtime-adapter\"",
"data-settings-section=\"its-documentation-access\"",
"Task/session policy",
"Docker/runtime adapter",
"ITS/documentation access",
"Audit",
"Backup/restore",
"SFERA_ITS_URL",
"SFERA_ITS_USERNAME",
"SFERA_ITS_PASSWORD",
".env.local",
"&lt;set locally&gt;"
],
mustNotInclude: ["Open in editor"]
},
{
name: "module mode",
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=module&routine=${routine}`,
mustInclude: ["data-ide-workspace", "data-left-navigation-panel", "data-right-context-inspector", "data-open-objects-bar", "data-open-document-pin", "data-open-document-close", "data-fallback-tree-search", "data-fallback-tree-filters", "Alt+1 Alt+2 Alt+3", "Редактор BSL", "Код модуля не загружен", "Выберите реальный модуль", "Основная конфигурация", "Расширение: &lt;Имя&gt;", "SFERA", "Среды"]
},
{
name: "form mode",
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=form&routine=${routine}`,
mustInclude: ["Дизайнер формы", "Провести и закрыть", "Товары"]
},
{
name: "events mode",
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=events&routine=${routine}`,
mustInclude: ["Инспектор событий", "ПриСозданииНаСервере", "ПередЗаписью"]
},
{
name: "learning mode",
url: `${baseUrl}/editor?lang=ru&project=${projectId}&mode=learning&routine=${routine}`,
mustInclude: ["Обучение", "переменные доступны", "стандарты команды"]
}
];
for (const check of checks) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await runCheck(check);
console.log(`ok ${check.name}`);
lastError = undefined;
break;
} catch (error) {
lastError = error;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
if (lastError) {
throw lastError;
}
}
async function runCheck(check) {
const response = await fetch(check.url, { headers: { Accept: "text/html" } });
if (!response.ok) {
throw new Error(`${check.name}: ${response.status} ${response.statusText}`);
}
const html = await response.text();
for (const expected of check.mustInclude ?? []) {
if (!html.includes(expected)) {
throw new Error(`${check.name}: missing "${expected}"`);
}
}
for (const forbidden of check.mustNotInclude ?? []) {
if (html.includes(forbidden)) {
throw new Error(`${check.name}: unexpected "${forbidden}"`);
}
}
}
@@ -0,0 +1,292 @@
import { chromium } from "playwright-core";
import { existsSync } from "node:fs";
const baseUrl = process.env.SFERA_WEB_URL ?? "http://192.168.200.60:3000";
const projectId = process.env.SFERA_PROJECT_ID ?? "demo";
const uniqueSuffix = Date.now().toString(36);
const browserPath =
process.env.SFERA_BROWSER_PATH ??
[
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
].find((path) => existsSync(path));
if (!browserPath) {
throw new Error("No installed Chrome or Edge browser found. Set SFERA_BROWSER_PATH.");
}
const runtimeErrors = [];
const browser = await chromium.launch({
executablePath: browserPath,
headless: true
});
function assertNoFatalRuntimeErrors(stage) {
const fatalErrors = runtimeErrors.filter((message) => {
return (
message.includes("Runtime TypeError") ||
message.includes("Cannot read properties") ||
message.includes("Unhandled Runtime Error")
);
});
if (fatalErrors.length > 0) {
throw new Error(`${stage} failed:\n${fatalErrors.join("\n")}`);
}
}
async function collapseBottomPanelIfOpen(page) {
if ((await page.locator("[data-bottom-tool-panel]").count()) > 0) {
await page.keyboard.press("Alt+3");
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
}
}
try {
const page = await browser.newPage();
await page.setViewportSize({ width: 1680, height: 1050 });
page.on("pageerror", (error) => {
runtimeErrors.push(`pageerror: ${error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
runtimeErrors.push(`console: ${message.text()}`);
}
});
const response = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=module`, {
waitUntil: "load",
timeout: 60000
});
if (!response?.ok()) {
throw new Error(`editor runtime smoke: ${response?.status()} ${response?.statusText()}`);
}
await page.getByRole("heading", { name: "Редактор BSL" }).waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("heading", { name: "Семантический diff" }).waitFor({ state: "visible", timeout: 15000 });
for (const marker of ["snapshot", "agent", "parser", "diagnostics", "active-task", "privacy", "ai-tokens", "current-user"]) {
await page.locator(`[data-status-item="${marker}"]`).waitFor({ state: "visible", timeout: 15000 });
}
await page.locator("[data-global-search-input]").waitFor({ state: "visible", timeout: 15000 });
await page.keyboard.press("Control+K");
const globalSearchFocused = await page.locator("[data-global-search-input]").evaluate((input) => document.activeElement === input);
if (!globalSearchFocused) {
throw new Error("Ctrl+K did not focus global search");
}
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "F5", bubbles: true, cancelable: true }));
});
await page.locator("[data-run-check-status]").waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-left-navigation-panel]").waitFor({ state: "visible", timeout: 15000 });
const hasVirtualScrollContainer =
(await page.locator("[data-virtual-scroll]").count()) > 0 ||
(await page.locator("[data-fallback-tree-scroll]").count()) > 0;
if (!hasVirtualScrollContainer) {
throw new Error("Left navigation panel has no virtual/fallback scroll container marker");
}
await page.locator("[data-right-context-inspector]").waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
for (const tabName of ["Проблемы", "Semantic diff", "Вывод", "История", "Тесты", "AI"]) {
await page.locator("[data-bottom-tool-panel]").getByText(tabName).first().waitFor({ state: "visible", timeout: 15000 });
}
await page.locator("[data-fallback-tree-search-input]").fill("SFERA");
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-fallback-tree-search-input]").fill("");
await page.locator("[data-fallback-tree-filter='sfera']").click();
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-fallback-tree-filter='all']").click();
const lazyFilterCount = await page.locator("[data-lazy-tree-filter]").count();
if (lazyFilterCount > 0) {
await page.locator("[data-lazy-tree-filter='sfera']").click();
await page.getByText("SFERA", { exact: true }).first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-lazy-tree-filter='all']").click();
}
await page.keyboard.press("Alt+1");
await page.locator("[data-left-navigation-panel]").waitFor({ state: "detached", timeout: 15000 });
await page.locator("[data-panel-rail]").first().click();
await page.locator("[data-left-navigation-panel]").waitFor({ state: "visible", timeout: 15000 });
await page.keyboard.press("Alt+2");
await page.locator("[data-right-context-inspector]").waitFor({ state: "detached", timeout: 15000 });
await page.locator("[data-panel-rail]").last().click();
await page.locator("[data-right-context-inspector]").waitFor({ state: "visible", timeout: 15000 });
await page.keyboard.press("Alt+3");
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
await page.getByRole("button", { name: "Нижняя панель" }).click();
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
await page.keyboard.press("Alt+3");
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "detached", timeout: 15000 });
const activeDocumentBeforeNext = await page.locator('[data-open-document][aria-pressed="true"]').first().getAttribute("data-open-document");
const openDocumentCount = await page.locator("[data-open-document]").count();
if (openDocumentCount < 2) {
throw new Error("Open Objects Bar has fewer than two switchable objects");
}
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", ctrlKey: true, bubbles: true, cancelable: true }));
});
const activeDocumentAfterNext = await page.locator('[data-open-document][aria-pressed="true"]').first().getAttribute("data-open-document");
if (!activeDocumentAfterNext || activeDocumentAfterNext === activeDocumentBeforeNext) {
throw new Error("Ctrl+Tab did not switch the active open object");
}
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }));
});
await page.locator(`[data-open-document="${activeDocumentBeforeNext}"][aria-pressed="true"]`).waitFor({ state: "visible", timeout: 15000 });
await page.locator(`[data-open-document-pin="${activeDocumentAfterNext}"]`).click();
const pinnedCloseButton = page.locator(`[data-open-document-close="${activeDocumentAfterNext}"]`);
if (!(await pinnedCloseButton.isDisabled())) {
throw new Error("Pinned open object close button is not disabled");
}
await page.locator(`[data-open-document-pin="${activeDocumentAfterNext}"]`).click();
await pinnedCloseButton.click();
await page.locator(`[data-open-document="${activeDocumentAfterNext}"]`).waitFor({ state: "detached", timeout: 15000 });
const ctrlWCandidate = await page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll<HTMLElement>("[data-open-document]"));
const candidate = buttons.find((button) => button.getAttribute("data-open-document-mode") !== "module");
return candidate?.getAttribute("data-open-document") ?? null;
});
if (ctrlWCandidate) {
const targetTab = page.locator(`[data-open-document="${ctrlWCandidate}"]`);
await targetTab.click();
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "w", ctrlKey: true, bubbles: true, cancelable: true }));
});
await targetTab.waitFor({ state: "detached", timeout: 15000 });
const moduleTabs = page.locator('[data-open-document-mode="module"]');
const moduleTabCount = await moduleTabs.count();
if (moduleTabCount > 0) {
await moduleTabs.first().click();
}
}
const openObjectsState = await page.evaluate((storageKey) => window.localStorage.getItem(storageKey), `sfera.open-objects.${projectId}`);
if (!openObjectsState?.includes(activeDocumentAfterNext)) {
throw new Error("Open Objects Bar state was not persisted");
}
await page.reload({ waitUntil: "load", timeout: 60000 });
await page.getByRole("heading", { name: "Редактор BSL" }).waitFor({ state: "visible", timeout: 15000 });
const restoredState = await page.evaluate((storageKey) => window.localStorage.getItem(storageKey), `sfera.open-objects.${projectId}`);
if (restoredState !== openObjectsState) {
throw new Error("Open Objects Bar state was not restored after reload");
}
await page.locator(`[data-open-document="${activeDocumentAfterNext}"]`).waitFor({ state: "detached", timeout: 15000 });
const restoredModuleTabs = page.locator('[data-open-document-mode="module"]');
if ((await restoredModuleTabs.count()) > 0) {
await restoredModuleTabs.first().click();
}
await page.locator(".monaco-editor").waitFor({ state: "visible", timeout: 15000 });
await page.locator("#symbol-search-input").fill("Проверить");
await page.locator("#symbol-search-input").press("Enter");
await page.locator("[data-symbol-result]").first().waitFor({ state: "visible", timeout: 15000 });
await page.locator('button[data-editor-action="find-usages"]').click();
await page.locator("[data-symbol-references]").waitFor({ state: "visible", timeout: 15000 });
const applyButton = page.locator('button[data-editor-action="apply-to-sfera"]');
await applyButton.waitFor({ state: "visible", timeout: 15000 });
if (await applyButton.isEnabled()) {
await applyButton.click();
await page.getByText("Записано в SFERA").waitFor({ state: "visible", timeout: 15000 });
}
assertNoFatalRuntimeErrors("editor module runtime smoke");
const versionsResponse = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=versions`, {
waitUntil: "load",
timeout: 60000
});
if (!versionsResponse?.ok()) {
throw new Error(`editor versions runtime smoke: ${versionsResponse?.status()} ${versionsResponse?.statusText()}`);
}
await page.getByRole("heading", { name: "Версии" }).waitFor({ state: "visible", timeout: 15000 });
await collapseBottomPanelIfOpen(page);
await page.getByText("Линия версий").first().waitFor({ state: "visible", timeout: 15000 });
const versionButtons = page.locator("button[data-version-id]");
const versionButtonCount = await versionButtons.count();
if (versionButtonCount > 0) {
await versionButtons.first().click();
await page.getByText("Полные данные").waitFor({ state: "visible", timeout: 15000 });
}
assertNoFatalRuntimeErrors("editor versions runtime smoke");
const rollbackButtons = page.locator('button[data-editor-action="rollback-plan"]');
const rollbackButtonCount = await rollbackButtons.count();
if (rollbackButtonCount > 0) {
await rollbackButtons.first().click();
await page.locator("[data-rollback-preview]").waitFor({ state: "visible", timeout: 15000 });
const applyRollbackButton = page.locator('button[data-editor-action="apply-rollback"]');
await applyRollbackButton.waitFor({ state: "visible", timeout: 15000 });
if (await applyRollbackButton.isEnabled()) {
await applyRollbackButton.click();
await page.locator("[data-rollback-apply-message]").waitFor({ state: "visible", timeout: 15000 });
}
}
assertNoFatalRuntimeErrors("editor rollback runtime smoke");
const metadataResponse = await page.goto(`${baseUrl}/editor?lang=ru&project=${encodeURIComponent(projectId)}&mode=properties`, {
waitUntil: "load",
timeout: 60000
});
if (!metadataResponse?.ok()) {
throw new Error(`editor metadata runtime smoke: ${metadataResponse?.status()} ${metadataResponse?.statusText()}`);
}
await page.getByRole("heading", { name: "Свойства" }).waitFor({ state: "visible", timeout: 15000 });
await collapseBottomPanelIfOpen(page);
await page.getByLabel("Имя объекта").fill(`АвтоОбъект${uniqueSuffix}`);
await page.getByLabel("Синоним").fill(`Авто объект ${uniqueSuffix}`);
const addAttributeButton = page.locator('button[data-editor-action="add-metadata-attribute"]');
await addAttributeButton.waitFor({ state: "visible", timeout: 15000 });
await addAttributeButton.click();
await page.getByRole("textbox", { name: "Имя реквизита" }).nth(1).fill("Комментарий");
await page.getByRole("textbox", { name: "Тип" }).nth(1).fill("Строка250");
const addFormButton = page.locator('button[data-editor-action="add-metadata-form"]');
await addFormButton.waitFor({ state: "visible", timeout: 15000 });
await addFormButton.click();
await page.getByRole("textbox", { name: "Имя формы" }).last().fill(`ФормаВыбора${uniqueSuffix}`);
const addCommandButton = page.locator('button[data-editor-action="add-metadata-command"]');
await addCommandButton.waitFor({ state: "visible", timeout: 15000 });
await addCommandButton.click();
await page.getByRole("textbox", { name: "Имя команды" }).last().fill(`Отправить${uniqueSuffix}`);
await page.getByRole("textbox", { name: "Обработчик" }).last().fill(`Отправить${uniqueSuffix}Команда`);
const metadataButton = page.locator('button[data-editor-action="apply-metadata-object"]');
await metadataButton.waitFor({ state: "visible", timeout: 15000 });
if (await metadataButton.isEnabled()) {
await metadataButton.click();
const draftMessage = page.locator("[data-metadata-draft-message]");
await draftMessage.waitFor({ state: "visible", timeout: 15000 });
try {
await page.locator("[data-metadata-draft-preview]").waitFor({ state: "visible", timeout: 15000 });
} catch (error) {
throw new Error(`metadata draft preview did not appear: ${await draftMessage.textContent()}`);
}
}
assertNoFatalRuntimeErrors("editor metadata runtime smoke");
const englishResponse = await page.goto(`${baseUrl}/editor?lang=en&project=${encodeURIComponent(projectId)}&mode=module`, {
waitUntil: "load",
timeout: 60000
});
if (!englishResponse?.ok()) {
throw new Error(`editor english runtime smoke: ${englishResponse?.status()} ${englishResponse?.statusText()}`);
}
await page.getByRole("heading", { name: "BSL Editor" }).waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-top-project-bar]").waitFor({ state: "visible", timeout: 15000 });
for (const selector of ["workspace", "project", "environment", "active-task"]) {
await page.locator(`[data-top-bar-selector="${selector}"]`).waitFor({ state: "visible", timeout: 15000 });
}
for (const action of ["project-settings", "create-project"]) {
await page.locator(`[data-top-bar-action="${action}"]`).waitFor({ state: "visible", timeout: 15000 });
}
for (const badge of ["api-status", "agent-status"]) {
await page.locator(`[data-top-bar-badge="${badge}"]`).waitFor({ state: "visible", timeout: 15000 });
}
await page.locator("[data-top-bar-language]").waitFor({ state: "visible", timeout: 15000 });
await page.locator('[data-top-bar-button="profile"]').waitFor({ state: "visible", timeout: 15000 });
await page.locator("[data-bottom-tool-panel]").waitFor({ state: "visible", timeout: 15000 });
for (const tabName of ["Problems", "Semantic diff", "Output", "Change history", "Tests", "AI"]) {
await page.locator("[data-bottom-tool-panel]").getByText(tabName).first().waitFor({ state: "visible", timeout: 15000 });
}
for (const statusMarker of ["snapshot", "agent", "parser", "diagnostics", "active-task", "privacy", "ai-tokens", "current-user"]) {
await page.locator(`[data-status-item="${statusMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
}
assertNoFatalRuntimeErrors("editor english runtime smoke");
console.log("ok editor runtime");
} finally {
await browser.close();
}
@@ -0,0 +1,152 @@
import { chromium } from "playwright-core";
import { existsSync } from "node:fs";
const baseUrl = process.env.SFERA_WEB_URL ?? "http://localhost:3000";
const projectId = process.env.SFERA_PROJECT_ID ?? "default";
const browserPath =
process.env.SFERA_BROWSER_PATH ??
[
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
].find((path) => existsSync(path));
if (!browserPath) {
throw new Error("No installed Chrome or Edge browser found. Set SFERA_BROWSER_PATH.");
}
await prepareIndexedProject();
const runtimeErrors = [];
const browser = await chromium.launch({
executablePath: browserPath,
headless: true
});
try {
const page = await browser.newPage();
page.on("pageerror", (error) => {
runtimeErrors.push(`pageerror: ${error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
runtimeErrors.push(`console: ${message.text()}`);
}
});
const response = await page.goto(`${baseUrl}/project-settings`, { waitUntil: "load", timeout: 60000 });
if (!response?.ok()) {
throw new Error(`project setup smoke: ${response?.status()} ${response?.statusText()}`);
}
await page.locator("[data-project-import-center]").waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("heading", { name: "Project Settings" }).waitFor({ state: "visible", timeout: 15000 });
for (const section of ["Пользователи и доступ", "Интеграции задач", "Docker/runtime adapter", "Audit", "Backup/restore"]) {
await page.getByText(section).first().waitFor({ state: "visible", timeout: 15000 });
}
for (const sectionMarker of ["docker-runtime-adapter", "its-documentation-access"]) {
await page.locator(`[data-settings-section="${sectionMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
}
await page.getByText("ITS/documentation access").first().waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_URL").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_USERNAME").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_PASSWORD").waitFor({ state: "visible", timeout: 15000 });
await page.locator("input[value*='its.1c.ru/db/v838doc']").first().waitFor({ state: "visible", timeout: 15000 });
await page.getByText(".env.local").first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("input[value='<set locally>']").first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("select").filter({ hasText: "REFERENCE_CONFIGURATION" }).first().waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("button", { name: /Reference config/ }).waitFor({ state: "visible", timeout: 15000 });
const hasMetadataOnlyPrivacy = await page.locator("select").evaluateAll((selects) => {
return selects.some((select) => select.value === "METADATA_ONLY" || [...select.options].some((option) => option.value === "METADATA_ONLY"));
});
if (!hasMetadataOnlyPrivacy) {
throw new Error("project setup smoke: missing METADATA_ONLY privacy mode");
}
await page.getByText("Project Setup / Import Center").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("Режим работы с конфигурацией 1С").waitFor({ state: "visible", timeout: 15000 });
await page.locator('[data-import-mode="FULL_REPLACE"]').waitFor({ state: "visible", timeout: 15000 });
await page.locator('[data-run-import-mode="SYNC_PREVIEW"]').click();
const importMode = await page.evaluate(() => window.localStorage.getItem("sfera.import.mode"));
if (importMode !== "SYNC_PREVIEW") {
throw new Error(`project setup smoke: import mode was not persisted, got ${importMode}`);
}
await page.locator('[data-run-import-mode="FULL_REPLACE"]').click();
await page.locator("[data-import-validation-panel]").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("Не заполнен путь к источнику").waitFor({ state: "visible", timeout: 15000 });
await page.locator('input[placeholder*="/mnt/share/project"]').fill("/tmp/nonexistent-smoke-import");
await page.locator('[data-run-import-mode="FULL_REPLACE"]').click();
await page.getByText("Подтвердите полное обновление").waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("button", { name: "Отмена" }).click();
await page.locator('[data-run-import-mode="SYNC_PREVIEW"]').click();
await page.locator('[data-import-action="XML_DUMP:check"]').click();
await page.locator("[data-import-check-panel]").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("Source preflight").waitFor({ state: "visible", timeout: 15000 });
const englishResponse = await page.goto(`${baseUrl}/project-settings?lang=en`, { waitUntil: "load", timeout: 60000 });
if (!englishResponse?.ok()) {
throw new Error(`project setup smoke (en): ${englishResponse?.status()} ${englishResponse?.statusText()}`);
}
await page.getByRole("heading", { name: "Project Settings" }).waitFor({ state: "visible", timeout: 15000 });
for (const section of ["Task/session policy", "Docker/runtime adapter", "ITS/documentation access", "Audit", "Backup/restore"]) {
await page.getByText(section).first().waitFor({ state: "visible", timeout: 15000 });
}
for (const sectionMarker of ["task-session-policy", "docker-runtime-adapter", "its-documentation-access"]) {
await page.locator(`[data-settings-section="${sectionMarker}"]`).waitFor({ state: "visible", timeout: 15000 });
}
await page.locator("select").filter({ hasText: "REFERENCE_CONFIGURATION" }).first().waitFor({ state: "visible", timeout: 15000 });
await page.getByRole("button", { name: /Reference config/ }).waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_URL").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_USERNAME").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("SFERA_ITS_PASSWORD").waitFor({ state: "visible", timeout: 15000 });
await page.getByText(".env.local").first().waitFor({ state: "visible", timeout: 15000 });
await page.locator("input[value='<set locally>']").first().waitFor({ state: "visible", timeout: 15000 });
await page.locator('[data-import-action="XML_DUMP:check"]').click();
await page.locator("[data-import-check-panel]").waitFor({ state: "visible", timeout: 15000 });
await page.getByText("Source preflight").waitFor({ state: "visible", timeout: 15000 });
assertNoFatalRuntimeErrors();
console.log("ok project setup runtime");
} finally {
await browser.close();
}
async function prepareIndexedProject() {
await postJson(`/api/sfera/projects/${encodeURIComponent(projectId)}/settings`, {
name: "SFERA Smoke Project",
structure_source: "XML_DUMP",
platform_version: "8.3.24",
compatibility_mode: "8.3.20"
});
await postJson(`/api/sfera/projects/${encodeURIComponent(projectId)}/imports/XML_DUMP`, {
source: "XML_DUMP",
metadata: {
platform_version: "8.3.24",
compatibility_mode: "8.3.20"
}
});
}
async function postJson(path, body) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
}
return response.json();
}
function assertNoFatalRuntimeErrors() {
const fatalErrors = runtimeErrors.filter((message) => {
return (
message.includes("Runtime TypeError") ||
message.includes("Cannot read properties") ||
message.includes("Unhandled Runtime Error")
);
});
if (fatalErrors.length > 0) {
throw new Error(`project setup runtime smoke failed:\n${fatalErrors.join("\n")}`);
}
}
@@ -0,0 +1,79 @@
import { resolveApiUrl } from "@/lib/api";
type RouteContext = {
params: Promise<{ path: string[] }>;
};
export async function GET(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function POST(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function PUT(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function PATCH(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function DELETE(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function OPTIONS(request: Request, context: RouteContext) {
return proxy(request, context);
}
export async function HEAD(request: Request, context: RouteContext) {
return proxy(request, context);
}
async function proxy(request: Request, context: RouteContext) {
const { path } = await context.params;
const apiUrl = resolveApiUrl(request.headers.get("host"));
const sourceUrl = new URL(request.url);
const target = `${apiUrl}/${path.join("/")}${sourceUrl.search}`;
const method = request.method;
const hasBody = method !== "GET" && method !== "HEAD" && request.body !== null;
const body = hasBody ? await request.arrayBuffer() : undefined;
const contentType = request.headers.get("Content-Type");
const accept = request.headers.get("Accept") ?? "application/json";
const publicHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
const publicProto = request.headers.get("x-forwarded-proto") ?? "http";
const publicOrigin = publicHost ? `${publicProto}://${publicHost}` : sourceUrl.origin;
const headers: Record<string, string> = {
Accept: accept,
"X-Sfera-Public-Origin": publicOrigin
};
if (hasBody) {
headers["Content-Type"] = contentType ?? "application/json";
}
const response = await fetch(target, {
method,
cache: "no-store",
headers,
body
});
const responseHeaders: Record<string, string> = {
"Content-Type": response.headers.get("Content-Type") ?? "application/json"
};
const contentDisposition = response.headers.get("Content-Disposition");
if (contentDisposition) {
responseHeaders["Content-Disposition"] = contentDisposition;
}
const cacheControl = response.headers.get("Cache-Control");
if (cacheControl) {
responseHeaders["Cache-Control"] = cacheControl;
}
return new Response(response.body, {
status: response.status,
headers: responseHeaders
});
}
+113
View File
@@ -0,0 +1,113 @@
import { AlertTriangle } from "lucide-react";
import { headers } from "next/headers";
import { IdeWorkspace } from "@/components/editor/ide-workspace";
import { AppShell } from "@/components/layout/app-shell";
import { Card } from "@/components/ui/card";
import {
getApiHealth,
getProjects,
getProjectWorkspaceData,
resolveApiUrl,
type ProjectSummary
} from "@/lib/api";
import { messages, normalizeLanguage, type UiLanguage } from "@/lib/i18n";
export default async function EditorPage({
searchParams
}: Readonly<{
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
}>) {
const params = await searchParams;
const language = normalizeLanguage(params?.lang);
const requestHeaders = await headers();
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
const bootstrap = await loadEditorBootstrap(apiUrl, language, params?.project);
const projectData =
bootstrap.status === "ok" && bootstrap.projectId ? await getProjectWorkspaceData(bootstrap.projectId, apiUrl, params?.routine, params?.mode) : null;
return (
<AppShell
apiStatus={bootstrap.status}
language={language}
projectId={bootstrap.status === "ok" ? bootstrap.projectId : undefined}
projectOptions={bootstrap.status === "ok" ? bootstrap.projects : []}
>
<main className="pb-8">
{bootstrap.status === "error" ? (
<ErrorState language={language} message={bootstrap.error} />
) : projectData ? (
<IdeWorkspace
key={projectData.projectId}
data={projectData}
initialMode={params?.mode}
language={language}
routineName={params?.routine}
/>
) : (
<ErrorState language={language} message={messages[language].noProjectDataDescription} />
)}
</main>
</AppShell>
);
}
async function loadEditorBootstrap(apiUrl: string, language: UiLanguage, projectId?: string): Promise<
| { status: "ok"; projectId: string | undefined; projects: ProjectSummary[] }
| { status: "error"; error: string }
> {
try {
const [projectsResponse] = await Promise.all([getProjects(apiUrl), getApiHealth(apiUrl)]);
const projects = uniqueProjects(projectsResponse);
return { status: "ok", projectId: projectId || pickDefaultProject(projectsResponse), projects };
} catch (error) {
return {
status: "error",
error: error instanceof Error ? error.message : messages[language].unknownApiError
};
}
}
function ErrorState({
message,
language
}: Readonly<{
message: string;
language: UiLanguage;
}>) {
const t = messages[language];
return (
<div className="mx-auto flex min-h-[70vh] max-w-xl items-center justify-center">
<Card className="w-full">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-1 h-5 w-5 text-destructive" aria-hidden="true" />
<div>
<h1 className="text-base font-semibold">{t.apiUnavailable}</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
</div>
</div>
</Card>
</div>
);
}
function pickDefaultProject(projects: ProjectSummary[]) {
return (
projects.find((project) => project.project_id === "ifcm-upo")?.project_id ??
projects.find((project) => project.project_id !== "demo")?.project_id ??
projects.at(-1)?.project_id ??
projects.find((project) => project.project_id === "demo")?.project_id
);
}
function uniqueProjects(projects: ProjectSummary[]) {
const byId = new Map<string, ProjectSummary>();
for (const project of projects) {
const projectId = project.project_id.trim();
if (projectId) {
byId.set(projectId, { ...project, project_id: projectId, name: project.name || projectId });
}
}
return Array.from(byId.values()).sort((left, right) => (left.name || left.project_id).localeCompare(right.name || right.project_id, "ru"));
}
+70
View File
@@ -0,0 +1,70 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 210 20% 98%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--muted: 210 17% 94%;
--muted-foreground: 215 16% 47%;
--border: 214 20% 88%;
--input: 214 20% 88%;
--primary: 198 93% 32%;
--primary-foreground: 0 0% 100%;
--secondary: 168 42% 39%;
--secondary-foreground: 0 0% 100%;
--destructive: 0 72% 45%;
--warning: 38 92% 48%;
--success: 142 54% 36%;
--info: 214 84% 56%;
}
.dark {
--background: 220 24% 10%;
--foreground: 210 20% 96%;
--card: 220 20% 14%;
--card-foreground: 210 20% 96%;
--muted: 220 16% 20%;
--muted-foreground: 215 15% 66%;
--border: 220 16% 24%;
--input: 220 16% 24%;
--primary: 194 80% 46%;
--primary-foreground: 0 0% 100%;
--secondary: 164 52% 43%;
--secondary-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--warning: 38 92% 52%;
--success: 142 54% 42%;
--info: 214 84% 62%;
}
* {
box-sizing: border-box;
}
html {
background: hsl(var(--background));
}
body {
min-height: 100vh;
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
::selection {
background: hsl(var(--primary) / 0.18);
}
@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ status: "ok", service: "sfera-web" });
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type React from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "SFERA",
description: "Semantic 1C operating workspace"
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ru">
<body>{children}</body>
</html>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
export default async function HomePage({
searchParams
}: Readonly<{
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
}>) {
const params = await searchParams;
const nextParams = new URLSearchParams();
if (params?.lang === "en") {
nextParams.set("lang", "en");
}
for (const key of ["project", "mode", "routine"] as const) {
const value = params?.[key];
if (value) {
nextParams.set(key, value);
}
}
const suffix = nextParams.size > 0 ? `?${nextParams.toString()}` : "";
redirect(`/editor${suffix}`);
}
@@ -0,0 +1,80 @@
import { headers } from "next/headers";
import { ProjectSetupClient, type ProjectSetup } from "@/components/project-setup/project-setup-client";
import { getProjects, resolveApiUrl } from "@/lib/api";
export default async function ProjectSettingsPage({
searchParams
}: Readonly<{ searchParams?: Promise<{ project?: string; new?: string }> }>) {
const requestHeaders = await headers();
const params = searchParams ? await searchParams : {};
if (params.new === "1") {
return <ProjectSetupClient initialSetup={newProjectSetup()} />;
}
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
const projectId = normalizeProjectId(params.project);
const setup = await loadSetup(apiUrl, projectId);
return <ProjectSetupClient initialSetup={setup} />;
}
function newProjectSetup(): ProjectSetup {
return {
project_id: "__new__",
status: "NOT_CONFIGURED",
message: "Новый проект. Заполните основные параметры и сохраните.",
settings: defaultProjectSettings(""),
current_source: null,
last_import: null,
import_history: [],
import_sources: []
};
}
async function loadSetup(apiUrl: string, projectId: string): Promise<ProjectSetup> {
try {
const response = await fetch(`${apiUrl}/projects/${encodeURIComponent(projectId)}/setup`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} catch {
const projects = await getProjects(apiUrl).catch(() => []);
const fallbackProjectId = projects.at(0)?.project_id ?? "default";
if (fallbackProjectId !== projectId) {
return loadSetup(apiUrl, fallbackProjectId);
}
return {
project_id: fallbackProjectId,
status: "NOT_CONFIGURED",
message: "Проект не проиндексирован. Выберите способ получения структуры конфигурации.",
settings: defaultProjectSettings("SFERA Project"),
current_source: null,
last_import: null,
import_history: [],
import_sources: []
};
}
}
function defaultProjectSettings(name = "SFERA Project") {
return {
name,
configuration_source: null,
structure_source: null,
platform_version: null,
compatibility_mode: null,
extensions: [],
environments: {},
agent: {},
server_import: {},
privacy_mode: "METADATA_ONLY",
knowledge_sources: [],
task_session_policy: {}
};
}
function normalizeProjectId(projectId?: string) {
const trimmed = projectId?.trim();
return trimmed || "default";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,955 @@
"use client";
import { ChevronRight, Filter, Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { getMetadataTreeChildren, getMetadataTreePath, searchMetadataTree, type MetadataTreeNode } from "@/lib/api";
import type { UiLanguage } from "@/lib/i18n";
type TreeMode = "overview" | "module" | "form" | "properties" | "events" | "versions" | "documentation" | "knowledge" | "learning" | "flowchart";
type TreeFilter = "all" | "onec" | "sfera" | "environments";
type LazyMetadataTreeProps = {
apiUrl?: string;
className?: string;
language: UiLanguage;
projectId: string;
root: MetadataTreeNode;
selectedNode?: MetadataTreeNode | null;
title: string;
};
const PAGE_SIZE = 80;
const iconFiles: Record<string, string> = {
attribute: "attribute.svg",
"business-process": "businessProcess.svg",
catalog: "catalog.svg",
command: "command.svg",
common: "common.svg",
configuration: "common.svg",
constant: "constant.svg",
document: "document.svg",
enum: "enum.svg",
environment: "folder.svg",
environments: "folder.svg",
event: "eventSubscription.svg",
"external-source": "externalDataSource.svg",
extension: "folder.svg",
form: "form.svg",
folder: "folder.svg",
"exchange-plan": "exchangePlan.svg",
"scheduled-job": "scheduledJob.svg",
journal: "documentJournal.svg",
layout: "template.svg",
metadata: "folder.svg",
module: "commonModule.svg",
movement: "operation.svg",
plan: "chartsOfAccount.svg",
processing: "dataProcessor.svg",
project: "folder.svg",
register: "accumulationRegister.svg",
report: "report.svg",
role: "role.svg",
service: "http.svg",
tabular: "tabularSection.svg",
table: "tabularSection.svg",
task: "task.svg",
web: "http.svg"
};
export function LazyMetadataTree({
apiUrl,
className = "",
language,
projectId,
root,
selectedNode,
title
}: LazyMetadataTreeProps) {
const router = useRouter();
const ROW_HEIGHT = 24;
const OVERSCAN = 8;
const [query, setQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<TreeFilter>("all");
const [activeRoutine, setActiveRoutine] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(["main-configuration", "common"]));
const [nodesById, setNodesById] = useState(() => {
const cachedRoot = typeof window === "undefined" ? null : readCachedTree(projectId);
return reindexTree(mergeTreeRoots(root, cachedRoot));
});
const [searchResults, setSearchResults] = useState<MetadataTreeNode[]>([]);
const [searchTotal, setSearchTotal] = useState(0);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: MetadataTreeNode } | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const treeViewportRef = useRef<HTMLDivElement | null>(null);
const autoExpandedPathKey = useRef<string | null>(null);
const selectedScrollKey = useRef<string | null>(null);
const lastProjectId = useRef(projectId);
const restoredScrollRef = useRef(false);
const rootNode = nodesById[root.id] ?? root;
const serverSearchActive = query.trim().length >= 2;
const visibleChildren = useMemo(() => {
const source = serverSearchActive ? searchResults : filterTreeChildren(rootNode.children, query);
return applyTreeFilter(source, activeFilter);
}, [activeFilter, query, rootNode.children, searchResults, serverSearchActive]);
const visibleCount = visibleChildren.length;
const virtualStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const virtualEnd = Math.min(visibleCount, Math.ceil((scrollTop + Math.max(viewportHeight, ROW_HEIGHT)) / ROW_HEIGHT) + OVERSCAN);
const virtualItems = visibleChildren.slice(virtualStart, virtualEnd);
const topSpacer = virtualStart * ROW_HEIGHT;
const bottomSpacer = Math.max(0, (visibleCount - virtualEnd) * ROW_HEIGHT);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setActiveRoutine(params.get("routine"));
}, []);
useEffect(() => {
setNodesById((current) => {
const cachedRoot = readCachedTree(projectId);
const currentRoot = current[root.id];
const mergedRoot = mergeTreeRoots(root, cachedRoot ?? currentRoot);
return reindexTree(mergedRoot);
});
autoExpandedPathKey.current = null;
selectedScrollKey.current = null;
if (lastProjectId.current !== projectId) {
setScrollTop(0);
lastProjectId.current = projectId;
restoredScrollRef.current = false;
}
}, [projectId, root]);
useEffect(() => {
const defaults = new Set(["main-configuration", "common"]);
try {
const saved = window.localStorage.getItem(expandedStorageKey(projectId));
setExpandedIds(saved ? new Set(JSON.parse(saved) as string[]) : defaults);
} catch {
setExpandedIds(defaults);
}
}, [projectId]);
useEffect(() => {
try {
const saved = Number(window.localStorage.getItem(scrollStorageKey(projectId)) ?? "0");
if (!Number.isFinite(saved) || saved <= 0) {
return;
}
window.requestAnimationFrame(() => {
const viewport = treeViewportRef.current;
if (!viewport) {
return;
}
viewport.scrollTop = saved;
setScrollTop(saved);
restoredScrollRef.current = true;
});
} catch {
// Ignore local storage failures; the tree remains usable without persisted scroll.
}
}, [projectId]);
useEffect(() => {
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
}, [expandedIds, projectId]);
useEffect(() => {
const rootForCache = nodesById[root.id];
if (rootForCache) {
writeCachedTree(projectId, rootForCache);
}
}, [nodesById, projectId, root.id]);
useEffect(() => {
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("keydown", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", close);
};
}, []);
useEffect(() => {
const viewport = treeViewportRef.current;
if (!viewport) {
return;
}
const syncViewport = () => {
setViewportHeight(viewport.clientHeight);
setScrollTop(viewport.scrollTop);
try {
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
} catch {
// Ignore local storage failures; scroll persistence is a convenience.
}
};
syncViewport();
const observer = new ResizeObserver(syncViewport);
observer.observe(viewport);
viewport.addEventListener("scroll", syncViewport, { passive: true });
return () => {
observer.disconnect();
viewport.removeEventListener("scroll", syncViewport);
};
}, [projectId]);
useEffect(() => {
if (!selectedNode?.id) {
return;
}
const pathKey = `${projectId}:${selectedNode.id}`;
if (autoExpandedPathKey.current === pathKey) {
return;
}
autoExpandedPathKey.current = pathKey;
let cancelled = false;
const expandSelectedPath = async () => {
try {
const pathResponse = await getMetadataTreePath(projectId, selectedNode.id, { apiUrl });
if (cancelled) {
return;
}
setExpandedIds((current) => new Set([...current, ...pathResponse.path.slice(0, -1)]));
let localRoot = nodesById[root.id] ?? root;
let localIndex = reindexTree(localRoot);
for (const step of pathResponse.steps) {
const parentId = step.parent_id;
const childId = step.child_id;
const pageOffset = Math.floor(step.offset / PAGE_SIZE) * PAGE_SIZE;
let parent = localIndex[parentId];
if (parent && !parent.children.some((child) => child.id === childId) && parent.has_more) {
const response = await getMetadataTreeChildren(projectId, parentId, {
apiUrl,
offset: pageOffset,
limit: PAGE_SIZE
});
if (cancelled) {
return;
}
localRoot = mergeTreeChildren(localRoot, parentId, response.children, response.has_more, response.total, response.offset);
localIndex = reindexTree(localRoot);
parent = localIndex[parentId];
}
}
if (!cancelled) {
setNodesById(reindexTree(localRoot));
}
} catch {
// The pinned current object still gives a stable navigation target if a path cannot be resolved.
}
};
void expandSelectedPath();
return () => {
cancelled = true;
};
}, [apiUrl, nodesById, projectId, root, selectedNode?.id]);
useEffect(() => {
if (!selectedNode?.id || selectedScrollKey.current === selectedNode.id) {
return;
}
if (restoredScrollRef.current) {
selectedScrollKey.current = selectedNode.id;
return;
}
const timer = window.setTimeout(() => {
const selectedElement = document.querySelector(`[data-tree-node-id="${escapeCssAttribute(selectedNode.id)}"]`);
if (selectedElement) {
selectedElement.scrollIntoView({ block: "center", inline: "nearest" });
selectedScrollKey.current = selectedNode.id;
}
}, 100);
return () => window.clearTimeout(timer);
}, [nodesById, selectedNode?.id]);
useEffect(() => {
const normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
setSearchResults([]);
setSearchTotal(0);
setSearchError(null);
setSearchLoading(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setSearchLoading(true);
setSearchError(null);
try {
const response = await searchMetadataTree(projectId, normalizedQuery, { apiUrl, limit: 80 });
if (!controller.signal.aborted) {
setSearchResults(response.results);
setSearchTotal(response.total);
}
} catch (error) {
if (!controller.signal.aborted) {
setSearchError(error instanceof Error ? error.message : "Search failed");
setSearchResults([]);
setSearchTotal(0);
}
} finally {
if (!controller.signal.aborted) {
setSearchLoading(false);
}
}
}, 250);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [apiUrl, projectId, query]);
const setNodeExpanded = (nodeId: string, expanded: boolean) => {
setExpandedIds((current) => {
const next = new Set(current);
if (expanded) {
next.add(nodeId);
} else {
next.delete(nodeId);
}
return next;
});
};
const updateNode = (nodeId: string, updater: (node: MetadataTreeNode) => MetadataTreeNode) => {
setNodesById((current) => {
const target = current[nodeId];
if (!target) {
return current;
}
const updated = updater(target);
return reindexTree(replaceTreeNode(current[root.id] ?? rootNode, updated));
});
};
const mergeChildren = (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => {
updateNode(nodeId, (node) => {
return mergeNodeChildren(node, children, hasMore, total, offset);
});
};
const navigateToNode = (href: string) => {
if (href !== "#") {
persistViewportState(projectId, treeViewportRef.current, expandedIds);
const [, query = ""] = href.split("?");
setActiveRoutine(new URLSearchParams(query).get("routine"));
router.push(href, { scroll: false });
}
};
return (
<div className={["flex min-h-0 flex-col", className].join(" ")}>
<div className="border-b border-border bg-[#efeddc] px-2 py-2">
<div className="flex items-center gap-2 px-1">
<TreeIcon icon="configuration" />
<h3 className="text-sm font-semibold">{title}</h3>
<span className="ml-auto text-xs text-muted-foreground">{projectId}</span>
</div>
<div className="mt-2 flex h-7 items-center gap-1 border-y border-border/70 px-1 text-muted-foreground">
{["+", "✎", "×", "↑", "↓", "⟳", "⚙"].map((item) => (
<button className="flex h-5 w-5 items-center justify-center rounded border border-transparent text-xs hover:border-border hover:bg-card" key={item} type="button">
{item}
</button>
))}
</div>
<label className="mt-2 flex min-w-0 items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground">
<Search className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<input
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
onChange={(event) => setQuery(event.target.value)}
placeholder={language === "ru" ? "Поиск в дереве" : "Search tree"}
value={query}
/>
{searchLoading ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden="true" /> : <Filter className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
</label>
<div className="mt-2 flex items-center gap-1 text-[11px]">
<FilterButton active={activeFilter === "all"} label={language === "ru" ? "Все" : "All"} onClick={() => setActiveFilter("all")} value="all" />
<FilterButton active={activeFilter === "onec"} label="1C" onClick={() => setActiveFilter("onec")} value="onec" />
<FilterButton active={activeFilter === "sfera"} label="SFERA" onClick={() => setActiveFilter("sfera")} value="sfera" />
<FilterButton
active={activeFilter === "environments"}
label={language === "ru" ? "Среды" : "Environments"}
onClick={() => setActiveFilter("environments")}
value="environments"
/>
</div>
</div>
{selectedNode ? (
<div className="border-b border-border bg-[#fffceb] px-2 py-2">
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
{language === "ru" ? "Текущий объект" : "Current Object"}
</div>
<a
className="mt-1 flex min-h-7 min-w-0 items-center gap-1.5 border border-border bg-background px-1.5 text-sm text-primary hover:bg-[#e9f1ff]"
href={hrefForNode(language, projectId, selectedNode)}
onClick={(event) => {
event.preventDefault();
navigateToNode(hrefForNode(language, projectId, selectedNode));
}}
title={selectedNode.qualified_name ?? selectedNode.label}
>
<TreeIcon icon={selectedNode.icon} />
<span className="min-w-0 flex-1 truncate">{selectedNode.qualified_name ?? selectedNode.label}</span>
</a>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-auto px-1 py-2" data-virtual-scroll ref={treeViewportRef}>
{searchError ? <div className="px-2 py-1 text-xs text-destructive">{searchError}</div> : null}
{topSpacer > 0 ? <div aria-hidden="true" style={{ height: `${topSpacer}px` }} /> : null}
{virtualItems.map((node) => (
<LazyTreeNode
activeRoutine={activeRoutine}
apiUrl={apiUrl}
key={node.id}
language={language}
mergeChildren={mergeChildren}
node={node}
projectId={projectId}
query={query}
expandedIds={expandedIds}
openContextMenu={setContextMenu}
navigateToNode={navigateToNode}
searchMode={serverSearchActive}
selectedNodeId={selectedNode?.id}
setNodeExpanded={setNodeExpanded}
/>
))}
{bottomSpacer > 0 ? <div aria-hidden="true" style={{ height: `${bottomSpacer}px` }} /> : null}
</div>
{contextMenu ? (
<TreeContextMenu
language={language}
navigateToNode={navigateToNode}
node={contextMenu.node}
projectId={projectId}
x={contextMenu.x}
y={contextMenu.y}
/>
) : null}
<div className="border-t border-border bg-[#f7f5e7] px-2 py-1 text-[11px] text-muted-foreground">
{query
? language === "ru"
? serverSearchActive
? `Найдено во всем проекте: ${formatCount(searchTotal, language)}`
: `Найдено в загруженных узлах: ${visibleChildren.length}`
: serverSearchActive
? `Matches in project: ${formatCount(searchTotal, language)}`
: `Matches in loaded nodes: ${visibleChildren.length}`
: language === "ru"
? "Ветки загружаются при раскрытии"
: "Branches load on expand"}
</div>
</div>
);
}
function LazyTreeNode({
activeRoutine,
apiUrl,
expandedIds,
language,
mergeChildren,
navigateToNode,
node,
openContextMenu,
projectId,
query,
searchMode,
selectedNodeId,
setNodeExpanded
}: {
activeRoutine: string | null;
apiUrl?: string;
expandedIds: Set<string>;
language: UiLanguage;
mergeChildren: (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => void;
navigateToNode: (href: string) => void;
node: MetadataTreeNode;
openContextMenu: (menu: { x: number; y: number; node: MetadataTreeNode }) => void;
projectId: string;
query: string;
searchMode: boolean;
selectedNodeId?: string;
setNodeExpanded: (nodeId: string, expanded: boolean) => void;
}) {
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const expanded = expandedIds.has(node.id);
const childNodes = useMemo(() => filterTreeChildren(node.children, query), [node.children, query]);
const canExpand = node.children.length > 0 || node.has_more;
const title = node.count > 0 ? `${node.label} (${formatCount(node.count, language)})` : node.label;
const isActive = node.id === selectedNodeId || Boolean(activeRoutine && (node.qualified_name === activeRoutine || node.label === activeRoutine));
const loadedLabel =
node.has_more && node.count > 0
? `${formatCount(node.loaded_count || node.children.length, language)}/${formatCount(node.count, language)}`
: null;
async function loadMore() {
if (loading) {
return;
}
setLoading(true);
setLoadError(null);
try {
const response = await getMetadataTreeChildren(projectId, node.id, {
apiUrl,
offset: node.loaded_count || node.children.length,
limit: PAGE_SIZE
});
mergeChildren(node.id, response.children, response.has_more, response.total, response.offset);
} catch (error) {
setLoadError(error instanceof Error ? error.message : "Не удалось загрузить ветку");
} finally {
setLoading(false);
}
}
async function toggle() {
if (searchMode) {
return;
}
const nextExpanded = !expanded;
setNodeExpanded(node.id, nextExpanded);
if (nextExpanded && node.children.length === 0 && node.has_more) {
await loadMore();
}
}
const row = (
<div
className={[
"flex min-h-6 min-w-0 items-center gap-1.5 px-1.5 text-sm text-foreground hover:bg-[#e9f1ff]",
isActive ? "bg-[#dbeafe] text-primary" : ""
].join(" ")}
data-tree-node-id={node.id}
onContextMenu={(event) => {
event.preventDefault();
openContextMenu({ x: event.clientX, y: event.clientY, node });
}}
onClick={(event) => {
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest("button,a")) {
return;
}
const href = hrefForNode(language, projectId, node);
navigateToNode(href);
}}
onDoubleClick={(event) => {
const href = hrefForNode(language, projectId, node);
if (href !== "#") {
event.preventDefault();
navigateToNode(href);
}
}}
title={node.qualified_name ?? node.label}
>
<button
aria-label={expanded ? "Свернуть" : "Развернуть"}
className="flex h-4 w-4 shrink-0 items-center justify-center"
disabled={!canExpand || searchMode}
onClick={(event) => {
event.stopPropagation();
void toggle();
}}
type="button"
>
{canExpand ? (
loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronRight className={["h-3.5 w-3.5 transition", expanded ? "rotate-90" : ""].join(" ")} />
) : (
<span className="h-3.5 w-3.5" />
)}
</button>
<TreeIcon icon={node.icon} />
<a
className="min-w-0 flex-1 truncate"
href={hrefForNode(language, projectId, node)}
onClick={(event) => {
event.preventDefault();
navigateToNode(hrefForNode(language, projectId, node));
}}
>
{title}
</a>
{loadedLabel ? <span className="rounded border border-border bg-background px-1 text-[10px] text-muted-foreground">{loadedLabel}</span> : null}
</div>
);
return (
<div>
{row}
{expanded ? (
<div className="ml-4 border-l border-border/70 pl-1">
{childNodes.map((child) => (
<LazyTreeNode
activeRoutine={activeRoutine}
apiUrl={apiUrl}
key={child.id}
language={language}
mergeChildren={mergeChildren}
navigateToNode={navigateToNode}
node={child}
openContextMenu={openContextMenu}
projectId={projectId}
query={query}
expandedIds={expandedIds}
searchMode={searchMode}
selectedNodeId={selectedNodeId}
setNodeExpanded={setNodeExpanded}
/>
))}
{node.has_more ? (
<button className="ml-6 mt-1 h-6 px-2 text-xs text-primary hover:bg-[#e9f1ff]" disabled={loading} onClick={loadMore} type="button">
{loading ? (language === "ru" ? "Загрузка..." : "Loading...") : (language === "ru" ? "Загрузить еще" : "Load more")}
</button>
) : null}
{loadError ? <div className="ml-6 px-2 py-1 text-xs text-destructive">{loadError}</div> : null}
</div>
) : null}
</div>
);
}
function TreeContextMenu({
language,
navigateToNode,
node,
projectId,
x,
y
}: {
language: UiLanguage;
navigateToNode: (href: string) => void;
node: MetadataTreeNode;
projectId: string;
x: number;
y: number;
}) {
const openHref = hrefForNode(language, projectId, node);
const items = contextMenuItemsForNode(node, openHref, language);
return (
<div
className="fixed z-50 w-56 border border-border bg-[#3f3f3f] py-1 text-sm text-white shadow-xl"
style={{ left: x, top: y }}
>
<div className="border-b border-white/10 px-3 py-2 text-xs text-white/70">{node.qualified_name ?? node.label}</div>
{items.map((item) => (
<a
className="block px-3 py-2 hover:bg-white/10"
href={item.href}
key={item.label}
onClick={(event) => {
event.preventDefault();
navigateToNode(item.href);
}}
>
{item.label}
</a>
))}
</div>
);
}
function contextMenuItemsForNode(node: MetadataTreeNode, openHref: string, language: UiLanguage) {
const labels = {
open: language === "ru" ? "Открыть" : "Open",
openModule: language === "ru" ? "Открыть код" : "Open code",
openHandler: language === "ru" ? "Открыть обработчик" : "Open handler",
openForm: language === "ru" ? "Открыть форму" : "Open form",
properties: language === "ru" ? "Свойства" : "Properties",
versions: language === "ru" ? "Версии" : "Versions",
impact: language === "ru" ? "Связи и влияние" : "Impact",
knowledge: language === "ru" ? "Знания объекта" : "Object knowledge"
};
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
const items = [{ label: labels.open, href: openHref }];
if (value.includes("module") || value.includes("модул") || value.includes("procedure") || value.includes("function") || value.includes("процед") || value.includes("функц")) {
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
return items;
}
if (value.includes("command") || value.includes("команд") || value.includes("event") || value.includes("событ")) {
items.push({ label: labels.openHandler, href: withMode(openHref, "module") });
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
return items;
}
if (value.includes("form") || value.includes("форма")) {
items.push({ label: labels.openForm, href: withMode(openHref, "form") });
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
return items;
}
if (node.qualified_name) {
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
items.push({ label: labels.impact, href: withMode(openHref, "documentation") });
items.push({ label: labels.knowledge, href: withMode(openHref, "knowledge") });
return items;
}
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
return items;
}
function TreeIcon({ icon }: { icon: string }) {
const file = iconFiles[icon] ?? iconFiles.folder;
return <img alt="" aria-hidden="true" className="h-4 w-4 shrink-0" src={`/icons/1c-metadata/light/${file}`} />;
}
function FilterButton({
active,
label,
onClick,
value
}: {
active: boolean;
label: string;
onClick: () => void;
value: TreeFilter;
}) {
return (
<button
className={[
"h-6 border px-2",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-card"
].join(" ")}
data-lazy-tree-filter={value}
onClick={onClick}
type="button"
>
{label}
</button>
);
}
function hrefForNode(language: UiLanguage, projectId: string, node: MetadataTreeNode) {
if (!node.qualified_name && node.kind !== "SFERA_SECTION") {
return "#";
}
if (node.kind === "SFERA_SECTION" && node.label === "Обзор проекта") {
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=overview`;
}
if (node.kind === "SFERA_SECTION" && node.label === "Блок-схема") {
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=flowchart`;
}
const mode = modeForNode(node);
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=${mode}&routine=${encodeURIComponent(node.qualified_name ?? node.label)}`;
}
function withMode(href: string, mode: TreeMode) {
if (href === "#") {
return href;
}
const [path, query = ""] = href.split("?");
const params = new URLSearchParams(query);
params.set("mode", mode);
return `${path}?${params.toString()}`;
}
function modeForNode(node: MetadataTreeNode): TreeMode {
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
if (value.includes("form") || value.includes("форма")) {
return "form";
}
if (value.includes("event") || value.includes("событ")) {
return "events";
}
if (value.includes("version") || value.includes("верс")) {
return "versions";
}
if (value.includes("knowledge") || value.includes("знан")) {
return "knowledge";
}
if (value.includes("flowchart") || value.includes("блок-схем")) {
return "flowchart";
}
return "module";
}
function filterTreeChildren(children: MetadataTreeNode[], query: string) {
const normalized = query.trim().toLocaleLowerCase("ru-RU");
if (!normalized) {
return children;
}
return children
.map((child) => filterTreeNode(child, normalized))
.filter((child): child is MetadataTreeNode => Boolean(child));
}
function filterTreeNode(node: MetadataTreeNode, normalizedQuery: string): MetadataTreeNode | null {
const selfMatches =
node.label.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ||
(node.qualified_name?.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ?? false);
const children = node.children
.map((child) => filterTreeNode(child, normalizedQuery))
.filter((child): child is MetadataTreeNode => Boolean(child));
if (!selfMatches && children.length === 0) {
return null;
}
return { ...node, children };
}
function indexTree(root: MetadataTreeNode) {
return reindexTree(root);
}
function reindexTree(root: MetadataTreeNode) {
const result: Record<string, MetadataTreeNode> = {};
const visit = (node: MetadataTreeNode) => {
result[node.id] = node;
node.children.forEach(visit);
};
visit(root);
return result;
}
function mergeTreeRoots(base: MetadataTreeNode, preserved?: MetadataTreeNode | null): MetadataTreeNode {
if (!preserved || base.id !== preserved.id) {
return base;
}
const preservedById = reindexTree(preserved);
const mergeNode = (node: MetadataTreeNode): MetadataTreeNode => {
const cached = preservedById[node.id];
const cachedChildren = cached?.children ?? [];
const mergedChildrenById = new Map<string, MetadataTreeNode>();
for (const child of node.children) {
mergedChildrenById.set(child.id, mergeNode(child));
}
for (const child of cachedChildren) {
if (!mergedChildrenById.has(child.id)) {
mergedChildrenById.set(child.id, child);
}
}
const nextChildren = [...mergedChildrenById.values()];
const cachedLoaded = cached?.loaded_count ?? cachedChildren.length;
const nodeLoaded = node.loaded_count ?? node.children.length;
return {
...node,
children: nextChildren,
loaded_count: Math.max(nodeLoaded, cachedLoaded, nextChildren.length),
has_more: cached ? node.has_more && cached.has_more : node.has_more,
count: Math.max(node.count ?? 0, cached?.count ?? 0)
};
};
return mergeNode(base);
}
function replaceTreeNode(root: MetadataTreeNode, replacement: MetadataTreeNode): MetadataTreeNode {
if (root.id === replacement.id) {
return replacement;
}
return {
...root,
children: root.children.map((child) => replaceTreeNode(child, replacement))
};
}
function mergeTreeChildren(
root: MetadataTreeNode,
nodeId: string,
children: MetadataTreeNode[],
hasMore: boolean,
total: number,
offset = 0
): MetadataTreeNode {
const target = reindexTree(root)[nodeId];
if (!target) {
return root;
}
return replaceTreeNode(root, mergeNodeChildren(target, children, hasMore, total, offset));
}
function mergeNodeChildren(
node: MetadataTreeNode,
children: MetadataTreeNode[],
hasMore: boolean,
total: number,
offset = node.children.length
): MetadataTreeNode {
const existingIds = new Set(node.children.map((child) => child.id));
const nextChildren = [...node.children, ...children.filter((child) => !existingIds.has(child.id))];
return {
...node,
children: nextChildren,
count: total || node.count,
loaded_count: Math.max(node.loaded_count || 0, offset + children.length, nextChildren.length),
has_more: hasMore
};
}
function formatCount(value: number, language: UiLanguage) {
return new Intl.NumberFormat(language === "ru" ? "ru-RU" : "en-US").format(value);
}
function expandedStorageKey(projectId: string) {
return `sfera.metadata-tree.expanded.${projectId}`;
}
function scrollStorageKey(projectId: string) {
return `sfera.metadata-tree.scroll.${projectId}`;
}
function treeCacheStorageKey(projectId: string) {
return `sfera.metadata-tree.cache.${projectId}`;
}
function readCachedTree(projectId: string) {
try {
const payload = window.sessionStorage.getItem(treeCacheStorageKey(projectId));
return payload ? (JSON.parse(payload) as MetadataTreeNode) : null;
} catch {
return null;
}
}
function writeCachedTree(projectId: string, root: MetadataTreeNode) {
try {
window.sessionStorage.setItem(treeCacheStorageKey(projectId), JSON.stringify(root));
} catch {
try {
window.sessionStorage.removeItem(treeCacheStorageKey(projectId));
} catch {
// Ignore storage failures; server paging still works.
}
}
}
function persistViewportState(projectId: string, viewport: HTMLDivElement | null, expandedIds: Set<string>) {
try {
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
if (viewport) {
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
}
} catch {
// Ignore local storage failures; this only affects visual state restoration.
}
}
function applyTreeFilter(children: MetadataTreeNode[], filter: TreeFilter) {
if (filter === "all") {
return children;
}
if (filter === "sfera") {
return children.filter((child) => child.kind === "SFERA_ROOT" || child.kind === "SFERA_SECTION" || child.label === "SFERA");
}
if (filter === "environments") {
return children.filter((child) => child.kind === "ENVIRONMENTS" || child.label === "Среды");
}
return children.filter((child) => {
if (child.kind === "MAIN_CONFIGURATION" || child.kind === "EXTENSION" || child.kind === "CONTEXT_CONFIGURATION" || child.kind === "REFERENCE_CONFIGURATION") {
return true;
}
const lower = child.label.toLocaleLowerCase("ru-RU");
return lower.includes("конфигурац") || lower.includes("расширение");
});
}
function escapeCssAttribute(value: string) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
@@ -0,0 +1,302 @@
"use client";
import {
Bell,
Bot,
ChevronDown,
FolderPlus,
Search,
Settings,
Sparkles,
UserCircle
} from "lucide-react";
import { useEffect, useState } from "react";
import type React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { messages, type UiLanguage } from "@/lib/i18n";
import { cn } from "@/lib/utils";
export const environmentProjectStorageKey = "sfera.environment.selected-project";
export type ProjectOption = {
project_id: string;
name: string;
};
const languageOptions = [
{ language: "ru", labelKey: "languageRu" },
{ language: "en", labelKey: "languageEn" }
] as const;
export function AppShell({
children,
apiStatus,
language = "ru",
projectId,
projectOptions = []
}: Readonly<{
children: React.ReactNode;
apiStatus: "ok" | "error";
language?: UiLanguage;
projectId?: string;
projectOptions?: ProjectOption[];
}>) {
useEffect(() => {
const url = new URL(window.location.href);
const urlProjectId = url.searchParams.get("project")?.trim();
const savedProjectId = window.localStorage.getItem(environmentProjectStorageKey)?.trim();
if (urlProjectId) {
window.localStorage.setItem(environmentProjectStorageKey, urlProjectId);
return;
}
if (savedProjectId && savedProjectId !== projectId) {
url.searchParams.set("project", savedProjectId);
if (language === "en") {
url.searchParams.set("lang", "en");
}
window.location.replace(url.toString());
return;
}
if (projectId) {
window.localStorage.setItem(environmentProjectStorageKey, projectId);
}
}, [language, projectId]);
return (
<div className="min-h-screen bg-background">
<TopEnvironmentBar apiStatus={apiStatus} language={language} projectId={projectId} projectOptions={projectOptions} />
{children}
<EnvironmentStatusBar apiStatus={apiStatus} language={language} projectId={projectId} />
</div>
);
}
export function TopEnvironmentBar({
agentStatusLabel,
agentStatusTone = "info",
apiStatus,
language = "ru",
onProjectChange,
projectId,
projectOptions = [],
statusLabel
}: Readonly<{
agentStatusLabel?: string;
agentStatusTone?: "success" | "warning" | "danger" | "info" | "neutral";
apiStatus: "ok" | "error";
language?: UiLanguage;
onProjectChange?: (projectId: string) => void;
projectId?: string;
projectOptions?: ProjectOption[];
statusLabel?: string;
}>) {
const t = messages[language];
const [globalSearch, setGlobalSearch] = useState("");
const projectIds = projectOptions.map((project) => project.project_id);
const projects = projectIds.includes(projectId ?? "")
? projectOptions
: ([projectId ? { project_id: projectId, name: projectId } : null, ...projectOptions].filter(Boolean) as ProjectOption[]);
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur">
<div className="flex h-14 items-center gap-2 px-3 sm:px-4" data-top-project-bar>
<a className="flex h-9 shrink-0 items-center gap-2 rounded-md px-2 text-sm font-semibold text-foreground" data-top-bar-logo href={editorHref(projectId, language)}>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles className="h-4 w-4" aria-hidden="true" />
</span>
SFERA
</a>
<TopSelector className="hidden xl:flex" dataMarker="workspace" label={t.workspaceSelector} value="Рабочее пространство" />
<ProjectSelector
currentProjectId={projectId}
language={language}
label={t.projectSelector}
onProjectChange={onProjectChange}
projects={projects}
/>
<TopActionLink className="hidden lg:inline-flex" dataMarker="project-settings" href={projectSettingsHref(projectId)} icon={Settings} label={t.projectSettings} />
<TopActionLink className="hidden xl:inline-flex" dataMarker="create-project" href="/project-settings?new=1" icon={FolderPlus} label={t.createProject} />
<TopSelector className="hidden lg:flex" dataMarker="environment" label={t.environmentSelector} value="Dev" />
<TopSelector className="hidden 2xl:flex" dataMarker="active-task" label={t.activeTaskSelector} value={t.none} />
<label className="flex min-w-[220px] flex-1 items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground" data-global-search>
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<input
aria-label={t.searchPlaceholder}
className="h-5 min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
data-global-search-input
onChange={(event) => setGlobalSearch(event.target.value)}
placeholder={t.searchPlaceholder}
value={globalSearch}
/>
<span className="hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground lg:inline">Ctrl+K</span>
</label>
{statusLabel ? <Badge data-top-bar-badge="work-status">{statusLabel}</Badge> : null}
<Badge data-top-bar-badge="api-status" tone={apiStatus === "ok" ? "success" : "danger"}>
{apiStatus === "ok" ? t.apiOnline : t.apiOffline}
</Badge>
<Badge data-top-bar-badge="agent-status" tone={agentStatusTone} className="hidden xl:inline-flex">
<Bot className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{agentStatusLabel ?? t.agentOnline}
</Badge>
<Button aria-label={t.notifications} className="hidden w-9 px-0 lg:inline-flex" data-top-bar-button="notifications" variant="ghost">
<Bell className="h-4 w-4" aria-hidden="true" />
</Button>
<div className="hidden h-9 items-center rounded-md border border-border bg-card p-1 sm:flex" data-top-bar-language>
{languageOptions.map((option) => (
<a
className={cn(
"flex h-7 items-center rounded px-2 text-xs font-medium transition",
language === option.language
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
href={editorHref(projectId, option.language)}
key={option.language}
>
{t[option.labelKey]}
</a>
))}
</div>
<Button data-top-bar-button="profile" variant="primary">
<UserCircle className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">{t.profile}</span>
</Button>
</div>
</header>
);
}
export function EnvironmentStatusBar({
apiStatus,
language = "ru",
projectId
}: Readonly<{ apiStatus: "ok" | "error"; language?: UiLanguage; projectId?: string }>) {
const t = messages[language];
return (
<footer className="fixed bottom-0 left-0 right-0 z-20 flex h-8 items-center gap-4 overflow-x-auto border-t border-border bg-card px-4 text-xs text-muted-foreground" data-status-bar>
<StatusItem label={language === "ru" ? "Проект" : "Project"} value={projectId ?? "не выбран"} marker="project" />
<StatusItem label={t.snapshotStatus} value="2026.05.09.001" marker="snapshot" />
<StatusItem label={t.agentStatus} value={apiStatus === "ok" ? t.online : t.offline} marker="agent" />
<StatusItem label={t.parserStatus} value="OK" marker="parser" />
<StatusItem label={t.diagnosticsStatus} value="3" marker="diagnostics" />
<StatusItem label={t.taskStatus} value={t.none} marker="active-task" />
<StatusItem label={t.privacyStatus} value={t.metadataOnly} marker="privacy" />
<StatusItem className="lg:ml-auto" label={t.aiTokens} value="12k/100k" marker="ai-tokens" />
<StatusItem label={language === "ru" ? "Пользователь" : "Current user"} value="current-user" marker="current-user" />
</footer>
);
}
function ProjectSelector({
currentProjectId,
label,
language,
onProjectChange,
projects
}: Readonly<{ currentProjectId?: string; label: string; language: UiLanguage; onProjectChange?: (projectId: string) => void; projects: ProjectOption[] }>) {
function switchProject(projectId: string) {
if (!projectId || projectId === currentProjectId) {
return;
}
window.localStorage.setItem(environmentProjectStorageKey, projectId);
if (onProjectChange) {
onProjectChange(projectId);
return;
}
const params = new URLSearchParams(window.location.search);
params.set("project", projectId);
if (language === "en") {
params.set("lang", "en");
}
window.location.href = `${window.location.pathname}?${params.toString()}`;
}
return (
<label
className="flex h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground"
data-top-bar-selector="project"
>
<span className="text-muted-foreground">{label}</span>
<select
aria-label={language === "ru" ? "Выбор проекта" : "Project selector"}
className="h-7 max-w-[190px] bg-transparent font-semibold outline-none"
onChange={(event) => switchProject(event.target.value)}
value={currentProjectId ?? ""}
>
{projects.length ? null : <option value="">Нет проектов</option>}
{projects.map((project) => (
<option key={project.project_id} value={project.project_id}>
{project.name || project.project_id}
</option>
))}
</select>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
</label>
);
}
function projectSettingsHref(projectId?: string) {
return projectId ? `/project-settings?project=${encodeURIComponent(projectId)}` : "/project-settings";
}
function editorHref(projectId?: string, language: UiLanguage = "ru") {
const params = new URLSearchParams();
if (language === "en") {
params.set("lang", "en");
}
if (projectId) {
params.set("project", projectId);
}
const query = params.toString();
return query ? `/editor?${query}` : "/editor";
}
function StatusItem({
className,
label,
marker,
value
}: Readonly<{ className?: string; label: string; marker: string; value: string }>) {
return (
<span className={cn("shrink-0", className)} data-status-item={marker}>
{label}: {value}
</span>
);
}
function TopSelector({ className, dataMarker, label, value }: Readonly<{ className?: string; dataMarker: string; label: string; value: string }>) {
return (
<button className={cn("h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)} data-top-bar-selector={dataMarker} type="button">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
</button>
);
}
function TopActionLink({
className,
dataMarker,
href,
icon: Icon,
label
}: Readonly<{
className?: string;
dataMarker: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
}>) {
return (
<a
className={cn("h-9 shrink-0 items-center gap-2 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)}
data-top-bar-action={dataMarker}
href={href}
>
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span>{label}</span>
</a>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import type React from "react";
import { cn } from "@/lib/utils";
type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info";
const tones: Record<BadgeTone, string> = {
neutral: "border-border bg-muted text-muted-foreground",
success: "border-success/25 bg-success/10 text-success",
warning: "border-warning/30 bg-warning/10 text-warning",
danger: "border-destructive/25 bg-destructive/10 text-destructive",
info: "border-info/25 bg-info/10 text-info"
};
export function Badge({
children,
tone = "neutral",
className,
...props
}: React.HTMLAttributes<HTMLSpanElement> & {
tone?: BadgeTone;
}) {
return (
<span
className={cn(
"inline-flex h-6 items-center rounded-full border px-2.5 text-xs font-medium leading-none",
tones[tone],
className
)}
{...props}
>
{children}
</span>
);
}
@@ -0,0 +1,30 @@
import type React from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost";
const variants: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:bg-muted",
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground"
};
export function Button({
children,
className,
variant = "secondary",
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
return (
<button
className={cn(
"inline-flex h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50",
variants[variant],
className
)}
{...props}
>
{children}
</button>
);
}
@@ -0,0 +1,14 @@
import type React from "react";
import { cn } from "@/lib/utils";
export function Card({
children,
className,
...props
}: Readonly<React.HTMLAttributes<HTMLElement>>) {
return (
<section className={cn("rounded-2xl border border-border bg-card p-4 shadow-soft", className)} {...props}>
{children}
</section>
);
}
File diff suppressed because it is too large Load Diff
+580
View File
@@ -0,0 +1,580 @@
export type UiLanguage = "ru" | "en";
export function normalizeLanguage(value: string | undefined): UiLanguage {
return value === "en" ? "en" : "ru";
}
export const messages = {
ru: {
productSubtitle: "Семантическое пространство 1С",
nav: {
overview: "Обзор",
projects: "Проекты 1С",
graph: "Граф",
objects: "Объекты",
review: "Проверка",
knowledge: "Знания",
patterns: "Паттерны",
privacy: "Приватность",
aiUsage: "Расход ИИ",
operations: "Операции",
settings: "Настройки"
},
searchPlaceholder: "Поиск по 1С объектам, процедурам, знаниям",
rights: "Права",
projectSettings: "Настройки",
createProject: "+ Проект",
notifications: "Уведомления",
profile: "Профиль",
workspaceSelector: "Компания",
projectSelector: "Проект",
environmentSelector: "Среда",
activeTaskSelector: "Задача",
languageRu: "Русский",
languageEn: "English",
commandLanguageNote: "Команды: русский по умолчанию, английский доступен",
apiOnline: "API доступен",
apiOffline: "API недоступен",
overview: "Обзор",
projects: "Проекты",
objects: "Объекты",
configurationTree: "Дерево конфигурации",
openWindows: "Открытые окна",
projectDashboard: "Обзор проекта 1С",
projectDashboardDescription: "Рабочая сводка по выбранной 1С-конфигурации: состояние, проверки, снимки и быстрый переход в открытые объекты.",
contextPanel: "Контекст",
contextInspector: "Контекстный инспектор",
owner: "Владелец",
subsystem: "Подсистема",
criticality: "Критичность",
activeTask: "Активная задача",
calls: "Вызовы",
riskContext: "Риски и изменения",
runtimeIncidents: "Инциденты выполнения",
heroBadge: "Семантическое ядро 1С",
title: "Операционный контур 1С",
subtitle: "Семантический граф, проверка, знания, приватность и управление ИИ в одном рабочем представлении.",
review: "Проверка",
graph: "Граф 1С",
knowledge: "Знания",
governance: "Управление",
aiPolicy: "Политика ИИ",
aiUsage: "Расход ИИ",
projectWorkspace: "Рабочее пространство 1С",
projectWorkspaceDescription: "Снимок, проверка, знания, UI-формы, интеграции и регламентные задания выбранного 1С-проекта.",
selectedProject: "Выбранный проект",
openProject: "Открыть проект",
openInEditor: "Открыть в редакторе",
nodes: "Узлы",
edges: "Связи",
procedures: "Процедуры",
queries: "Запросы",
writes: "Записи",
reviewFindings: "Замечания проверки",
noReviewFindings: "Замечаний нет",
severity: "Уровень",
finding: "Замечание",
source: "Источник",
forms: "Формы",
commands: "Команды",
elements: "Элементы",
integrations: "Интеграции",
scheduledJobs: "Регламентные задания",
knowledgeCoverage: "Покрытие знаниями",
covered: "Покрыто",
uncovered: "Не покрыто",
unsecuredObjects: "Без прав ролей",
unownedObjects: "Без владельца",
sensitiveFields: "Чувствительные поля",
permissionState: "Права доступа",
permissionStateDescription: "Текущий экран показывает только чтение семантического состояния; действия изменения будут требовать роли владельца проекта.",
commandPalette: "Командная строка",
commandPlaceholder: "Найти объект 1С, процедуру или команду",
savedView: "Представление",
auditTrail: "Аудит",
authoringHistory: "История изменений",
noAuthoringChanges: "Пока нет сохранённых authoring change-set",
version: "Версия",
approvedBy: "Подтвердил",
aiContext: "Контекст ИИ",
model: "Модель",
tokenImpact: "Влияние на токены",
noProjectData: "Нет данных выбранного проекта",
noProjectDataDescription: "Снимок сохранён, но проектные данные ещё не доступны для панели.",
ideWorkspace: "IDE 1С",
ideWorkspaceDescription: "Современная рабочая среда 1С: модуль, форма, свойства, события, версии, документация, знания, обучение, AI-подсказки и semantic diff.",
objectTree: "Дерево объектов",
bslEditor: "Редактор BSL",
procedureOutline: "Outline процедур",
findUsages: "Использования",
quickFixes: "Быстрые исправления",
insertGuardClause: "Вставить проверку отказа",
extractProcedure: "Выделить процедуру",
addKnowledgeLink: "Связать со знанием",
moduleMode: "Модуль",
formMode: "Форма",
propertiesMode: "Свойства",
eventsMode: "События",
versionsMode: "Версии",
documentationMode: "Документация",
knowledgeMode: "Знания",
learningMode: "Обучение",
formDesigner: "Дизайнер формы",
eventsInspector: "Инспектор событий",
knowledgeLearning: "Знания и обучение",
knowledgeLearningDescription: "AI связывает текущий объект с документацией, паттернами, историей решений и учебными материалами команды.",
postAndClose: "Провести и закрыть",
saveAndClose: "Записать и закрыть",
save: "Записать",
create: "Создать",
search: "Поиск",
emptyList: "Список пуст",
mainSection: "Основное",
nameField: "Наименование",
code: "Код",
comment: "Комментарий",
client: "Клиент",
agent: "Агент",
sites: "Сайты",
compensationTerms: "Условия возмещения",
agencyAgreements: "Агентские соглашения",
telegram: "Телеграм",
mail: "Почта",
sentToBankCompanyName: "Наименование компании, отправленное в банк",
mergeProject: "Мерч проект",
legalEntity: "Юр лицо",
result: "Результат",
author: "Автор",
editor: "Редактор",
creationDate: "Дата создания",
editDate: "Дата редактирования",
supplier: "Поставщик",
contract: "Договор",
documentNumber: "Номер",
operation: "Операция",
goods: "Товары",
services: "Услуги",
additional: "Дополнительно",
numberSign: "N",
item: "Номенклатура",
quantity: "Количество",
price: "Цена",
amount: "Сумма",
eventName: "Событие",
objectVersioningDescription: "История SFERA хранится на уровне объекта 1С: каждое изменение связано с diff, задачей, автором и rollback-точкой.",
documentationModeDescription: "Документация открывается рядом с объектом: назначение, бизнес-правила, связи с формами, отчётами, командами и регламентами.",
knowledgeModeDescription: "База знаний связывает текущий объект с паттернами команды, BSP/vendor docs, обсуждениями, решениями и известными рисками.",
learningModeDescription: "Обучение показывает, что можно безопасно написать в текущем контексте, какие переменные доступны и какие стандарты команды применимы.",
problemsPanel: "Проблемы",
outputPanel: "Вывод",
testsPanel: "Тесты",
referencesPanel: "Ссылки",
propertiesInspector: "Инспектор свойств",
semanticDiff: "Семантический diff",
aiPairProgrammer: "AI-помощник",
currentContext: "Текущий контекст",
availableVariables: "Доступные переменные",
localVariables: "Локальные переменные",
objectAttributes: "Реквизиты объекта",
tabularSections: "Табличные части",
formElements: "Элементы формы",
metadataDraft: "Черновик объекта 1С",
metadataDraftDescription: "SFERA создаёт объект как версионированный черновик workspace: реквизиты, табличные части, формы и diff без записи в production 1С.",
objectKind: "Тип объекта",
objectName: "Имя объекта",
attributeName: "Имя реквизита",
attributeType: "Тип",
tabularSectionName: "Имя табличной части",
formName: "Имя формы",
commandName: "Имя команды",
commandHandler: "Обработчик",
synonym: "Синоним",
requiredFlag: "Обязательный",
addAttribute: "Добавить реквизит",
addTabularColumn: "Добавить колонку",
addForm: "Добавить форму",
addCommand: "Добавить команду",
applyMetadataDraft: "Создать черновик",
suggestedCompletion: "Предложение продолжения",
guardedApply: "Безопасное применение",
versionPreview: "Preview версии",
affectedNodes: "Затронутые узлы",
applyBlocked: "Применение заблокировано",
workspaceApplyReady: "Можно сохранить в workspace-историю SFERA",
productionApplyDisabled: "Запись в production 1С отключена",
previewRequired: "Требуется preview",
applyToSfera: "Применить в SFERA",
rollbackPlan: "План отката",
authoringMode: "Режим разработки",
workspaceHistoryOnly: "только workspace-история SFERA",
impactBeforeApply: "Анализ влияния до применения",
reviewBeforeApply: "Проверка до применения",
versionKind: "Тип версии",
lineage: "Линия версий",
parentVersion: "Родительская версия",
versionDiff: "Diff версии",
taskLabel: "Задача",
sessionLabel: "Сессия",
fullPayload: "Полные данные",
summaryOnly: "Кратко",
loading: "Загрузка",
rootVersion: "корневая",
agentOnline: "Агент онлайн",
online: "онлайн",
offline: "офлайн",
snapshotStatus: "Снимок",
agentStatus: "Агент",
parserStatus: "Парсер",
diagnosticsStatus: "Диагностика",
taskStatus: "Задача",
privacyStatus: "Приватность",
metadataOnly: "только метаданные",
sferaProjectTree: "Дерево проекта SFERA",
sirObjects: "Объекты SIR",
aiHandlers: "AI-обработчики",
semanticRules: "Семантические правила",
reviewPolicies: "Политики проверки",
knowledgeBindings: "Связи знаний",
agentCommands: "Команды агентов",
rollbackTemplates: "Шаблоны отката",
highReviewFindings: "Критичные замечания проверки",
policyGated: "по политике доступа",
graphEdges: "Связи графа",
aiShort: "ИИ",
appliedToWorkspace: "Записано в workspace",
rolledBackToWorkspace: "Откат записан в workspace",
blocked: "заблокировано",
ready: "готово",
required: "требуется",
checked: "проверено",
applying: "Применяется...",
rollingBack: "Откатываем...",
building: "Строим...",
savedToSfera: "Записано в SFERA",
guardedApplyNote: "Безопасная запись из рабочего места SFERA IDE",
rollbackApplyNote: "План отката проверен в SFERA IDE",
aiSuggestion: "AI предлагает код с учётом текущей процедуры, переменных, регистра и прав доступа.",
editorDirty: "Есть несохранённые изменения",
readOnlyPrototype: "Preview-режим: запись только в workspace-историю SFERA.",
addLine: "Добавить строку",
removeLine: "Удалить строку",
selectedObject: "Выбранный объект",
objectOverview: "Обзор объекта",
objectOverviewDescription: "Сводка выбранного объекта 1С: структура, формы, команды, связи, риски, знания и версии.",
line: "Строка",
snapshots: "Снимки",
activeProjects: "активных проектов",
relations: "связей",
packages: "пакетов",
aiTokens: "AI токены",
requests: "запросов",
current: "актуально",
latestSnapshots: "Последние снимки SIR",
snapshotsDescription: "Проекты, доступные для проверки, графа и анализа влияния.",
open: "Открыть",
project: "Проект",
snapshot: "Снимок",
hash: "Хэш",
status: "Статус",
stored: "сохранено",
none: "нет",
noSnapshots: "Нет сохранённых снимков",
noSnapshotsDescription: "После индексации 1С-конфигурации список появится здесь.",
governanceDescription: "Контроль владельцев, приватности, расхода ИИ и покрытия знаниями.",
owners: "Владельцы",
tasks: "Задачи",
privacy: "Приватность",
tokenLimit: "Лимит токенов",
used: "Использовано",
remaining: "Осталось",
unlimited: "без лимита",
apiUnavailable: "API недоступен",
unknownApiError: "Неизвестная ошибка API"
},
en: {
productSubtitle: "1C Semantic Workspace",
nav: {
overview: "Overview",
projects: "1C Projects",
graph: "Graph",
objects: "Objects",
review: "Review",
knowledge: "Knowledge",
patterns: "Patterns",
privacy: "Privacy",
aiUsage: "AI Usage",
operations: "Operations",
settings: "Settings"
},
searchPlaceholder: "Search 1C objects, routines, knowledge",
rights: "Access",
projectSettings: "Settings",
createProject: "+ Project",
notifications: "Notifications",
profile: "Profile",
workspaceSelector: "Workspace",
projectSelector: "Project",
environmentSelector: "Env",
activeTaskSelector: "Task",
languageRu: "Русский",
languageEn: "English",
commandLanguageNote: "Commands: Russian by default, English available",
apiOnline: "API online",
apiOffline: "API offline",
overview: "Overview",
projects: "Projects",
objects: "Objects",
configurationTree: "Configuration tree",
openWindows: "Open windows",
projectDashboard: "1C project overview",
projectDashboardDescription: "Working summary for the selected 1C configuration: state, checks, snapshots, and quick jumps to open objects.",
contextPanel: "Context",
contextInspector: "Context inspector",
owner: "Owner",
subsystem: "Subsystem",
criticality: "Criticality",
activeTask: "Active task",
calls: "Calls",
riskContext: "Risks and changes",
runtimeIncidents: "Runtime incidents",
heroBadge: "1C semantic core",
title: "1C Operational Workspace",
subtitle: "Semantic graph, review, knowledge, privacy, and AI governance in one working view.",
review: "Review",
graph: "1C Graph",
knowledge: "Knowledge",
governance: "Governance",
aiPolicy: "AI policy",
aiUsage: "AI usage",
projectWorkspace: "1C Workspace",
projectWorkspaceDescription: "Snapshot, review, knowledge, UI forms, integrations, and scheduled jobs for the selected 1C project.",
selectedProject: "Selected project",
openProject: "Open project",
openInEditor: "Open in editor",
nodes: "Nodes",
edges: "Edges",
procedures: "Procedures",
queries: "Queries",
writes: "Writes",
reviewFindings: "Review findings",
noReviewFindings: "No findings",
severity: "Severity",
finding: "Finding",
source: "Source",
forms: "Forms",
commands: "Commands",
elements: "Elements",
integrations: "Integrations",
scheduledJobs: "Scheduled jobs",
knowledgeCoverage: "Knowledge coverage",
covered: "Covered",
uncovered: "Uncovered",
unsecuredObjects: "No role access",
unownedObjects: "No owner",
sensitiveFields: "Sensitive fields",
permissionState: "Permissions",
permissionStateDescription: "This screen currently exposes read-only semantic state; mutation actions will require the project owner role.",
commandPalette: "Command line",
commandPlaceholder: "Find a 1C object, routine, or command",
savedView: "View",
auditTrail: "Audit",
authoringHistory: "Change history",
noAuthoringChanges: "No saved authoring change sets yet",
version: "Version",
approvedBy: "Approved by",
aiContext: "AI context",
model: "Model",
tokenImpact: "Token impact",
noProjectData: "No selected project data",
noProjectDataDescription: "The snapshot is stored, but project details are not yet available to the panel.",
ideWorkspace: "1C IDE",
ideWorkspaceDescription: "A modern 1C workspace: module, form, properties, events, versions, docs, knowledge, training, AI suggestions, and semantic diff.",
objectTree: "Object tree",
bslEditor: "BSL editor",
procedureOutline: "Procedure outline",
findUsages: "Find usages",
quickFixes: "Quick fixes",
insertGuardClause: "Insert guard clause",
extractProcedure: "Extract procedure",
addKnowledgeLink: "Link knowledge",
moduleMode: "Module",
formMode: "Form",
propertiesMode: "Properties",
eventsMode: "Events",
versionsMode: "Versions",
documentationMode: "Docs",
knowledgeMode: "Knowledge",
learningMode: "Training",
formDesigner: "Form designer",
eventsInspector: "Events inspector",
knowledgeLearning: "Knowledge and training",
knowledgeLearningDescription: "AI links the current object with docs, patterns, decision history, and team learning material.",
postAndClose: "Post and close",
saveAndClose: "Save and close",
save: "Save",
create: "Create",
search: "Search",
emptyList: "List is empty",
mainSection: "Main",
nameField: "Name",
code: "Code",
comment: "Comment",
client: "Client",
agent: "Agent",
sites: "Sites",
compensationTerms: "Compensation terms",
agencyAgreements: "Agency agreements",
telegram: "Telegram",
mail: "Mail",
sentToBankCompanyName: "Company name sent to bank",
mergeProject: "Merge project",
legalEntity: "Legal entity",
result: "Result",
author: "Author",
editor: "Editor",
creationDate: "Creation date",
editDate: "Edit date",
supplier: "Supplier",
contract: "Contract",
documentNumber: "Number",
operation: "Operation",
goods: "Goods",
services: "Services",
additional: "Additional",
numberSign: "No.",
item: "Item",
quantity: "Quantity",
price: "Price",
amount: "Amount",
eventName: "Event",
objectVersioningDescription: "SFERA history is stored at the 1C object level: every change is linked to a diff, task, author, and rollback point.",
documentationModeDescription: "Documentation opens next to the object: purpose, business rules, links with forms, reports, commands, and jobs.",
knowledgeModeDescription: "The knowledge base links the current object with team patterns, BSP/vendor docs, discussions, decisions, and known risks.",
learningModeDescription: "Training shows what can be safely written in the current context, which variables are available, and which team standards apply.",
problemsPanel: "Problems",
outputPanel: "Output",
testsPanel: "Tests",
referencesPanel: "References",
propertiesInspector: "Properties inspector",
semanticDiff: "Semantic diff",
aiPairProgrammer: "AI pair programmer",
currentContext: "Current context",
availableVariables: "Available variables",
localVariables: "Local variables",
objectAttributes: "Object attributes",
tabularSections: "Tabular sections",
formElements: "Form elements",
metadataDraft: "1C object draft",
metadataDraftDescription: "SFERA creates the object as a versioned workspace draft: attributes, tabular sections, forms, and diff without writing to production 1C.",
objectKind: "Object kind",
objectName: "Object name",
attributeName: "Attribute name",
attributeType: "Type",
tabularSectionName: "Tabular section name",
formName: "Form name",
commandName: "Command name",
commandHandler: "Handler",
synonym: "Synonym",
requiredFlag: "Required",
addAttribute: "Add attribute",
addTabularColumn: "Add column",
addForm: "Add form",
addCommand: "Add command",
applyMetadataDraft: "Create draft",
suggestedCompletion: "Suggested completion",
guardedApply: "Guarded apply",
versionPreview: "Version preview",
affectedNodes: "Affected nodes",
applyBlocked: "Apply blocked",
workspaceApplyReady: "Ready to save into SFERA workspace history",
productionApplyDisabled: "Production 1C write is disabled",
previewRequired: "Preview required",
applyToSfera: "Apply to SFERA",
rollbackPlan: "Rollback plan",
authoringMode: "Authoring mode",
workspaceHistoryOnly: "SFERA workspace history only",
impactBeforeApply: "Impact before apply",
reviewBeforeApply: "Review before apply",
versionKind: "Version kind",
lineage: "Lineage",
parentVersion: "Parent version",
versionDiff: "Version diff",
taskLabel: "Task",
sessionLabel: "Session",
fullPayload: "Full payload",
summaryOnly: "Summary",
loading: "Loading",
rootVersion: "root",
agentOnline: "Agent online",
online: "online",
offline: "offline",
snapshotStatus: "Snapshot",
agentStatus: "Agent",
parserStatus: "Parser",
diagnosticsStatus: "Diagnostics",
taskStatus: "Task",
privacyStatus: "Privacy",
metadataOnly: "metadata-only",
sferaProjectTree: "SFERA Project",
sirObjects: "SIR objects",
aiHandlers: "AI handlers",
semanticRules: "Semantic rules",
reviewPolicies: "Review policies",
knowledgeBindings: "Knowledge bindings",
agentCommands: "Agent commands",
rollbackTemplates: "Rollback templates",
highReviewFindings: "Review HIGH findings",
policyGated: "policy-gated",
graphEdges: "Graph edges",
aiShort: "AI",
appliedToWorkspace: "Applied to workspace",
rolledBackToWorkspace: "Rolled back to workspace",
blocked: "blocked",
ready: "ready",
required: "required",
checked: "checked",
applying: "Applying...",
rollingBack: "Rolling back...",
building: "Loading...",
savedToSfera: "Saved to SFERA",
guardedApplyNote: "Guarded apply from SFERA IDE workbench",
rollbackApplyNote: "Rollback preview checked in SFERA IDE",
aiSuggestion: "AI suggests code using the current routine, variables, register, and access context.",
editorDirty: "Unsaved changes",
readOnlyPrototype: "Preview mode: writes only to SFERA workspace history.",
addLine: "Add line",
removeLine: "Remove line",
selectedObject: "Selected object",
objectOverview: "Object overview",
objectOverviewDescription: "Summary of the selected 1C object: structure, forms, commands, links, risks, knowledge, and versions.",
line: "Line",
snapshots: "Snapshots",
activeProjects: "active projects",
relations: "relations",
packages: "packs",
aiTokens: "AI tokens",
requests: "requests",
current: "current",
latestSnapshots: "Latest SIR snapshots",
snapshotsDescription: "Projects available for review, graph, and impact analysis.",
open: "Open",
project: "Project",
snapshot: "Snapshot",
hash: "Hash",
status: "Status",
stored: "stored",
none: "none",
noSnapshots: "No stored snapshots",
noSnapshotsDescription: "Indexed 1C configurations will appear here.",
governanceDescription: "Owners, privacy, AI usage, and knowledge coverage controls.",
owners: "Owners",
tasks: "Tasks",
privacy: "Privacy",
tokenLimit: "Token limit",
used: "Used",
remaining: "Remaining",
unlimited: "unlimited",
apiUnavailable: "API unavailable",
unknownApiError: "Unknown API error"
}
} as const;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+34
View File
@@ -0,0 +1,34 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: "hsl(var(--card))",
"card-foreground": "hsl(var(--card-foreground))",
muted: "hsl(var(--muted))",
"muted-foreground": "hsl(var(--muted-foreground))",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
primary: "hsl(var(--primary))",
"primary-foreground": "hsl(var(--primary-foreground))",
secondary: "hsl(var(--secondary))",
"secondary-foreground": "hsl(var(--secondary-foreground))",
destructive: "hsl(var(--destructive))",
warning: "hsl(var(--warning))",
success: "hsl(var(--success))",
info: "hsl(var(--info))"
},
boxShadow: {
soft: "0 12px 32px rgba(15, 23, 42, 0.08)"
}
}
},
plugins: []
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}