From 9dc35bae200d18952ac548be08fe8563fa8137a9 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 17:14:22 +0300 Subject: [PATCH] Include source-only forms in IDE rendering --- frontend/sfera-web/src/lib/api.ts | 13 +++- services/api-server/src/api_server/main.py | 77 +++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/frontend/sfera-web/src/lib/api.ts b/frontend/sfera-web/src/lib/api.ts index bd6e603..7dc5454 100644 --- a/frontend/sfera-web/src/lib/api.ts +++ b/frontend/sfera-web/src/lib/api.ts @@ -825,6 +825,14 @@ function ownerQualifiedNameForForm(formQualifiedName: string) { return parts.length > 1 ? parts.slice(0, -1).join(".") : formQualifiedName; } +function looksLikeObjectFormQualifiedName(qualifiedName: string) { + const parts = qualifiedName.split("."); + if (parts[0] === "ОбщаяФорма") { + return parts.length === 2; + } + return parts.length >= 3; +} + export async function getProjectWorkspaceData(projectId: string, apiUrl = resolveApiUrl(), selectedRoutine?: string | null, activeMode?: string | null) { const selectedRoutineName = selectedRoutine?.trim() ?? null; const workspaceMode = activeMode?.trim() || "overview"; @@ -862,7 +870,10 @@ export async function getProjectWorkspaceData(projectId: string, apiUrl = resolv const selectedFormQualifiedName = selectedTreeNode?.kind === "FORM" ? selectedTreeNode.qualified_name - : selectedRoutineName && selectedRoutineName.split(".").at(-1)?.toLocaleLowerCase("ru-RU").includes("форма") + : selectedRoutineName && ( + selectedRoutineName.split(".").at(-1)?.toLocaleLowerCase("ru-RU").includes("форма") || + looksLikeObjectFormQualifiedName(selectedRoutineName) + ) ? selectedRoutineName : null; const selectedObjectName = selectedFormQualifiedName diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 620c20e..fda14d0 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -7926,10 +7926,16 @@ def _hydrate_object_ui_forms_from_source( source_root = _current_project_source_root(project_id) if source_root is None: return forms - return [ + hydrated = [ _hydrate_form_elements_from_source(project_id, source_root, object_node, form) for form in forms ] + existing = {form.form.qualified_name for form in hydrated} + for form in _source_forms_for_object(project_id, source_root, object_node.qualified_name): + if form.form.qualified_name not in existing: + hydrated.append(form) + existing.add(form.form.qualified_name) + return sorted(hydrated, key=lambda item: item.form.name) def _common_form_ui_from_source(project_id: str, object_name: str) -> ObjectUiResponse | None: @@ -7978,6 +7984,55 @@ def _common_form_ui_from_source(project_id: str, object_name: str) -> ObjectUiRe return ObjectUiResponse(object=form_node, forms=[form]) +def _source_forms_for_object( + project_id: str, + source_root: Path, + object_qualified_name: str, +) -> list[FormSemanticsResponse]: + forms: list[FormSemanticsResponse] = [] + for form_file in _edt_form_files_for_object(source_root, object_qualified_name): + form = _source_form_response(project_id, form_file) + if form is not None: + forms.append(form) + return forms + + +def _source_form_response(project_id: str, form_file: Path) -> FormSemanticsResponse | None: + try: + xml_objects = parse_one_c_xml_file(form_file) + except (OSError, UnicodeDecodeError, ET.ParseError): + return None + form_object = next((item for item in xml_objects if item.object_kind == "FORM"), None) + if form_object is None: + return None + source_path = form_file.as_posix() + form_node = NamedNode( + lineage_id=make_lineage_id( + NodeKind.FORM.value, + f"{project_id}:{source_path}:FORM:{form_object.qualified_name}", + ), + kind=NodeKind.FORM.value, + name=form_object.name, + qualified_name=form_object.qualified_name, + attributes=form_object.attributes, + ) + elements = [ + NamedNode( + lineage_id=make_lineage_id( + NodeKind.FORM_ELEMENT.value, + f"{project_id}:{source_path}:ELEMENT:{item.qualified_name}", + ), + kind=NodeKind.FORM_ELEMENT.value, + name=item.name, + qualified_name=item.qualified_name, + attributes=item.attributes, + ) + for item in xml_objects + if item.object_kind == "ELEMENT" and item.qualified_name.startswith(f"{form_node.qualified_name}.") + ] + return FormSemanticsResponse(form=form_node, commands=[], elements=elements, command_handlers={}) + + def _hydrate_form_elements_from_source( project_id: str, source_root: Path, @@ -8047,6 +8102,26 @@ def _edt_form_file_for_object(source_root: Path, object_qualified_name: str, for return next((candidate for candidate in candidates if candidate.exists()), None) +def _edt_form_files_for_object(source_root: Path, object_qualified_name: str) -> list[Path]: + parts = object_qualified_name.split(".") + if len(parts) < 2: + return [] + prefix, object_name = parts[0], parts[1] + source_dirs = [source_root / "src", *(path / "src" for path in source_root.iterdir() if path.is_dir())] + if prefix == "ОбщаяФорма": + candidates = [source_dir / "CommonForms" / object_name / "Form.form" for source_dir in source_dirs] + else: + directory = _EDT_SOURCE_DIRECTORY_BY_PREFIX.get(prefix) + if directory is None: + return [] + candidates = [ + form_file + for source_dir in source_dirs + for form_file in (source_dir / directory / object_name / "Forms").glob("*/Form.form") + ] + return sorted({candidate for candidate in candidates if candidate.exists()}) + + def _persist_job(job: OperationJob) -> OperationJob: _storage.write_document("operations_jobs", job.job_id, job.model_dump(mode="json")) return job