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
@@ -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));
}