Files
sfera/frontend/sfera-web/src/components/editor/ide-workspace.tsx
T
m 9f1f1a8ee1
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
Fix form selection and common form rendering
2026-05-21 17:03:32 +03:00

4929 lines
221 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Bot,
Brain as BrainIcon,
Braces,
CheckCircle2,
ChevronDown,
Code2,
Copy,
Database as DatabaseIcon,
Filter,
FlaskConical,
FileText,
FileCode2,
GitCompareArrows,
GraduationCap,
History,
Layers3,
ListTree,
Lock,
Maximize2,
Monitor,
MoreVertical,
PanelBottom,
PanelRight,
Pin,
Plus,
Search,
Sparkles,
TriangleAlert,
X
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { LazyMetadataTree } from "@/components/editor/lazy-metadata-tree";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import {
applyAuthoringChangeSet,
ensureAuthoringSession,
getAuthoringSemanticDiffPreview,
applyAuthoringMetadataObject,
applyAuthoringRollback,
getAuthoringMetadataObjectPreview,
getBslCompletions,
getLineageVersions,
getProjectSymbolDefinition,
getProjectFlowchart,
getProjectSymbolReferences,
getVersionDiff,
getAuthoringRollbackPreview,
searchProjectSymbols,
type AuthoringMetadataObjectDraft,
type BslCompletionItem,
type MetadataTreeNode,
type MetadataTypeSpec,
type FlowchartNode,
type ProjectFlowchart,
type SymbolReferences,
type SymbolResult,
type getProjectWorkspaceData,
resolveApiUrl,
type SirNode
} from "@/lib/api";
import { messages, type UiLanguage } from "@/lib/i18n";
type ProjectWorkspaceData = Awaited<ReturnType<typeof getProjectWorkspaceData>>;
type AuthoringChange = ProjectWorkspaceData["authoringChanges"][number];
type ProjectVersion = ProjectWorkspaceData["projectVersions"][number];
type VersionDetail = Awaited<ReturnType<typeof getLineageVersions>>[number];
type VersionDiff = Awaited<ReturnType<typeof getVersionDiff>>;
type GuardCheck = { name: string; status: string; message: string };
type WorkspaceMode = "overview" | "module" | "form" | "properties" | "events" | "versions" | "documentation" | "knowledge" | "learning" | "flowchart";
type OpenDocument = {
id: string;
mode: WorkspaceMode;
label: string;
meta: string;
icon: React.ComponentType<{ className?: string }>;
};
type OneCIconKind =
| "attribute"
| "business-process"
| "catalog"
| "command"
| "common"
| "constant"
| "document"
| "exchange-plan"
| "event"
| "enum"
| "external-source"
| "extension"
| "form"
| "integration"
| "journal"
| "language"
| "layout"
| "module"
| "palette"
| "plan"
| "processing"
| "register"
| "report"
| "role"
| "scheduled-job"
| "service"
| "settings"
| "subsystem"
| "tabular"
| "task"
| "tree"
| "web";
const oneCIconFiles: Record<OneCIconKind, string> = {
attribute: "attribute.svg",
"business-process": "businessProcess.svg",
catalog: "catalog.svg",
command: "command.svg",
common: "common.svg",
constant: "constant.svg",
document: "document.svg",
event: "eventSubscription.svg",
"exchange-plan": "exchangePlan.svg",
enum: "enum.svg",
"external-source": "externalDataSource.svg",
extension: "folder.svg",
form: "form.svg",
integration: "ws.svg",
journal: "documentJournal.svg",
language: "style.svg",
layout: "template.svg",
module: "commonModule.svg",
palette: "picture.svg",
plan: "chartsOfAccount.svg",
processing: "dataProcessor.svg",
register: "accumulationRegister.svg",
report: "report.svg",
role: "role.svg",
"scheduled-job": "scheduledJob.svg",
service: "http.svg",
settings: "parameter.svg",
subsystem: "subsystem.svg",
tabular: "tabularSection.svg",
task: "task.svg",
tree: "folder.svg",
web: "http.svg"
};
const sampleCode = [
"// Код модуля не загружен.",
"// Выберите реальный модуль в дереве проекта или выполните индексирование с исходниками BSL."
];
const suggestedCode = [
"// AI-подсказка появится после выбора реального контекста."
];
const outlineItems = [
"Выберите модуль в дереве"
];
const knownObjectKinds = new Set([
"CATALOG",
"DOCUMENT",
"REGISTER",
"COMMON_MODULE",
"EXCHANGE_PLAN",
"EVENT_SUBSCRIPTION",
"SCHEDULED_JOB",
"BUSINESS_PROCESS",
"TASK",
"FORM",
"COMMAND",
"ATTRIBUTE",
"TABULAR_SECTION",
"PROCEDURE",
"FUNCTION"
]);
function versionToSummary(version: {
version_id: string;
lineage_id: string;
semantic_id: string;
object_hash: string;
parent_version_id?: string | null;
task_id?: string | null;
session_id?: string | null;
}): ProjectVersion {
return {
version_id: version.version_id,
lineage_id: version.lineage_id,
semantic_id: version.semantic_id,
object_hash: version.object_hash,
parent_version_id: version.parent_version_id ?? null,
task_id: version.task_id ?? null,
session_id: version.session_id ?? null
};
}
export function IdeWorkspace({
data,
initialMode,
language,
routineName
}: Readonly<{
data: ProjectWorkspaceData;
initialMode?: string;
language: UiLanguage;
routineName?: string;
}>) {
const t = messages[language];
const treeNodes = data.exportSnapshot?.nodes ?? [];
const objectNodes = treeNodes.filter((node) => knownObjectKinds.has(node.kind)).slice(0, 14);
const selectedObject =
objectNodes.find((node) => node.lineage_id === data.authoringPreview?.context.object?.lineage_id) ??
objectNodes[0];
const selectedMetadataNode =
data.selectedMetadataNode ??
findMetadataTreeNode(data.metadataTree?.root, routineName) ??
flattenMetadataTree(data.metadataTree?.root, 1)[0];
const problems = data.review.slice(0, 3);
const [activeMode, setActiveMode] = useState<WorkspaceMode>(() => normalizeWorkspaceMode(initialMode));
const [authoringChanges, setAuthoringChanges] = useState<AuthoringChange[]>(data.authoringChanges);
const [projectVersions, setProjectVersions] = useState<ProjectVersion[]>(data.projectVersions);
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(true);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(true);
const [isBottomPanelOpen, setIsBottomPanelOpen] = useState(true);
const [leftPanelWidth, setLeftPanelWidth] = useState<number | null>(null);
const [checkStatus, setCheckStatus] = useState<string | null>(null);
const [closedOpenDocumentIds, setClosedOpenDocumentIds] = useState<Set<string>>(() => new Set());
const [pinnedOpenDocumentIds, setPinnedOpenDocumentIds] = useState<Set<string>>(() => new Set());
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const workspaceData = { ...data, authoringChanges, projectVersions };
const registerAuthoringChange = (change: AuthoringChange, version?: ProjectVersion) => {
setAuthoringChanges((current) => [change, ...current.filter((item) => item.change_id !== change.change_id)]);
if (version) {
setProjectVersions((current) => [version, ...current.filter((item) => item.version_id !== version.version_id)]);
}
};
const workspaceModes: Array<{ id: WorkspaceMode; label: string; icon: React.ComponentType<{ className?: string }> }> = [
{ id: "module", label: t.moduleMode, icon: Code2 },
{ id: "form", label: t.formMode, icon: Layers3 },
{ id: "properties", label: t.propertiesMode, icon: PanelRight },
{ id: "events", label: t.eventsMode, icon: Braces },
{ id: "flowchart", label: language === "ru" ? "Блок-схема" : "Flowchart", icon: GitCompareArrows },
{ id: "versions", label: t.versionsMode, icon: History },
{ id: "documentation", label: t.documentationMode, icon: FileText },
{ id: "knowledge", label: t.knowledgeMode, icon: Sparkles },
{ id: "learning", label: t.learningMode, icon: GraduationCap }
];
const allOpenDocuments = useMemo(() => buildOpenDocuments(data.metadataTree?.root, language), [data.metadataTree?.root, language]);
const openDocuments = useMemo(() => {
const visible = allOpenDocuments.filter((document) => pinnedOpenDocumentIds.has(document.id) || !closedOpenDocumentIds.has(document.id));
return visible.length > 0 ? visible : allOpenDocuments.slice(0, 1);
}, [allOpenDocuments, closedOpenDocumentIds, pinnedOpenDocumentIds]);
useEffect(() => {
setAuthoringChanges(data.authoringChanges);
setProjectVersions(data.projectVersions);
setClosedOpenDocumentIds(new Set());
setPinnedOpenDocumentIds(new Set());
setCheckStatus(null);
setActiveMode(normalizeWorkspaceMode(initialMode));
}, [data.projectId, data.authoringChanges, data.projectVersions, initialMode]);
useEffect(() => {
try {
const saved = JSON.parse(window.localStorage.getItem(workspacePanelStorageKey(data.projectId)) ?? "{}") as {
leftOpen?: boolean;
rightOpen?: boolean;
bottomOpen?: boolean;
leftWidth?: number;
};
setIsLeftPanelOpen(saved.leftOpen ?? true);
setIsRightPanelOpen(saved.rightOpen ?? true);
setIsBottomPanelOpen(saved.bottomOpen ?? true);
setLeftPanelWidth(saved.leftWidth && saved.leftWidth >= 240 ? Math.min(saved.leftWidth, 520) : null);
} catch {
setIsLeftPanelOpen(true);
setIsRightPanelOpen(true);
setIsBottomPanelOpen(true);
setLeftPanelWidth(null);
}
}, [data.projectId]);
useEffect(() => {
const payload = {
leftOpen: isLeftPanelOpen,
rightOpen: isRightPanelOpen,
bottomOpen: isBottomPanelOpen,
leftWidth: leftPanelWidth
};
window.localStorage.setItem(workspacePanelStorageKey(data.projectId), JSON.stringify(payload));
}, [data.projectId, isBottomPanelOpen, isLeftPanelOpen, isRightPanelOpen, leftPanelWidth]);
useEffect(() => {
const panel = leftPanelRef.current;
if (!panel || !isLeftPanelOpen) {
return;
}
const observer = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
if (width >= 240 && width <= 520) {
setLeftPanelWidth(width);
}
});
observer.observe(panel);
return () => observer.disconnect();
}, [isLeftPanelOpen]);
useEffect(() => {
try {
const saved = window.localStorage.getItem(openObjectsStorageKey(data.projectId));
if (!saved) {
return;
}
const parsed = JSON.parse(saved) as { closed?: string[]; pinned?: string[] };
setClosedOpenDocumentIds(new Set(parsed.closed ?? []));
setPinnedOpenDocumentIds(new Set(parsed.pinned ?? []));
} catch {
setClosedOpenDocumentIds(new Set());
setPinnedOpenDocumentIds(new Set());
}
}, [data.projectId]);
useEffect(() => {
window.localStorage.setItem(
openObjectsStorageKey(data.projectId),
JSON.stringify({ closed: [...closedOpenDocumentIds], pinned: [...pinnedOpenDocumentIds] })
);
}, [closedOpenDocumentIds, data.projectId, pinnedOpenDocumentIds]);
function closeOpenDocument(documentId: string) {
const target = openDocuments.find((document) => document.id === documentId);
if (!target || pinnedOpenDocumentIds.has(documentId) || openDocuments.length <= 1) {
return;
}
const currentIndex = Math.max(0, openDocuments.findIndex((document) => document.id === documentId));
const nextDocument = openDocuments[currentIndex + 1] ?? openDocuments[currentIndex - 1] ?? openDocuments[0];
setClosedOpenDocumentIds((current) => new Set([...current, documentId]));
if (target.mode === activeMode && nextDocument?.id !== target.id) {
switchMode(nextDocument.mode, setActiveMode);
}
}
function togglePinnedOpenDocument(documentId: string) {
setPinnedOpenDocumentIds((current) => {
const next = new Set(current);
if (next.has(documentId)) {
next.delete(documentId);
} else {
next.add(documentId);
}
return next;
});
setClosedOpenDocumentIds((current) => {
const next = new Set(current);
next.delete(documentId);
return next;
});
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isEditableTarget =
target?.tagName === "INPUT" ||
target?.tagName === "TEXTAREA" ||
target?.tagName === "SELECT" ||
target?.isContentEditable;
if (event.altKey && !event.ctrlKey && !event.metaKey) {
if (event.key === "1") {
event.preventDefault();
setIsLeftPanelOpen((current) => !current);
} else if (event.key === "2") {
event.preventDefault();
setIsRightPanelOpen((current) => !current);
} else if (event.key === "3") {
event.preventDefault();
setIsBottomPanelOpen((current) => !current);
}
}
if (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === "F5") {
event.preventDefault();
setIsBottomPanelOpen(true);
setCheckStatus(language === "ru" ? "Проверка запущена" : "Check started");
return;
}
if (isEditableTarget || event.altKey || event.metaKey || !event.ctrlKey) {
if (event.ctrlKey && !event.altKey && !event.metaKey && event.key.toLowerCase() === "k") {
event.preventDefault();
document.querySelector<HTMLInputElement>("[data-global-search-input]")?.focus();
}
return;
}
if (event.key.toLowerCase() === "k") {
event.preventDefault();
document.querySelector<HTMLInputElement>("[data-global-search-input]")?.focus();
} else if (event.key === "Tab") {
event.preventDefault();
switchOpenDocument(event.shiftKey ? -1 : 1, activeMode, openDocuments, setActiveMode);
} else if (event.key.toLowerCase() === "w") {
event.preventDefault();
const activeDocument = openDocuments.find((document) => document.mode === activeMode);
if (activeDocument) {
closeOpenDocument(activeDocument.id);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeMode, language, openDocuments, pinnedOpenDocumentIds]);
return (
<section className="flex h-[calc(100vh-5.5rem)] min-h-0 flex-col overflow-hidden" data-ide-workspace>
<div className="flex min-h-0 flex-1">
{isLeftPanelOpen ? (
<div
className="min-h-0 min-w-[240px] max-w-[520px] resize-x overflow-auto border-r border-border bg-[#fbfaf0] lg:w-80"
data-left-navigation-panel
ref={leftPanelRef}
style={leftPanelWidth ? { width: leftPanelWidth } : undefined}
>
<ObjectTree data={workspaceData} language={language} nodes={treeNodes} selectedObject={selectedObject} title={t.objectTree} />
</div>
) : (
<PanelRail
ariaLabel={language === "ru" ? "Открыть левую панель" : "Open left panel"}
icon={ListTree}
onClick={() => setIsLeftPanelOpen(true)}
/>
)}
<div
className="grid min-h-0 min-w-0 flex-1 border-r border-border"
data-main-workspace
style={{ gridTemplateRows: isBottomPanelOpen ? "minmax(0, 1fr) 240px" : "minmax(0, 1fr)" }}
>
<div className="flex min-h-0 flex-col">
<WorkspaceChrome
isBottomPanelOpen={isBottomPanelOpen}
isLeftPanelOpen={isLeftPanelOpen}
isRightPanelOpen={isRightPanelOpen}
checkStatus={checkStatus}
language={language}
onToggleBottom={() => setIsBottomPanelOpen((current) => !current)}
onToggleLeft={() => setIsLeftPanelOpen((current) => !current)}
onToggleRight={() => setIsRightPanelOpen((current) => !current)}
/>
<div className="min-h-0 flex-1">
<WorkspaceModePanel
activeMode={activeMode}
data={workspaceData}
language={language}
onAuthoringChangeApplied={registerAuthoringChange}
selectedObject={selectedObject}
selectedMetadataNode={selectedMetadataNode}
setActiveMode={setActiveMode}
/>
</div>
</div>
{isBottomPanelOpen ? <BottomWorkbenchPanel data={workspaceData} language={language} problems={problems} /> : null}
</div>
{isRightPanelOpen ? (
<div className="grid min-h-0 w-[360px] shrink-0 grid-rows-[auto_auto_minmax(0,1fr)_240px]" data-right-context-inspector>
<InspectorPanel data={workspaceData} language={language} selectedMetadataNode={selectedMetadataNode} selectedObject={selectedObject} />
<KnowledgeLearningPanel data={workspaceData} language={language} />
<DiffPanel data={workspaceData} language={language} />
<HistoryPanel data={workspaceData} language={language} onAuthoringChangeApplied={registerAuthoringChange} />
</div>
) : (
<PanelRail
ariaLabel={language === "ru" ? "Открыть правую панель" : "Open right panel"}
icon={PanelRight}
onClick={() => setIsRightPanelOpen(true)}
/>
)}
</div>
<div className="relative z-10 flex min-h-11 shrink-0 items-center gap-2 overflow-x-auto border-t border-border bg-card px-3" data-open-objects-bar>
<div className="mr-1 hidden shrink-0 items-center gap-2 text-xs font-medium uppercase text-muted-foreground md:flex">
<History className="h-3.5 w-3.5" aria-hidden="true" />
{t.openWindows}
</div>
{openDocuments.map((document) => {
const isPinned = pinnedOpenDocumentIds.has(document.id);
const isActive = activeMode === document.mode;
return (
<div
className={[
"flex h-8 shrink-0 items-center border border-border text-xs font-medium transition-colors",
isActive ? "bg-primary text-primary-foreground" : "bg-background text-foreground hover:bg-muted"
].join(" ")}
data-open-document-tab={document.id}
key={document.id}
>
<button
aria-pressed={isActive}
className="flex h-full min-w-0 items-center gap-2 px-2"
data-open-document={document.id}
data-open-document-mode={document.mode}
onClick={() => switchMode(document.mode, setActiveMode)}
type="button"
>
<document.icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="max-w-40 truncate">{document.label}</span>
<span className="hidden text-[10px] opacity-75 xl:inline">{document.meta}</span>
</button>
<button
aria-label={isPinned ? `${document.label}: открепить` : `${document.label}: закрепить`}
aria-pressed={isPinned}
className={["flex h-full w-6 items-center justify-center border-l border-border/70", isPinned ? "text-current" : "text-current/55"].join(" ")}
data-open-document-pin={document.id}
onClick={() => togglePinnedOpenDocument(document.id)}
type="button"
>
<Pin className="h-3 w-3" aria-hidden="true" />
</button>
<button
aria-label={`${document.label}: закрыть`}
className="flex h-full w-6 items-center justify-center border-l border-border/70 text-current/70 disabled:cursor-not-allowed disabled:opacity-35"
data-open-document-close={document.id}
disabled={isPinned || openDocuments.length <= 1}
onClick={() => closeOpenDocument(document.id)}
type="button"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</div>
);
})}
<span className="ml-auto hidden shrink-0 text-xs text-muted-foreground lg:inline">
{routineName ?? t.bslEditor}
</span>
</div>
</section>
);
}
function WorkspaceChrome({
checkStatus,
isBottomPanelOpen,
isLeftPanelOpen,
isRightPanelOpen,
language,
onToggleBottom,
onToggleLeft,
onToggleRight
}: Readonly<{
checkStatus: string | null;
isBottomPanelOpen: boolean;
isLeftPanelOpen: boolean;
isRightPanelOpen: boolean;
language: UiLanguage;
onToggleBottom: () => void;
onToggleLeft: () => void;
onToggleRight: () => void;
}>) {
return (
<div className="flex h-8 shrink-0 items-center gap-1 border-b border-border bg-card px-2" data-ide-panel-controls>
<IconToggle
active={isLeftPanelOpen}
ariaLabel={language === "ru" ? "Левая панель" : "Left panel"}
icon={ListTree}
onClick={onToggleLeft}
/>
<IconToggle
active={isRightPanelOpen}
ariaLabel={language === "ru" ? "Правая панель" : "Right panel"}
icon={PanelRight}
onClick={onToggleRight}
/>
<IconToggle
active={isBottomPanelOpen}
ariaLabel={language === "ru" ? "Нижняя панель" : "Bottom panel"}
icon={PanelBottom}
onClick={onToggleBottom}
/>
{checkStatus ? (
<div className="ml-auto text-[11px] font-medium text-success" data-run-check-status>{checkStatus}</div>
) : (
<div className="ml-auto text-[11px] text-muted-foreground">Alt+1 Alt+2 Alt+3</div>
)}
</div>
);
}
function IconToggle({
active,
ariaLabel,
icon: Icon,
onClick
}: Readonly<{ active: boolean; ariaLabel: string; icon: React.ComponentType<{ className?: string }>; onClick: () => void }>) {
return (
<button
aria-label={ariaLabel}
aria-pressed={active}
className={[
"flex h-6 w-6 items-center justify-center border border-border text-muted-foreground hover:bg-muted hover:text-foreground",
active ? "bg-background text-foreground" : "bg-muted/50"
].join(" ")}
onClick={onClick}
type="button"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
}
function PanelRail({
ariaLabel,
icon: Icon,
onClick
}: Readonly<{ ariaLabel: string; icon: React.ComponentType<{ className?: string }>; onClick: () => void }>) {
return (
<button
aria-label={ariaLabel}
data-panel-rail
className="flex w-8 shrink-0 items-start justify-center border-r border-border bg-card pt-2 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={onClick}
type="button"
>
<Icon className="h-4 w-4" aria-hidden="true" />
</button>
);
}
function switchOpenDocument(
direction: -1 | 1,
activeMode: WorkspaceMode,
openDocuments: Array<{ mode: WorkspaceMode }>,
setActiveMode: (mode: WorkspaceMode) => void
) {
if (openDocuments.length === 0) {
return;
}
const currentIndex = Math.max(0, openDocuments.findIndex((document) => document.mode === activeMode));
const nextIndex = (currentIndex + direction + openDocuments.length) % openDocuments.length;
switchMode(openDocuments[nextIndex].mode, setActiveMode);
}
function openObjectsStorageKey(projectId: string) {
return `sfera.open-objects.${projectId}`;
}
function workspacePanelStorageKey(projectId: string) {
return `sfera.workspace-panels.${projectId}`;
}
function normalizeWorkspaceMode(value: string | undefined): WorkspaceMode {
const allowedModes = new Set<WorkspaceMode>([
"overview",
"module",
"form",
"properties",
"events",
"flowchart",
"versions",
"documentation",
"knowledge",
"learning"
]);
return allowedModes.has(value as WorkspaceMode) ? (value as WorkspaceMode) : "module";
}
function switchMode(nextMode: WorkspaceMode, setActiveMode: (mode: WorkspaceMode) => void) {
setActiveMode(nextMode);
const url = new URL(window.location.href);
url.searchParams.set("mode", nextMode);
window.history.replaceState(null, "", url);
}
function buildOpenDocuments(root: MetadataTreeNode | undefined, language: UiLanguage) {
const t = messages[language];
const nodes = flattenMetadataTree(root, 6);
const fallbackDocuments = [
{ id: "overview", mode: "overview" as WorkspaceMode, label: t.overview, meta: t.projectWorkspace, icon: FileText },
{ id: "flowchart", mode: "flowchart" as WorkspaceMode, label: language === "ru" ? "Блок-схема" : "Flowchart", meta: language === "ru" ? "Связи конфигурации" : "Configuration links", icon: GitCompareArrows },
{ id: "module", mode: "module" as WorkspaceMode, label: t.moduleMode, meta: t.bslEditor, icon: Code2 },
{ id: "form", mode: "form" as WorkspaceMode, label: t.formMode, meta: t.formDesigner, icon: Layers3 },
{ id: "task", mode: "knowledge" as WorkspaceMode, label: "TASK-123", meta: t.activeTask, icon: Bot }
];
if (nodes.length === 0) {
return fallbackDocuments;
}
const documents = nodes.map((node) => ({
id: node.id,
mode: modeForMetadataNode(node),
label: node.label,
meta: node.kind,
icon: iconForMetadataNode(node)
}));
const existingModes = new Set(documents.map((document) => document.mode));
return [
...documents,
...fallbackDocuments.filter((document) => !existingModes.has(document.mode))
].slice(0, 6);
}
function flattenMetadataTree(root: MetadataTreeNode | undefined, limit: number) {
const result: MetadataTreeNode[] = [];
const visit = (node: MetadataTreeNode) => {
if (result.length >= limit) {
return;
}
if (node.qualified_name && node.kind !== "CONFIGURATOR" && node.kind !== "COMMON") {
result.push(node);
}
for (const child of node.children) {
visit(child);
if (result.length >= limit) {
return;
}
}
};
if (root) {
visit(root);
}
return result;
}
function findMetadataTreeNode(root: MetadataTreeNode | undefined, routineName?: string) {
if (!root || !routineName) {
return undefined;
}
const normalizedRoutine = routineName.toLocaleLowerCase("ru-RU");
const stack = [root];
while (stack.length > 0) {
const node = stack.pop()!;
const qualifiedName = node.qualified_name?.toLocaleLowerCase("ru-RU");
if (node.label.toLocaleLowerCase("ru-RU") === normalizedRoutine || qualifiedName === normalizedRoutine) {
return node;
}
stack.push(...node.children);
}
return undefined;
}
function childLabelsByName(node: MetadataTreeNode | undefined, groupName: string) {
const group = node?.children.find((child) => child.label === groupName);
return group?.children.map((child) => child.label) ?? [];
}
function childGroupCountByName(node: MetadataTreeNode | undefined, groupName: string) {
return node?.children.find((child) => child.label === groupName)?.count ?? 0;
}
function modeForMetadataNode(node: MetadataTreeNode): WorkspaceMode {
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("flowchart") || value.includes("блок-схем")) {
return "flowchart";
}
return "module";
}
function iconForMetadataNode(node: MetadataTreeNode): React.ComponentType<{ className?: string }> {
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
if (value.includes("form") || value.includes("форма")) {
return Layers3;
}
if (value.includes("register") || value.includes("регистр")) {
return DatabaseIcon;
}
if (value.includes("event") || value.includes("событ")) {
return Braces;
}
if (value.includes("flowchart") || value.includes("блок-схем")) {
return GitCompareArrows;
}
if (value.includes("knowledge") || value.includes("знан")) {
return BrainIcon;
}
return FileCode2;
}
function ObjectTree({
data,
language,
title,
nodes,
selectedObject
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
title: string;
nodes: SirNode[];
selectedObject?: SirNode;
}>) {
const t = messages[language];
const projectId = data.projectId;
const [query, setQuery] = useState("");
const [treeFilter, setTreeFilter] = useState<"all" | "metadata" | "sfera" | "environments">("all");
const normalizedQuery = normalizeTreeSearch(query);
const commonSectionIcons = Object.fromEntries(commonMetadataSections.map((item) => [item.label, item.iconKind]));
const commonSections = (data.metadataCatalog?.common_branch_children ?? commonMetadataSections.map((item) => item.label)).map((label) => ({
label,
iconKind: commonSectionIcons[label] ?? kindForTreeLabel(label)
}));
const metadataTypes = data.metadataCatalog?.types.filter((spec) => !["COMMON", "COMMON_MODULE", "EXTENSION"].includes(spec.code)) ?? fallbackMetadataTypeSpecs;
const filteredNodes = filterSirNodesForTree(nodes, normalizedQuery);
const filteredCommonSections = commonSections.filter((item) => matchesTreeSearch(item.label, normalizedQuery));
const filteredMetadataTypes = metadataTypes.filter((spec) => {
const specNodes = filteredNodes.filter((node) => nodeMatchesMetadataSpec(node, spec));
return matchesTreeSearch(`${spec.tree_branch} ${spec.russian_name} ${spec.code}`, normalizedQuery) || specNodes.length > 0;
});
const showMetadataTree = treeFilter === "all" || treeFilter === "metadata";
const showSferaTree = treeFilter === "all" || treeFilter === "sfera";
const showEnvironmentTree = treeFilter === "all" || treeFilter === "environments";
const sferaSections = [t.aiHandlers, t.semanticRules, t.reviewPolicies, t.knowledgeBindings, t.agentCommands, t.rollbackTemplates].filter((item) => matchesTreeSearch(item, normalizedQuery));
const sferaWorkspaces = ["Задачи", "Проверки", "Версии и изменения", "Инциденты", "Runtime", "Знания", "Документация", "Паттерны", "Релизы", "Агенты"].filter((item) => matchesTreeSearch(item, normalizedQuery));
const environments = ["Dev", "Test", "Stage", "Prod"].filter((item) => matchesTreeSearch(item, normalizedQuery));
const useServerTree = Boolean(data.metadataTree && (nodes.length === 0 || (data.snapshot?.node_count ?? 0) > 20000));
if (useServerTree && data.metadataTree) {
return (
<aside className="min-h-0 overflow-hidden bg-[#fbfaf0]">
<LazyMetadataTree
apiUrl="/api/sfera"
className="h-full"
language={language}
projectId={projectId}
root={data.metadataTree.root}
selectedNode={data.selectedMetadataNode}
title={title}
/>
</aside>
);
}
return (
<aside className="min-h-0 overflow-hidden bg-[#fbfaf0]">
<div className="border-b border-border bg-[#efeddc] px-2 py-2">
<div className="flex items-center gap-2 px-1">
<ListTree className="h-4 w-4 text-primary" aria-hidden="true" />
<h3 className="text-sm font-semibold">{title}</h3>
</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" data-fallback-tree-search>
<Search className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<input
className="min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
data-fallback-tree-search-input
onChange={(event) => setQuery(event.target.value)}
placeholder={language === "ru" ? "Поиск в дереве" : "Search tree"}
value={query}
/>
<Filter className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
</label>
<div className="mt-2 grid grid-cols-4 gap-1" data-fallback-tree-filters>
{[
{ id: "all", label: language === "ru" ? "Все" : "All" },
{ id: "metadata", label: "1C" },
{ id: "sfera", label: "SFERA" },
{ id: "environments", label: language === "ru" ? "Среды" : "Env" }
].map((item) => (
<button
aria-pressed={treeFilter === item.id}
className={[
"h-7 border border-border px-1 text-xs font-medium hover:bg-card",
treeFilter === item.id ? "bg-primary text-primary-foreground" : "bg-background text-muted-foreground"
].join(" ")}
data-fallback-tree-filter={item.id}
key={item.id}
onClick={() => setTreeFilter(item.id as typeof treeFilter)}
type="button"
>
{item.label}
</button>
))}
</div>
</div>
<div className="h-[calc(100%-132px)] overflow-auto px-1 py-2" data-fallback-tree-scroll>
<EditorTreeGroup title={t.sferaProjectTree}>
<EditorTreeBranch defaultOpen iconKind="tree" storageScope={projectId} title={`Проект: ${projectId}`}>
{showMetadataTree ? (
<>
<ConfigurationLikeRoot
commonSections={filteredCommonSections}
language={language}
metadataTypes={filteredMetadataTypes}
nodes={filteredNodes}
projectId={projectId}
selectedObject={selectedObject}
title="Основная конфигурация"
/>
<ConfigurationLikeRoot
commonSections={filteredCommonSections}
language={language}
metadataTypes={filteredMetadataTypes}
nodes={[]}
projectId={projectId}
title="Расширение: <Имя>"
/>
</>
) : null}
{showSferaTree ? (
<EditorTreeBranch iconKind="service" storageScope={projectId} title="SFERA">
{sferaSections.map((item) => (
<EditorTreeLeaf iconKind="service" key={item} label={item} />
))}
{sferaWorkspaces.map((item) => (
<EditorTreeBranch iconKind={kindForTreeLabel(item)} key={item} storageScope={projectId} title={item}>
<EditorTreeLeaf iconKind={kindForTreeLabel(item)} label={item === "Задачи" ? "Активная задача" : "Обзор"} />
<EditorTreeLeaf iconKind={item === "Знания" ? "service" : "journal"} label={item === "Знания" ? "Покрытие знаний" : "История"} />
</EditorTreeBranch>
))}
</EditorTreeBranch>
) : null}
{showEnvironmentTree ? (
<EditorTreeBranch iconKind="service" storageScope={projectId} title="Среды">
{environments.map((item) => (
<EditorTreeLeaf iconKind="service" key={item} label={item} />
))}
</EditorTreeBranch>
) : null}
</EditorTreeBranch>
</EditorTreeGroup>
{filteredNodes.length > 0 && treeFilter !== "sfera" && treeFilter !== "environments" ? (
<EditorTreeGroup title={t.sirObjects}>
{filteredNodes.map((node) => (
<EditorTreeLink
active={node.lineage_id === selectedObject?.lineage_id}
href={editorHref(language, projectId, "module", node.name)}
iconKind={kindForSirNode(node.kind)}
key={node.lineage_id}
label={node.name}
meta={node.kind}
/>
))}
</EditorTreeGroup>
) : null}
</div>
</aside>
);
}
function normalizeTreeSearch(value: string) {
return value.trim().toLocaleLowerCase("ru-RU");
}
function matchesTreeSearch(value: string, query: string) {
return query.length === 0 || value.toLocaleLowerCase("ru-RU").includes(query);
}
function filterSirNodesForTree(nodes: SirNode[], query: string) {
if (!query) {
return nodes;
}
return nodes.filter((node) => matchesTreeSearch(`${node.name} ${node.kind} ${node.qualified_name}`, query));
}
function ConfigurationLikeRoot({
commonSections,
language,
metadataTypes,
nodes,
projectId,
selectedObject,
title
}: Readonly<{
commonSections: Array<{ label: string; iconKind: OneCIconKind }>;
language: UiLanguage;
metadataTypes: MetadataTypeSpec[];
nodes: SirNode[];
projectId: string;
selectedObject?: SirNode;
title: string;
}>) {
return (
<EditorTreeBranch defaultOpen={title === "Основная конфигурация"} iconKind="tree" storageScope={projectId} title={title}>
<EditorTreeLeaf iconKind="settings" label="Сведения" />
<EditorTreeBranch defaultOpen iconKind="common" storageScope={projectId} title="Общие">
{commonSections.map((item) => (
<CommonMetadataSection
key={item.label}
iconKind={item.iconKind}
language={language}
nodes={nodes}
projectId={projectId}
title={item.label}
/>
))}
</EditorTreeBranch>
{metadataTypes.map((spec) => (
<MetadataTypeBranch
key={spec.code}
language={language}
nodes={nodes}
projectId={projectId}
selectedObject={selectedObject}
spec={spec}
/>
))}
</EditorTreeBranch>
);
}
function ServerMetadataTreeNode({
language,
node,
projectId
}: Readonly<{
language: UiLanguage;
node: MetadataTreeNode;
projectId: string;
}>) {
const iconKind = isOneCIconKind(node.icon) ? node.icon : kindForTreeLabel(node.label);
const title = node.count > 0 ? `${node.label} (${node.count})` : node.label;
if (node.children.length === 0) {
return (
<EditorTreeLeaf
href={editorHref(language, projectId, modeForTreeGroup(node.label), node.qualified_name ?? node.label)}
iconKind={iconKind}
label={title}
/>
);
}
return (
<EditorTreeBranch defaultOpen={node.kind === "CONFIGURATOR" || node.kind === "COMMON"} iconKind={iconKind} storageScope={projectId} title={title}>
{node.children.map((child) => (
<ServerMetadataTreeNode key={child.id} language={language} node={child} projectId={projectId} />
))}
</EditorTreeBranch>
);
}
function CommonMetadataSection({
iconKind,
language,
nodes,
projectId,
title
}: Readonly<{
iconKind: OneCIconKind;
language: UiLanguage;
nodes: SirNode[];
projectId: string;
title: string;
}>) {
const sectionNodes = nodes.filter((node) => nodeMatchesCommonSection(node, title));
if (sectionNodes.length === 0) {
return <EditorTreeLeaf href={editorHref(language, projectId, "module", title)} iconKind={iconKind} label={title} />;
}
return (
<EditorTreeBranch iconKind={iconKind} storageScope={projectId} title={title}>
{sectionNodes.map((node) => (
<EditorTreeLink
active={false}
href={editorHref(language, projectId, modeForTreeGroup(title), node.name)}
iconKind={kindForSirNode(node.kind)}
key={node.lineage_id}
label={node.name}
meta={node.kind}
/>
))}
</EditorTreeBranch>
);
}
function MetadataTypeBranch({
language,
nodes,
projectId,
selectedObject,
spec
}: Readonly<{
language: UiLanguage;
nodes: SirNode[];
projectId: string;
selectedObject?: SirNode;
spec: MetadataTypeSpec;
}>) {
const objectNodes = nodes.filter((node) => nodeMatchesMetadataSpec(node, spec));
const iconKind = iconKindForMetadataSpec(spec);
const propertySummary = spec.properties?.slice(0, 5).join(", ");
const actionSummary = spec.context_actions?.slice(0, 4).join(", ");
return (
<EditorTreeBranch defaultOpen={objectNodes.length > 0} iconKind={iconKind} storageScope={projectId} title={spec.tree_branch}>
{propertySummary ? (
<div className="px-7 py-1 text-[11px] leading-4 text-muted-foreground" title={spec.properties?.join(", ")}>
Свойства: {propertySummary}
</div>
) : null}
{actionSummary ? (
<div className="px-7 pb-1 text-[11px] leading-4 text-muted-foreground" title={spec.context_actions?.join(", ")}>
Действия: {actionSummary}
</div>
) : null}
{objectNodes.map((node) => (
<EditorTreeBranch
defaultOpen={node.lineage_id === selectedObject?.lineage_id}
iconKind={iconKind}
key={node.lineage_id}
storageScope={projectId}
title={node.name}
>
{[...spec.child_groups, ...spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания"].map((group) => (
<EditorTreeLeaf
href={editorHref(language, projectId, modeForTreeGroup(group), group)}
iconKind={kindForTreeLabel(group)}
key={`${node.lineage_id}.${group}`}
label={group}
/>
))}
</EditorTreeBranch>
))}
</EditorTreeBranch>
);
}
function editorHref(language: UiLanguage, projectId: string, mode: WorkspaceMode, routine: string) {
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=${mode}&routine=${encodeURIComponent(routine)}`;
}
const fallbackMetadataTypeSpecs: MetadataTypeSpec[] = [
{ code: "CONSTANT", russian_name: "Константа", tree_branch: "Константы", icon: "constant", child_groups: ["Формы", "Команды", "Права"], module_kinds: [] },
{ code: "CATALOG", russian_name: "Справочник", tree_branch: "Справочники", icon: "catalog", child_groups: ["Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Права", "Предопределенные данные"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "DOCUMENT", russian_name: "Документ", tree_branch: "Документы", icon: "document", child_groups: ["Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Права", "Движения", "Последовательности", "Нумераторы"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "DOCUMENT_JOURNAL", russian_name: "Журнал документов", tree_branch: "Журналы документов", icon: "journal", child_groups: ["Графы", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль менеджера"] },
{ code: "ENUM", russian_name: "Перечисление", tree_branch: "Перечисления", icon: "enum", child_groups: ["Значения", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль менеджера"] },
{ code: "REPORT", russian_name: "Отчет", tree_branch: "Отчеты", icon: "report", child_groups: ["Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Табличные документы", "СКД", "Варианты отчета", "Настройки", "Хранилище вариантов", "Хранилище настроек", "Справка", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "DATA_PROCESSOR", russian_name: "Обработка", tree_branch: "Обработки", icon: "processing", child_groups: ["Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "CHART_OF_CHARACTERISTIC_TYPES", russian_name: "План видов характеристик", tree_branch: "Планы видов характеристик", icon: "plan", child_groups: ["Реквизиты", "Табличные части", "Формы", "Команды", "Макеты", "Права", "Предопределенные данные"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "CHART_OF_ACCOUNTS", russian_name: "План счетов", tree_branch: "Планы счетов", icon: "plan", child_groups: ["Признаки учета", "Признаки учета субконто", "Табличные части", "Формы", "Команды", "Макеты", "Права", "Предопределенные данные"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "CHART_OF_CALCULATION_TYPES", russian_name: "План видов расчета", tree_branch: "Планы видов расчета", icon: "plan", child_groups: ["Реквизиты", "Табличные части", "Вытесняющие виды расчета", "Ведущие виды расчета", "Базовые виды расчета", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "INFORMATION_REGISTER", russian_name: "Регистр сведений", tree_branch: "Регистры сведений", icon: "register", child_groups: ["Измерения", "Ресурсы", "Реквизиты", "Формы", "Команды", "Макеты", "Кто пишет", "Кто читает", "Права"], module_kinds: ["Модуль набора записей", "Модуль менеджера"] },
{ code: "ACCUMULATION_REGISTER", russian_name: "Регистр накопления", tree_branch: "Регистры накопления", icon: "register", child_groups: ["Измерения", "Ресурсы", "Реквизиты", "Формы", "Команды", "Макеты", "Кто пишет", "Кто читает", "Права"], module_kinds: ["Модуль набора записей", "Модуль менеджера"] },
{ code: "ACCOUNTING_REGISTER", russian_name: "Регистр бухгалтерии", tree_branch: "Регистры бухгалтерии", icon: "register", child_groups: ["Измерения", "Ресурсы", "Реквизиты", "Признаки учета", "Признаки учета субконто", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль набора записей", "Модуль менеджера"] },
{ code: "CALCULATION_REGISTER", russian_name: "Регистр расчета", tree_branch: "Регистры расчета", icon: "register", child_groups: ["Измерения", "Ресурсы", "Реквизиты", "Перерасчеты", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль набора записей", "Модуль менеджера"] },
{ code: "BUSINESS_PROCESS", russian_name: "Бизнес-процесс", tree_branch: "Бизнес-процессы", icon: "business-process", child_groups: ["Реквизиты", "Табличные части", "Карта маршрута", "Точки маршрута", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "TASK", russian_name: "Задача 1С", tree_branch: "Задачи", icon: "task", child_groups: ["Реквизиты", "Табличные части", "Адресация", "Формы", "Команды", "Макеты", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "EXCHANGE_PLAN", russian_name: "План обмена", tree_branch: "Планы обмена", icon: "exchange-plan", child_groups: ["Реквизиты", "Команды", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "EVENT_SUBSCRIPTION", russian_name: "Подписка на событие", tree_branch: "Подписки на события", icon: "event", child_groups: ["События"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "SCHEDULED_JOB", russian_name: "Регламентное задание", tree_branch: "Регламентные задания", icon: "scheduled-job", child_groups: ["Команды", "Права"], module_kinds: ["Модуль объекта", "Модуль менеджера"] },
{ code: "EXTERNAL_DATA_SOURCE", russian_name: "Внешний источник данных", tree_branch: "Внешние источники данных", icon: "external-source", child_groups: ["Таблицы", "Кубы", "Функции", "Формы", "Команды", "Макеты"], module_kinds: [] },
{ code: "WEB_SERVICE", russian_name: "Web-сервис", tree_branch: "Web-сервисы", icon: "service", child_groups: ["Операции", "Параметры", "Модуль"], module_kinds: [] },
{ code: "HTTP_SERVICE", russian_name: "HTTP-сервис", tree_branch: "HTTP-сервисы", icon: "service", child_groups: ["Шаблоны URL", "Методы", "Модуль"], module_kinds: [] },
{ code: "WS_REFERENCE", russian_name: "WS-ссылка", tree_branch: "WS-ссылки", icon: "service", child_groups: ["Операции", "Параметры"], module_kinds: [] },
{ code: "WEBSOCKET_CLIENT", russian_name: "WebSocket-клиент", tree_branch: "WebSocket-клиенты", icon: "service", child_groups: ["Модуль"], module_kinds: [] },
{ code: "INTEGRATION_SERVICE", russian_name: "Сервис интеграции", tree_branch: "Сервисы интеграции", icon: "service", child_groups: ["Каналы", "Сообщения", "Модуль"], module_kinds: [] },
{ code: "BOT", russian_name: "Бот", tree_branch: "Боты", icon: "service", child_groups: ["Команды", "Модуль"], module_kinds: [] }
];
const commonMetadataSections: Array<{ label: string; iconKind: OneCIconKind }> = [
{ label: "Подсистемы", iconKind: "subsystem" },
{ label: "Общие модули", iconKind: "module" },
{ label: "Параметры сеанса", iconKind: "settings" },
{ label: "Роли", iconKind: "role" },
{ label: "Общие реквизиты", iconKind: "attribute" },
{ label: "Планы обмена", iconKind: "exchange-plan" },
{ label: "Критерии отбора", iconKind: "settings" },
{ label: "Подписки на события", iconKind: "event" },
{ label: "Регламентные задания", iconKind: "scheduled-job" },
{ label: "Боты", iconKind: "service" },
{ label: "Функциональные опции", iconKind: "settings" },
{ label: "Параметры функциональных опций", iconKind: "settings" },
{ label: "Определяемые типы", iconKind: "plan" },
{ label: "Хранилища настроек", iconKind: "settings" },
{ label: "Общие команды", iconKind: "command" },
{ label: "Группы команд", iconKind: "command" },
{ label: "Общие формы", iconKind: "form" },
{ label: "Общие макеты", iconKind: "layout" },
{ label: "Общие картинки", iconKind: "layout" },
{ label: "XDTO-пакеты", iconKind: "integration" },
{ label: "Web-сервисы", iconKind: "web" },
{ label: "HTTP-сервисы", iconKind: "web" },
{ label: "WS-ссылки", iconKind: "web" },
{ label: "WebSocket-клиенты", iconKind: "web" },
{ label: "Сервисы интеграции", iconKind: "integration" },
{ label: "Цвета палитры", iconKind: "palette" },
{ label: "Элементы стиля", iconKind: "palette" },
{ label: "Стили", iconKind: "palette" },
{ label: "Языки", iconKind: "language" }
];
function iconKindForMetadataSpec(spec: MetadataTypeSpec): OneCIconKind {
return isOneCIconKind(spec.icon) ? spec.icon : kindForTreeLabel(spec.tree_branch);
}
function isOneCIconKind(value: string): value is OneCIconKind {
return value in oneCIconFiles;
}
function modeForTreeGroup(group: string): WorkspaceMode {
const value = group.toLowerCase();
if (value.includes("форм")) return "form";
if (value.includes("событ")) return "events";
if (value.includes("верс")) return "versions";
if (value.includes("знан")) return "knowledge";
return "module";
}
function nodeMatchesCommonSection(node: SirNode, sectionTitle: string): boolean {
const kind = node.kind.toUpperCase();
const qualifiedName = normalizeQualifiedName(node.qualified_name);
if (sectionTitle === "Общие модули") return kind === "COMMON_MODULE" || qualifiedName.startsWith("общиймодуль.");
if (sectionTitle === "Роли") return kind === "ROLE" || qualifiedName.startsWith("роль.");
if (sectionTitle === "Планы обмена") return kind === "EXCHANGE_PLAN" || qualifiedName.startsWith("планобмена.");
if (sectionTitle === "Подписки на события") return kind === "EVENT_SUBSCRIPTION" || qualifiedName.startsWith("подписканасобытие.");
if (sectionTitle === "Регламентные задания") return kind === "SCHEDULED_JOB" || qualifiedName.startsWith("регламентноезадание.");
if (sectionTitle === "Общие формы") return kind === "FORM" && qualifiedName.startsWith("общаяформа.");
if (sectionTitle === "Общие команды") return kind === "COMMAND" && qualifiedName.startsWith("общаякоманда.");
if (sectionTitle === "HTTP-сервисы") return qualifiedName.startsWith("httpсервис.") || qualifiedName.startsWith("http-сервис.");
if (sectionTitle === "Web-сервисы") return qualifiedName.startsWith("webсервис.") || qualifiedName.startsWith("web-сервис.");
if (sectionTitle === "WS-ссылки") return qualifiedName.startsWith("wsссылка.") || qualifiedName.startsWith("ws-ссылка.");
return false;
}
function nodeMatchesMetadataSpec(node: SirNode, spec: MetadataTypeSpec): boolean {
const kind = node.kind.toUpperCase();
const qualifiedName = normalizeQualifiedName(node.qualified_name);
const topLevel = isTopLevelMetadataQualifiedName(qualifiedName);
switch (spec.code) {
case "CONSTANT":
return topLevel && qualifiedName.startsWith("константа.");
case "CATALOG":
return kind === "CATALOG" || (topLevel && qualifiedName.startsWith("справочник."));
case "DOCUMENT":
return kind === "DOCUMENT" || (topLevel && qualifiedName.startsWith("документ."));
case "DOCUMENT_JOURNAL":
return topLevel && qualifiedName.startsWith("журналдокументов.");
case "ENUM":
return topLevel && qualifiedName.startsWith("перечисление.");
case "REPORT":
return topLevel && (qualifiedName.startsWith("отчет.") || qualifiedName.startsWith("отчёт."));
case "DATA_PROCESSOR":
return topLevel && qualifiedName.startsWith("обработка.");
case "CHART_OF_CHARACTERISTIC_TYPES":
return topLevel && qualifiedName.startsWith("планвидовхарактеристик.");
case "CHART_OF_ACCOUNTS":
return topLevel && qualifiedName.startsWith("плансчетов.");
case "CHART_OF_CALCULATION_TYPES":
return topLevel && (qualifiedName.startsWith("планвидоврасчета.") || qualifiedName.startsWith("планвидоврасчёта."));
case "INFORMATION_REGISTER":
return topLevel && qualifiedName.startsWith("регистрсведений.");
case "ACCUMULATION_REGISTER":
return topLevel && qualifiedName.startsWith("регистрнакопления.");
case "ACCOUNTING_REGISTER":
return topLevel && qualifiedName.startsWith("регистрбухгалтерии.");
case "CALCULATION_REGISTER":
return topLevel && (qualifiedName.startsWith("регистррасчета.") || qualifiedName.startsWith("регистррасчёта."));
case "BUSINESS_PROCESS":
return kind === "BUSINESS_PROCESS" || (topLevel && qualifiedName.startsWith("бизнеспроцесс."));
case "TASK":
return kind === "TASK" || (topLevel && qualifiedName.startsWith("задача."));
case "EXTERNAL_DATA_SOURCE":
return topLevel && qualifiedName.startsWith("внешнийисточникданных.");
case "EXCHANGE_PLAN":
return kind === "EXCHANGE_PLAN" || topLevel && qualifiedName.startsWith("планобмена.");
case "EVENT_SUBSCRIPTION":
return kind === "EVENT_SUBSCRIPTION" || topLevel && qualifiedName.startsWith("подписканасобытие.");
case "SCHEDULED_JOB":
return kind === "SCHEDULED_JOB" || topLevel && qualifiedName.startsWith("регламентноезадание.");
default:
return false;
}
}
function normalizeQualifiedName(value: string) {
return value.toLowerCase().replace(/\s+/g, "");
}
function isTopLevelMetadataQualifiedName(value: string) {
return (value.match(/\./g)?.length ?? 0) === 1;
}
function kindForTreeLabel(label: string): OneCIconKind {
const value = label.toLowerCase();
if (value.includes("справочник")) return "catalog";
if (value.includes("документ") && !value.includes("журнал")) return "document";
if (value.includes("журнал")) return "journal";
if (value.includes("перечислен")) return "enum";
if (value.includes("отчет") || value.includes("отчёт")) return "report";
if (value.includes("обработ")) return "processing";
if (value.includes("событ")) return "event";
if (value.includes("обмен") || value.includes("обмена")) return "exchange-plan";
if (value.includes("регистр")) return "register";
if (value.includes("план")) return "plan";
if (value.includes("бизнес")) return "business-process";
if (value.includes("задач")) return "task";
if (value.includes("источник")) return "external-source";
if (value.includes("реквизит") || value.includes("измерен") || value.includes("ресурс")) return "attribute";
if (value.includes("таблич")) return "tabular";
if (value.includes("форм")) return "form";
if (value.includes("команд")) return "command";
if (value.includes("макет")) return "layout";
if (value.includes("модул")) return "module";
if (value.includes("прав") || value.includes("рол")) return "role";
if (value.includes("http") || value.includes("web") || value.includes("ws-")) return "web";
if (value.includes("интеграц")) return "integration";
if (value.includes("регламент")) return "scheduled-job";
if (value.includes("констант")) return "constant";
if (value.includes("язык")) return "language";
if (value.includes("стил") || value.includes("палитр")) return "palette";
if (value.includes("настрой") || value.includes("параметр") || value.includes("индекс")) return "settings";
return "common";
}
function kindForSirNode(kind: string): OneCIconKind {
const value = kind.toUpperCase();
if (value.includes("EXCHANGE")) return "exchange-plan";
if (value.includes("DOCUMENT")) return "document";
if (value.includes("CATALOG")) return "catalog";
if (value.includes("REGISTER")) return "register";
if (value.includes("REPORT")) return "report";
if (value.includes("PROCESSING")) return "processing";
if (value.includes("FORM")) return "form";
if (value.includes("COMMAND")) return "command";
if (value.includes("ATTRIBUTE")) return "attribute";
if (value.includes("TABULAR")) return "tabular";
if (value.includes("EVENT")) return "event";
if (value.includes("SCHEDULED_JOB")) return "scheduled-job";
if (value.includes("MODULE") || value.includes("PROCEDURE") || value.includes("FUNCTION")) return "module";
return "common";
}
function EditorTreeGroup({ title, children }: Readonly<{ title: string; children: React.ReactNode }>) {
return (
<div className="mb-4">
<div className="px-2 py-1 text-xs font-semibold uppercase text-muted-foreground">{title}</div>
<div>{children}</div>
</div>
);
}
function EditorTreeBranch({
children,
defaultOpen = false,
icon: Icon = ListTree,
iconKind,
storageScope,
title
}: Readonly<{
children: React.ReactNode;
defaultOpen?: boolean;
icon?: React.ComponentType<{ className?: string }>;
iconKind?: OneCIconKind;
storageScope?: string;
title: string;
}>) {
const storageKey = `sfera.editor-tree.branch.${storageScope ? `${storageScope}.` : ""}${title}`;
const [open, setOpen] = useState(() => {
if (typeof window === "undefined") {
return defaultOpen;
}
const saved = window.localStorage.getItem(storageKey);
return saved ? saved === "true" : defaultOpen;
});
useEffect(() => {
const saved = window.localStorage.getItem(storageKey);
setOpen(saved ? saved === "true" : defaultOpen);
}, [defaultOpen, storageKey]);
useEffect(() => {
window.localStorage.setItem(storageKey, String(open));
}, [open, storageKey]);
return (
<details className="group/tree" onToggle={(event) => setOpen(event.currentTarget.open)} open={open}>
<summary className="flex min-h-6 cursor-pointer list-none items-center gap-1.5 px-1.5 text-sm font-medium text-foreground hover:bg-[#e9f1ff]">
<Braces className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition group-open/tree:rotate-90" aria-hidden="true" />
{iconKind ? <OneCTreeIcon kind={iconKind} /> : <Icon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />}
<span className="truncate">{title}</span>
</summary>
<div className="ml-4 border-l border-border/70 pl-1">{children}</div>
</details>
);
}
function OneCTreeIcon({ kind }: Readonly<{ kind: OneCIconKind }>) {
const icon = oneCIconFiles[kind] ?? oneCIconFiles.common;
return (
<img
aria-hidden="true"
className="h-4 w-4 shrink-0 object-contain"
src={`/icons/1c-metadata/light/${icon}`}
alt=""
loading="lazy"
/>
);
}
function EditorTreeLeaf({ href = "#", iconKind = "attribute", label }: Readonly<{ href?: string; iconKind?: OneCIconKind; label: string }>) {
return (
<a className="flex min-h-6 items-center gap-1.5 px-1.5 text-sm text-muted-foreground hover:bg-[#e9f1ff] hover:text-foreground" href={href}>
<OneCTreeIcon kind={iconKind} />
<span className="truncate">{label}</span>
</a>
);
}
function EditorTreeLink({
active,
href,
iconKind,
label,
meta
}: Readonly<{
active: boolean;
href: string;
iconKind: OneCIconKind;
label: string;
meta: string;
}>) {
return (
<a className={["flex min-h-7 items-center gap-1.5 px-1.5 text-sm", active ? "bg-[#d8e9ff] text-primary" : "text-foreground hover:bg-[#e9f1ff]"].join(" ")} href={href}>
<OneCTreeIcon kind={iconKind} />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{label}</span>
<span className="block truncate text-xs text-muted-foreground">{meta}</span>
</span>
</a>
);
}
function WorkspaceModePanel({
activeMode,
data,
language,
onAuthoringChangeApplied,
selectedObject,
selectedMetadataNode,
setActiveMode
}: Readonly<{
activeMode: WorkspaceMode;
data: ProjectWorkspaceData;
language: UiLanguage;
onAuthoringChangeApplied: (change: AuthoringChange, version?: ProjectVersion) => void;
selectedObject?: SirNode;
selectedMetadataNode?: MetadataTreeNode;
setActiveMode: (mode: WorkspaceMode) => void;
}>) {
return (
<div className="grid h-full min-h-0 grid-rows-[42px_minmax(0,1fr)]">
<ObjectInternalTabs activeMode={activeMode} language={language} setActiveMode={setActiveMode} />
<WorkspaceModeBody
activeMode={activeMode}
data={data}
language={language}
onAuthoringChangeApplied={onAuthoringChangeApplied}
selectedObject={selectedObject}
selectedMetadataNode={selectedMetadataNode}
setActiveMode={setActiveMode}
/>
</div>
);
}
function WorkspaceModeBody({
activeMode,
data,
language,
onAuthoringChangeApplied,
selectedObject,
selectedMetadataNode,
setActiveMode
}: Readonly<{
activeMode: WorkspaceMode;
data: ProjectWorkspaceData;
language: UiLanguage;
onAuthoringChangeApplied: (change: AuthoringChange, version?: ProjectVersion) => void;
selectedObject?: SirNode;
selectedMetadataNode?: MetadataTreeNode;
setActiveMode: (mode: WorkspaceMode) => void;
}>) {
if (activeMode === "overview") {
return <ObjectOverviewPanel data={data} language={language} selectedMetadataNode={selectedMetadataNode} selectedObject={selectedObject} />;
}
if (activeMode === "form") {
return <FormDesignerPanel data={data} language={language} modeId={activeMode} />;
}
if (activeMode === "properties" || activeMode === "events") {
return (
<MetadataDesignerPanel
activeMode={activeMode}
data={data}
language={language}
onAuthoringChangeApplied={onAuthoringChangeApplied}
selectedMetadataNode={selectedMetadataNode}
selectedObject={selectedObject}
/>
);
}
if (activeMode === "versions") {
return <VersionsModePanel data={data} language={language} selectedObject={selectedObject} />;
}
if (activeMode === "flowchart") {
return <FlowchartPanel data={data} language={language} selectedMetadataNode={selectedMetadataNode} selectedObject={selectedObject} />;
}
if (activeMode === "documentation" || activeMode === "knowledge" || activeMode === "learning") {
return <KnowledgeModePanel activeMode={activeMode} data={data} language={language} selectedObject={selectedObject} />;
}
return (
<EditorPanel
data={data}
language={language}
onAuthoringChangeApplied={onAuthoringChangeApplied}
selectedMetadataNode={selectedMetadataNode}
selectedObject={selectedObject}
setActiveMode={setActiveMode}
/>
);
}
function ObjectInternalTabs({
activeMode,
language,
setActiveMode
}: Readonly<{
activeMode: WorkspaceMode;
language: UiLanguage;
setActiveMode: (mode: WorkspaceMode) => void;
}>) {
const t = messages[language];
const tabs: Array<{ label: string; mode: WorkspaceMode }> = [
{ label: t.objectOverview, mode: "overview" },
{ label: t.objectAttributes, mode: "properties" },
{ label: t.forms, mode: "form" },
{ label: t.moduleMode, mode: "module" },
{ label: language === "ru" ? "Блок-схема" : "Flowchart", mode: "flowchart" },
{ label: t.permissionState, mode: "properties" },
{ label: t.versionsMode, mode: "versions" },
{ label: t.authoringHistory, mode: "versions" }
];
return (
<div className="flex items-center gap-1 overflow-x-auto border-b border-border bg-card px-2">
{tabs.map((tab) => (
<button
className={[
"h-7 shrink-0 border-b-2 px-2 text-xs font-medium",
activeMode === tab.mode ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
].join(" ")}
key={tab.label}
onClick={() => switchMode(tab.mode, setActiveMode)}
type="button"
>
{tab.label}
</button>
))}
</div>
);
}
function ObjectOverviewPanel({
data,
language,
selectedMetadataNode,
selectedObject
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
selectedMetadataNode?: MetadataTreeNode;
selectedObject?: SirNode;
}>) {
const t = messages[language];
const context = data.authoringPreview?.context;
const selectedName = context?.object?.qualified_name ?? selectedObject?.qualified_name ?? selectedMetadataNode?.qualified_name ?? selectedMetadataNode?.label ?? t.none;
const selectedForms = data.selectedObjectUi?.forms ?? [];
const formRows = selectedForms.length > 0 ? selectedForms.map((item) => item.form.name) : childLabelsByName(selectedMetadataNode, "Формы");
const formCount = childGroupCountByName(selectedMetadataNode, "Формы");
const visibleFormRows = formRows.length > 0 ? formRows : formCount > 0 ? [`${formCount} ${language === "ru" ? "форм, загружаются при раскрытии" : "forms, load on expand"}`] : [];
const schema = data.selectedObjectSchema;
const impact = data.selectedObjectImpact;
const commandRows = impact?.commands.map((node) => node.name) ?? context?.commands.map((node) => node.name) ?? childLabelsByName(selectedMetadataNode, "Команды");
const formElementCount = selectedForms.reduce((total, item) => total + item.elements.length, 0);
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode="overview">
<PanelTitle icon={FileCode2} title={t.objectOverview} />
<div className="grid h-[calc(100%-45px)] min-h-0 gap-0 overflow-auto lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4 p-4">
<div>
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.selectedObject}</div>
<h3 className="mt-2 text-lg font-semibold">{selectedName}</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{t.objectOverviewDescription}</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
<OverviewMetric label={t.objectAttributes} value={String(schema?.attributes.length ?? context?.object_attributes.length ?? 0)} />
<OverviewMetric label={t.tabularSections} value={String(schema?.tabular_sections.length ?? context?.tabular_sections.length ?? 0)} />
<OverviewMetric label={t.formElements} value={String(formElementCount || context?.form_elements.length || 0)} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<OverviewList title={t.objectAttributes} rows={(schema?.attributes ?? context?.object_attributes ?? []).slice(0, 12).map((node) => node.name)} />
<OverviewList title={t.tabularSections} rows={(schema?.tabular_sections ?? []).slice(0, 10).map((section) => `${section.tabular_section.name}${section.columns.length ? ` (${section.columns.length})` : ""}`)} />
<OverviewList title={t.forms} rows={visibleFormRows} />
<OverviewList title={t.commands} rows={commandRows} />
<OverviewList title={t.calls} rows={impact?.routines.slice(0, 8).map((node) => node.qualified_name) ?? context?.available_methods.slice(0, 5) ?? []} />
<OverviewList title={t.writes} rows={impact?.writes.map((node) => node.qualified_name) ?? context?.writes.map((node) => node.qualified_name) ?? []} />
</div>
</div>
<div className="border-l border-border bg-muted/20 p-4">
<OverviewList title={t.riskContext} rows={[`${t.reviewFindings}: ${context?.review_findings.length ?? 0}`, `${t.runtimeIncidents}: 0`, `${t.activeTask}: ${t.none}`, `${t.knowledgeCoverage}: ${data.coverage?.covered_count ?? 0}`]} />
<div className="mt-4">
<OverviewList title={language === "ru" ? "Impact" : "Impact"} rows={[
`${t.forms}: ${impact?.forms.length ?? selectedForms.length}`,
`${t.commands}: ${impact?.commands.length ?? commandRows.length}`,
`${language === "ru" ? "Роли" : "Roles"}: ${impact?.roles.length ?? 0}`,
`${language === "ru" ? "Регламентные задания" : "Jobs"}: ${impact?.jobs.length ?? 0}`
]} />
</div>
<div className="mt-4">
<OverviewList title={t.versionsMode} rows={[data.semanticDiffPreview?.version_preview?.current_version_id ?? t.none, data.semanticDiffPreview?.version_preview?.next_version_id ?? t.workspaceApplyReady]} />
</div>
</div>
</div>
</Card>
);
}
function OverviewMetric({ label, value }: Readonly<{ label: string; value: string }>) {
return (
<div className="border border-border bg-card p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold">{value}</div>
</div>
);
}
function FlowchartPanel({
data,
language,
selectedMetadataNode,
selectedObject
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
selectedMetadataNode?: MetadataTreeNode;
selectedObject?: SirNode;
}>) {
const [chart, setChart] = useState<ProjectFlowchart | null>(data.flowchart);
const [selectedKind, setSelectedKind] = useState<string | null>(null);
const [loadingFocus, setLoadingFocus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedQualifiedName = selectedMetadataNode?.qualified_name ?? selectedObject?.qualified_name ?? null;
const nodesById = useMemo(() => new Map((chart?.nodes ?? []).map((node) => [node.id, node])), [chart?.nodes]);
const kindObjects = useMemo(() => {
if (!selectedKind) {
return [];
}
return (data.exportSnapshot?.nodes ?? [])
.filter((node) => node.kind === selectedKind)
.sort((left, right) => left.qualified_name.localeCompare(right.qualified_name, "ru"))
.slice(0, 80);
}, [data.exportSnapshot?.nodes, selectedKind]);
useEffect(() => {
setChart(data.flowchart);
setSelectedKind(null);
setError(null);
}, [data.flowchart, data.projectId]);
async function loadFocus(focus: string | null) {
setError(null);
setLoadingFocus(focus ?? "overview");
try {
const next = await getProjectFlowchart(data.projectId, {
apiUrl: data.apiUrl,
focus,
depth: focus ? 2 : undefined,
limit: focus ? 120 : 80
});
setChart(next);
if (!focus) {
setSelectedKind(null);
}
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Не удалось загрузить блок-схему");
} finally {
setLoadingFocus(null);
}
}
function handleNodeClick(node: FlowchartNode) {
if (node.id.startsWith("kind:")) {
setSelectedKind(node.kind);
return;
}
void loadFocus(node.id);
}
const selectedKindLabel = selectedKind ? flowchartKindLabel(selectedKind, language) : null;
const activeNode = chart?.focus ? nodesById.get(chart.focus) : null;
const positions = chart ? flowchartPositions(chart.nodes) : new Map<string, { x: number; y: number }>();
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode="flowchart">
<PanelTitle icon={GitCompareArrows} title={language === "ru" ? "Блок-схема конфигурации" : "Configuration flowchart"} />
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[280px_minmax(0,1fr)_320px]">
<aside className="min-h-0 overflow-auto border-r border-border bg-muted/20 p-3">
<div className="grid grid-cols-2 gap-2">
<FlowchartActionButton active={chart?.mode === "overview"} disabled={loadingFocus !== null} label={language === "ru" ? "Укрупненно" : "Overview"} onClick={() => void loadFocus(null)} />
<FlowchartActionButton disabled={!selectedQualifiedName || loadingFocus !== null} label={language === "ru" ? "Текущий" : "Current"} onClick={() => void loadFocus(selectedQualifiedName)} />
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<OverviewMetric label={language === "ru" ? "Узлов" : "Nodes"} value={String(chart?.total_nodes ?? 0)} />
<OverviewMetric label={language === "ru" ? "Связей" : "Edges"} value={String(chart?.total_edges ?? 0)} />
</div>
<div className="mt-4 text-xs font-semibold uppercase text-muted-foreground">
{chart?.mode === "overview" ? (language === "ru" ? "Крупные блоки" : "Blocks") : (language === "ru" ? "Фокус" : "Focus")}
</div>
<div className="mt-2 space-y-2">
{(chart?.nodes ?? []).slice(0, 28).map((node) => (
<button
className={[
"w-full border border-border bg-background p-2 text-left text-xs hover:border-primary",
node.id === chart?.focus || node.kind === selectedKind ? "border-primary bg-primary/10" : ""
].join(" ")}
key={node.id}
onClick={() => handleNodeClick(node)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{node.label}</span>
<Badge>{node.count > 1 ? node.count : node.kind}</Badge>
</div>
<div className="mt-1 truncate text-muted-foreground">{node.qualified_name ?? flowchartKindLabel(node.kind, language)}</div>
</button>
))}
</div>
</aside>
<main className="relative min-h-0 overflow-auto bg-[#f7f8fb]">
{loadingFocus ? (
<div className="absolute right-4 top-4 z-20 rounded border border-border bg-background px-3 py-2 text-sm shadow-sm">
{language === "ru" ? "Загрузка схемы..." : "Loading chart..."}
</div>
) : null}
{error ? (
<div className="absolute left-4 right-4 top-4 z-20 rounded border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="relative min-h-[680px] min-w-[920px]">
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none" viewBox="0 0 1000 680">
<defs>
<marker id="flowchart-arrow" markerHeight="7" markerWidth="7" orient="auto" refX="6" refY="3.5">
<path d="M0,0 L7,3.5 L0,7 Z" fill="#64748b" />
</marker>
</defs>
{(chart?.edges ?? []).map((edge) => {
const source = positions.get(edge.source);
const target = positions.get(edge.target);
if (!source || !target) {
return null;
}
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
return (
<g key={edge.id}>
<line markerEnd="url(#flowchart-arrow)" stroke="#64748b" strokeWidth={edge.count > 1 ? 2 : 1.4} x1={source.x} x2={target.x} y1={source.y} y2={target.y} />
<text fill="#475569" fontSize="11" textAnchor="middle" x={midX} y={midY - 5}>
{edge.count > 1 ? `${edge.label} ${edge.count}` : edge.label}
</text>
</g>
);
})}
</svg>
{(chart?.nodes ?? []).map((node) => {
const position = positions.get(node.id) ?? { x: 80, y: 80 };
return (
<button
className={[
"absolute w-44 -translate-x-1/2 -translate-y-1/2 border bg-background p-3 text-left text-xs shadow-sm transition hover:border-primary hover:shadow-md",
node.id === chart?.focus ? "border-primary ring-2 ring-primary/20" : "border-border"
].join(" ")}
key={node.id}
onClick={() => handleNodeClick(node)}
style={{ left: `${position.x / 10}%`, top: `${position.y / 6.8}%` }}
type="button"
>
<div className="flex items-center gap-2">
<OneCTreeIcon kind={iconKindForFlowchart(node.kind)} />
<span className="min-w-0 truncate font-semibold">{node.label}</span>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{flowchartKindLabel(node.kind, language)}</span>
<span>{node.count > 1 ? node.count : node.expandable ? "..." : ""}</span>
</div>
</button>
);
})}
</div>
</main>
<aside className="min-h-0 overflow-auto border-l border-border bg-background p-3">
<div className="text-sm font-semibold">{language === "ru" ? "Детализация" : "Details"}</div>
<div className="mt-2 text-xs leading-5 text-muted-foreground">
{chart?.mode === "overview"
? language === "ru" ? "Нажмите крупный блок, затем выберите объект. Для объекта схема подгрузит соседние связи." : "Select a block, then an object. Object focus loads nearby links."
: activeNode?.qualified_name ?? activeNode?.label ?? ""}
</div>
{activeNode?.qualified_name ? (
<a className="mt-3 block border border-border bg-muted px-3 py-2 text-center text-sm font-medium hover:border-primary" href={editorHref(language, data.projectId, modeForFlowchartNode(activeNode), activeNode.qualified_name)}>
{language === "ru" ? "Открыть объект" : "Open object"}
</a>
) : null}
{selectedKindLabel ? (
<div className="mt-4">
<div className="text-xs font-semibold uppercase text-muted-foreground">{selectedKindLabel}</div>
<div className="mt-2 max-h-64 space-y-1 overflow-auto">
{kindObjects.length > 0 ? kindObjects.map((node) => (
<button className="w-full truncate border border-border px-2 py-1.5 text-left text-xs hover:border-primary" key={node.lineage_id} onClick={() => void loadFocus(node.lineage_id)} type="button">
{node.qualified_name}
</button>
)) : (
<div className="border border-border px-2 py-2 text-xs text-muted-foreground">
{language === "ru" ? "Список объектов доступен после полной загрузки снимка. Выберите объект в дереве слева." : "Object list is available when snapshot export is loaded. Select an object in the left tree."}
</div>
)}
</div>
</div>
) : null}
<div className="mt-4">
<div className="text-xs font-semibold uppercase text-muted-foreground">{language === "ru" ? "Связи" : "Links"}</div>
<div className="mt-2 space-y-2">
{(chart?.edges ?? []).slice(0, 40).map((edge) => {
const source = nodesById.get(edge.source);
const target = nodesById.get(edge.target);
return (
<button className="w-full border border-border p-2 text-left text-xs hover:border-primary" key={edge.id} onClick={() => void loadFocus(edge.target)} type="button">
<div className="truncate font-medium">{source?.label ?? edge.source} -&gt; {target?.label ?? edge.target}</div>
<div className="mt-1 text-muted-foreground">{edge.count > 1 ? `${edge.label}: ${edge.count}` : edge.label}</div>
</button>
);
})}
</div>
</div>
</aside>
</div>
</Card>
);
}
function FlowchartActionButton({ active = false, disabled, label, onClick }: Readonly<{ active?: boolean; disabled?: boolean; label: string; onClick: () => void }>) {
return (
<button
className={["h-8 border px-2 text-xs font-medium", active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background hover:bg-muted"].join(" ")}
disabled={disabled}
onClick={onClick}
type="button"
>
{label}
</button>
);
}
function flowchartPositions(nodes: ProjectFlowchart["nodes"]) {
const positions = new Map<string, { x: number; y: number }>();
if (nodes.length === 0) {
return positions;
}
const overview = nodes.some((node) => node.id.startsWith("kind:"));
const levels = new Map<number, ProjectFlowchart["nodes"]>();
nodes.forEach((node, index) => {
const level = overview ? Math.floor(index / 4) : node.level;
const current = levels.get(level) ?? [];
current.push(node);
levels.set(level, current);
});
const sortedLevels = [...levels.entries()].sort(([left], [right]) => left - right);
sortedLevels.forEach(([level, levelNodes], levelIndex) => {
const columns = overview ? Math.min(4, Math.max(1, levelNodes.length)) : 1;
levelNodes.forEach((node, index) => {
positions.set(node.id, overview ? {
x: 150 + (index % columns) * 230,
y: 90 + level * 150
} : {
x: 180 + levelIndex * 260,
y: 90 + index * Math.max(84, Math.min(150, 520 / Math.max(1, levelNodes.length - 1 || 1)))
});
});
});
return positions;
}
function flowchartKindLabel(kind: string, language: UiLanguage) {
const ru: Record<string, string> = {
CATALOG: "Справочник",
DOCUMENT: "Документ",
COMMON_MODULE: "Общий модуль",
MODULE: "Модуль",
PROCEDURE: "Процедура",
FUNCTION: "Функция",
FORM: "Форма",
COMMAND: "Команда",
ROLE: "Роль",
ATTRIBUTE: "Реквизит",
TABULAR_SECTION: "Табличная часть",
QUERY: "Запрос",
TABLE: "Таблица",
REGISTER: "Регистр",
REPORT: "Отчет",
DATA_PROCESSOR: "Обработка",
HTTP_SERVICE: "HTTP-сервис",
INTEGRATION_ENDPOINT: "Интеграция",
SCHEDULED_JOB: "Регламентное задание",
EXTENSION: "Расширение"
};
return language === "ru" ? ru[kind] ?? kind : kind.replaceAll("_", " ").toLowerCase();
}
function iconKindForFlowchart(kind: string): OneCIconKind {
if (kind.includes("DOCUMENT")) return "document";
if (kind.includes("CATALOG")) return "catalog";
if (kind.includes("MODULE") || kind === "PROCEDURE" || kind === "FUNCTION" || kind === "QUERY") return "module";
if (kind.includes("FORM")) return "form";
if (kind.includes("COMMAND")) return "command";
if (kind.includes("ROLE")) return "role";
if (kind.includes("REPORT")) return "report";
if (kind.includes("REGISTER")) return "register";
if (kind.includes("HTTP") || kind.includes("INTEGRATION")) return "web";
if (kind.includes("TABULAR")) return "tabular";
if (kind.includes("ATTRIBUTE")) return "attribute";
return "tree";
}
function modeForFlowchartNode(node: { kind: string }): WorkspaceMode {
if (node.kind === "FORM") return "form";
if (node.kind === "ROLE" || node.kind === "ATTRIBUTE" || node.kind === "TABULAR_SECTION") return "properties";
return "module";
}
function OverviewList({ rows, title }: Readonly<{ rows: string[]; title: string }>) {
return (
<div className="border border-border bg-card p-3">
<div className="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
<div className="mt-2 space-y-1">
{rows.length === 0 ? (
<div className="text-sm text-muted-foreground">нет</div>
) : (
rows.map((row) => <div className="truncate text-sm" key={row}>{row}</div>)
)}
</div>
</div>
);
}
function EditorPanel({
data,
language,
onAuthoringChangeApplied,
selectedMetadataNode,
selectedObject,
setActiveMode
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
onAuthoringChangeApplied: (change: AuthoringChange, version?: ProjectVersion) => void;
selectedMetadataNode?: MetadataTreeNode;
selectedObject?: SirNode;
setActiveMode: (mode: WorkspaceMode) => void;
}>) {
const t = messages[language];
const initialProposedText = data.editorSourceText ?? data.editorProposedText ?? sampleCode.join("\n");
const [proposedText, setProposedText] = useState(initialProposedText);
const [semanticDiffPreview, setSemanticDiffPreview] = useState(data.semanticDiffPreview);
const [isRefreshingPreview, setIsRefreshingPreview] = useState(false);
const sourceText = data.editorSourceText && data.editorSourceText.trim() ? data.editorSourceText : sampleCode.join("\n");
const monacoValue = proposedText || sourceText;
const isRealSourceLoaded = Boolean(data.editorSourceText && !data.editorSourceText.startsWith("// Код модуля не загружен."));
const hasEditorChanges = normalizeEditorText(proposedText) !== normalizeEditorText(sourceText);
const applyEnabled = semanticDiffPreview?.version_preview?.apply_available ?? false;
const activeGuardChecks = semanticDiffPreview?.checks ?? data.authoringPreview?.checks ?? [];
const blockedGuardChecks = getBlockingGuardChecks(activeGuardChecks);
const firstBlockedGuardCheck = blockedGuardChecks[0] ?? null;
const isGuardBlocked = blockedGuardChecks.length > 0;
const canApplyPayload = Boolean(data.editorSelectedObject && data.editorSourceText && proposedText && hasEditorChanges);
const navigationTarget = data.authoringPreview?.context.routine ?? data.authoringPreview?.context.object ?? selectedObject ?? null;
const initialSymbolQuery = data.authoringPreview?.context.routine?.name ?? selectedMetadataNode?.label ?? selectedObject?.name ?? "Проведение";
const [applyState, setApplyState] = useState<"idle" | "pending" | "applied" | "error">("idle");
const [applyMessage, setApplyMessage] = useState("");
const [symbolQuery, setSymbolQuery] = useState(initialSymbolQuery);
const [symbolResults, setSymbolResults] = useState<SymbolResult[]>([]);
const [symbolReferences, setSymbolReferences] = useState<SymbolReferences | null>(null);
const [symbolDefinition, setSymbolDefinition] = useState<SymbolResult | null>(null);
const [symbolState, setSymbolState] = useState<"idle" | "loading" | "error">("idle");
const [symbolMessage, setSymbolMessage] = useState("");
const isApplying = applyState === "pending";
const applyLabel =
applyState === "pending"
? t.applying
: applyState === "applied"
? t.savedToSfera
: t.applyToSfera;
const activeSymbol = symbolResults[0] ?? (navigationTarget ? { node: navigationTarget, source: sourceLocationFromNode(selectedObject) } : null);
const routineName = data.authoringPreview?.context.routine?.name ?? data.editorSelectedRoutine ?? "Procedure";
const guardClauseSnippet = `Процедура ${routineName}\n Если ЗначениеЗаполнено(${routineName}) Тогда\n Возврат;\n КонецЕсли;\nКонецПроцедуры`;
const extractProcedureSnippet = `&НаКлиенте\nПроцедура ExtractedProcedure()\n // TODO: перенести логику в новую процедуру\nКонецПроцедуры`;
const knowledgeLinkSnippet = `// TODO [Knowledge]: ссылку на профильный шаблон/регламент добавить здесь`;
useEffect(() => {
setProposedText(data.editorSourceText ?? data.editorProposedText ?? sampleCode.join("\n"));
}, [data.editorProposedText, data.editorSourceText, data.editorSelectedObject, data.editorSelectedRoutine]);
useEffect(() => {
setSemanticDiffPreview(data.semanticDiffPreview);
}, [data.semanticDiffPreview]);
useEffect(() => {
if (!hasEditorChanges || !isRealSourceLoaded) {
return;
}
const timeout = window.setTimeout(() => {
void refreshSemanticDiff(proposedText);
}, 700);
return () => window.clearTimeout(timeout);
}, [hasEditorChanges, isRealSourceLoaded, proposedText]);
async function refreshSemanticDiff(nextProposedText: string) {
const objectName = data.editorSelectedObject ?? data.authoringPreview?.context.object?.name ?? selectedMetadataNode?.label ?? null;
if (!objectName || !data.editorSourceText || !nextProposedText) {
setSemanticDiffPreview(null);
return;
}
setIsRefreshingPreview(true);
try {
const response = await getAuthoringSemanticDiffPreview(
data.projectId,
{
object_name: objectName,
routine_name: data.editorSelectedRoutine ?? data.authoringPreview?.context.routine?.name ?? null,
original_text: data.editorSourceText,
proposed_text: nextProposedText,
...(await ensureAuthoringSession(data.projectId, data.apiUrl))
},
data.apiUrl
);
setSemanticDiffPreview(response);
} catch {
setSemanticDiffPreview(null);
} finally {
setIsRefreshingPreview(false);
}
}
function setSuggestedText(next: string) {
setProposedText((current) => {
const nextProposedText = `${current.trimEnd()}\n\n${next}`;
void refreshSemanticDiff(nextProposedText);
return nextProposedText;
});
}
function applyInsertGuardClause() {
setSuggestedText(guardClauseSnippet);
}
function applyExtractProcedure() {
setSuggestedText(extractProcedureSnippet);
}
function applyAddKnowledgeLink() {
setSuggestedText(knowledgeLinkSnippet);
}
function openSemanticDiff() {
setActiveMode("versions");
}
function openKnowledgePanel() {
setActiveMode("knowledge");
}
function openDocumentationPanel() {
setActiveMode("documentation");
}
async function handleSymbolSearch() {
const query = symbolQuery.trim();
if (!query) {
setSymbolResults([]);
setSymbolReferences(null);
setSymbolDefinition(null);
setSymbolMessage(language === "ru" ? "Введите имя символа." : "Enter a symbol name.");
return;
}
setSymbolState("loading");
setSymbolMessage("");
try {
const results = await searchProjectSymbols(data.projectId, query, { apiUrl: data.apiUrl, limit: 12 });
setSymbolResults(results);
setSymbolReferences(null);
setSymbolDefinition(null);
setSymbolState("idle");
setSymbolMessage(results.length === 0 ? t.emptyList : `${t.search}: ${results.length}`);
} catch (error) {
setSymbolState("error");
setSymbolMessage(error instanceof Error ? error.message : language === "ru" ? "Не удалось выполнить поиск символов." : "Symbol search failed.");
}
}
async function handleDefinition(lineageId = activeSymbol?.node.lineage_id) {
if (!lineageId) {
setSymbolMessage(language === "ru" ? "Нет выбранного символа." : "No selected symbol.");
return;
}
setSymbolState("loading");
setSymbolMessage("");
try {
const definition = await getProjectSymbolDefinition(data.projectId, lineageId, data.apiUrl);
setSymbolDefinition(definition);
setSymbolState("idle");
setSymbolMessage(`${language === "ru" ? "Определение" : "Definition"}: ${formatSourceLocation(definition.source, language)}`);
} catch (error) {
setSymbolState("error");
setSymbolMessage(error instanceof Error ? error.message : language === "ru" ? "Не удалось открыть определение." : "Definition lookup failed.");
}
}
async function handleReferences(lineageId = activeSymbol?.node.lineage_id) {
if (!lineageId) {
setSymbolMessage(language === "ru" ? "Нет выбранного символа." : "No selected symbol.");
return;
}
setSymbolState("loading");
setSymbolMessage("");
try {
const references = await getProjectSymbolReferences(data.projectId, lineageId, { apiUrl: data.apiUrl, direction: "incoming" });
setSymbolReferences(references);
setSymbolState("idle");
setSymbolMessage(`${t.referencesPanel}: ${references.references.length}`);
} catch (error) {
setSymbolState("error");
setSymbolMessage(error instanceof Error ? error.message : language === "ru" ? "Не удалось найти использования." : "References lookup failed.");
}
}
async function handleApplyToSfera() {
const versionPreview = semanticDiffPreview?.version_preview;
if (isGuardBlocked) {
setApplyState("error");
setApplyMessage(firstBlockedGuardCheck?.message ?? t.blocked);
return;
}
if (!versionPreview?.apply_available || !canApplyPayload) {
setApplyState("error");
setApplyMessage(t.previewRequired);
return;
}
setApplyState("pending");
setApplyMessage("");
try {
const apiUrl =
data.apiUrl ??
(typeof window === "undefined" ? resolveApiUrl() : resolveApiUrl(window.location.host));
const authoringSession = await ensureAuthoringSession(data.projectId, apiUrl);
const response = await applyAuthoringChangeSet(
data.projectId,
{
object_name: data.editorSelectedObject ?? data.authoringPreview?.context.object?.name ?? selectedMetadataNode?.label ?? null,
routine_name: data.editorSelectedRoutine ?? data.authoringPreview?.context.routine?.name ?? null,
original_text: data.editorSourceText ?? sourceText,
proposed_text: proposedText ?? sourceText,
task_id: authoringSession.task_id,
session_id: authoringSession.session_id,
user_id: authoringSession.user_id,
estimated_tokens: 2550,
expected_next_version_id: versionPreview.next_version_id,
approved_by: authoringSession.user_id,
approval_note: t.guardedApplyNote,
apply_to_production: false
},
apiUrl
);
onAuthoringChangeApplied(
{
change_id: response.change_id,
project_id: response.project_id,
status: response.status,
target: response.preview.target,
version_id: response.version.version_id,
approved_by: authoringSession.user_id,
approval_note: t.guardedApplyNote,
task_id: response.version.task_id,
session_id: response.version.session_id,
added_lines: response.preview.added_lines,
removed_lines: response.preview.removed_lines,
production_applied: response.production_applied
},
versionToSummary(response.version)
);
setApplyState("applied");
setApplyMessage(`${formatChangeStatus(response.status, language)}: ${response.version.version_id}`);
} catch (error) {
setApplyState("error");
setApplyMessage(error instanceof Error ? error.message : language === "ru" ? "Ошибка применения." : "Apply failed.");
}
}
return (
<section className="flex h-full min-h-0 flex-col bg-background" data-active-mode="module">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-card px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<Code2 className="h-4 w-4 text-primary" aria-hidden="true" />
<h3 className="truncate text-sm font-semibold">{data.editorModuleName ?? t.bslEditor}</h3>
<Badge tone={isRealSourceLoaded ? "success" : "warning"}>{isRealSourceLoaded ? "BSL" : t.previewRequired}</Badge>
{hasEditorChanges ? <Badge tone="warning">{t.editorDirty}</Badge> : null}
{data.editorSourcePath ? <span className="hidden max-w-[34rem] truncate text-xs text-muted-foreground xl:inline">{data.editorSourcePath}</span> : null}
{symbolMessage ? <span className={["hidden max-w-64 truncate text-xs lg:inline", symbolState === "error" ? "text-destructive" : "text-muted-foreground"].join(" ")}>{symbolMessage}</span> : null}
{applyMessage ? <span className={["hidden max-w-64 truncate text-xs lg:inline", applyState === "error" ? "text-destructive" : "text-success"].join(" ")}>{applyMessage}</span> : null}
</div>
<div className="flex items-center gap-2">
<EditorToolbarButton actionId="symbol-search" disabled={symbolState === "loading"} icon={Search} label={symbolState === "loading" ? t.loading : t.search} onClick={handleSymbolSearch} />
<EditorToolbarButton actionId="go-to-definition" disabled={symbolState === "loading" || !activeSymbol} icon={FileCode2} label={language === "ru" ? "Определение" : "Definition"} onClick={() => handleDefinition()} />
<EditorToolbarButton actionId="find-usages" disabled={symbolState === "loading" || !activeSymbol} icon={ListTree} label={t.findUsages} onClick={() => handleReferences()} />
<EditorToolbarButton icon={GitCompareArrows} label={t.semanticDiff} onClick={openSemanticDiff} />
<EditorToolbarButton icon={Sparkles} label={t.quickFixes} onClick={openKnowledgePanel} />
<EditorToolbarButton icon={FileText} label={t.documentationMode} onClick={openDocumentationPanel} />
<EditorToolbarButton actionId="apply-to-sfera" disabled={!applyEnabled || isGuardBlocked || isApplying || isRefreshingPreview || !canApplyPayload} icon={CheckCircle2} label={applyLabel} onClick={handleApplyToSfera} />
<EditorToolbarButton icon={History} label={t.rollbackPlan} onClick={openSemanticDiff} />
{isGuardBlocked ? (
<Badge tone="danger" title={firstBlockedGuardCheck?.message}>{language === "ru" ? `Блокировки: ${blockedGuardChecks.length}` : `Blocked: ${blockedGuardChecks.length}`}</Badge>
) : (
<Badge tone={canApplyPayload ? "success" : "warning"}>{canApplyPayload ? t.workspaceApplyReady : t.previewRequired}</Badge>
)}
</div>
</div>
<div className="min-h-0 flex-1">
<FastBslEditor
data={data}
onChange={setProposedText}
readOnly={!isRealSourceLoaded}
value={monacoValue}
/>
</div>
<SymbolNavigationPanel
definition={symbolDefinition}
language={language}
message={symbolMessage}
onDefinition={handleDefinition}
onQueryChange={setSymbolQuery}
onReferences={handleReferences}
onSearch={handleSymbolSearch}
query={symbolQuery}
references={symbolReferences}
results={symbolResults}
state={symbolState}
/>
</section>
);
}
function SymbolNavigationPanel({
definition,
language,
message,
onDefinition,
onQueryChange,
onReferences,
onSearch,
query,
references,
results,
state
}: Readonly<{
definition: SymbolResult | null;
language: UiLanguage;
message: string;
onDefinition: (lineageId?: string) => void;
onQueryChange: (value: string) => void;
onReferences: (lineageId?: string) => void;
onSearch: () => void;
query: string;
references: SymbolReferences | null;
results: SymbolResult[];
state: "idle" | "loading" | "error";
}>) {
const t = messages[language];
const referenceRows = references?.references.slice(0, 8) ?? [];
return (
<div className="max-h-56 shrink-0 overflow-auto border-t border-border bg-card p-3" data-symbol-navigation-panel>
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground" htmlFor="symbol-search-input">{t.referencesPanel}</label>
<input
className="h-8 min-w-48 flex-1 border border-border bg-background px-2 text-xs outline-none focus:border-primary"
id="symbol-search-input"
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSearch();
}
}}
placeholder={language === "ru" ? "Найти символ" : "Find symbol"}
value={query}
/>
<ActionButton actionId="symbol-search-panel" disabled={state === "loading"} label={state === "loading" ? t.loading : t.search} onClick={onSearch} />
<Badge tone={state === "error" ? "danger" : referenceRows.length > 0 ? "success" : "neutral"}>{referenceRows.length}</Badge>
</div>
{message ? (
<div className={["mt-2 truncate text-xs", state === "error" ? "text-destructive" : "text-muted-foreground"].join(" ")} data-symbol-message>{message}</div>
) : null}
<div className="mt-3 grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.8fr)]">
<div className="min-w-0 space-y-2">
{results.length === 0 ? (
<div className="text-xs text-muted-foreground">{language === "ru" ? "Выполните поиск символа или откройте использования выбранного объекта." : "Search a symbol or open references for the selected object."}</div>
) : (
results.map((result) => (
<div className="border border-border bg-background p-2" data-symbol-result key={result.node.lineage_id}>
<div className="truncate text-xs font-medium">{result.node.qualified_name}</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground">{result.node.kind} · {formatSourceLocation(result.source, language)}</div>
<div className="mt-2 flex gap-2">
<button className="border border-border px-2 py-1 text-[11px] hover:bg-muted" data-editor-action="symbol-definition-row" onClick={() => onDefinition(result.node.lineage_id)} type="button">
{language === "ru" ? "Определение" : "Definition"}
</button>
<button className="border border-border px-2 py-1 text-[11px] hover:bg-muted" data-editor-action="symbol-references-row" onClick={() => onReferences(result.node.lineage_id)} type="button">
{t.findUsages}
</button>
</div>
</div>
))
)}
</div>
<div className="min-w-0 space-y-2">
{definition ? (
<div className="border border-primary/30 bg-primary/5 p-2 text-xs" data-symbol-definition>
<div className="font-medium">{definition.node.qualified_name}</div>
<div className="mt-1 text-muted-foreground">{formatSourceLocation(definition.source, language)}</div>
</div>
) : null}
{referenceRows.length > 0 ? (
<div className="space-y-1" data-symbol-references>
{referenceRows.map((reference) => (
<div className="border border-border bg-muted/30 p-2 text-xs" key={reference.edge_id}>
<div className="truncate font-medium">{reference.source?.qualified_name ?? reference.target?.qualified_name ?? reference.kind}</div>
<div className="mt-1 truncate text-muted-foreground">{reference.kind} · {reference.direction} · {formatSourceLocation(reference.location, language)}</div>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
);
}
function EditorToolbarButton({
actionId,
disabled = false,
icon: Icon,
label,
onClick
}: Readonly<{
actionId?: string;
disabled?: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick?: () => void;
}>) {
return (
<button className="hidden h-7 items-center gap-1 border border-border bg-background px-2 text-xs font-medium hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 md:flex" data-editor-action={actionId} disabled={disabled} onClick={onClick} type="button">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</button>
);
}
function FastBslEditor({
data,
onChange,
readOnly,
value
}: Readonly<{
data: ProjectWorkspaceData;
onChange: (value: string) => void;
readOnly: boolean;
value: string;
}>) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const highlightRef = useRef<HTMLPreElement | null>(null);
const [completionState, setCompletionState] = useState<{
items: BslCompletionItem[];
left: number;
prefix: string;
receiver: string | null;
replaceStart: number;
replaceEnd: number;
selectedIndex: number;
top: number;
} | null>(null);
const localCompletionItems = useMemo(() => buildLocalBslCompletionItems(data), [data]);
const highlightedLines = useMemo(() => value.split("\n"), [value]);
function syncScroll() {
const textarea = textareaRef.current;
const highlight = highlightRef.current;
if (!textarea || !highlight) {
return;
}
highlight.scrollTop = textarea.scrollTop;
highlight.scrollLeft = textarea.scrollLeft;
}
async function refreshCompletions(nextValue = value, caret = textareaRef.current?.selectionStart ?? 0, force = false) {
const context = getBslCompletionContext(nextValue, caret);
if (!context && !force) {
setCompletionState(null);
return;
}
const normalizedContext = context ?? {
prefix: "",
receiver: null,
replaceStart: caret,
replaceEnd: caret
};
let items = filterCompletionItems(localCompletionItems, normalizedContext.prefix, normalizedContext.receiver);
if (normalizedContext.receiver) {
try {
const remoteItems = await getBslCompletions(data.projectId, {
apiUrl: data.apiUrl,
receiver: normalizedContext.receiver,
q: normalizedContext.prefix,
qualifiedName: data.editorSelectedObject,
limit: 80
});
items = mergeCompletionItems(remoteItems, items);
} catch {
items = filterCompletionItems(localCompletionItems, normalizedContext.prefix, normalizedContext.receiver);
}
}
setCompletionState(
items.length
? {
...normalizedContext,
items,
...completionPopupPosition(nextValue, caret, textareaRef.current),
selectedIndex: 0
}
: null
);
}
function updateValue(nextValue: string) {
onChange(nextValue);
window.requestAnimationFrame(() => {
const caret = textareaRef.current?.selectionStart ?? nextValue.length;
void refreshCompletions(nextValue, caret);
});
}
function insertCompletion(item: BslCompletionItem) {
const textarea = textareaRef.current;
if (!textarea || !completionState) {
return;
}
const insertText = item.insert_text ?? item.label;
const nextValue = `${value.slice(0, completionState.replaceStart)}${insertText}${value.slice(completionState.replaceEnd)}`;
const nextCaret = completionState.replaceStart + insertText.length;
onChange(nextValue);
setCompletionState(null);
window.requestAnimationFrame(() => {
textarea.focus();
textarea.selectionStart = nextCaret;
textarea.selectionEnd = nextCaret;
syncScroll();
});
}
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.ctrlKey && event.code === "Space") {
event.preventDefault();
void refreshCompletions(value, event.currentTarget.selectionStart, true);
return;
}
if (!completionState) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setCompletionState((current) => current ? { ...current, selectedIndex: (current.selectedIndex + 1) % current.items.length } : current);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setCompletionState((current) => current ? { ...current, selectedIndex: (current.selectedIndex - 1 + current.items.length) % current.items.length } : current);
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
insertCompletion(completionState.items[completionState.selectedIndex]);
} else if (event.key === "Escape") {
event.preventDefault();
setCompletionState(null);
}
}
return (
<div className="relative h-full min-h-0 overflow-hidden bg-[#fbfaf7] font-mono text-[13px] leading-5" data-fast-bsl-editor>
<pre
aria-hidden="true"
className="pointer-events-none absolute inset-0 m-0 overflow-auto whitespace-pre px-4 py-3 text-foreground"
ref={highlightRef}
>
{highlightedLines.map((line, index) => (
<span className="block min-h-5" key={`${index}:${line}`}>
{highlightBslLine(line)}
{"\n"}
</span>
))}
</pre>
<textarea
className="absolute inset-0 h-full w-full resize-none overflow-auto border-0 bg-transparent px-4 py-3 font-mono text-[13px] leading-5 text-transparent caret-foreground outline-none selection:bg-primary/20"
onBlur={() => window.setTimeout(() => setCompletionState(null), 150)}
onChange={(event) => updateValue(event.target.value)}
onClick={(event) => void refreshCompletions(value, event.currentTarget.selectionStart)}
onKeyDown={handleKeyDown}
onScroll={syncScroll}
readOnly={readOnly}
ref={textareaRef}
spellCheck={false}
value={value}
wrap="off"
/>
{completionState ? (
<div
className="absolute z-20 max-h-64 w-72 overflow-auto border border-[#8b8b8b] bg-white py-0.5 text-[13px] leading-5 shadow-xl"
data-bsl-completion-popup
style={{ left: completionState.left, top: completionState.top }}
>
{completionState.items.slice(0, 40).map((item, index) => (
<button
className={[
"grid h-6 w-full grid-cols-[34px_minmax(0,1fr)] items-center gap-1 px-1 text-left font-normal",
index === completionState.selectedIndex ? "bg-[#6f99d4] text-white" : "text-black hover:bg-[#e8f0fb]"
].join(" ")}
key={`${item.kind}:${item.label}:${index}`}
onMouseDown={(event) => {
event.preventDefault();
insertCompletion(item);
}}
type="button"
>
<span className={["font-mono text-[15px] font-semibold", index === completionState.selectedIndex ? "text-[#eaffdf]" : completionIconClass(item.kind)].join(" ")}>
{completionIcon(item.kind)}
</span>
<span className="min-w-0 truncate">{item.label}</span>
</button>
))}
</div>
) : null}
</div>
);
}
const BSL_KEYWORDS = new Set([
"процедура", "функция", "конецпроцедуры", "конецфункции", "если", "тогда", "иначе", "иначеесли", "конецесли",
"для", "каждого", "из", "цикл", "конеццикла", "пока", "попытка", "исключение", "конецпопытки", "возврат",
"перем", "экспорт", "знач", "новый", "и", "или", "не", "истина", "ложь", "неопределено", "null",
"procedure", "function", "endprocedure", "endfunction", "if", "then", "else", "elsif", "endif", "for", "each",
"in", "do", "enddo", "while", "try", "except", "endtry", "return", "var", "export", "new", "true", "false",
"undefined"
]);
const BSL_BUILTINS = new Set([
"сообщить", "предупреждение", "вопрос", "значениезаполнено", "типзнч", "строка", "число", "дата", "формат",
"сокрлп", "сокрл", "сокрп", "лев", "прав", "сред", "найти", "стрнайти", "стрзаменить", "стрразделить",
"получить", "вставить", "добавить", "количество", "очистить", "удалить", "установить", "записать",
"прочитать", "выбрать", "найтипокоду", "найтипонаименованию", "создатьэлемент", "создатьнаборзаписей",
"выполнить", "загрузить", "выгрузить", "заполнитьзначениясвойств", "копироватьданныеформы", "новый"
]);
const BSL_GLOBAL_COMPLETIONS: BslCompletionItem[] = [
"Процедура", "Функция", "Если", "Тогда", "Иначе", "КонецЕсли", "Для", "Каждого", "Цикл", "КонецЦикла", "Возврат",
"Попытка", "Исключение", "КонецПопытки", "Перем", "Экспорт", "&НаСервере", "&НаКлиенте", "&НаСервереБезКонтекста",
"&НаКлиентеНаСервереБезКонтекста", "#Область", "#КонецОбласти", "Сообщить()", "Новый", "Запрос", "ТаблицаЗначений",
"Структура", "Соответствие", "Массив", "Справочники", "Документы", "РегистрыСведений", "РегистрыНакопления",
"РегистрыБухгалтерии", "ПланыОбмена", "Перечисления", "Обработки", "Отчеты"
].map((label) => ({
label,
kind: label.startsWith("&") || label.startsWith("#") ? "DIRECTIVE" : BSL_KEYWORDS.has(label.toLocaleLowerCase("ru")) ? "KEYWORD" : "GLOBAL",
insert_text: label
}));
function buildLocalBslCompletionItems(data: ProjectWorkspaceData): BslCompletionItem[] {
const routineItems: BslCompletionItem[] = [];
const moduleName = data.editorModuleName ?? data.editorSelectedObject ?? "";
for (const routine of data.editorModules?.flatMap((module) => module.routines) ?? []) {
routineItems.push({
label: routine.name,
kind: routine.kind === "FUNCTION" ? "FUNCTION" : "PROCEDURE",
detail: `${moduleName}${routine.export ? " · Export" : ""}`,
insert_text: `${routine.name}()`
});
}
const context = data.authoringPreview?.context;
const contextItems: BslCompletionItem[] = [
...(context?.parameters ?? []).map((label) => ({ label, kind: "PARAMETER", insert_text: label })),
...(context?.local_variables ?? []).map((label) => ({ label, kind: "VARIABLE", insert_text: label })),
...(context?.object_attributes ?? []).map((node) => ({ label: node.name, kind: "PROPERTY", detail: node.qualified_name, insert_text: node.name })),
...(context?.tabular_sections ?? []).map((node) => ({ label: node.name, kind: "COLLECTION", detail: node.qualified_name, insert_text: node.name })),
...(context?.form_elements ?? []).map((node) => ({ label: node.name, kind: "FORM", detail: node.qualified_name, insert_text: node.name })),
...(context?.commands ?? []).map((node) => ({ label: node.name, kind: "COMMAND", detail: node.qualified_name, insert_text: node.name })),
...(context?.available_methods ?? []).map((label) => ({ label, kind: "METHOD", insert_text: `${label}()` }))
];
return mergeCompletionItems(contextItems, routineItems, BSL_GLOBAL_COMPLETIONS);
}
function mergeCompletionItems(...groups: BslCompletionItem[][]) {
const unique = new Map<string, BslCompletionItem>();
for (const item of groups.flat()) {
const key = `${item.kind}:${item.label}`.toLocaleLowerCase("ru");
if (!unique.has(key)) {
unique.set(key, item);
}
}
return [...unique.values()];
}
function filterCompletionItems(items: BslCompletionItem[], prefix: string, receiver: string | null) {
const normalizedPrefix = prefix.toLocaleLowerCase("ru");
return items
.filter((item) => !normalizedPrefix || item.label.toLocaleLowerCase("ru").startsWith(normalizedPrefix))
.filter((item) => receiver || item.kind !== "PROPERTY")
.slice(0, 80);
}
function getBslCompletionContext(value: string, caret: number) {
const beforeCaret = value.slice(0, caret);
const match = /([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)?\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)?$|([A-Za-zА-Яа-яЁё_&$#][\wА-Яа-яЁё]*)$/u.exec(beforeCaret);
if (!match) {
return null;
}
if (match[0].includes(".")) {
const receiver = match[1] ?? null;
const prefix = match[2] ?? "";
return {
prefix,
receiver,
replaceStart: caret - prefix.length,
replaceEnd: caret
};
}
const prefix = match[3] ?? "";
if (prefix.length < 2 && !prefix.startsWith("&") && !prefix.startsWith("#")) {
return null;
}
return {
prefix,
receiver: null,
replaceStart: caret - prefix.length,
replaceEnd: caret
};
}
function highlightBslLine(line: string) {
const commentIndex = line.indexOf("//");
const codePart = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
const commentPart = commentIndex >= 0 ? line.slice(commentIndex) : "";
const nodes = tokenizeBslCode(codePart).map((token, index) => (
<span className={classNameForBslToken(token.kind)} key={`${index}:${token.text}`}>
{token.text}
</span>
));
if (commentPart) {
nodes.push(
<span className="text-[#15803d]" key="comment">
{commentPart}
</span>
);
}
return nodes.length ? nodes : " ";
}
function tokenizeBslCode(code: string) {
const tokens: Array<{ text: string; kind: "plain" | "keyword" | "directive" | "string" | "number" | "builtin" }> = [];
const pattern = /("[^"]*"|&[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*|#[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*|\b\d+(?:[.,]\d+)?\b|\b[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*\b)/gu;
let cursor = 0;
for (const match of code.matchAll(pattern)) {
if (match.index === undefined) {
continue;
}
if (match.index > cursor) {
tokens.push({ text: code.slice(cursor, match.index), kind: "plain" });
}
const text = match[0];
const lowered = text.toLocaleLowerCase("ru");
const kind = text.startsWith("\"")
? "string"
: text.startsWith("&") || text.startsWith("#")
? "directive"
: /^\d/u.test(text)
? "number"
: BSL_KEYWORDS.has(lowered)
? "keyword"
: BSL_BUILTINS.has(lowered)
? "builtin"
: "plain";
tokens.push({ text, kind });
cursor = match.index + text.length;
}
if (cursor < code.length) {
tokens.push({ text: code.slice(cursor), kind: "plain" });
}
return tokens;
}
function classNameForBslToken(kind: "plain" | "keyword" | "directive" | "string" | "number" | "builtin") {
if (kind === "keyword") {
return "font-semibold text-[#b91c1c]";
}
if (kind === "directive") {
return "text-[#1d4ed8]";
}
if (kind === "string") {
return "text-[#7c3aed]";
}
if (kind === "number") {
return "text-[#0f766e]";
}
if (kind === "builtin") {
return "font-medium text-[#047857]";
}
return "text-foreground";
}
function completionPopupPosition(value: string, caret: number, textarea: HTMLTextAreaElement | null) {
const lineHeight = 20;
const charWidth = 7.8;
const beforeCaret = value.slice(0, caret);
const lines = beforeCaret.split("\n");
const lineIndex = lines.length - 1;
const columnIndex = lines[lineIndex]?.length ?? 0;
const viewportWidth = textarea?.clientWidth ?? 900;
const viewportHeight = textarea?.clientHeight ?? 500;
const scrollLeft = textarea?.scrollLeft ?? 0;
const scrollTop = textarea?.scrollTop ?? 0;
const left = Math.min(Math.max(16, 16 + columnIndex * charWidth - scrollLeft), Math.max(16, viewportWidth - 300));
const preferredTop = 12 + (lineIndex + 1) * lineHeight - scrollTop;
const top = preferredTop + 260 > viewportHeight ? Math.max(12, preferredTop - 264) : Math.max(12, preferredTop);
return { left, top };
}
function completionIcon(kind: string) {
const normalized = kind.toUpperCase();
if (normalized === "FUNCTION") {
return "f()";
}
if (normalized === "PROCEDURE" || normalized === "METHOD") {
return "p()";
}
if (normalized === "PROPERTY" || normalized === "PARAMETER" || normalized === "VARIABLE") {
return "p[]";
}
if (normalized === "COLLECTION" || normalized === "FORM" || normalized === "COMMAND") {
return "c[]";
}
if (normalized === "KEYWORD") {
return "kw";
}
if (normalized === "DIRECTIVE") {
return "#";
}
return "g";
}
function completionIconClass(kind: string) {
const normalized = kind.toUpperCase();
if (normalized === "FUNCTION") {
return "text-[#2f7d32]";
}
if (normalized === "PROCEDURE" || normalized === "METHOD") {
return "text-[#4f9d45]";
}
if (normalized === "PROPERTY" || normalized === "PARAMETER" || normalized === "VARIABLE") {
return "text-[#2f7d32]";
}
if (normalized === "KEYWORD" || normalized === "DIRECTIVE") {
return "text-[#1d4ed8]";
}
return "text-[#6b7280]";
}
function MonacoFallback() {
return (
<div className="h-full overflow-auto bg-muted/30 font-mono text-sm">
{sampleCode.map((line, index) => (
<EditorLine key={`${index}:${line}`} line={line} lineNumber={index + 1} />
))}
{suggestedCode.map((line, index) => (
<EditorLine key={`preview:${index}:${line}`} line={line} lineNumber={index + sampleCode.length + 1} preview />
))}
</div>
);
}
function configureMonaco(editor: unknown, monaco: {
editor: {
defineTheme: (name: string, theme: unknown) => void;
setTheme: (name: string) => void;
setModelMarkers: (model: unknown, owner: string, markers: unknown[]) => void;
};
languages: {
register: (language: { id: string }) => void;
setMonarchTokensProvider: (languageId: string, provider: unknown) => void;
registerCompletionItemProvider: (languageId: string, provider: unknown) => void;
registerHoverProvider: (languageId: string, provider: unknown) => void;
CompletionItemKind: Record<string, number>;
};
Range: new (startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) => unknown;
MarkerSeverity: Record<string, number>;
}, data: ProjectWorkspaceData) {
monaco.languages.register({ id: "bsl" });
monaco.languages.setMonarchTokensProvider("bsl", {
tokenizer: {
root: [
[/\b(Процедура|Функция|КонецПроцедуры|КонецФункции|Если|Тогда|Иначе|ИначеЕсли|КонецЕсли|Для|Каждого|Из|Цикл|КонецЦикла|Возврат|Перем|Экспорт|Procedure|Function|EndProcedure|EndFunction|If|Then|Else|EndIf|For|Each|In|Do|EndDo|Return|Var|Export)\b/i, "keyword"],
[/&[A-Za-zА-Яа-я]+/, "annotation"],
[/#(Область|КонецОбласти|Region|EndRegion)\b/i, "tag"],
[/\/\/.*$/, "comment"],
[/"([^"\\]|\\.)*$/, "string.invalid"],
[/"/, "string", "@string"]
],
string: [
[/[^\\"]+/, "string"],
[/"/, "string", "@pop"]
]
}
});
monaco.editor.defineTheme("sfera-bsl-light", {
base: "vs",
inherit: true,
rules: [
{ token: "keyword", foreground: "d92020" },
{ token: "annotation", foreground: "1d4ed8" },
{ token: "tag", foreground: "b45309" },
{ token: "comment", foreground: "15803d" },
{ token: "string", foreground: "7c3aed" }
],
colors: {
"editor.background": "#fbfaf7",
"editor.lineHighlightBackground": "#e9f1ff",
"editorGutter.background": "#efeddc"
}
});
monaco.editor.setTheme("sfera-bsl-light");
registerBslIntelligence(monaco, data);
const model = (editor as { getModel?: () => unknown }).getModel?.();
if (model) {
monaco.editor.setModelMarkers(model, "sfera", buildEditorMarkers(monaco, data));
}
}
function buildEditorMarkers(monaco: {
MarkerSeverity: Record<string, number>;
}, data: ProjectWorkspaceData) {
const lineCount = data.editorSourceText?.split(/\r?\n/).length ?? sampleCode.length;
const reviewMarkers = data.review.slice(0, 8).map((finding, index) => ({
severity: finding.severity === "ERROR" ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
message: `${finding.title}: ${finding.message}`,
startLineNumber: finding.line_start ?? Math.min(index + 4, lineCount),
startColumn: 1,
endLineNumber: finding.line_start ?? Math.min(index + 4, lineCount),
endColumn: 120
}));
const semanticMarkers = (data.authoringPreview?.checks ?? []).map((check, index) => ({
severity: check.status === "OK" ? monaco.MarkerSeverity.Info : monaco.MarkerSeverity.Warning,
message: `${check.name}: ${check.message}`,
startLineNumber: Math.min(20 + index, lineCount),
startColumn: 1,
endLineNumber: Math.min(20 + index, lineCount),
endColumn: 120
}));
return [...reviewMarkers, ...semanticMarkers];
}
function registerBslIntelligence(monaco: {
languages: {
registerCompletionItemProvider: (languageId: string, provider: unknown) => void;
registerHoverProvider: (languageId: string, provider: unknown) => void;
CompletionItemKind: Record<string, number>;
};
Range: new (startLineNumber: number, startColumn: number, endLineNumber: number, endColumn: number) => unknown;
}, data: ProjectWorkspaceData) {
const context = data.authoringPreview?.context;
const keywordSuggestions = [
"Процедура", "Функция", "Если", "Тогда", "Иначе", "КонецЕсли", "Для", "Каждого", "Цикл", "КонецЦикла", "Возврат",
"Procedure", "Function", "If", "Then", "Else", "EndIf", "For", "Each", "Do", "EndDo", "Return",
"&НаСервере", "&НаКлиенте", "&AtServer", "&AtClient", "#Область", "#КонецОбласти", "#Region", "#EndRegion"
];
const contextSuggestions = [
...(context?.parameters ?? []),
...(context?.local_variables ?? []),
...(context?.object_attributes.map((node) => node.name) ?? []),
...(context?.tabular_sections.map((node) => node.name) ?? []),
...(context?.form_elements.map((node) => node.name) ?? []),
...(context?.commands.map((node) => node.name) ?? []),
...(context?.available_methods ?? [])
];
monaco.languages.registerCompletionItemProvider("bsl", {
triggerCharacters: [".", "&", "#"],
provideCompletionItems: (model: { getWordUntilPosition: (position: { lineNumber: number; column: number }) => { word: string; startColumn: number; endColumn: number } }, position: { lineNumber: number; column: number }) => {
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn);
const suggestions = [
...keywordSuggestions.map((label) => ({
label,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: label,
range
})),
...Array.from(new Set(contextSuggestions)).map((label) => ({
label,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: label,
detail: "SFERA semantic context",
range
})),
{
label: "ПроверитьОтказИВернуться",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "Если Отказ Тогда\n Возврат;\nКонецЕсли;",
detail: "SFERA quick snippet",
range
}
];
return { suggestions };
}
});
monaco.languages.registerHoverProvider("bsl", {
provideHover: (model: { getWordAtPosition: (position: { lineNumber: number; column: number }) => { word: string } | null }, position: { lineNumber: number; column: number }) => {
const word = model.getWordAtPosition(position)?.word;
if (!word) {
return null;
}
if (contextSuggestions.includes(word)) {
return {
contents: [
{ value: `**${word}**` },
{ value: "Доступно из semantic context текущего объекта 1С." }
]
};
}
if (["Движения", "Записать", "Процедура", "Procedure"].includes(word)) {
return {
contents: [
{ value: `**${word}**` },
{ value: "SFERA проверяет записи, права, impact и версии перед применением." }
]
};
}
return null;
}
});
}
function EditorGutterMark({
line,
lineNumber,
preview = false
}: Readonly<{
line: string;
lineNumber: number;
preview?: boolean;
}>) {
const hasSemanticMark = line.includes("Движения.") || line.includes("Проверить");
const blame = "";
return (
<div className={["grid h-[21px] grid-cols-[20px_1fr] items-center px-1 text-[10px]", preview ? "text-success" : "text-muted-foreground"].join(" ")}>
<span>{hasSemanticMark ? "●" : preview ? "+" : ""}</span>
<span className="truncate">{blame}</span>
</div>
);
}
function EditorLine({
line,
lineNumber,
preview = false
}: Readonly<{
line: string;
lineNumber: number;
preview?: boolean;
}>) {
const hasSemanticMark = line.includes("Движения.") || line.includes("Проверить");
const blame = "";
return (
<div className={[
"grid grid-cols-[52px_26px_minmax(0,1fr)_92px] border-b last:border-0",
preview ? "border-success/20 bg-success/5" : "border-border/70"
].join(" ")}>
<div className={["select-none px-3 py-2 text-right text-xs", preview ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"].join(" ")}>
{preview ? `+${lineNumber}` : lineNumber}
</div>
<div className="flex items-center justify-center bg-muted/60">
{hasSemanticMark ? <span className="h-2 w-2 rounded-full bg-warning" title="semantic mark" /> : null}
</div>
<pre className={["overflow-x-auto px-3 py-2 leading-6", preview ? "text-success" : "text-foreground"].join(" ")}>{line}</pre>
<div className="truncate bg-muted/40 px-2 py-2 text-xs text-muted-foreground">{blame}</div>
</div>
);
}
function FormDesignerPanel({
data,
language,
modeId
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
modeId: WorkspaceMode;
}>) {
const t = messages[language];
const objectForms = data.selectedObjectUi?.forms.length ? data.selectedObjectUi.forms : data.forms;
const [selectedFormId, setSelectedFormId] = useState<string | null>(objectForms[0]?.form.lineage_id ?? null);
const [titleByForm, setTitleByForm] = useState<Record<string, string>>({});
const [layoutByForm, setLayoutByForm] = useState<Record<string, "auto" | "columns" | "compact">>({});
const [elementDrafts, setElementDrafts] = useState<Record<string, IdeFormElementDraft[]>>({});
const [newElementName, setNewElementName] = useState("");
const [newElementKind, setNewElementKind] = useState<IdeFormElementDraft["controlKind"]>("input");
const selectedFormQualifiedName = data.editorSelectedForm;
useEffect(() => {
if (selectedFormQualifiedName) {
const requestedForm = objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName);
if (requestedForm && requestedForm.form.lineage_id !== selectedFormId) {
setSelectedFormId(requestedForm.form.lineage_id);
return;
}
}
if (objectForms.length > 0 && !objectForms.some((item) => item.form.lineage_id === selectedFormId)) {
setSelectedFormId(objectForms[0].form.lineage_id);
}
}, [objectForms, selectedFormId, selectedFormQualifiedName]);
const form =
objectForms.find((item) => item.form.qualified_name === selectedFormQualifiedName) ??
objectForms.find((item) => item.form.lineage_id === selectedFormId) ??
objectForms[0];
const commands = form?.commands.slice(0, 6) ?? [];
const formKey = form?.form.lineage_id ?? "draft";
const baseElements = useMemo(() => buildIdeFormElements(form), [form]);
const elements = elementDrafts[formKey] ?? baseElements;
const flatElements = useMemo(() => flattenIdeFormElements(elements), [elements]);
const sidebarElements = flatElements.slice(0, 160);
const propertyElements = flatElements.slice(0, 48);
const formTitle = titleByForm[formKey] ?? form?.form.name ?? "ФормаДокумента";
const formObjectCaption = language === "ru" ? `${formTitle} (форма 1С 8.5)` : `${formTitle} (1C 8.5 form)`;
const layout = layoutByForm[formKey] ?? "auto";
useEffect(() => {
if (form && !elementDrafts[form.form.lineage_id]) {
setElementDrafts((current) => ({ ...current, [form.form.lineage_id]: buildIdeFormElements(form) }));
}
}, [elementDrafts, form]);
const updateElement = (id: string, patch: Partial<IdeFormElementDraft>) => {
setElementDrafts((current) => ({
...current,
[formKey]: updateIdeFormElementTree(current[formKey] ?? baseElements, id, patch)
}));
};
const addElement = () => {
const name = newElementName.trim();
if (!name) {
return;
}
const next: IdeFormElementDraft = {
id: `draft.${formKey}.${Date.now()}`,
name,
caption: name,
controlKind: newElementKind,
binding: name,
width: "stretch",
children: []
};
setElementDrafts((current) => ({ ...current, [formKey]: [...(current[formKey] ?? baseElements), next] }));
setNewElementName("");
};
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={modeId}>
<PanelTitle icon={Layers3} title={t.formDesigner} />
<div className="grid h-[calc(100%-45px)] min-h-0 grid-cols-[260px_minmax(0,1fr)_340px]">
<div className="min-h-0 overflow-auto border-r border-border bg-[#f4f4f4] p-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div>
<button className="flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground hover:bg-background" type="button" title={t.search}>
<Search className="h-4 w-4" />
</button>
</div>
<div className="mt-3 space-y-1">
{objectForms.length === 0 ? (
<div className="text-sm text-muted-foreground">{t.none}</div>
) : (
objectForms.map((item) => (
<button
className={[
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-background",
item.form.lineage_id === form?.form.lineage_id ? "bg-background text-primary" : ""
].join(" ")}
key={item.form.lineage_id}
onClick={() => setSelectedFormId(item.form.lineage_id)}
type="button"
>
<OneCTreeIcon kind="form" />
<span className="min-w-0 flex-1 truncate">{item.form.name}</span>
<span className="text-[11px] text-muted-foreground">{item.elements.length + item.commands.length}</span>
</button>
))
)}
</div>
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.formElements}</div>
<div className="mt-3 space-y-1">
{flatElements.length === 0 ? (
<div className="text-sm text-muted-foreground">{t.none}</div>
) : (
sidebarElements.map((element) => (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={element.id}>
<OneCTreeIcon kind={element.controlKind === "table" ? "tabular" : "attribute"} />
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{element.name}</div>
<div className="truncate text-[11px] text-muted-foreground">{element.controlKind} · {element.binding}</div>
</div>
</div>
))
)}
{flatElements.length > sidebarElements.length ? (
<div className="px-2 py-1 text-xs text-muted-foreground">
+{flatElements.length - sidebarElements.length} {language === "ru" ? "элементов в макете" : "layout items"}
</div>
) : null}
</div>
<div className="mt-5 border-t border-border pt-3 text-xs font-semibold uppercase text-muted-foreground">{t.commands}</div>
<div className="mt-3 space-y-1">
{commands.map((command) => (
<div className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-background" key={command.lineage_id}>
<OneCTreeIcon kind="command" />
<span className="truncate">{command.name}</span>
</div>
))}
</div>
</div>
<div className="min-h-0 overflow-auto bg-[#ececec] p-5">
<div className="mx-auto flex min-h-[760px] max-w-5xl flex-col border border-[#b8c0ca] bg-[#fbfbfc] shadow-[0_18px_55px_rgba(15,23,42,0.18)]" data-ide-form-window data-ide-form-layout={layout}>
<div className="flex min-h-9 items-center justify-between border-b border-[#cbd3df] bg-gradient-to-b from-[#f8f9fb] to-[#e9edf3] px-3 text-sm text-slate-950">
<div>
<div className="font-semibold">{formObjectCaption}</div>
<div className="text-[11px] text-slate-500">{data.selectedObjectSchema?.object.qualified_name ?? form?.form.qualified_name ?? data.projectId}</div>
</div>
<div className="flex items-center gap-2 text-slate-500">
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="menu">
<MoreVertical className="h-4 w-4" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="expand">
<Maximize2 className="h-4 w-4" />
</button>
<button className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200" type="button" title="close">
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b border-[#ccd4df] bg-[#f4f6f9] px-3 py-2">
{commands.length ? commands.map((command) => (
<button className="h-7 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-[#1f2937]" key={command.lineage_id} type="button">
{command.name}
</button>
)) : <span className="text-xs text-muted-foreground">{language === "ru" ? "Команды формы не описаны" : "No form commands"}</span>}
</div>
<div className={[
"grid grid-cols-12 gap-x-3 gap-y-2 p-5",
layout === "compact" ? "gap-y-1" : ""
].join(" ")}>
{elements.length ? (
elements.map((element) => (
<IdeFormControl element={element} forceHalf={layout === "columns"} key={element.id} />
))
) : (
<div className="col-span-12 border border-dashed border-[#aeb8c6] bg-white px-4 py-6 text-sm text-slate-600" data-ide-form-empty>
<div className="font-semibold text-slate-900">{language === "ru" ? "Структура элементов формы не загружена" : "Form element structure is not loaded"}</div>
<div className="mt-1 text-xs">
{form?.form.qualified_name ?? form?.form.name ?? data.projectId}
</div>
<div className="mt-3 text-xs">
{language === "ru"
? "В текущем индексе для этой формы нет узлов элементов. SFERA не подставляет шаблонные поля, чтобы не искажать объект 1С."
: "The current index has no element nodes for this form. SFERA does not insert template fields because that would distort the 1C object."}
</div>
</div>
)}
</div>
<div className="mt-auto border-t border-[#ccd4df] bg-white px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
{commands.slice(0, 3).map((command) => (
<button className="h-8 border border-[#aeb8c6] bg-gradient-to-b from-white to-[#edf1f6] px-3 text-xs font-medium text-slate-700" key={`bottom-${command.lineage_id}`} type="button">
{command.name}
</button>
))}
<button className="h-8 bg-yellow-400 px-4 text-sm font-semibold text-slate-950 hover:bg-yellow-300" type="button">
{t.saveAndClose}
</button>
<button className="h-8 bg-slate-200 px-4 text-sm font-semibold text-slate-900 hover:bg-slate-300" type="button">
{t.save}
</button>
</div>
</div>
</div>
</div>
<aside className="min-h-0 overflow-auto border-l border-border bg-background">
<div className="border-b border-border p-3">
<div className="text-sm font-medium">{language === "ru" ? "Свойства формы" : "Form properties"}</div>
<label className="mt-3 grid gap-1 text-xs font-medium text-muted-foreground">
{language === "ru" ? "Заголовок" : "Title"}
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={formTitle} onChange={(event) => setTitleByForm((current) => ({ ...current, [formKey]: event.target.value }))} />
</label>
<label className="mt-2 grid gap-1 text-xs font-medium text-muted-foreground">
{language === "ru" ? "Размещение" : "Layout"}
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={layout} onChange={(event) => setLayoutByForm((current) => ({ ...current, [formKey]: event.target.value as "auto" | "columns" | "compact" }))}>
<option value="auto">{language === "ru" ? "Авто 1С" : "1C auto"}</option>
<option value="columns">{language === "ru" ? "Колонки" : "Columns"}</option>
<option value="compact">{language === "ru" ? "Компактно" : "Compact"}</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2 border-b border-border p-3">
<IdeFormMetric label="elements" value={flatElements.length} />
<IdeFormMetric label="commands" value={commands.length} />
</div>
<div className="border-b border-border p-3">
<div className="text-xs font-medium text-muted-foreground">{language === "ru" ? "Добавить элемент" : "Add element"}</div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_110px] gap-2">
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" placeholder={language === "ru" ? "Новый реквизит" : "New attribute"} value={newElementName} onChange={(event) => setNewElementName(event.target.value)} />
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={newElementKind} onChange={(event) => setNewElementKind(event.target.value as IdeFormElementDraft["controlKind"])}>
<option value="input">{language === "ru" ? "Поле" : "Input"}</option>
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
</select>
</div>
<button className="mt-2 h-8 w-full bg-primary px-3 text-sm font-medium text-primary-foreground" onClick={addElement} type="button">
{language === "ru" ? "Добавить в макет" : "Add to layout"}
</button>
</div>
<div className="divide-y divide-border">
{propertyElements.map((element) => (
<div className="grid gap-2 p-3" key={`props-${element.id}`}>
<div className="truncate text-xs font-semibold">{element.name}</div>
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.caption} onChange={(event) => updateElement(element.id, { caption: event.target.value })} />
<div className="grid grid-cols-2 gap-2">
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.controlKind} onChange={(event) => updateElement(element.id, { controlKind: event.target.value as IdeFormElementDraft["controlKind"] })}>
<option value="input">{language === "ru" ? "Поле ввода" : "Input"}</option>
<option value="date">{language === "ru" ? "Дата" : "Date"}</option>
<option value="checkbox">{language === "ru" ? "Флажок" : "Checkbox"}</option>
<option value="table">{language === "ru" ? "Таблица" : "Table"}</option>
<option value="group">{language === "ru" ? "Группа" : "Group"}</option>
<option value="text">{language === "ru" ? "Текст" : "Text"}</option>
</select>
<select className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.width} onChange={(event) => updateElement(element.id, { width: event.target.value as IdeFormElementDraft["width"] })}>
<option value="stretch">{language === "ru" ? "Вся строка" : "Full"}</option>
<option value="half">1/2</option>
<option value="third">1/3</option>
</select>
</div>
<input className="h-8 border border-border bg-background px-2 text-sm text-foreground" value={element.binding} onChange={(event) => updateElement(element.id, { binding: event.target.value })} />
</div>
))}
{flatElements.length > propertyElements.length ? (
<div className="p-3 text-xs text-muted-foreground">
{language === "ru"
? `Показаны первые ${propertyElements.length} свойств из ${flatElements.length}. Остальные элементы доступны в макете формы.`
: `Showing first ${propertyElements.length} properties out of ${flatElements.length}. Other items are available in the form layout.`}
</div>
) : null}
</div>
</aside>
</div>
</Card>
);
}
type IdeFormElementDraft = {
id: string;
name: string;
caption: string;
controlKind: "input" | "date" | "checkbox" | "table" | "group" | "text";
binding: string;
width: "stretch" | "half" | "third";
qualifiedName?: string;
parentQualifiedName?: string | null;
children: IdeFormElementDraft[];
};
function IdeFormControl({ element, forceHalf }: Readonly<{ element: IdeFormElementDraft; forceHalf: boolean }>) {
const span = element.controlKind === "table" || element.controlKind === "group" || element.controlKind === "text" ? "col-span-12" : forceHalf || element.width === "half" ? "col-span-6" : element.width === "third" ? "col-span-4" : "col-span-12";
if (element.controlKind === "table") {
return (
<section className={`${span} border border-[#aeb8c6] bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<div className="flex min-h-8 items-center justify-between border-b border-[#ccd4df] bg-[#eef2f7] px-2 text-xs font-semibold text-[#1f2937]">
<span>{element.caption}</span>
<span className="text-[11px] font-medium text-[#687385]">{element.binding}</span>
</div>
{ideFormControlInput(element)}
</section>
);
}
if (element.controlKind === "group") {
return (
<fieldset className={`${span} border border-[#b9c1cd] bg-[#f6f8fb] px-3 pb-3 pt-2`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<legend className="px-1 text-xs font-semibold text-[#4b5563]">{element.caption}</legend>
<div className="grid grid-cols-12 gap-x-3 gap-y-2">
{element.children.length ? element.children.map((child) => <IdeFormControl element={child} forceHalf={forceHalf} key={child.id} />) : null}
</div>
</fieldset>
);
}
if (element.controlKind === "text") {
return (
<div className={`${span} border border-transparent px-1 py-1`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
{ideFormControlInput(element)}
</div>
);
}
return (
<div className={`${span} grid min-h-8 grid-cols-[150px_minmax(0,1fr)] items-center gap-2 border border-transparent px-1 py-1 hover:border-[#b9c1cd] hover:bg-white`} data-ide-form-element={element.id} data-ide-form-control={element.controlKind}>
<label className="truncate text-xs font-semibold text-[#4b5563]">{element.caption}</label>
{ideFormControlInput(element)}
</div>
);
}
function ideFormControlInput(element: IdeFormElementDraft) {
if (element.controlKind === "table") {
const columns = element.children.filter((child) => child.controlKind !== "text" && child.controlKind !== "group");
const visibleColumns = columns.length ? columns : [{ ...element, caption: element.caption, name: element.name, id: `${element.id}.column`, children: [] }];
return (
<div className="min-h-32 overflow-hidden bg-white text-xs">
<div className="grid" style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
{visibleColumns.slice(0, 8).map((column) => (
<span className="min-h-7 border-b border-r border-[#d7dde6] bg-[#f4f6f9] px-2 py-1 font-semibold text-[#374151] last:border-r-0" key={column.id}>
{column.caption}
</span>
))}
</div>
{[0, 1, 2].map((row) => (
<div className="grid" key={row} style={{ gridTemplateColumns: `repeat(${Math.min(visibleColumns.length, 8)}, minmax(120px, 1fr))` }}>
{visibleColumns.slice(0, 8).map((column) => (
<span className="min-h-7 border-b border-r border-[#edf1f6] px-2 py-1 last:border-r-0" key={`${row}-${column.id}`} />
))}
</div>
))}
</div>
);
}
if (element.controlKind === "checkbox") {
return <label className="flex items-center gap-2 text-xs text-slate-800"><input checked readOnly type="checkbox" />{element.binding}</label>;
}
if (element.controlKind === "group") {
return <div className="min-h-11 border border-dashed border-[#aeb8c6] bg-[#f6f8fb] px-2 py-2 text-xs font-medium text-muted-foreground">{element.binding}</div>;
}
if (element.controlKind === "text") {
return <div className="whitespace-pre-wrap border border-transparent bg-transparent px-1 py-1 text-xs leading-5 text-[#374151]">{element.caption || element.binding}</div>;
}
return <input className="h-7 border border-[#aeb8c6] bg-white px-2 text-xs" readOnly value={element.binding} />;
}
function IdeFormMetric({ label, value }: Readonly<{ label: string; value: number }>) {
return (
<div className="border border-border p-2">
<div className="text-base font-semibold">{value}</div>
<div className="text-[11px] text-muted-foreground">{label}</div>
</div>
);
}
function buildIdeFormElements(form: ProjectWorkspaceData["forms"][number] | undefined): IdeFormElementDraft[] {
const explicitElements = form?.elements ?? [];
if (explicitElements.length) {
const formQualifiedName = form?.form.qualified_name ?? "";
const drafts = explicitElements
.filter((element) => formElementString(element.attributes, ["visible"]) !== "false")
.map((element, index) => {
const qualifiedName = element.qualified_name ?? element.name;
return {
id: element.lineage_id || `element.${index}`,
name: element.name,
caption: formElementCaption(element),
controlKind: controlKindForFormNode(element.name, formElementString(element.attributes, ["control_kind", "control", "type", "kind"]) ?? element.kind),
binding: formElementString(element.attributes, ["binding", "dataPath", "data_path", "path"]) ?? qualifiedName ?? element.name,
width: formElementWidth(element.attributes, index),
qualifiedName,
parentQualifiedName: parentQualifiedNameForElement(qualifiedName, formQualifiedName),
children: []
} satisfies IdeFormElementDraft;
});
return nestIdeFormElements(drafts);
}
return [];
}
function formElementCaption(element: ProjectWorkspaceData["forms"][number]["elements"][number]) {
const title = formElementString(element.attributes, ["caption", "title", "synonym"]);
if (title) return title;
const dataPath = formElementString(element.attributes, ["dataPath", "data_path", "path"]);
if (dataPath?.includes(".")) return dataPath.split(".").at(-1) ?? element.name;
return element.name;
}
function parentQualifiedNameForElement(qualifiedName: string, formQualifiedName: string) {
const dot = qualifiedName.lastIndexOf(".");
if (dot < 0) return null;
const parent = qualifiedName.slice(0, dot);
return parent === formQualifiedName ? null : parent;
}
function nestIdeFormElements(elements: IdeFormElementDraft[]) {
const byQualifiedName = new Map(elements.map((element) => [element.qualifiedName, element]));
const roots: IdeFormElementDraft[] = [];
for (const element of elements) {
const parent = element.parentQualifiedName ? byQualifiedName.get(element.parentQualifiedName) : null;
if (parent) {
parent.children.push(element);
} else {
roots.push(element);
}
}
return roots;
}
function flattenIdeFormElements(elements: IdeFormElementDraft[]): IdeFormElementDraft[] {
return elements.flatMap((element) => [element, ...flattenIdeFormElements(element.children)]);
}
function updateIdeFormElementTree(elements: IdeFormElementDraft[], id: string, patch: Partial<IdeFormElementDraft>): IdeFormElementDraft[] {
return elements.map((element) => (
element.id === id
? { ...element, ...patch }
: { ...element, children: updateIdeFormElementTree(element.children, id, patch) }
));
}
function formElementString(attributes: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = attributes[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function formElementWidth(attributes: Record<string, unknown>, index: number): IdeFormElementDraft["width"] {
const raw = formElementString(attributes, ["width", "layout_width", "placement"]);
if (raw === "half" || raw === "third" || raw === "stretch") {
return raw;
}
return index < 2 ? "half" : "stretch";
}
function controlKindForFormNode(name: string, kind: string): IdeFormElementDraft["controlKind"] {
const normalized = `${name} ${kind}`.toLowerCase();
if (normalized.includes("decoration") || normalized.includes("label") || normalized.includes("надп")) return "text";
if (normalized.includes("group") || normalized.includes("группа") || normalized.includes("pages") || normalized.includes("page")) return "group";
if (normalized.includes("таб") || normalized.includes("table")) return "table";
if (normalized.includes("дата") || normalized.includes("date")) return "date";
if (normalized.includes("флаг") || normalized.includes("boolean") || normalized.includes("булево")) return "checkbox";
return "input";
}
function MetadataDesignerPanel({
activeMode,
data,
language,
onAuthoringChangeApplied,
selectedMetadataNode,
selectedObject
}: Readonly<{
activeMode: WorkspaceMode;
data: ProjectWorkspaceData;
language: UiLanguage;
onAuthoringChangeApplied: (change: AuthoringChange, version?: ProjectVersion) => void;
selectedMetadataNode?: MetadataTreeNode;
selectedObject?: SirNode;
}>) {
const t = messages[language];
const context = data.authoringPreview?.context;
const schema = data.selectedObjectSchema;
const impact = data.selectedObjectImpact;
const selectedQualifiedName = schema?.object.qualified_name ?? selectedObject?.qualified_name ?? selectedMetadataNode?.qualified_name ?? t.none;
const schemaAttributes = schema?.attributes ?? context?.object_attributes ?? [];
const schemaSections = schema?.tabular_sections ?? [];
const schemaSectionNames = schemaSections.map((section) => `${section.tabular_section.name}${section.columns.length ? ` (${section.columns.length})` : ""}`);
const impactCommandNames = impact?.commands.map((node) => node.name) ?? context?.commands.map((node) => node.name) ?? [];
const [draftKind, setDraftKind] = useState("DOCUMENT");
const [draftName, setDraftName] = useState("НовыйОбъект");
const [draftSynonym, setDraftSynonym] = useState("Новый объект");
const [draftAttributes, setDraftAttributes] = useState([
{ name: "НовыйРеквизит", type: "Строка", required: false }
]);
const [draftTabularSectionName, setDraftTabularSectionName] = useState("ТабличнаяЧасть1");
const [draftTabularAttributes, setDraftTabularAttributes] = useState([
{ name: "НоваяКолонка", type: "Строка" }
]);
const [draftForms, setDraftForms] = useState(["НоваяФорма"]);
const [draftCommands, setDraftCommands] = useState([
{ name: "НоваяКоманда", handler: "НоваяКомандаОбработка" }
]);
const metadataDraft: AuthoringMetadataObjectDraft = {
object_kind: draftKind,
name: draftName.trim() || "НовыйОбъект",
synonym: draftSynonym.trim() || null,
attributes: draftAttributes
.filter((attribute) => attribute.name.trim().length > 0)
.map((attribute) => ({ ...attribute, name: attribute.name.trim(), type: attribute.type.trim() || "Строка" })),
tabular_sections: [
{
name: draftTabularSectionName.trim() || "ТабличнаяЧасть",
attributes: draftTabularAttributes
.filter((attribute) => attribute.name.trim().length > 0)
.map((attribute) => ({ name: attribute.name.trim(), type: attribute.type.trim() || "Строка" }))
}
],
forms: draftForms.map((form) => form.trim()).filter(Boolean),
commands: draftCommands
.filter((command) => command.name.trim().length > 0)
.map((command) => ({
name: command.name.trim(),
handler: command.handler.trim() || null
}))
};
const [draftState, setDraftState] = useState<"idle" | "previewing" | "applying" | "applied" | "error">("idle");
const [draftMessage, setDraftMessage] = useState("");
const [draftDiff, setDraftDiff] = useState<Array<{ kind: string; text: string }>>([]);
const resetMetadataDraftResult = () => {
setDraftDiff([]);
setDraftMessage("");
setDraftState("idle");
};
const rows =
activeMode === "events"
? [
[t.eventName, "ПриСозданииНаСервере", "Процедура ПриСозданииНаСервере"],
[t.eventName, "ПриОткрытии", "Процедура ПриОткрытии"],
[t.eventName, "ПередЗаписью", "Процедура ПередЗаписью"],
[t.eventName, "ОбработкаКоманды", "Процедура ОбработкаКоманды"]
]
: [
[t.selectedObject, selectedQualifiedName],
[t.objectAttributes, formatList(schemaAttributes.map((node) => node.name))],
[t.tabularSections, formatList(schemaSectionNames)],
[t.formElements, formatList(context?.form_elements.map((node) => node.name) ?? [])],
[t.commands, formatList(impactCommandNames)]
];
const authoringMetadataTypes = (
data.metadataCatalog?.types.filter((spec) => !["COMMON", "EXTENSION"].includes(spec.code)) ?? [
{
code: "COMMON_MODULE",
russian_name: "Общий модуль",
tree_branch: "Общие модули",
icon: "module",
child_groups: ["Процедуры", "Функции", "Экспортные методы"],
module_kinds: ["Модуль"]
},
...fallbackMetadataTypeSpecs
]
).filter((spec) => spec.code !== "EXTERNAL_DATA_SOURCE");
async function handleApplyMetadataDraft() {
setDraftState("previewing");
setDraftMessage("");
setDraftDiff([]);
try {
const apiUrl =
data.apiUrl ??
(typeof window === "undefined" ? resolveApiUrl() : resolveApiUrl(window.location.host));
const authoringSession = await ensureAuthoringSession(data.projectId, apiUrl);
const sessionDraft = { ...metadataDraft, ...authoringSession };
const preview = await getAuthoringMetadataObjectPreview(data.projectId, sessionDraft, apiUrl);
setDraftDiff(preview.semantic_diff);
setDraftState("applying");
const response = await applyAuthoringMetadataObject(
data.projectId,
{
...sessionDraft,
expected_next_version_id: preview.version_preview.next_version_id,
approved_by: authoringSession.user_id,
approval_note: t.metadataDraftDescription,
apply_to_production: false
},
apiUrl
);
onAuthoringChangeApplied(
{
change_id: response.change_id,
project_id: response.project_id,
status: response.status,
target: response.preview.target,
version_id: response.version.version_id,
approved_by: authoringSession.user_id,
approval_note: t.metadataDraftDescription,
task_id: response.version.task_id,
session_id: response.version.session_id,
added_lines: response.preview.added_lines,
removed_lines: response.preview.removed_lines,
production_applied: response.production_applied
},
versionToSummary(response.version)
);
setDraftState("applied");
setDraftMessage(`${formatChangeStatus(response.status, language)}: ${response.version.version_id}`);
} catch (error) {
setDraftState("error");
setDraftMessage(error instanceof Error ? error.message : language === "ru" ? "Ошибка создания черновика объекта." : "Object draft failed.");
}
}
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={activeMode}>
<PanelTitle icon={activeMode === "events" ? Braces : PanelRight} title={activeMode === "events" ? t.eventsInspector : t.propertiesMode} />
<div className="h-[calc(100%-45px)] overflow-auto p-4">
<div className="mb-4 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="font-medium">{selectedQualifiedName}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.previewRequired} · {t.guardedApply}</div>
</div>
<table className="w-full border-collapse text-sm">
<tbody>
{rows.map((row) => (
<tr className="border-b border-border" key={row.join(":")}>
<td className="w-56 px-3 py-3 text-xs uppercase text-muted-foreground">{row[0]}</td>
<td className="px-3 py-3 font-medium">{row[1]}</td>
<td className="px-3 py-3 text-muted-foreground">{row[2] ?? selectedObject?.kind ?? ""}</td>
</tr>
))}
</tbody>
</table>
{activeMode === "properties" && schemaSections.length > 0 ? (
<div className="mt-4 grid gap-3 xl:grid-cols-2">
{schemaSections.slice(0, 8).map((section) => (
<div className="border border-border bg-card p-3" key={section.tabular_section.lineage_id}>
<div className="flex items-center gap-2 text-sm font-semibold">
<OneCTreeIcon kind="tabular" />
<span className="min-w-0 truncate">{section.tabular_section.name}</span>
<span className="ml-auto rounded border border-border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">{section.columns.length}</span>
</div>
<div className="mt-2 grid gap-1">
{section.columns.length === 0 ? (
<div className="text-sm text-muted-foreground">{t.none}</div>
) : (
section.columns.slice(0, 12).map((column) => (
<div className="flex items-center gap-2 text-sm" key={column.lineage_id}>
<OneCTreeIcon kind="attribute" />
<span className="min-w-0 flex-1 truncate">{column.name}</span>
</div>
))
)}
</div>
</div>
))}
</div>
) : null}
{activeMode === "properties" ? (
<div className="mt-4 border border-border bg-card p-3 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold">{t.metadataDraft}</div>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">{t.metadataDraftDescription}</p>
</div>
<ActionButton
actionId="apply-metadata-object"
disabled={draftState === "previewing" || draftState === "applying" || draftName.trim().length === 0}
label={draftState === "previewing" || draftState === "applying" ? t.applying : t.applyMetadataDraft}
onClick={handleApplyMetadataDraft}
/>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-3">
<label className="block border border-border bg-background p-2 text-xs uppercase text-muted-foreground">
{t.objectKind}
<select
className="mt-1 h-8 w-full border border-border bg-card px-2 text-sm font-medium normal-case text-foreground"
onChange={(event) => {
setDraftKind(event.target.value);
resetMetadataDraftResult();
}}
value={draftKind}
>
{authoringMetadataTypes.map((spec) => (
<option key={spec.code} value={spec.code}>
{spec.russian_name}
</option>
))}
</select>
</label>
<label className="block border border-border bg-background p-2 text-xs uppercase text-muted-foreground">
{t.objectName}
<input
className="mt-1 h-8 w-full border border-border bg-card px-2 text-sm font-medium normal-case text-foreground"
onChange={(event) => {
setDraftName(event.target.value);
resetMetadataDraftResult();
}}
spellCheck={false}
value={draftName}
/>
</label>
<label className="block border border-border bg-background p-2 text-xs uppercase text-muted-foreground">
{t.synonym}
<input
className="mt-1 h-8 w-full border border-border bg-card px-2 text-sm font-medium normal-case text-foreground"
onChange={(event) => {
setDraftSynonym(event.target.value);
resetMetadataDraftResult();
}}
value={draftSynonym}
/>
</label>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
<div className="border border-border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.objectAttributes}</div>
<ActionButton
actionId="add-metadata-attribute"
label={t.addAttribute}
onClick={() => {
setDraftAttributes((current) => [...current, { name: "НовыйРеквизит", type: "Строка", required: false }]);
resetMetadataDraftResult();
}}
/>
</div>
<div className="space-y-2">
{draftAttributes.map((attribute, index) => (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_120px_32px]" key={`attribute:${index}`}>
<input
aria-label={t.attributeName}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftAttributes((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, name: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={attribute.name}
/>
<input
aria-label={t.attributeType}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftAttributes((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, type: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={attribute.type}
/>
<label className="flex h-8 items-center gap-2 border border-border bg-card px-2 text-xs text-muted-foreground">
<input
checked={attribute.required}
onChange={(event) => {
const value = event.target.checked;
setDraftAttributes((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, required: value } : item)));
resetMetadataDraftResult();
}}
type="checkbox"
/>
{t.requiredFlag}
</label>
<button
aria-label={t.removeLine}
className="h-8 border border-border bg-card text-xs hover:bg-muted disabled:opacity-50"
disabled={draftAttributes.length === 1}
onClick={() => {
setDraftAttributes((current) => current.filter((_, itemIndex) => itemIndex !== index));
resetMetadataDraftResult();
}}
type="button"
>
x
</button>
</div>
))}
</div>
</div>
<div className="border border-border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<label className="flex min-w-0 flex-1 items-center gap-2 text-xs font-semibold uppercase text-muted-foreground">
{t.tabularSections}
<input
aria-label={t.tabularSectionName}
className="h-8 min-w-0 flex-1 border border-border bg-card px-2 text-xs font-medium normal-case text-foreground"
onChange={(event) => {
setDraftTabularSectionName(event.target.value);
resetMetadataDraftResult();
}}
spellCheck={false}
value={draftTabularSectionName}
/>
</label>
<ActionButton
actionId="add-tabular-column"
label={t.addTabularColumn}
onClick={() => {
setDraftTabularAttributes((current) => [...current, { name: "НоваяКолонка", type: "Строка" }]);
resetMetadataDraftResult();
}}
/>
</div>
<div className="space-y-2">
{draftTabularAttributes.map((attribute, index) => (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px]" key={`tabular:${index}`}>
<input
aria-label={t.attributeName}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftTabularAttributes((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, name: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={attribute.name}
/>
<input
aria-label={t.attributeType}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftTabularAttributes((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, type: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={attribute.type}
/>
<button
aria-label={t.removeLine}
className="h-8 border border-border bg-card text-xs hover:bg-muted disabled:opacity-50"
disabled={draftTabularAttributes.length === 1}
onClick={() => {
setDraftTabularAttributes((current) => current.filter((_, itemIndex) => itemIndex !== index));
resetMetadataDraftResult();
}}
type="button"
>
x
</button>
</div>
))}
</div>
</div>
</div>
<div className="mt-3 border border-border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.forms}</div>
<ActionButton
actionId="add-metadata-form"
label={t.addForm}
onClick={() => {
setDraftForms((current) => [...current, "НоваяФорма"]);
resetMetadataDraftResult();
}}
/>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{draftForms.map((form, index) => (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_32px]" key={`form:${index}`}>
<input
aria-label={t.formName}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftForms((current) => current.map((item, itemIndex) => (itemIndex === index ? value : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={form}
/>
<button
aria-label={t.removeLine}
className="h-8 border border-border bg-card text-xs hover:bg-muted disabled:opacity-50"
disabled={draftForms.length === 1}
onClick={() => {
setDraftForms((current) => current.filter((_, itemIndex) => itemIndex !== index));
resetMetadataDraftResult();
}}
type="button"
>
x
</button>
</div>
))}
</div>
</div>
<div className="mt-3 border border-border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.commands}</div>
<ActionButton
actionId="add-metadata-command"
label={t.addCommand}
onClick={() => {
setDraftCommands((current) => [...current, { name: "НоваяКоманда", handler: "НоваяКомандаОбработка" }]);
resetMetadataDraftResult();
}}
/>
</div>
<div className="grid gap-2 lg:grid-cols-2">
{draftCommands.map((command, index) => (
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_32px]" key={`command:${index}`}>
<input
aria-label={t.commandName}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftCommands((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, name: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={command.name}
/>
<input
aria-label={t.commandHandler}
className="h-8 border border-border bg-card px-2 text-xs"
onChange={(event) => {
const value = event.target.value;
setDraftCommands((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, handler: value } : item)));
resetMetadataDraftResult();
}}
spellCheck={false}
value={command.handler}
/>
<button
aria-label={t.removeLine}
className="h-8 border border-border bg-card text-xs hover:bg-muted disabled:opacity-50"
disabled={draftCommands.length === 1}
onClick={() => {
setDraftCommands((current) => current.filter((_, itemIndex) => itemIndex !== index));
resetMetadataDraftResult();
}}
type="button"
>
x
</button>
</div>
))}
</div>
</div>
{draftDiff.length > 0 ? (
<div className="mt-3 border border-border bg-muted/30 p-2 font-mono text-xs" data-metadata-draft-preview>
{draftDiff.slice(0, 8).map((line) => (
<div className="truncate" key={`${line.kind}:${line.text}`}>{line.kind} {line.text}</div>
))}
</div>
) : null}
{draftMessage ? (
<div className={`mt-2 text-xs ${draftState === "error" ? "text-destructive" : "text-success"}`} data-metadata-draft-message>{draftMessage}</div>
) : null}
</div>
) : null}
</div>
</Card>
);
}
function VersionsModePanel({
data,
language,
selectedObject
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
selectedObject?: SirNode;
}>) {
const t = messages[language];
const changes = data.authoringChanges.slice(0, 8);
const versions = data.projectVersions.slice(0, 10);
const [selectedVersion, setSelectedVersion] = useState<ProjectVersion | null>(versions[0] ?? null);
const [versionDetail, setVersionDetail] = useState<VersionDetail | null>(null);
const [versionDiff, setVersionDiff] = useState<VersionDiff | null>(null);
const [versionDetailState, setVersionDetailState] = useState<"idle" | "loading" | "error">("idle");
const [versionDetailMessage, setVersionDetailMessage] = useState("");
async function openVersion(version: ProjectVersion) {
setSelectedVersion(version);
setVersionDetailState("loading");
setVersionDetailMessage("");
try {
const lineageVersions = await getLineageVersions(version.lineage_id, data.apiUrl);
setVersionDetail(lineageVersions.find((item) => item.version_id === version.version_id) ?? null);
if (version.parent_version_id) {
setVersionDiff(await getVersionDiff(version.lineage_id, version.parent_version_id, version.version_id, data.apiUrl));
} else {
setVersionDiff(null);
}
setVersionDetailState("idle");
} catch (error) {
setVersionDetail(null);
setVersionDiff(null);
setVersionDetailState("error");
setVersionDetailMessage(error instanceof Error ? error.message : language === "ru" ? "Не удалось открыть версию." : "Failed to open version.");
}
}
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode="versions">
<PanelTitle icon={History} title={t.versionsMode} />
<div className="h-[calc(100%-45px)] overflow-auto p-4">
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="font-medium">{selectedObject?.qualified_name ?? t.none}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.objectVersioningDescription}</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-3">
<OverviewMetric label={t.versionsMode} value={String(versions.length)} />
<OverviewMetric label={t.authoringHistory} value={String(data.authoringChanges.length)} />
<OverviewMetric label={t.rollbackPlan} value={String(data.authoringChanges.filter((change) => change.status.includes("ROLL")).length)} />
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.8fr)]">
<div className="rounded-lg border border-border">
<div className="border-b border-border px-4 py-3 text-sm font-semibold">{t.versionsMode}</div>
<div className="max-h-[360px] overflow-auto divide-y divide-border">
{versions.length === 0 ? (
<div className="px-4 py-5 text-sm text-muted-foreground">{t.none}</div>
) : (
versions.map((version, index) => (
<button
className={[
"block w-full p-3 text-left text-sm hover:bg-muted/60",
selectedVersion?.version_id === version.version_id ? "bg-primary/5" : ""
].join(" ")}
data-version-id={version.version_id}
key={`${version.version_id}:${index}`}
onClick={() => openVersion(version)}
type="button"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium">{version.version_id}</div>
<Badge tone={version.parent_version_id ? "info" : "neutral"}>{version.parent_version_id ? t.parentVersion : t.rootVersion}</Badge>
</div>
<div className="mt-2 grid gap-1 text-xs text-muted-foreground">
<span className="truncate">{t.lineage}: {version.lineage_id}</span>
<span className="truncate">{t.parentVersion}: {version.parent_version_id ?? t.none}</span>
<span className="truncate">{t.taskLabel}: {version.task_id ?? t.none}</span>
</div>
</button>
))
)}
</div>
</div>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-muted/20 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold">{t.version}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{selectedVersion?.version_id ?? t.none}</div>
</div>
<Badge tone={versionDetail ? "success" : "neutral"}>{versionDetailState === "loading" ? t.loading : versionDetail ? t.fullPayload : t.summaryOnly}</Badge>
</div>
{versionDetailState === "error" ? (
<div className="mt-3 text-xs text-destructive">{versionDetailMessage}</div>
) : (
<VersionPayloadDetails detail={versionDetail} diff={versionDiff} language={language} selectedVersion={selectedVersion} />
)}
</div>
{changes.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-5 text-sm text-muted-foreground">{t.noAuthoringChanges}</div>
) : (
changes.map((change, index) => (
<div className="rounded-lg border border-border p-3 text-sm" key={`${change.change_id}:${index}`}>
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{change.version_id}</div>
<Badge tone={change.production_applied ? "success" : "warning"}>{formatChangeStatus(change.status, language)}</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{change.target?.qualified_name ?? t.none} · +{change.added_lines} / -{change.removed_lines}
</div>
</div>
))
)}
</div>
</div>
</div>
</Card>
);
}
function VersionPayloadDetails({
detail,
diff,
language,
selectedVersion
}: Readonly<{
detail: VersionDetail | null;
diff: VersionDiff | null;
language: UiLanguage;
selectedVersion: ProjectVersion | null;
}>) {
const t = messages[language];
const payload = detail?.payload ?? {};
const kind = typeof payload.kind === "string" ? formatVersionKind(payload.kind, language) : t.summaryOnly;
const approvedBy = typeof payload.approved_by === "string" ? payload.approved_by : t.none;
const approvalNote = typeof payload.approval_note === "string" ? payload.approval_note : t.none;
const rollbackOf = typeof payload.rollback_of_version_id === "string" ? payload.rollback_of_version_id : null;
const semanticDiff = Array.isArray(payload.semantic_diff)
? payload.semantic_diff
.map((line) => {
if (!line || typeof line !== "object") {
return null;
}
const item = line as { kind?: unknown; text?: unknown };
return typeof item.kind === "string" && typeof item.text === "string" ? `${formatDiffKind(item.kind, language)}: ${item.text}` : null;
})
.filter((line): line is string => Boolean(line))
.slice(0, 5)
: [];
const rows = [
[t.versionKind, kind],
[t.lineage, detail?.lineage_id ?? selectedVersion?.lineage_id ?? t.none],
[t.parentVersion, detail?.parent_version_id ?? selectedVersion?.parent_version_id ?? t.none],
[t.taskLabel, detail?.task_id ?? selectedVersion?.task_id ?? t.none],
[t.sessionLabel, detail?.session_id ?? selectedVersion?.session_id ?? t.none],
[t.approvedBy, approvedBy],
[t.auditTrail, approvalNote],
[t.rollbackPlan, rollbackOf ?? t.none]
];
return (
<div className="mt-3 space-y-3">
<div className="grid gap-1 text-xs text-muted-foreground">
{rows.map(([label, value]) => (
<div className="grid grid-cols-[90px_minmax(0,1fr)] gap-2" key={label}>
<span className="uppercase">{label}</span>
<span className="truncate text-foreground">{value}</span>
</div>
))}
</div>
<div>
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.semanticDiff}</div>
<div className="mt-2 space-y-1">
{semanticDiff.length === 0 ? (
<div className="text-xs text-muted-foreground">{detail ? t.none : t.previewRequired}</div>
) : (
semanticDiff.map((line) => (
<div className="truncate border border-border bg-background px-2 py-1 text-xs text-muted-foreground" key={line}>{line}</div>
))
)}
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.versionDiff}</div>
<div className="mt-2 space-y-1">
{!selectedVersion?.parent_version_id ? (
<div className="text-xs text-muted-foreground">{t.rootVersion}</div>
) : diff === null ? (
<div className="text-xs text-muted-foreground">{t.loading}</div>
) : diff.entries.length === 0 ? (
<div className="text-xs text-muted-foreground">{t.none}</div>
) : (
diff.entries.slice(0, 6).map((entry) => (
<div className="border border-border bg-background px-2 py-1 text-xs" key={`${entry.path}:${entry.kind}`}>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-medium">{entry.path}</span>
<Badge tone={entry.kind === "REMOVE" ? "danger" : entry.kind === "ADD" ? "success" : "warning"}>
{formatDiffKind(entry.kind, language)}
</Badge>
</div>
<div className="mt-1 truncate text-muted-foreground">{formatVersionDiffValue(entry.after ?? entry.before)}</div>
</div>
))
)}
</div>
</div>
</div>
);
}
function formatVersionDiffValue(value: unknown) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(value);
}
function formatVersionKind(kind: string, language: UiLanguage) {
if (language === "en") {
return kind;
}
if (kind === "AUTHORING_CHANGE_SET") {
return "Правка workspace";
}
if (kind === "AUTHORING_ROLLBACK") {
return "Откат workspace";
}
return kind;
}
function formatDiffKind(kind: string, language: UiLanguage) {
if (language === "en") {
return kind;
}
if (kind === "ADD") {
return "Добавить";
}
if (kind === "REMOVE") {
return "Удалить";
}
return kind;
}
function formatChangeStatus(status: string, language: UiLanguage) {
const t = messages[language];
if (status === "APPLIED_TO_WORKSPACE") {
return t.appliedToWorkspace;
}
if (status === "ROLLED_BACK_TO_WORKSPACE") {
return t.rolledBackToWorkspace;
}
if (status === "METADATA_DRAFT_APPLIED_TO_WORKSPACE") {
return t.appliedToWorkspace;
}
return status;
}
function formatGuardStatus(status: string, language: UiLanguage) {
const t = messages[language];
if (language === "en") {
return status;
}
if (status === "BLOCKED") {
return t.blocked;
}
if (status === "READY") {
return t.ready;
}
if (status === "REQUIRED") {
return t.required;
}
if (status === "OK") {
return t.checked;
}
return status;
}
function formatGuardName(name: string, language: UiLanguage) {
const labels: Record<string, { en: string; ru: string }> = {
"ai-token-budget": { en: "AI token budget", ru: "Бюджет AI-токенов" },
apply: { en: "Apply", ru: "Применение" },
impact: { en: "Impact", ru: "Влияние" },
preview: { en: "Preview", ru: "Предпросмотр" },
privacy: { en: "Privacy", ru: "Приватность" },
"production-1c": { en: "Production 1C", ru: "Продуктивная 1С" },
rbac: { en: "RBAC", ru: "Права доступа" },
review: { en: "Review", ru: "Ревью" },
"task-session": { en: "Task session", ru: "Задача и сессия" },
"workspace-history": { en: "Workspace history", ru: "История workspace" }
};
const label = labels[name];
if (label) {
return label[language];
}
return name.replaceAll("-", " ");
}
function getGuardTone(status: string): "neutral" | "success" | "warning" | "danger" | "info" {
if (status === "BLOCKED") {
return "danger";
}
if (status === "READY" || status === "OK") {
return "success";
}
if (status === "REQUIRED") {
return "warning";
}
return "info";
}
function getBlockingGuardChecks(checks: GuardCheck[]) {
return checks.filter((check) => check.status === "BLOCKED");
}
function GuardChecklist({
checks,
emptyRows,
language
}: Readonly<{
checks: GuardCheck[];
emptyRows: string[];
language: UiLanguage;
}>) {
return (
<div className="space-y-2" data-guard-summary>
{checks.length === 0 ? (
emptyRows.map((row) => (
<div className="truncate text-xs text-muted-foreground" key={row}>{row}</div>
))
) : (
checks.map((check) => (
<div className="grid gap-1 border-b border-border/60 pb-2 last:border-b-0 last:pb-0" data-guard-check data-guard-name={check.name} data-guard-status={check.status} key={`${check.name}:${check.status}:${check.message}`}>
<div className="flex min-w-0 items-center gap-2">
<Badge tone={getGuardTone(check.status)}>{formatGuardStatus(check.status, language)}</Badge>
<span className="truncate text-xs font-medium">{formatGuardName(check.name, language)}</span>
</div>
<div className="text-xs leading-5 text-muted-foreground">{check.message}</div>
</div>
))
)}
</div>
);
}
function sourceLocationFromNode(node?: SirNode | null) {
return {
source_path: node?.source_path ?? null,
line_start: node?.line_start ?? null,
line_end: node?.line_end ?? null,
column_start: null,
column_end: null
};
}
function normalizeEditorText(value: string | null | undefined) {
return (value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").trimEnd();
}
function formatSourceLocation(
source: {
source_path: string | null;
line_start: number | null;
line_end: number | null;
column_start: number | null;
column_end: number | null;
} | null,
language: UiLanguage
) {
if (!source?.source_path) {
return messages[language].none;
}
const line = source.line_start ? `:${source.line_start}` : "";
return `${source.source_path}${line}`;
}
function KnowledgeModePanel({
activeMode,
data,
language,
selectedObject
}: Readonly<{
activeMode: WorkspaceMode;
data: ProjectWorkspaceData;
language: UiLanguage;
selectedObject?: SirNode;
}>) {
const t = messages[language];
const title =
activeMode === "documentation" ? t.documentationMode : activeMode === "knowledge" ? t.knowledgeMode : t.learningMode;
const description =
activeMode === "documentation"
? t.documentationModeDescription
: activeMode === "knowledge"
? t.knowledgeModeDescription
: t.learningModeDescription;
const uncovered = data.coverage?.uncovered.slice(0, 6) ?? [];
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none" data-active-mode={activeMode}>
<PanelTitle icon={activeMode === "learning" ? GraduationCap : activeMode === "knowledge" ? Sparkles : FileText} title={title} />
<div className="h-[calc(100%-45px)] overflow-auto p-4">
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="text-sm font-medium">{selectedObject?.qualified_name ?? t.none}</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">{description}</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<KnowledgeMetric label={t.documentationMode} value={String(data.coverage?.covered_count ?? 0)} />
<KnowledgeMetric label={t.knowledgeMode} value={String(data.coverage?.uncovered_count ?? 0)} />
<KnowledgeMetric label={t.aiContext} value={String(data.authoringPreview?.context.available_methods.length ?? 0)} />
</div>
<div className="mt-4 rounded-lg border border-border">
<div className="border-b border-border px-4 py-3 text-sm font-semibold">{t.uncovered}</div>
<div className="divide-y divide-border">
{uncovered.length === 0 ? (
<div className="px-4 py-4 text-sm text-muted-foreground">{t.noReviewFindings}</div>
) : (
uncovered.map((node) => (
<div className="px-4 py-3 text-sm" key={node.lineage_id}>
<div className="font-medium">{node.qualified_name}</div>
<div className="text-xs text-muted-foreground">{node.kind}</div>
</div>
))
)}
</div>
</div>
</div>
</Card>
);
}
function InspectorPanel({
data,
language,
selectedMetadataNode,
selectedObject
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
selectedMetadataNode?: MetadataTreeNode;
selectedObject?: SirNode;
}>) {
const t = messages[language];
const context = data.authoringPreview?.context;
const schema = data.selectedObjectSchema;
const impact = data.selectedObjectImpact;
const selectedQualifiedName = schema?.object.qualified_name ?? context?.object?.qualified_name ?? selectedObject?.qualified_name ?? selectedMetadataNode?.qualified_name ?? selectedMetadataNode?.label ?? t.none;
const selectedChildren = selectedMetadataNode?.children ?? [];
const schemaAttributes = schema?.attributes ?? [];
const schemaSections = schema?.tabular_sections ?? [];
return (
<Card className="rounded-none border-0 border-b border-border p-0 shadow-none">
<PanelTitle icon={PanelRight} title={t.contextInspector} />
<div className="max-h-[360px] overflow-auto p-3">
<InspectorSection title={t.selectedObject}>
<InspectorRow label={t.selectedObject} value={selectedQualifiedName} />
<InspectorRow label={language === "ru" ? "Тип" : "Type"} value={selectedMetadataNode?.kind ?? selectedObject?.kind ?? t.none} />
<InspectorRow label={language === "ru" ? "Дочерние узлы" : "Child nodes"} value={String(selectedChildren.length)} />
<InspectorRow label={t.owner} value={t.none} />
<InspectorRow label={t.subsystem} value={t.none} />
<InspectorRow label={t.criticality} value={t.none} />
<InspectorRow label={t.activeTask} value={t.none} />
</InspectorSection>
{schema ? (
<InspectorSection title={language === "ru" ? "Схема объекта" : "Object Schema"}>
<InspectorRow label={t.objectAttributes} value={String(schemaAttributes.length)} />
<InspectorRow label={t.tabularSections} value={String(schemaSections.length)} />
<InspectorRow label={t.forms} value={String(data.selectedObjectUi?.forms.length ?? 0)} />
<InspectorRow label={t.commands} value={String(impact?.commands.length ?? context?.commands.length ?? 0)} />
<InspectorRow label={language === "ru" ? "Роли" : "Roles"} value={String(impact?.roles.length ?? 0)} />
<InspectorRow label={language === "ru" ? "Записи" : "Writes"} value={String(impact?.writes.length ?? 0)} />
<InspectorRow label={language === "ru" ? "Запросы" : "Queries"} value={String(impact?.query_tables.length ?? 0)} />
</InspectorSection>
) : null}
{selectedChildren.length > 0 ? (
<InspectorSection title={language === "ru" ? "Структура объекта" : "Object Structure"}>
{selectedChildren.slice(0, 8).map((node) => (
<InspectorRow key={node.id} label={node.label} value={node.has_more ? "lazy" : String(node.count)} />
))}
</InspectorSection>
) : null}
<InspectorSection title={t.currentContext}>
<InspectorRow label={t.currentContext} value={context?.routine?.qualified_name ?? t.none} />
<InspectorRow label={t.availableVariables} value={formatList(context?.parameters ?? [])} />
<InspectorRow label={t.localVariables} value={formatList(context?.local_variables ?? [])} />
<InspectorRow label={t.calls} value={formatList(context?.available_methods.slice(0, 5) ?? [])} />
<InspectorRow label={t.queries} value={formatList(context?.query_tables.map((node) => node.name) ?? [])} />
<InspectorRow label={t.writes} value={formatList(context?.writes.map((node) => node.name) ?? [])} />
</InspectorSection>
<InspectorSection title={t.riskContext}>
<InspectorRow label={t.reviewFindings} value={String(context?.review_findings.length ?? 0)} />
<InspectorRow label={t.runtimeIncidents} value="0" />
<InspectorRow label={t.version} value={data.semanticDiffPreview?.version_preview?.current_version_id ?? t.none} />
<InspectorRow label={t.permissionState} value={t.workspaceApplyReady} />
</InspectorSection>
</div>
<div className="border-t border-border bg-muted/30 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Bot className="h-4 w-4 text-primary" aria-hidden="true" />
{t.aiPairProgrammer}
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">{t.aiSuggestion}</p>
</div>
</Card>
);
}
function InspectorSection({ children, title }: Readonly<{ children: React.ReactNode; title: string }>) {
return (
<div className="mb-4 last:mb-0">
<div className="mb-2 text-xs font-semibold uppercase text-muted-foreground">{title}</div>
<div className="space-y-2">{children}</div>
</div>
);
}
function KnowledgeLearningPanel({
data,
language
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
}>) {
const t = messages[language];
const covered = data.coverage?.covered_count ?? 0;
const uncovered = data.coverage?.uncovered_count ?? 0;
const coveragePercent = covered + uncovered > 0 ? Math.round((covered / (covered + uncovered)) * 100) : 0;
return (
<Card className="rounded-none border-0 border-b border-border p-0 shadow-none">
<PanelTitle icon={GraduationCap} title={t.knowledgeLearning} />
<div className="grid grid-cols-3 divide-x divide-border text-sm">
<KnowledgeMetric label={t.documentationMode} value={String(covered)} />
<KnowledgeMetric label={t.knowledgeMode} value={`${coveragePercent}%`} />
<KnowledgeMetric label={t.learningMode} value={String(uncovered)} />
</div>
<div className="border-t border-border px-4 py-3 text-xs leading-5 text-muted-foreground">
{t.knowledgeLearningDescription}
</div>
</Card>
);
}
function KnowledgeMetric({ label, value }: Readonly<{ label: string; value: string }>) {
return (
<div className="min-w-0 px-3 py-3">
<div className="truncate text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-base font-semibold">{value}</div>
</div>
);
}
function BottomWorkbenchPanel({
data,
language,
problems
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
problems: ProjectWorkspaceData["review"];
}>) {
const t = messages[language];
const semanticPreview = data.semanticDiffPreview;
const changes = data.authoringChanges.slice(0, 4);
const checks = semanticPreview?.checks ?? data.authoringPreview?.checks ?? [];
const outputRows = [
`${t.parserStatus}: OK · ${data.snapshot?.diagnostics_count ?? 0} ${t.diagnosticsStatus.toLowerCase()}`,
`SIR: ${data.snapshot?.snapshot_id ?? t.none}`,
`${t.graphEdges}: ${data.exportSnapshot?.edges.length ?? 0}`,
`${t.authoringMode}: ${t.workspaceHistoryOnly}`,
t.productionApplyDisabled
];
const aiRows = [
t.aiSuggestion,
`${t.availableVariables}: ${formatList(data.authoringPreview?.context.parameters ?? [])}`,
`${t.objectAttributes}: ${formatList(data.authoringPreview?.context.object_attributes.map((node) => node.name) ?? [])}`
];
return (
<Card className="min-h-0 rounded-none border-0 border-t border-border p-0 shadow-none" data-bottom-tool-panel>
<div className="flex h-10 items-center gap-1 overflow-x-auto border-b border-border bg-card px-2">
<BottomTab icon={TriangleAlert} label={t.problemsPanel} count={problems.length} active />
<BottomTab icon={GitCompareArrows} label={t.semanticDiff} count={semanticPreview?.semantic_diff.length ?? data.authoringPreview?.semantic_diff.length ?? 0} />
<BottomTab icon={Monitor} label={t.outputPanel} />
<BottomTab icon={History} label={t.authoringHistory} count={changes.length} />
<BottomTab icon={FlaskConical} label={t.testsPanel} />
<BottomTab icon={Bot} label={t.aiShort} />
</div>
<div className="grid h-[calc(100%-40px)] min-h-0 grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)] overflow-hidden">
<div className="min-h-0 overflow-auto divide-y divide-border">
{problems.length === 0 ? (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />
{t.noReviewFindings}
</div>
) : (
problems.map((problem) => (
<div className="grid gap-1 px-4 py-3 text-sm" key={problem.finding_id ?? problem.title}>
<div className="flex items-center gap-2">
<Badge tone={problem.severity === "WARNING" ? "warning" : "info"}>{problem.severity}</Badge>
<span className="font-medium">{problem.title}</span>
</div>
<div className="text-xs leading-5 text-muted-foreground">{problem.message}</div>
</div>
))
)}
</div>
<div className="min-h-0 overflow-auto border-l border-border bg-muted/20">
<BottomList title={t.outputPanel} rows={outputRows} />
<div className="border-b border-border p-3">
<div className="text-xs font-semibold uppercase text-muted-foreground">{t.guardedApply}</div>
<div className="mt-2">
<GuardChecklist checks={checks} emptyRows={[t.previewRequired, t.impactBeforeApply, t.reviewBeforeApply]} language={language} />
</div>
</div>
<BottomList title={t.aiShort} rows={aiRows} />
</div>
</div>
</Card>
);
}
function BottomTab({
active = false,
count,
icon: Icon,
label
}: Readonly<{
active?: boolean;
count?: number;
icon: React.ComponentType<{ className?: string }>;
label: string;
}>) {
return (
<button
className={[
"flex h-7 shrink-0 items-center gap-1.5 border px-2 text-xs font-medium",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-muted"
].join(" ")}
type="button"
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
{typeof count === "number" ? <span className="ml-1 rounded bg-muted px-1 text-[10px] text-muted-foreground">{count}</span> : null}
</button>
);
}
function BottomList({ rows, title }: Readonly<{ rows: string[]; title: string }>) {
return (
<div className="border-b border-border p-3">
<div className="text-xs font-semibold uppercase text-muted-foreground">{title}</div>
<div className="mt-2 space-y-1">
{rows.map((row) => (
<div className="truncate text-xs text-muted-foreground" key={row}>{row}</div>
))}
</div>
</div>
);
}
function DiffPanel({
data,
language
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
}>) {
const t = messages[language];
const semanticPreview = data.semanticDiffPreview;
const diffLines =
semanticPreview?.semantic_diff ??
data.authoringPreview?.semantic_diff ??
suggestedCode.map((text) => ({ kind: "ADD", text }));
const checks = semanticPreview?.checks ?? data.authoringPreview?.checks ?? [];
const blockedChecks = getBlockingGuardChecks(checks);
const isApplyBlocked = blockedChecks.length > 0;
return (
<Card className="min-h-0 rounded-none border-0 border-b border-border p-0 shadow-none">
<PanelTitle icon={GitCompareArrows} title={t.semanticDiff} />
<div className="h-[calc(100%-45px)] space-y-3 overflow-auto p-4">
{semanticPreview?.version_preview ? (
<div className="rounded-lg border border-border bg-muted/40 p-3 text-sm">
<div className="font-medium">{t.versionPreview}</div>
<div className="mt-2 grid gap-1 text-xs text-muted-foreground">
<span>{semanticPreview.version_preview.next_version_id}</span>
<span>{t.affectedNodes}: {semanticPreview.affected_nodes.length}</span>
<span>{isApplyBlocked ? `${t.blocked}: ${blockedChecks.length}` : semanticPreview.version_preview.apply_available ? t.guardedApply : t.workspaceApplyReady}</span>
<span>{t.productionApplyDisabled}</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
<ActionButton label={t.previewRequired} />
<ActionButton disabled={!semanticPreview.version_preview.apply_available || isApplyBlocked} label={t.applyToSfera} />
<ActionButton label={t.rollbackPlan} />
</div>
</div>
) : null}
{diffLines.map((line) => (
<DiffLine
key={`${line.kind}:${line.text}`}
tone={line.kind === "REMOVE" ? "danger" : "success"}
label={line.kind === "REMOVE" ? t.removeLine : t.addLine}
value={line.text}
/>
))}
<div className="rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
<div className="font-medium text-warning">{t.guardedApply}</div>
<div className="mt-2">
<GuardChecklist checks={checks} emptyRows={[t.previewRequired, t.impactBeforeApply, t.reviewBeforeApply]} language={language} />
</div>
</div>
</div>
</Card>
);
}
function ActionButton({
actionId,
disabled = false,
label,
onClick
}: Readonly<{
actionId?: string;
disabled?: boolean;
label: string;
onClick?: () => void;
}>) {
return (
<button className="h-8 border border-border bg-background px-2 text-xs font-medium hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" data-editor-action={actionId} disabled={disabled} onClick={onClick} type="button">
{label}
</button>
);
}
function HistoryPanel({
data,
language,
onAuthoringChangeApplied
}: Readonly<{
data: ProjectWorkspaceData;
language: UiLanguage;
onAuthoringChangeApplied: (change: AuthoringChange, version?: ProjectVersion) => void;
}>) {
const t = messages[language];
const changes = data.authoringChanges.slice(0, 5);
const [rollbackPreview, setRollbackPreview] = useState<Awaited<ReturnType<typeof getAuthoringRollbackPreview>> | null>(null);
const [rollbackState, setRollbackState] = useState<"idle" | "pending" | "applying" | "applied" | "error">("idle");
const [rollbackMessage, setRollbackMessage] = useState("");
async function loadRollbackPreview(changeId: string) {
setRollbackState("pending");
setRollbackMessage("");
try {
const preview = await getAuthoringRollbackPreview(data.projectId, changeId, data.apiUrl);
setRollbackPreview(preview);
setRollbackState("idle");
} catch (error) {
setRollbackPreview(null);
setRollbackState("error");
setRollbackMessage(error instanceof Error ? error.message : language === "ru" ? "Ошибка построения rollback preview." : "Rollback preview failed.");
}
}
async function applyRollbackPreview() {
if (!rollbackPreview?.apply_available) {
return;
}
setRollbackState("applying");
setRollbackMessage("");
try {
const authoringSession = await ensureAuthoringSession(data.projectId, data.apiUrl);
const response = await applyAuthoringRollback(
data.projectId,
rollbackPreview.change_id,
{
expected_rollback_version_id: rollbackPreview.rollback_version_id,
approved_by: authoringSession.user_id,
approval_note: t.rollbackApplyNote,
task_id: authoringSession.task_id,
session_id: authoringSession.session_id,
apply_to_production: false
},
data.apiUrl
);
onAuthoringChangeApplied(
{
change_id: response.rollback_change_id,
project_id: response.project_id,
status: response.status,
target: response.preview.target,
version_id: response.version.version_id,
approved_by: authoringSession.user_id,
approval_note: t.rollbackApplyNote,
task_id: response.version.task_id,
session_id: response.version.session_id,
added_lines: response.preview.semantic_diff.filter((line) => line.kind === "ADD").length,
removed_lines: response.preview.semantic_diff.filter((line) => line.kind === "REMOVE").length,
production_applied: response.production_applied
},
versionToSummary(response.version)
);
setRollbackState("applied");
setRollbackMessage(`${formatChangeStatus(response.status, language)}: ${response.version.version_id}`);
} catch (error) {
setRollbackState("error");
setRollbackMessage(error instanceof Error ? error.message : language === "ru" ? "Ошибка применения rollback." : "Rollback apply failed.");
}
}
return (
<Card className="min-h-0 rounded-none border-0 p-0 shadow-none">
<PanelTitle icon={History} title={t.authoringHistory} />
{rollbackPreview ? (
<div className="border-b border-border bg-muted/30 p-3 text-xs" data-rollback-preview>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold">{t.rollbackPlan}</div>
<div className="mt-1 truncate text-muted-foreground">{rollbackPreview.rollback_version_id}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge tone={rollbackPreview.apply_available ? "success" : "warning"}>
{rollbackPreview.apply_available ? t.workspaceApplyReady : t.previewRequired}
</Badge>
<ActionButton
actionId="apply-rollback"
disabled={!rollbackPreview.apply_available || rollbackState === "applying"}
label={rollbackState === "applying" ? t.rollingBack : t.applyToSfera}
onClick={applyRollbackPreview}
/>
</div>
</div>
<div className="mt-2 grid gap-1 text-muted-foreground">
{rollbackPreview.checks.map((check) => (
<span key={`${check.name}:${check.status}`}>{formatGuardStatus(check.status, language)}: {check.message}</span>
))}
</div>
{rollbackMessage && rollbackState !== "error" ? (
<div className="mt-2 truncate text-success" data-rollback-apply-message>{rollbackMessage}</div>
) : null}
</div>
) : rollbackState === "error" ? (
<div className="border-b border-border bg-destructive/10 px-4 py-3 text-xs text-destructive" data-rollback-apply-message>{rollbackMessage}</div>
) : null}
{changes.length === 0 ? (
<div className="px-4 py-5 text-sm text-muted-foreground">{t.noAuthoringChanges}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[860px] border-collapse text-sm">
<thead className="bg-card text-left text-xs uppercase text-muted-foreground">
<tr className="border-b border-border">
<th className="px-4 py-3 font-medium">{t.status}</th>
<th className="px-4 py-3 font-medium">{t.selectedObject}</th>
<th className="px-4 py-3 font-medium">{t.version}</th>
<th className="px-4 py-3 font-medium">{t.approvedBy}</th>
<th className="px-4 py-3 text-right font-medium">{t.semanticDiff}</th>
<th className="px-4 py-3 text-right font-medium">{t.rollbackPlan}</th>
</tr>
</thead>
<tbody>
{changes.map((change, index) => (
<tr className="border-b border-border last:border-0" key={`${change.change_id}:${index}`}>
<td className="px-4 py-3">
<Badge tone={change.production_applied ? "success" : "warning"}>{formatChangeStatus(change.status, language)}</Badge>
</td>
<td className="px-4 py-3 font-medium">{change.target?.qualified_name ?? change.target?.name ?? t.none}</td>
<td className="max-w-64 truncate px-4 py-3 text-muted-foreground">{change.version_id}</td>
<td className="px-4 py-3 text-muted-foreground">{change.approved_by}</td>
<td className="px-4 py-3 text-right text-muted-foreground">
+{change.added_lines} / -{change.removed_lines}
</td>
<td className="px-4 py-3 text-right">
{change.change_id.startsWith("rollback.") ? (
<span className="text-xs text-muted-foreground">{t.none}</span>
) : (
<ActionButton
actionId="rollback-plan"
disabled={rollbackState === "pending"}
label={rollbackState === "pending" ? t.building : t.rollbackPlan}
onClick={() => loadRollbackPreview(change.change_id)}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
);
}
function PanelTitle({
icon: Icon,
title
}: Readonly<{
icon: React.ComponentType<{ className?: string }>;
title: string;
}>) {
return (
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Icon className="h-4 w-4 text-primary" aria-hidden="true" />
<h3 className="text-sm font-semibold">{title}</h3>
</div>
);
}
function InspectorRow({ label, value }: Readonly<{ label: string; value: string }>) {
return (
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 break-words font-medium">{value}</div>
</div>
);
}
function DiffLine({
tone,
label,
value
}: Readonly<{
tone: "success" | "danger";
label: string;
value: string;
}>) {
return (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-2 font-mono text-xs">
<Badge tone={tone}>{label}</Badge>
<Braces className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="break-all">{value}</span>
</div>
);
}
function formatList(items: string[]) {
return items.length > 0 ? items.join(", ") : "нет";
}