v0.1.4-pre.009
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/README.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# `ksp-app-config-desk`
|
||||
|
||||
@@ -25,6 +25,8 @@ Le gabarit desktop actif fournit maintenant :
|
||||
- le frontend Vanilla TypeScript + Vite sous `frontend/` ;
|
||||
- SCSS, Bootstrap et Font Awesome ;
|
||||
- SimpleBar + `resize-observer-polyfill` pour le scrolling du shell ;
|
||||
- DataTables 3 + Select 4 pour l’inventaire interactif des documents Config ;
|
||||
- le panneau Documents générique alimenté par le registre `ksp-config-lib`, avec diagnostics backend et réparation validée ;
|
||||
- `tauri-plugin-tracing` côté Rust et `@fltsci/tauri-plugin-tracing` côté frontend ;
|
||||
- les ports dédiés `1430` pour Vite HTTP et `1431` pour le WebSocket de développement ;
|
||||
- la destination frontend externe commune à Tauri et Vite;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<!-- file: crates/ksp-app-config-desk/TODO.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# TODO `ksp-app-config-desk`
|
||||
|
||||
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP et le lifecycle `splash -> main` sont maintenant présents.
|
||||
Le gabarit frontend, le bootstrap Config/Logging, `AppState`, le `LoggingGuard` durable, les premiers DTO TS-RS, le bridge frontend logging KSP, le lifecycle `splash -> main` et le panneau Documents sont maintenant présents.
|
||||
|
||||
Les prochaines tranches doivent encore :
|
||||
|
||||
- développer les panneaux Documents, Profils, Environnement/`.env`, Secrets et Logging ;
|
||||
- introduire DataTables au premier tableau qui exige tri, filtrage, sélection ou checkbox ;
|
||||
- développer les panneaux Profils, Environnement/`.env`, Secrets et Logging ;
|
||||
- faire évoluer le runtime Logging vers des fichiers applicatifs uniques par lancement (`app-name.<start-timestamp>.<ext>`) ;
|
||||
- ramener le target applicatif de `debug` à `info` ou `warn` avant clôture de la release ;
|
||||
- démontrer le hot reload Logging et le panneau de test de routage ;
|
||||
- étudier puis intégrer le retour Rust -> console WebKit via `tauri_plugin_tracing::WebviewLayer` dans le subscriber possédé par `ksp-logging-lib`, afin de pouvoir utiliser `attachConsole()` sans second subscriber, double émission ni boucle avec le bridge KSP.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!-- file: crates/ksp-app-config-desk/USAGE.md -->
|
||||
<!-- version: 8 -->
|
||||
<!-- version: 9 -->
|
||||
|
||||
# Utilisation de `ksp-app-config-desk`
|
||||
|
||||
## État actuel
|
||||
|
||||
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise désormais Config, le runtime Logging et `AppState`; les panneaux métier Config restent volontairement absents à ce stade.
|
||||
Le gabarit Rust/Tauri et le frontend Vite/TypeScript/SCSS sont présents. Le backend initialise Config, le runtime Logging et `AppState`. Le panneau Documents est fonctionnel ; les panneaux Profils, Environnement/`.env`, Secrets et Logging restent à compléter.
|
||||
|
||||
La fenêtre `splash` est visible au démarrage. Après readiness du frontend et temporisation résolue par Config, l'application effectue le fade-out, affiche/focalise `main` puis détruit `splash`. La fenêtre principale expose immédiatement la navigation monofenêtre de référence.
|
||||
|
||||
@@ -136,3 +136,20 @@ Seule la vue d'ensemble consomme déjà `get_app_snapshot`; les autres routes re
|
||||
## Asset font du splash
|
||||
|
||||
La police bot3 de référence est `DOS_Amazigh.ttf`. Vérifier l'asset local contre l'empreinte documentée dans `frontend/fonts/README.md` ; `frontend/sass/splash.scss` l'applique au titre du splash.
|
||||
|
||||
## Panneau Documents
|
||||
|
||||
La vue **Documents** liste les documents de kind `Config` depuis le registre `ksp-config-lib`; les schemas ne sont pas codés en dur dans le frontend. DataTables fournit tri, recherche, pagination et sélection simple.
|
||||
|
||||
Pour chaque document, le backend expose :
|
||||
|
||||
- `file_id` ;
|
||||
- filename mappé ;
|
||||
- schema associé ;
|
||||
- path résolu par Config ;
|
||||
- état valide/invalide ;
|
||||
- étape de diagnostic `read`, `json`, `schema`, `semantic`, `effective` ou `other`.
|
||||
|
||||
La sélection charge le source brut via `ConfigManagement::read_source()`, y compris lorsque le document est invalide mais lisible. Le bouton **Valider et sauvegarder** envoie le texte à `ConfigManagement::save_source_candidate()` : aucune écriture frontend directe du fichier n’est effectuée. Un candidat invalide est refusé avant persistence et le diagnostic backend reste l’autorité de validation.
|
||||
|
||||
Les clics, sélections, chargements, remplacements DOM et sauvegardes sont tracés via le bridge frontend KSP sans journaliser le contenu du source.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-app-config-desk/frontend/main.html -->
|
||||
<!-- version: 3 -->
|
||||
<!-- version: 4 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
|
||||
@@ -55,6 +55,67 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
<section id="documentsPanel" hidden aria-label="Documents Config">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Documents enregistrés</h2>
|
||||
<p class="text-body-secondary small mb-0">Le registre, la validation et la persistence restent exclusivement sous contrôle de ksp-config-lib.</p>
|
||||
</div>
|
||||
<button id="refreshDocuments" class="btn btn-outline-primary btn-sm" type="button">
|
||||
<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table id="configDocumentsTable" class="table table-striped table-hover align-middle w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">file_id</th>
|
||||
<th scope="col">Fichier</th>
|
||||
<th scope="col">Schema</th>
|
||||
<th scope="col">État</th>
|
||||
<th scope="col">Étape</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<section id="documentDetailPanel" hidden class="border rounded p-3 mb-4 bg-body-tertiary" aria-label="Détail du document Config">
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Détail du document</h2>
|
||||
<span id="documentDetailStatus" class="badge text-bg-secondary">—</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="reloadDocumentSource" class="btn btn-outline-secondary btn-sm" type="button">Recharger</button>
|
||||
<button id="saveDocumentSource" class="btn btn-primary btn-sm" type="button">Valider et sauvegarder</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="row small mb-3 app-document-metadata">
|
||||
<dt class="col-lg-3">file_id</dt>
|
||||
<dd id="documentDetailFileId" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Fichier mappé</dt>
|
||||
<dd id="documentDetailFilename" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Schema</dt>
|
||||
<dd id="documentDetailSchema" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Path résolu</dt>
|
||||
<dd id="documentDetailPath" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3">Étape diagnostic</dt>
|
||||
<dd id="documentDetailStage" class="col-lg-9">—</dd>
|
||||
<dt class="col-lg-3 mb-0">Diagnostic</dt>
|
||||
<dd id="documentDetailDiagnostic" class="col-lg-9 mb-0">—</dd>
|
||||
</dl>
|
||||
<label class="form-label fw-semibold" for="documentSourceEditor">Source brut Config</label>
|
||||
<textarea id="documentSourceEditor" class="form-control app-document-source" rows="18" spellcheck="false" autocomplete="off"></textarea>
|
||||
<div class="form-text">La sauvegarde est refusée tant que le candidat échoue au parsing JSON, au schema ou aux invariants sémantiques backend.</div>
|
||||
</section>
|
||||
|
||||
<div id="documentsStatus" class="alert alert-primary mb-0" role="status" aria-live="polite">Chargement de l'inventaire Config...</div>
|
||||
</section>
|
||||
|
||||
<section id="placeholderPanel" hidden aria-live="polite">
|
||||
<div class="alert alert-secondary mb-4" role="status">
|
||||
Cette route du shell est prête ; son contenu fonctionnel sera ajouté dans la prerelease prévue par le plan 0.1.4.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/sass/_app.scss
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
$app-header-height: 72px;
|
||||
$app-footer-height: 42px;
|
||||
@@ -52,7 +52,7 @@ body {
|
||||
}
|
||||
|
||||
.app-shell-card {
|
||||
max-width: 980px;
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.app-shell-status {
|
||||
@@ -72,3 +72,22 @@ body {
|
||||
.app-overview-list dd {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-metadata dd,
|
||||
.app-document-source,
|
||||
#configDocumentsTable td:first-child,
|
||||
#configDocumentsTable td:nth-child(2),
|
||||
#configDocumentsTable td:nth-child(3) {
|
||||
font-family: var(--bs-font-monospace);
|
||||
}
|
||||
|
||||
.app-document-source {
|
||||
min-height: 320px;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
#configDocumentsTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
279
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
279
crates/ksp-app-config-desk/frontend/ts/documents.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// file: crates/ksp-app-config-desk/frontend/ts/documents.ts
|
||||
// version: 1
|
||||
|
||||
//! Documents panel backed exclusively by ksp-config-lib inventory, validation and management APIs.
|
||||
|
||||
import DataTable from "datatables.net-bs5";
|
||||
import "datatables.net-select-bs5";
|
||||
import "datatables.net-bs5/css/dataTables.bootstrap5.css";
|
||||
import "datatables.net-select-bs5/css/select.bootstrap5.css";
|
||||
import type { ConfigDocumentDetailDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto";
|
||||
import type { ConfigDocumentErrorDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto";
|
||||
import type { ConfigDocumentSaveResultDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto";
|
||||
import type { ConfigDocumentSummaryDto } from "./bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto";
|
||||
import { frontendDebug, frontendTrace, frontendWarn } from "./frontend_log";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
interface DocumentsDataTable {
|
||||
destroy(): unknown;
|
||||
on(event: string, callback: (event: unknown, api: unknown, type: string, indexes: number[]) => void): unknown;
|
||||
row(selector: number): { node(): Node | null };
|
||||
}
|
||||
|
||||
let documentsTable: DocumentsDataTable | null = null;
|
||||
let activeDocument: ConfigDocumentDetailDto | null = null;
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
valid: "Valide",
|
||||
read: "Lecture",
|
||||
json: "JSON",
|
||||
schema: "Schema",
|
||||
semantic: "Sémantique",
|
||||
effective: "Effective",
|
||||
other: "Autre",
|
||||
};
|
||||
|
||||
function stageLabel(stage: string): string {
|
||||
return stageLabels[stage] ?? stage;
|
||||
}
|
||||
|
||||
function setText(selector: string, value: string): void {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setDocumentsStatus(message: string, tone: "primary" | "success" | "warning" | "danger" = "primary"): void {
|
||||
const status = document.querySelector<HTMLElement>("#documentsStatus");
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
status.className = `alert alert-${tone} mb-0`;
|
||||
status.textContent = message;
|
||||
frontendTrace("main", "Documents panel status replaced", { tone });
|
||||
}
|
||||
|
||||
function diagnosticMessage(summary: ConfigDocumentSummaryDto): string {
|
||||
if (!summary.diagnostic) {
|
||||
return "Aucun diagnostic backend.";
|
||||
}
|
||||
return `${summary.diagnostic.domain}.${summary.diagnostic.code} — ${summary.diagnostic.message}`;
|
||||
}
|
||||
|
||||
function renderDetail(detail: ConfigDocumentDetailDto): void {
|
||||
activeDocument = detail;
|
||||
setText("#documentDetailFileId", detail.summary.fileId);
|
||||
setText("#documentDetailFilename", detail.summary.filename);
|
||||
setText("#documentDetailSchema", detail.summary.schemaFileId ?? "—");
|
||||
setText("#documentDetailPath", detail.summary.path);
|
||||
setText("#documentDetailStage", stageLabel(detail.summary.diagnosticStage));
|
||||
setText("#documentDetailDiagnostic", diagnosticMessage(detail.summary));
|
||||
const badge = document.querySelector<HTMLElement>("#documentDetailStatus");
|
||||
if (badge) {
|
||||
const valid = detail.summary.validationStatus === "valid";
|
||||
badge.className = `badge ${valid ? "text-bg-success" : "text-bg-danger"}`;
|
||||
badge.textContent = valid ? "Valide" : "Invalide";
|
||||
}
|
||||
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
|
||||
if (editor) {
|
||||
editor.value = detail.source ?? "";
|
||||
editor.disabled = detail.source === null;
|
||||
}
|
||||
const saveButton = document.querySelector<HTMLButtonElement>("#saveDocumentSource");
|
||||
if (saveButton) {
|
||||
saveButton.disabled = detail.source === null;
|
||||
}
|
||||
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = false;
|
||||
}
|
||||
frontendTrace("main", "Config document detail rendered", {
|
||||
fileId: detail.summary.fileId,
|
||||
validationStatus: detail.summary.validationStatus,
|
||||
diagnosticStage: detail.summary.diagnosticStage,
|
||||
sourceAvailable: detail.source !== null,
|
||||
});
|
||||
}
|
||||
|
||||
function clearDetail(): void {
|
||||
activeDocument = null;
|
||||
const detailPanel = document.querySelector<HTMLElement>("#documentDetailPanel");
|
||||
if (detailPanel) {
|
||||
detailPanel.hidden = true;
|
||||
}
|
||||
frontendTrace("main", "Config document detail cleared");
|
||||
}
|
||||
|
||||
async function loadDetail(fileId: string, source: "selection" | "reload"): Promise<void> {
|
||||
frontendDebug("main", "Config document detail requested", { fileId, source });
|
||||
try {
|
||||
const detail = await invokeKsp<ConfigDocumentDetailDto>("main", "get_config_document_detail", { fileId });
|
||||
renderDetail(detail);
|
||||
setDocumentsStatus(`Document ${fileId} chargé depuis Config.`, "success");
|
||||
} catch (caughtError) {
|
||||
const error = asDocumentError(caughtError);
|
||||
const message = error
|
||||
? `${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}`
|
||||
: "Le détail du document n'a pas pu être chargé.";
|
||||
setDocumentsStatus(message, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function populateTableRows(documents: ConfigDocumentSummaryDto[]): void {
|
||||
const body = document.querySelector<HTMLTableSectionElement>("#configDocumentsTable tbody");
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
body.replaceChildren();
|
||||
for (const summary of documents) {
|
||||
const row = document.createElement("tr");
|
||||
row.dataset.fileId = summary.fileId;
|
||||
appendCell(row, summary.fileId);
|
||||
appendCell(row, summary.filename);
|
||||
appendCell(row, summary.schemaFileId ?? "—");
|
||||
appendCell(row, summary.validationStatus === "valid" ? "Valide" : "Invalide");
|
||||
appendCell(row, stageLabel(summary.diagnosticStage));
|
||||
body.append(row);
|
||||
}
|
||||
frontendTrace("main", "Config document inventory table DOM replaced", { documentCount: documents.length });
|
||||
}
|
||||
|
||||
function appendCell(row: HTMLTableRowElement, text: string): void {
|
||||
const cell = document.createElement("td");
|
||||
cell.textContent = text;
|
||||
row.append(cell);
|
||||
}
|
||||
|
||||
function initializeDataTable(): void {
|
||||
if (documentsTable) {
|
||||
documentsTable.destroy();
|
||||
documentsTable = null;
|
||||
}
|
||||
const table = new DataTable("#configDocumentsTable", {
|
||||
order: [[0, "asc"]],
|
||||
pageLength: 10,
|
||||
select: { style: "single" },
|
||||
language: {
|
||||
emptyTable: "Aucun document Config enregistré.",
|
||||
info: "_START_ à _END_ sur _TOTAL_ document(s)",
|
||||
infoEmpty: "0 document",
|
||||
lengthMenu: "Afficher _MENU_",
|
||||
search: "Filtrer :",
|
||||
zeroRecords: "Aucun document correspondant.",
|
||||
},
|
||||
}) as unknown as DocumentsDataTable;
|
||||
table.on("select", (event, api, type, indexes) => {
|
||||
void event;
|
||||
void api;
|
||||
if (type !== "row" || indexes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const node = table.row(indexes[0]).node();
|
||||
if (!(node instanceof HTMLTableRowElement)) {
|
||||
return;
|
||||
}
|
||||
const fileId = node.dataset.fileId;
|
||||
frontendTrace("main", "Config document table row selected", { fileId: fileId ?? null });
|
||||
if (fileId) {
|
||||
void loadDetail(fileId, "selection");
|
||||
}
|
||||
});
|
||||
documentsTable = table;
|
||||
frontendTrace("main", "DataTables Documents initialized", { selection: "single" });
|
||||
}
|
||||
|
||||
async function refreshInventory(): Promise<void> {
|
||||
frontendDebug("main", "Config document inventory refresh requested");
|
||||
clearDetail();
|
||||
try {
|
||||
const documents = await invokeKsp<ConfigDocumentSummaryDto[]>("main", "get_config_documents");
|
||||
populateTableRows(documents);
|
||||
initializeDataTable();
|
||||
setDocumentsStatus(`${documents.length} document(s) Config évalué(s) par le backend.`, "success");
|
||||
} catch {
|
||||
setDocumentsStatus("L'inventaire Config n'a pas pu être chargé.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadActiveDocument(): Promise<void> {
|
||||
if (!activeDocument) {
|
||||
return;
|
||||
}
|
||||
frontendDebug("main", "Config document reload clicked", { fileId: activeDocument.summary.fileId });
|
||||
await loadDetail(activeDocument.summary.fileId, "reload");
|
||||
}
|
||||
|
||||
async function saveActiveDocument(): Promise<void> {
|
||||
if (!activeDocument) {
|
||||
return;
|
||||
}
|
||||
const editor = document.querySelector<HTMLTextAreaElement>("#documentSourceEditor");
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const fileId = activeDocument.summary.fileId;
|
||||
frontendDebug("main", "Config document validated save clicked", { fileId });
|
||||
setDocumentsStatus(`Validation et sauvegarde de ${fileId} en cours...`, "primary");
|
||||
try {
|
||||
const result = await invokeKsp<ConfigDocumentSaveResultDto>("main", "save_config_document_source", {
|
||||
fileId,
|
||||
source: editor.value,
|
||||
});
|
||||
renderDetail(result.document);
|
||||
const outcome = result.sourceChanged ? "source modifiée" : "source inchangée";
|
||||
setDocumentsStatus(`Sauvegarde validée par Config : ${outcome}; reload_required=${result.reloadRequired}.`, "success");
|
||||
frontendDebug("main", "Config document validated save completed", {
|
||||
fileId,
|
||||
sourceChanged: result.sourceChanged,
|
||||
reloadRequired: result.reloadRequired,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const error = asDocumentError(caughtError);
|
||||
if (error) {
|
||||
setDocumentsStatus(
|
||||
`${stageLabel(error.diagnosticStage)} : ${error.error.domain}.${error.error.code} — ${error.error.message}`,
|
||||
"danger",
|
||||
);
|
||||
frontendWarn("main", "Config document candidate rejected by backend", { fileId, diagnosticStage: error.diagnosticStage });
|
||||
return;
|
||||
}
|
||||
setDocumentsStatus("Le candidat Config a été rejeté sans diagnostic exploitable.", "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function asDocumentError(value: unknown): ConfigDocumentErrorDto | null {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (typeof candidate.diagnosticStage !== "string" || typeof candidate.error !== "object" || candidate.error === null) {
|
||||
return null;
|
||||
}
|
||||
const error = candidate.error as Record<string, unknown>;
|
||||
if (typeof error.domain !== "string" || typeof error.code !== "string" || typeof error.message !== "string") {
|
||||
return null;
|
||||
}
|
||||
return value as ConfigDocumentErrorDto;
|
||||
}
|
||||
|
||||
function bindDocumentActions(): void {
|
||||
document.querySelector<HTMLButtonElement>("#refreshDocuments")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Documents refresh button clicked");
|
||||
void refreshInventory();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#reloadDocumentSource")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Document reload button clicked");
|
||||
void reloadActiveDocument();
|
||||
});
|
||||
document.querySelector<HTMLButtonElement>("#saveDocumentSource")?.addEventListener("click", () => {
|
||||
frontendTrace("main", "Document save button clicked");
|
||||
void saveActiveDocument();
|
||||
});
|
||||
frontendTrace("main", "Documents panel handlers installed");
|
||||
}
|
||||
|
||||
export function initializeDocumentsPanel(): void {
|
||||
bindDocumentActions();
|
||||
void refreshInventory();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import "simplebar";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import type { AppSnapshotDto } from "./bindings/ksp_app_config_desk/dto_common/AppSnapshotDto.ts";
|
||||
import { frontendDebug, frontendInfo, frontendTrace, installFrontendConsoleBridge } from "./frontend_log";
|
||||
import { initializeDocumentsPanel } from "./documents";
|
||||
import { invokeKsp } from "./invoke";
|
||||
|
||||
(window as Window & typeof globalThis & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver = ResizeObserver;
|
||||
@@ -21,7 +22,7 @@ const viewCopy: Record<ViewId, { title: string; description: string }> = {
|
||||
},
|
||||
documents: {
|
||||
title: "Documents",
|
||||
description: "Inventaire, diagnostics et réparation des documents Config — surface fonctionnelle prévue en pre.009.",
|
||||
description: "Inventaire, diagnostics et réparation validée des documents Config enregistrés.",
|
||||
},
|
||||
profiles: {
|
||||
title: "Profils",
|
||||
@@ -69,9 +70,13 @@ function activateView(viewId: ViewId, source: "startup" | "user"): void {
|
||||
if (overview) {
|
||||
overview.hidden = viewId !== "overview";
|
||||
}
|
||||
const documents = document.querySelector<HTMLElement>("#documentsPanel");
|
||||
if (documents) {
|
||||
documents.hidden = viewId !== "documents";
|
||||
}
|
||||
const placeholder = document.querySelector<HTMLElement>("#placeholderPanel");
|
||||
if (placeholder) {
|
||||
placeholder.hidden = viewId === "overview";
|
||||
placeholder.hidden = viewId === "overview" || viewId === "documents";
|
||||
}
|
||||
frontendTrace("main", "Main view DOM updated", { viewId, source });
|
||||
}
|
||||
@@ -130,6 +135,7 @@ async function initializeMain(): Promise<void> {
|
||||
const windowLabel = getCurrentWindow().label;
|
||||
frontendInfo("main", "Config Desk main frontend loaded", { windowLabel });
|
||||
bindNavigation();
|
||||
initializeDocumentsPanel();
|
||||
activateView("overview", "startup");
|
||||
try {
|
||||
await loadSnapshot();
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@fortawesome/fontawesome-free": "^7.3",
|
||||
"@tauri-apps/api": "^2.11",
|
||||
"bootstrap": "^5.3",
|
||||
"datatables.net-bs5": "^3.0",
|
||||
"datatables.net-select-bs5": "^4.0",
|
||||
"resize-observer-polyfill": "^1.5",
|
||||
"simplebar": "^6.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/app_state.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
//! Shared backend state owned by the Tauri application.
|
||||
|
||||
@@ -104,6 +104,12 @@ impl AppState {
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns the Config management facade owned by the application state.
|
||||
#[must_use]
|
||||
pub(crate) const fn config_management(&self) -> &ksp_config_lib::ConfigManagement {
|
||||
return &self.config_management;
|
||||
}
|
||||
|
||||
/// Returns the resolved splash timings captured during application bootstrap.
|
||||
#[must_use]
|
||||
pub(crate) const fn splash_settings(&self) -> crate::SplashSettings {
|
||||
|
||||
@@ -17,3 +17,5 @@ pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "config.bootstrap";
|
||||
pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend";
|
||||
/// Structured domain used by Tauri window lifecycle operations.
|
||||
pub(crate) const TRACING_DOMAIN_WINDOWS: &str = "desktop.window";
|
||||
/// Structured domain for Config document inventory, diagnostics and repair.
|
||||
pub(crate) const TRACING_DOMAIN_DOCUMENTS: &str = "config.documents";
|
||||
|
||||
275
crates/ksp-app-config-desk/src/documents.rs
Normal file
275
crates/ksp-app-config-desk/src/documents.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
// file: crates/ksp-app-config-desk/src/documents.rs
|
||||
// version: 1
|
||||
|
||||
//! Generic Config document inventory, diagnostics and validated repair services.
|
||||
|
||||
use ts_rs::TS; // rust-rules: derive-import
|
||||
|
||||
const STATUS_VALID: &str = "valid";
|
||||
const STATUS_INVALID: &str = "invalid";
|
||||
const STAGE_VALID: &str = "valid";
|
||||
const STAGE_READ: &str = "read";
|
||||
const STAGE_JSON: &str = "json";
|
||||
const STAGE_SCHEMA: &str = "schema";
|
||||
const STAGE_SEMANTIC: &str = "semantic";
|
||||
const STAGE_EFFECTIVE: &str = "effective";
|
||||
const STAGE_OTHER: &str = "other";
|
||||
|
||||
/// Safe inventory row for one Config document registered by `ksp-config-lib`.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSummaryDto.ts")]
|
||||
pub(crate) struct ConfigDocumentSummaryDto {
|
||||
/// Stable logical Config identifier.
|
||||
pub(crate) file_id: String,
|
||||
/// Current Config-managed relative filename mapping.
|
||||
pub(crate) filename: String,
|
||||
/// Registered validation schema identifier.
|
||||
pub(crate) schema_file_id: std::option::Option<String>,
|
||||
/// Resolved filesystem path controlled by Config.
|
||||
pub(crate) path: String,
|
||||
/// `valid` or `invalid` according to the backend Config authority.
|
||||
pub(crate) validation_status: String,
|
||||
/// Backend diagnostic stage: valid/read/json/schema/semantic/effective/other.
|
||||
pub(crate) diagnostic_stage: String,
|
||||
/// Safe bounded diagnostic when the document is invalid.
|
||||
pub(crate) diagnostic: std::option::Option<crate::CommandErrorDto>,
|
||||
}
|
||||
|
||||
/// Detailed management view for one registered Config document.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentDetailDto.ts")]
|
||||
pub(crate) struct ConfigDocumentDetailDto {
|
||||
/// Current validation/inventory summary.
|
||||
pub(crate) summary: ConfigDocumentSummaryDto,
|
||||
/// Raw Config-managed source when the file can be read, even if its JSON/schema/semantics are invalid.
|
||||
pub(crate) source: std::option::Option<String>,
|
||||
}
|
||||
|
||||
/// Result of one validated raw-source repair attempt that reached persistence successfully.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentSaveResultDto.ts")]
|
||||
pub(crate) struct ConfigDocumentSaveResultDto {
|
||||
/// Whether persisted source bytes changed.
|
||||
pub(crate) source_changed: bool,
|
||||
/// Whether Config consumers need a reload to observe the changed document.
|
||||
pub(crate) reload_required: bool,
|
||||
/// Fresh detail reloaded after persistence.
|
||||
pub(crate) document: ConfigDocumentDetailDto,
|
||||
}
|
||||
|
||||
/// Safe document-specific command error with backend-owned diagnostic classification.
|
||||
#[derive(Clone, Debug, serde::Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_config_desk/documents/ConfigDocumentErrorDto.ts")]
|
||||
pub(crate) struct ConfigDocumentErrorDto {
|
||||
/// Backend diagnostic stage associated with the failure.
|
||||
pub(crate) diagnostic_stage: String,
|
||||
/// Bounded KSP error projection without arbitrary context/source values.
|
||||
pub(crate) error: crate::CommandErrorDto,
|
||||
}
|
||||
|
||||
impl ConfigDocumentErrorDto {
|
||||
fn from_error(error: &ksp_core_lib::Error) -> Self {
|
||||
return Self { diagnostic_stage: classify_error(error).to_owned(), error: crate::CommandErrorDto::from_error(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all Config-kind documents from the Config registry and evaluates their current backend validation state.
|
||||
pub(crate) fn inventory(state: &crate::AppState) -> std::vec::Vec<ConfigDocumentSummaryDto> {
|
||||
let management = state.config_management();
|
||||
let engine = management.engine();
|
||||
let mut documents = std::vec::Vec::new();
|
||||
for descriptor in engine.registry().descriptors() {
|
||||
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
|
||||
continue;
|
||||
}
|
||||
documents.push(build_summary(management, descriptor));
|
||||
}
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
document_count = documents.len(),
|
||||
"Config document inventory evaluated"
|
||||
);
|
||||
return documents;
|
||||
}
|
||||
|
||||
/// Loads one Config document detail, including raw source when Config can read it.
|
||||
pub(crate) fn detail(state: &crate::AppState, file_id_text: &str) -> std::result::Result<ConfigDocumentDetailDto, ConfigDocumentErrorDto> {
|
||||
let file_id = parse_config_file_id(file_id_text);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let management = state.config_management();
|
||||
let descriptor = management.engine().registry().descriptor(&file_id);
|
||||
let descriptor = match descriptor {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
if descriptor.kind() != ksp_config_lib::ConfigFileKind::Config {
|
||||
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_KIND_INVALID, "requested document is not a Config-kind document");
|
||||
return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error));
|
||||
}
|
||||
let summary = build_summary(management, descriptor);
|
||||
let source = management.read_source(&file_id);
|
||||
let source = match source {
|
||||
std::result::Result::Ok(value) => std::option::Option::Some(value.content().to_owned()),
|
||||
std::result::Result::Err(_) => std::option::Option::None,
|
||||
};
|
||||
ksp_logging_lib::debug!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
file_id = file_id.as_str(),
|
||||
validation_status = summary.validation_status.as_str(),
|
||||
diagnostic_stage = summary.diagnostic_stage.as_str(),
|
||||
source_available = source.is_some(),
|
||||
"Config document detail loaded"
|
||||
);
|
||||
return std::result::Result::Ok(ConfigDocumentDetailDto { summary, source });
|
||||
}
|
||||
|
||||
/// Validates and atomically persists one raw Config source candidate, then reloads the document detail.
|
||||
pub(crate) fn save_source(
|
||||
state: &crate::AppState,
|
||||
file_id_text: &str,
|
||||
source: &str,
|
||||
) -> std::result::Result<ConfigDocumentSaveResultDto, ConfigDocumentErrorDto> {
|
||||
let file_id = parse_config_file_id(file_id_text);
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let report = state.config_management().save_source_candidate(&file_id, source);
|
||||
let report = match report {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(ConfigDocumentErrorDto::from_error(&error)),
|
||||
};
|
||||
let document = detail(state, file_id.as_str());
|
||||
let document = match document {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
ksp_logging_lib::info!(
|
||||
target: crate::TRACING_TARGET,
|
||||
domain = crate::TRACING_DOMAIN_DOCUMENTS,
|
||||
file_id = file_id.as_str(),
|
||||
source_changed = report.source_changed(),
|
||||
reload_required = report.reload_required(),
|
||||
"validated Config document source candidate persisted"
|
||||
);
|
||||
return std::result::Result::Ok(ConfigDocumentSaveResultDto {
|
||||
source_changed: report.source_changed(),
|
||||
reload_required: report.reload_required(),
|
||||
document,
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_config_file_id(value: &str) -> ksp_core_lib::Result<ksp_config_lib::ConfigFileId> {
|
||||
let file_id = ksp_config_lib::ConfigFileId::new(value.to_owned());
|
||||
let file_id = match file_id {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
return std::result::Result::Ok(file_id);
|
||||
}
|
||||
|
||||
fn build_summary(management: &ksp_config_lib::ConfigManagement, descriptor: &ksp_config_lib::ConfigFileDescriptor) -> ConfigDocumentSummaryDto {
|
||||
let engine = management.engine();
|
||||
let path = engine.registry().resolve_path(engine.bootstrap(), descriptor.file_id());
|
||||
let path = match path {
|
||||
std::result::Result::Ok(value) => value.to_string_lossy().into_owned(),
|
||||
std::result::Result::Err(_) => descriptor.filename().to_string_lossy().into_owned(),
|
||||
};
|
||||
let validation = validate_effective_document(management, descriptor.file_id());
|
||||
let (validation_status, diagnostic_stage, diagnostic) = match validation {
|
||||
std::result::Result::Ok(()) => (STATUS_VALID.to_owned(), STAGE_VALID.to_owned(), std::option::Option::None),
|
||||
std::result::Result::Err(error) => {
|
||||
(STATUS_INVALID.to_owned(), classify_error(&error).to_owned(), std::option::Option::Some(crate::CommandErrorDto::from_error(&error)))
|
||||
},
|
||||
};
|
||||
return ConfigDocumentSummaryDto {
|
||||
file_id: descriptor.file_id().as_str().to_owned(),
|
||||
filename: descriptor.filename().to_string_lossy().into_owned(),
|
||||
schema_file_id: descriptor.schema_file_id().map(|value| {
|
||||
return value.as_str().to_owned();
|
||||
}),
|
||||
path,
|
||||
validation_status,
|
||||
diagnostic_stage,
|
||||
diagnostic,
|
||||
};
|
||||
}
|
||||
|
||||
fn validate_effective_document(management: &ksp_config_lib::ConfigManagement, file_id: &ksp_config_lib::ConfigFileId) -> ksp_core_lib::Result<()> {
|
||||
let validated = management.engine().load_validated_document(file_id);
|
||||
match validated {
|
||||
std::result::Result::Ok(_) => {},
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
}
|
||||
if file_id.as_str() != ksp_config_lib::FILE_ID_STD_LOGGING {
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
let environment = ksp_config_lib::ConfigEnvironment::load();
|
||||
let environment = match environment {
|
||||
std::result::Result::Ok(value) => value,
|
||||
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||
};
|
||||
let resolved = management.engine().load_resolved_logging_config(std::option::Option::None, &environment);
|
||||
return match resolved {
|
||||
std::result::Result::Ok(_) => std::result::Result::Ok(()),
|
||||
std::result::Result::Err(error) => std::result::Result::Err(error),
|
||||
};
|
||||
}
|
||||
|
||||
fn classify_error(error: &ksp_core_lib::Error) -> &'static str {
|
||||
let code = error.code();
|
||||
if code == ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED {
|
||||
if error_context_file_id_is_schema(error) {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
return STAGE_READ;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID {
|
||||
if error_context_file_id_is_schema(error) {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
return STAGE_JSON;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_SCHEMA_INVALID || code == ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED {
|
||||
return STAGE_SCHEMA;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_PROFILE_NOT_FOUND
|
||||
|| code == ksp_config_lib::ERROR_CODE_COMPOSITE_REFERENCE_INVALID
|
||||
{
|
||||
return STAGE_SEMANTIC;
|
||||
}
|
||||
if code == ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_PLACEHOLDER_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VALUE_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_INVALID
|
||||
|| code == ksp_config_lib::ERROR_CODE_ENVIRONMENT_VARIABLE_MISSING
|
||||
|| code == ksp_config_lib::ERROR_CODE_DOTENV_FILE_READ_FAILED
|
||||
|| code == ksp_config_lib::ERROR_CODE_DOTENV_SYNTAX_INVALID
|
||||
{
|
||||
return STAGE_EFFECTIVE;
|
||||
}
|
||||
return STAGE_OTHER;
|
||||
}
|
||||
|
||||
fn error_context_file_id_is_schema(error: &ksp_core_lib::Error) -> bool {
|
||||
for field in error.context() {
|
||||
if field.key() == "file_id" {
|
||||
return field.value().starts_with("schema.");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../unit_tests/documents.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/errors.rs
|
||||
// version: 4
|
||||
// version: 5
|
||||
|
||||
//! Application-local error codes for the configuration desktop shell.
|
||||
|
||||
@@ -24,3 +24,6 @@ pub(crate) const ERROR_CODE_TAURI_WINDOW_MISSING: ksp_core_lib::ErrorCode = ksp_
|
||||
/// A Tauri window show/focus/destroy/event operation failed.
|
||||
pub(crate) const ERROR_CODE_TAURI_WINDOW_OPERATION_FAILED: ksp_core_lib::ErrorCode =
|
||||
ksp_core_lib::ErrorCode::new("config_desk", "tauri_window_operation_failed");
|
||||
|
||||
/// A Documents command requested a registered file that is not a Config-kind document.
|
||||
pub(crate) const ERROR_CODE_DOCUMENT_KIND_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config_desk", "document_kind_invalid");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/lib.rs
|
||||
// version: 5
|
||||
// version: 6
|
||||
|
||||
//! Tauri desktop application for managing and validating KSP configuration.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
mod app_state;
|
||||
mod bootstrap;
|
||||
mod constants;
|
||||
mod documents;
|
||||
mod dto_common;
|
||||
mod errors;
|
||||
mod frontend_logging;
|
||||
@@ -25,16 +26,22 @@ pub(crate) use self::app_state::AppState;
|
||||
pub(crate) use self::bootstrap::config_management;
|
||||
pub(crate) use self::bootstrap::initialize_logging;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_DOCUMENTS;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND;
|
||||
pub(crate) use self::constants::TRACING_DOMAIN_WINDOWS;
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND;
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN;
|
||||
pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH;
|
||||
pub(crate) use self::documents::ConfigDocumentDetailDto;
|
||||
pub(crate) use self::documents::ConfigDocumentErrorDto;
|
||||
pub(crate) use self::documents::ConfigDocumentSaveResultDto;
|
||||
pub(crate) use self::documents::ConfigDocumentSummaryDto;
|
||||
pub(crate) use self::dto_common::AppSnapshotDto;
|
||||
pub(crate) use self::dto_common::CommandErrorDto;
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED;
|
||||
pub(crate) use self::errors::ERROR_CODE_DOCUMENT_KIND_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID;
|
||||
pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-app-config-desk/src/tauri.rs
|
||||
// version: 6
|
||||
// version: 7
|
||||
|
||||
//! Tauri runtime assembly for the KSP configuration desktop application.
|
||||
|
||||
@@ -37,7 +37,14 @@ fn configure_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<taur
|
||||
|
||||
#[allow(clippy::question_mark_used)] // Tauri generates the question-mark operator internally for async command dispatch.
|
||||
fn configure_commands(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
return builder.invoke_handler(tauri::generate_handler![get_app_snapshot, emit_frontend_log, splash_frontend_ready]);
|
||||
return builder.invoke_handler(tauri::generate_handler![
|
||||
get_app_snapshot,
|
||||
get_config_documents,
|
||||
get_config_document_detail,
|
||||
save_config_document_source,
|
||||
emit_frontend_log,
|
||||
splash_frontend_ready
|
||||
]);
|
||||
}
|
||||
|
||||
fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
|
||||
@@ -63,6 +70,28 @@ fn get_app_snapshot(state: tauri::State<'_, crate::AppState>) -> std::result::Re
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_documents(state: tauri::State<'_, crate::AppState>) -> std::vec::Vec<crate::ConfigDocumentSummaryDto> {
|
||||
return crate::documents::inventory(&state);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config_document_detail(
|
||||
file_id: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigDocumentDetailDto, crate::ConfigDocumentErrorDto> {
|
||||
return crate::documents::detail(&state, file_id.as_str());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_config_document_source(
|
||||
file_id: String,
|
||||
source: String,
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> std::result::Result<crate::ConfigDocumentSaveResultDto, crate::ConfigDocumentErrorDto> {
|
||||
return crate::documents::save_source(&state, file_id.as_str(), source.as_str());
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
|
||||
let result = crate::emit_frontend_log_event(payload);
|
||||
|
||||
32
crates/ksp-app-config-desk/unit_tests/documents.rs
Normal file
32
crates/ksp-app-config-desk/unit_tests/documents.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
// file: crates/ksp-app-config-desk/unit_tests/documents.rs
|
||||
// version: 1
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn diagnostic_classifier_distinguishes_json_schema_semantic_and_effective_errors() {
|
||||
let json = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "json");
|
||||
assert_eq!(classify_error(&json), STAGE_JSON);
|
||||
let schema = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "schema");
|
||||
assert_eq!(classify_error(&schema), STAGE_SCHEMA);
|
||||
let semantic = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "semantic");
|
||||
assert_eq!(classify_error(&semantic), STAGE_SEMANTIC);
|
||||
let effective = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective");
|
||||
assert_eq!(classify_error(&effective), STAGE_EFFECTIVE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_source_read_errors_are_classified_as_schema_failures() {
|
||||
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_FILE_READ_FAILED, "missing schema").with_context("file_id", "schema.std.logging");
|
||||
assert_eq!(classify_error(&error), STAGE_SCHEMA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_error_projection_does_not_expose_arbitrary_error_context() {
|
||||
let error = ksp_core_lib::Error::new(ksp_config_lib::ERROR_CODE_JSON_SYNTAX_INVALID, "invalid source").with_context("detail", "sensitive-canary");
|
||||
let projection = ConfigDocumentErrorDto::from_error(&error);
|
||||
assert_eq!(projection.diagnostic_stage, STAGE_JSON);
|
||||
assert_eq!(projection.error.domain, "config");
|
||||
assert_eq!(projection.error.code, "json_syntax_invalid");
|
||||
assert_eq!(projection.error.message, "invalid source");
|
||||
}
|
||||
Reference in New Issue
Block a user