4929 lines
221 KiB
TypeScript
4929 lines
221 KiB
TypeScript
"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} -> {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(", ") : "нет";
|
||
}
|