Files
dbx/apps/desktop/src/components/docs/DatabaseDocsDialog.vue
T

243 lines
9.4 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Download, FileText, Network } from "@lucide/vue";
import DataDictionaryDialog from "@/components/docs/DataDictionaryDialog.vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DocsApp from "@/docs/DocsApp.vue";
import { emptyAnnotations, removeGroup, setColumnNote, setProjectNote, setTableGroup, setTableNote, upsertGroup } from "@/docs/annotationEdits";
import type { AnnotationFile, DocsEdit, SchemaSnapshot } from "@/docs/types";
import type { Translate } from "@/docs/docsWarnings";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useConnectionStore } from "@/stores/connectionStore";
import { createAutosave } from "./docsAutosave";
// The languages `to_standalone_html` accepts. Kept in sync manually — this
// component lives outside `src/docs/` and cannot import from dbx-core.
const EXPORT_LANGUAGES = ["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"];
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
}>();
const open = defineModel<boolean>("open", { default: false });
// This component lives OUTSIDE src/docs/, so it may and must use useI18n():
// it is what supplies the `translate` prop that the viewer components need,
// since they are banned from importing vue-i18n themselves.
const { t, locale } = useI18n();
// `Translate` is one narrow signature; vue-i18n's `t` is heavily overloaded and
// does not assign to it directly, so bridge it explicitly.
const translate: Translate = (key, params) => (params === undefined ? t(key) : t(key, params));
const connectionStore = useConnectionStore();
/** The snapshot as collected, kept so every edit can re-derive the merged view. */
const rawSnapshot = ref<SchemaSnapshot | null>(null);
const snapshot = ref<SchemaSnapshot | null>(null);
const annotations = ref<AnnotationFile>(emptyAnnotations());
const loading = ref(false);
const loadError = ref<string | null>(null);
/**
* Guards against a slow re-derivation landing after a newer one. Each load or
* edit takes the next number and only writes if it is still the latest, so a
* fast second edit is never overwritten by the first edit's stale response.
*/
let generation = 0;
const autosave = createAutosave(async (file) => {
const connectionId = props.prefillConnectionId;
if (connectionId === undefined || connectionId === "") {
return;
}
await api.saveDocsAnnotations(connectionId, file);
}, 500);
const status = autosave.status;
const statusLabel = computed(() => {
switch (status.value.state) {
case "saving":
return t("docs.saving");
case "saved":
return t("docs.saved");
case "failed":
return t("docs.saveFailed", { error: status.value.message });
default:
return "";
}
});
const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" && (props.prefillDatabase ?? "") !== "");
const exporting = ref(false);
const exportError = ref<string | null>(null);
const showDataDictionary = ref(false);
async function load(): Promise<void> {
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase;
if (connectionId === undefined || connectionId === "" || database === undefined || database === "") {
return;
}
const mine = ++generation;
loading.value = true;
loadError.value = null;
try {
// An absent schema means "everything the collector finds" rather than a
// filter naming nothing.
const schemas = props.prefillSchema ? [props.prefillSchema] : [];
const collected = await api.collectDocsSnapshot(connectionId, database, schemas, [], database);
const file = (await api.loadDocsAnnotations(connectionId)) ?? emptyAnnotations();
const merged = await api.applyDocsAnnotations(connectionId, collected, file);
if (mine !== generation) {
return;
}
rawSnapshot.value = collected;
annotations.value = file;
snapshot.value = merged;
} catch (error) {
if (mine === generation) {
loadError.value = error instanceof Error ? error.message : String(error);
}
} finally {
if (mine === generation) {
loading.value = false;
}
}
}
function nextAnnotations(file: AnnotationFile, edit: DocsEdit): AnnotationFile {
switch (edit.kind) {
case "projectNote":
return setProjectNote(file, edit.note);
case "tableNote":
return setTableNote(file, edit.tableKey, edit.note);
case "columnNote":
return setColumnNote(file, edit.tableKey, edit.column, edit.note);
case "tableGroup":
return setTableGroup(file, edit.tableKey, edit.groupId);
case "upsertGroup":
return upsertGroup(file, edit.group);
case "removeGroup":
return removeGroup(file, edit.groupId);
}
}
async function onEdit(edit: DocsEdit): Promise<void> {
const connectionId = props.prefillConnectionId;
const collected = rawSnapshot.value;
if (connectionId === undefined || connectionId === "" || collected === null) {
return;
}
const file = nextAnnotations(annotations.value, edit);
annotations.value = file;
// Schedule before re-deriving: the write must not wait on a display refresh.
autosave.schedule(file);
const mine = ++generation;
try {
const merged = await api.applyDocsAnnotations(connectionId, collected, file);
if (mine === generation) {
snapshot.value = merged;
}
} catch (error) {
if (mine === generation) {
loadError.value = error instanceof Error ? error.message : String(error);
}
}
}
function openDiagram(): void {
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase;
if (connectionId === undefined || database === undefined) {
return;
}
connectionStore.diagramSource = { connectionId, database, schema: props.prefillSchema };
}
async function exportHtml(): Promise<void> {
if (!snapshot.value) return;
exporting.value = true;
exportError.value = null;
try {
let outputPath = `${snapshot.value.project.name}-docs.html`;
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const chosen = await save({ defaultPath: outputPath, filters: [{ name: "HTML", extensions: ["html"] }] });
if (!chosen) return; // the user cancelled; not an error
outputPath = chosen as string;
}
// `to_standalone_html` rejects any language outside its fixed list; the
// app's locale is otherwise a superset risk, so fall back rather than
// surface that rejection to the user.
const lang = EXPORT_LANGUAGES.includes(locale.value) ? locale.value : "en";
await api.exportDocsHtml(outputPath, snapshot.value, annotations.value, lang);
} catch (error) {
// Never swallowed: a failed export that reports success is the worst
// outcome here, exactly as with a failed autosave.
exportError.value = t("docs.exportFailed", { error: String(error) });
} finally {
exporting.value = false;
}
}
watch(
open,
(isOpen, wasOpen) => {
if (isOpen) {
void load();
return;
}
if (wasOpen) {
// A note typed a moment before closing is still sitting behind the
// debounce; flushing here is what keeps it.
void autosave.flush();
}
},
{ immediate: true },
);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
<DialogHeader class="px-4 py-3 border-b pr-12">
<DialogTitle class="flex flex-wrap items-center gap-2">
<span>{{ t("docs.title") }}</span>
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ statusLabel }}
</span>
<span v-if="exportError" class="text-xs font-normal text-destructive">{{ exportError }}</span>
<Button v-if="canOpenDiagram" variant="outline" size="sm" class="ml-auto" @click="openDiagram()">
<Network class="w-4 h-4" />
{{ t("docs.openDiagram") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" @click="showDataDictionary = true">
<FileText class="w-4 h-4" />
{{ t("dataDictionary.title") }}
</Button>
<Button v-if="snapshot" variant="outline" size="sm" :disabled="exporting" @click="exportHtml()">
<Download class="w-4 h-4" />
{{ exporting ? t("docs.exporting") : t("docs.exportHtml") }}
</Button>
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-hidden">
<p v-if="loadError" class="p-4 text-sm text-destructive">{{ loadError }}</p>
<p v-else-if="loading || snapshot === null" class="p-4 text-sm text-muted-foreground">{{ t("common.loading") }}</p>
<DocsApp v-else :snapshot="snapshot" :annotations="annotations" :readonly="false" :translate="translate" @edit="onEdit" />
</div>
</DialogContent>
</Dialog>
<DataDictionaryDialog v-if="showDataDictionary && snapshot" v-model:open="showDataDictionary" :prefill-connection-id="prefillConnectionId" :prefill-database="prefillDatabase" :prefill-schema="prefillSchema" :snapshot="snapshot" />
</template>