feat(docs): redesign changelog page and add route progress bar

This commit is contained in:
t8y2
2026-08-26 21:38:27 +08:00
parent 96d2f0dd1c
commit 3a0e86956c
35 changed files with 918 additions and 15540 deletions
+34 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -10,6 +10,10 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || "";
const OUT_CN = "releases-cn.json";
const OUT_EN = "releases-en.json";
const OUT_INDEX_CN = "index-cn.json";
const OUT_INDEX_EN = "index-en.json";
const RELEASES_CN_DIR = "releases-cn";
const RELEASES_EN_DIR = "releases-en";
const LATEST_EN_OUT = "latest-en.json";
const LATEST_NOTES_OUT = "latest-notes.json";
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json";
@@ -68,6 +72,7 @@ function stripIssueRefs(text) {
// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。
function stripIssueRefsInReleases(json) {
for (const release of json.releases || []) {
if (release.markdown) release.markdown = stripIssueRefs(release.markdown);
for (const section of release.sections || []) {
for (const item of section.items || []) {
if (item.desc) item.desc = stripIssueRefs(item.desc);
@@ -138,12 +143,30 @@ export function buildReleasesJson(releases, now = new Date()) {
name: r.name || r.tag_name,
date: r.published_at.slice(0, 10),
_sourceHash: buildReleaseSourceHash(r),
markdown: stripIssueRefs(stripDownloadSection(r.body || "")),
sections: parseBody(r.body || ""),
})),
};
}
export function buildChangelogIndex(json) {
return {
updatedAt: json.updatedAt,
releases: json.releases.map(({ tag, name, date }) => ({ tag, name, date })),
};
}
function writeReleaseFiles(json, directory) {
rmSync(directory, { recursive: true, force: true });
mkdirSync(directory, { recursive: true });
for (const release of json.releases) {
writeFileSync(resolve(directory, `${release.tag}.json`), JSON.stringify(release, null, 2));
}
}
function releaseToMarkdown(release) {
if (release.markdown !== undefined) return release.markdown;
return release.sections
.map((s) => {
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n");
@@ -199,7 +222,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
for (const release of cnJson.releases) {
const cachedRelease = cachedByTag.get(release.tag);
if (cachedRelease?._sourceHash === release._sourceHash) {
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash });
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash, markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease) });
reusedCount++;
} else {
enReleases.push(release);
@@ -218,6 +241,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
name: release.name,
date: release.date,
_sourceHash: release._sourceHash,
markdown: cachedRelease.markdown || releaseToMarkdown(cachedRelease),
});
reusedCount++;
continue;
@@ -226,7 +250,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
const sectionsText = releaseToMarkdown(release);
if (!sectionsText.trim()) {
enReleases.push({ ...release, sections: [] });
enReleases.push({ ...release, sections: [], markdown: "" });
continue;
}
@@ -256,7 +280,7 @@ export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseek
const data = await res.json();
const translated = data.choices?.[0]?.message?.content || "";
const enSections = parseBody(translated);
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections });
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections, markdown: enSections.length > 0 ? translated.trim() : release.markdown });
translatedCount++;
await sleep(200);
@@ -276,6 +300,9 @@ async function main() {
writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2));
console.log(`Wrote ${OUT_CN}`);
writeFileSync(OUT_INDEX_CN, JSON.stringify(buildChangelogIndex(cnJson), null, 2));
writeReleaseFiles(cnJson, RELEASES_CN_DIR);
console.log(`Wrote ${OUT_INDEX_CN} and ${RELEASES_CN_DIR}/`);
const latestNotes = buildLatestReleaseNotes(releases);
if (latestNotes) {
@@ -293,6 +320,9 @@ async function main() {
stripIssueRefsInReleases(enJson);
writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2));
console.log(`Wrote ${OUT_EN}`);
writeFileSync(OUT_INDEX_EN, JSON.stringify(buildChangelogIndex(enJson), null, 2));
writeReleaseFiles(enJson, RELEASES_EN_DIR);
console.log(`Wrote ${OUT_INDEX_EN} and ${RELEASES_EN_DIR}/`);
// 应用内更新提示用的英文 notes(单条最新版本)
const latestEn = buildLatestEnNotes(enJson);
+20 -2
View File
@@ -31,11 +31,29 @@ jobs:
pip install awscli --quiet
aws s3 cp releases-cn.json "s3://${R2_BUCKET_NAME}/changelog/releases-cn.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 cp index-cn.json "s3://${R2_BUCKET_NAME}/changelog/index-cn.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 sync releases-cn/ "s3://${R2_BUCKET_NAME}/changelog/releases-cn/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--delete --content-type application/json \
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
if [ -f releases-en.json ]; then
aws s3 cp releases-en.json "s3://${R2_BUCKET_NAME}/changelog/releases-en.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 cp index-en.json "s3://${R2_BUCKET_NAME}/changelog/index-en.json" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--content-type application/json \
--cache-control "public, max-age=300, stale-while-revalidate=86400"
aws s3 sync releases-en/ "s3://${R2_BUCKET_NAME}/changelog/releases-en/" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--delete --content-type application/json \
--cache-control "public, max-age=86400, stale-while-revalidate=604800"
fi
if [ -f latest-en.json ]; then
aws s3 cp latest-en.json "s3://${R2_BUCKET_NAME}/changelog/latest-en.json" \
+6 -8
View File
@@ -1,7 +1,7 @@
import { LandingNav } from "@/components/landing/LandingNav";
import { LandingFooter } from "@/components/landing/LandingFooter";
import { ChangelogRuntime } from "@/components/landing/ChangelogRuntime";
import { fetchChangelog } from "@/lib/changelog";
import { loadChangelogBootstrap } from "@/lib/changelog";
import { buildMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
@@ -33,19 +33,17 @@ export default async function ChangelogPage({ params }: { params: Promise<{ lang
const { lang } = await params;
const l = lang === "cn" ? "cn" : "en";
const t = i18n[l];
const initialData = await fetchChangelog(l);
const initialData = await loadChangelogBootstrap(l);
return (
<main className="min-h-screen bg-[#08080a] text-landing-ink">
<LandingNav lang={l} active="changelog" />
<div className="max-w-[860px] mx-auto px-6 pt-32 pb-4 max-[760px]:px-[18px] max-[760px]:pt-28">
<h1 className="text-4xl font-[820] tracking-tight">{t.title}</h1>
<p className="mt-3 text-landing-muted text-lg">{t.desc}</p>
</div>
{/* 视觉隐藏:页面不再展示大标题,但保留语义 landmark 与 SEO */}
<h1 className="sr-only">{t.title}</h1>
<div className="max-w-[860px] mx-auto px-6 pb-24 max-[760px]:px-[18px]">
<ChangelogRuntime lang={l} initialReleases={initialData.releases} />
<div className="max-w-[1400px] mx-auto px-7 pt-28 pb-24 max-[760px]:px-[18px] max-[760px]:pt-24">
<ChangelogRuntime lang={l} index={initialData.index} initialRelease={initialData.initialRelease} fallbackReleases={initialData.fallbackReleases} />
</div>
<LandingFooter lang={l} />
+5 -1
View File
@@ -1,6 +1,7 @@
import "../global.css";
import type { ReactNode } from "react";
import type { Metadata, Viewport } from "next";
import { RouteProgress } from "@/components/RouteProgress";
import { buildMetadata, DEFAULT_DESCRIPTION, getHtmlLang, SITE_NAME, SITE_URL } from "@/lib/metadata";
import { buildSiteStructuredData } from "@/lib/structuredData";
@@ -72,7 +73,10 @@ export default async function LangLayout({ params, children }: { params: Promise
<script key={structuredData["@id"]} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
))}
</head>
<body className="flex min-h-screen flex-col">{children}</body>
<body className="flex min-h-screen flex-col">
<RouteProgress />
{children}
</body>
</html>
);
}
+306 -12
View File
@@ -2,6 +2,26 @@
@import "fumadocs-ui/css/ocean.css";
@import "fumadocs-ui/css/preset.css";
/* 路由跳转进度条(nprogress 风格),挂在语言布局 body 顶部 */
.route-progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2.5px;
z-index: 90;
transform-origin: 0 50%;
transform: scaleX(0);
opacity: 0;
background: linear-gradient(90deg, var(--color-landing-sky), var(--color-landing-blue));
box-shadow: 0 0 10px rgba(82, 213, 255, 0.5);
pointer-events: none;
transition:
transform 0.12s ease,
opacity 0.22s ease;
}
@theme {
--color-landing-ink: #ededf0;
--color-landing-muted: #9c9ea3;
@@ -1178,12 +1198,238 @@ a {
transform: translateY(-1px);
}
.changelog-release-summary {
display: none;
/* Changelog — three-pane layout */
.changelog-shell {
display: grid;
grid-template-columns: 208px minmax(0, 1fr) 224px;
gap: 44px;
align-items: start;
}
.changelog-release > .changelog-release-body {
display: block !important;
.changelog-sidebar {
position: sticky;
top: 104px;
}
.changelog-sidebar-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
color: var(--color-landing-ink);
font-size: 13.5px;
font-weight: 720;
}
.changelog-version-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: calc(100vh - 180px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
padding-right: 6px;
}
.changelog-version-link {
display: block;
padding: 7px 12px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--color-landing-muted);
font-size: 13.5px;
font-weight: 560;
text-align: left;
white-space: nowrap;
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease;
}
.changelog-version-link:hover {
color: var(--color-landing-ink);
background: var(--color-landing-soft);
}
.changelog-version-link.is-active {
color: var(--color-landing-ink);
background: var(--color-landing-panel);
border-color: var(--color-landing-line);
font-weight: 700;
}
.changelog-content {
min-width: 0;
}
.changelog-release-loading {
display: grid;
place-items: center;
min-height: 320px;
color: var(--color-landing-muted);
font-size: 14px;
}
.changelog-toc-title {
margin: 0;
color: #e4e7ea;
font-size: 14px;
font-weight: 720;
}
.changelog-toc-subtitle {
margin: 3px 0 14px;
color: var(--color-landing-muted);
font-size: 12px;
}
.changelog-toc-list {
display: flex;
flex-direction: column;
border-left: 1px solid var(--color-landing-line);
}
.changelog-toc-list a {
display: block;
padding: 5px 0 5px 14px;
color: var(--color-landing-muted);
font-size: 13px;
transition: color 0.15s ease;
}
.changelog-toc-list a:hover {
color: var(--color-landing-ink);
}
/* Release body markdown */
.changelog-markdown {
color: #bcc1c9;
font-size: 15px;
line-height: 1.72;
}
.changelog-markdown h2,
.changelog-markdown h3 {
color: #ededf0;
font-weight: 720;
scroll-margin-top: 112px;
}
.changelog-markdown h2 {
margin: 48px 0 18px;
font-size: 24px;
}
.changelog-markdown h3 {
margin: 44px 0 16px;
font-size: 20px;
}
.changelog-markdown h2:first-child,
.changelog-markdown h3:first-child {
margin-top: 0;
}
.changelog-markdown ul {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.changelog-markdown li {
position: relative;
padding-left: 19px;
}
.changelog-markdown li::before {
content: "";
position: absolute;
top: 0.66em;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: #52525b;
}
.changelog-markdown strong {
color: #e4e7ea;
font-weight: 660;
}
.changelog-markdown a {
color: var(--color-landing-blue);
text-decoration: none;
border-bottom: 1px solid rgba(110, 168, 255, 0.35);
transition: border-color 0.15s ease;
}
.changelog-markdown a:hover {
border-bottom-color: rgba(110, 168, 255, 0.75);
}
.changelog-markdown code {
padding: 2px 6px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.07);
color: #e4e7ea;
font-size: 0.88em;
}
.changelog-markdown pre {
margin: 14px 0;
padding: 14px 16px;
border: 1px solid var(--color-landing-line);
border-radius: 10px;
background: rgba(13, 14, 17, 0.9);
overflow-x: auto;
}
.changelog-markdown pre code {
padding: 0;
background: none;
}
.changelog-markdown p {
margin: 12px 0;
}
.changelog-markdown hr {
margin: 28px 0;
border: 0;
border-top: 1px solid var(--color-landing-line);
}
.changelog-markdown table {
width: 100%;
margin: 16px 0;
border-collapse: collapse;
font-size: 14px;
}
.changelog-markdown th,
.changelog-markdown td {
padding: 8px 12px;
border: 1px solid var(--color-landing-line);
text-align: left;
}
.changelog-markdown th {
color: #e4e7ea;
background: rgba(255, 255, 255, 0.03);
}
.changelog-markdown img {
max-width: 100%;
border: 1px solid var(--color-landing-line);
border-radius: 10px;
}
/* Responsive */
@@ -1200,6 +1446,15 @@ a {
.landing-workflow-card:nth-child(-n + 2) {
border-bottom: 1px solid var(--color-landing-line);
}
.changelog-shell {
grid-template-columns: 200px minmax(0, 1fr);
gap: 32px;
}
.changelog-sidebar-right {
display: none;
}
}
@media (max-width: 760px) {
@@ -1339,20 +1594,59 @@ a {
outline-offset: 2px;
}
.changelog-release-summary {
display: flex;
.changelog-shell {
display: block;
}
.changelog-release:not([open]) > .changelog-release-body {
display: none !important;
.changelog-sidebar,
.changelog-sidebar-left {
position: static;
}
.changelog-release[open] .changelog-release-chevron {
transform: rotate(180deg);
.changelog-sidebar-title {
display: none;
}
.changelog-release-chevron {
transition: transform 0.18s ease;
.changelog-version-list {
flex-direction: row;
gap: 8px;
max-height: none;
margin: 0 -18px 6px;
padding: 0 18px 10px;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
}
.changelog-version-list::-webkit-scrollbar {
display: none;
}
.changelog-version-link {
flex: 0 0 auto;
padding: 7px 14px;
border-color: var(--color-landing-line);
border-radius: 999px;
font-size: 13px;
}
.changelog-markdown {
font-size: 14px;
line-height: 1.66;
}
.changelog-markdown h2 {
margin: 34px 0 14px;
font-size: 20px;
}
.changelog-markdown h3 {
margin: 30px 0 13px;
font-size: 17px;
}
.changelog-markdown ul {
gap: 10px;
}
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
type ProgressApi = { start: () => void; done: () => void };
// nprogress 风格的路由跳转进度条:捕获站内链接点击或前进/后退时开始,
// App Router 的 pathname 变化(导航完成)时冲到 100% 并淡出。
// 全程命令式操作 DOM,不触发 React 渲染。
export function RouteProgress() {
const pathname = usePathname();
const barRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<ProgressApi | null>(null);
const isFirstRoute = useRef(true);
useEffect(() => {
const bar = barRef.current;
if (!bar) return;
let shown = false;
let progress = 0;
let trickleTimer = 0;
let hideTimer = 0;
const render = () => {
bar.style.transform = `scaleX(${progress})`;
bar.style.opacity = shown ? "1" : "0";
};
const start = () => {
if (shown) return;
shown = true;
progress = 0.08;
render();
window.clearInterval(trickleTimer);
trickleTimer = window.setInterval(() => {
// 越接近 90% 走得越慢,nprogress 的经典节奏
progress += (0.9 - progress) * 0.14;
render();
}, 120);
// 导航异常(目标不可达等)时兜底收尾,避免进度条卡在 90%
window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(done, 6000);
};
const done = () => {
if (!shown) return;
window.clearInterval(trickleTimer);
window.clearTimeout(hideTimer);
progress = 1;
render();
hideTimer = window.setTimeout(() => {
shown = false;
render();
window.setTimeout(() => {
progress = 0;
bar.style.transform = "scaleX(0)";
}, 240);
}, 200);
};
apiRef.current = { start, done };
const isInternalNavigation = (href: string) => {
try {
const url = new URL(href, window.location.origin);
return url.origin === window.location.origin && (url.pathname !== window.location.pathname || url.search !== window.location.search);
} catch {
return false;
}
};
const onClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as Element | null)?.closest?.("a[href]");
if (!anchor) return;
const href = anchor.getAttribute("href");
// 只处理站内路径跳转;纯 hash 锚点和外链不触发
if (!href || !href.startsWith("/") || href.startsWith("//")) return;
if (!isInternalNavigation(href)) return;
start();
};
const onPopState = () => start();
document.addEventListener("click", onClick, true);
window.addEventListener("popstate", onPopState);
return () => {
document.removeEventListener("click", onClick, true);
window.removeEventListener("popstate", onPopState);
window.clearInterval(trickleTimer);
window.clearTimeout(hideTimer);
};
}, []);
useEffect(() => {
if (isFirstRoute.current) {
isFirstRoute.current = false;
return;
}
apiRef.current?.done();
}, [pathname]);
return <div ref={barRef} className="route-progress-bar" aria-hidden="true" />;
}
@@ -11,12 +11,24 @@ type InfiniteMovingCardsProps = {
speed?: "slow" | "normal";
};
// 滚动一圈位移为半个 track(items.length × 卡片步距),
// 按目标线速度反推 duration,保证不同条数的行视觉速度一致。
// 36px/s 与首页数据库 pill 墙的最快行实测流速对齐。
// track 必须 shrink-0,否则作为 flex 子项会被压回容器宽度,
// -50% 位移随之下缩,实际线速度会远低于目标值。
const CARD_PITCH_PX = 354; // 340px 卡片 + 14px 间距
const SCROLL_SPEED_PX_PER_S = 36;
export function InfiniteMovingCards({ items, direction = "left", speed = "normal" }: InfiniteMovingCardsProps) {
const repeatedItems = [...items, ...items];
const durationSeconds = (items.length * CARD_PITCH_PX) / SCROLL_SPEED_PX_PER_S;
return (
<div className="landing-testimonial-marquee flex overflow-hidden" data-direction={direction} data-speed={speed} aria-label="Testimonials" tabIndex={0}>
<div className="landing-testimonial-track flex w-max min-w-full gap-3.5 px-[7px]">
<div
className="landing-testimonial-track flex w-max min-w-full shrink-0 gap-3.5 px-[7px]"
style={{ "--testimonial-duration": `${durationSeconds}s` } as React.CSSProperties}
>
{repeatedItems.map((item, index) => (
<figure aria-hidden={index >= items.length} className="landing-testimonial-card flex-[0_0_340px] min-h-[188px] rounded-[9px] m-0 p-[22px] max-[760px]:flex-[0_0_286px] max-[760px]:min-h-[210px] max-[760px]:p-[18px]" key={`${item.name}-${index}`}>
<div className="flex gap-3 items-center">
+172 -96
View File
@@ -1,26 +1,8 @@
"use client";
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import type { ChangelogRelease } from "@/lib/changelog";
import { ChevronDown, Tag } from "lucide-react";
const PAGE_SIZE = 5;
const DESKTOP_MEDIA = "(min-width: 761px)";
function subscribeToDesktop(onChange: () => void) {
const mql = window.matchMedia(DESKTOP_MEDIA);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}
function getDesktopSnapshot() {
return window.matchMedia(DESKTOP_MEDIA).matches;
}
function getDesktopServerSnapshot() {
return false;
}
import { marked, Renderer } from "marked";
import type { ChangelogIndexEntry, ChangelogRelease } from "@/lib/changelog";
import { Tag } from "lucide-react";
const sectionLabels: Record<string, Record<string, string>> = {
added: { en: "New Features", cn: "新功能" },
@@ -30,107 +12,201 @@ const sectionLabels: Record<string, Record<string, string>> = {
removed: { en: "Removed", cn: "移除" },
};
// 与 .github/scripts/sync-changelog.mjs 的 SECTION_MAP 保持一致,
// TOC 文案优先用本地化标签,未知标题原样展示。
const sectionTypeByTitle: Record<string, string> = {
新功能: "added",
Added: "added",
改进: "improved",
Improved: "improved",
修复: "fixed",
Fixed: "fixed",
变更: "changed",
Changed: "changed",
移除: "removed",
Removed: "removed",
};
function formatDate(dateStr: string, lang: string) {
const d = new Date(dateStr);
const date = new Date(dateStr);
if (lang === "cn") {
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
}
return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
function ReleaseCard({ release, lang, featured, expanded }: { release: ChangelogRelease; lang: string; featured: boolean; expanded: boolean }) {
const t = lang === "cn" ? { publishedOn: "发布于", download: "下载", seeGitHub: "查看 GitHub Release 获取详情" } : { publishedOn: "Published on", download: "Download", seeGitHub: "See GitHub Release for details" };
function releaseId(tag: string) {
return `changelog-release-${tag.replace(/[^a-zA-Z0-9-]/g, "-")}`;
}
function sectionId(tag: string, sectionIndex: number) {
return `${releaseId(tag)}-section-${sectionIndex}`;
}
function releaseToMarkdown(release: ChangelogRelease) {
return release.sections
.map((section) => {
const items = section.items.map((item) => (item.desc ? `- **${item.title}** — ${item.desc}` : `- ${item.title}`)).join("\n");
return `### ${section.title}\n${items}`;
})
.join("\n\n");
}
// 官网展示用:剥掉条目行尾的贡献/来源标注括号段,让页面更纯净,
// 如 “(contributed by @user) (PR [#123](…)) (commit [sha](…)) (closes #456)”。
// 括号段内可能嵌套 markdown 链接,需按配对括号从行尾向前定位段首;
// 普通内容括号(如 “(默认关闭)”)不含这些特征词,不会被误伤。
const ATTRIBUTION_HINT = /contributed by|\b(?:PR|closes|refs|fixes)\b|^commit\b|@[\w.-]+|^#\d/i;
function stripTrailingAttribution(line: string) {
let out = line.trimEnd();
for (;;) {
if (!out.endsWith(")")) break;
let depth = 0;
let start = -1;
for (let i = out.length - 1; i >= 0; i--) {
if (out[i] === ")") depth++;
else if (out[i] === "(" && --depth === 0) {
start = i;
break;
}
}
if (start < 1 || !ATTRIBUTION_HINT.test(out.slice(start + 1, -1))) break;
out = out.slice(0, start).trimEnd();
}
// 整条 desc 都是标注时避免留下悬空的破折号;横线分隔线不受影响
return out.replace(/[ \t]*[—–]$/, "");
}
function releaseDisplayMarkdown(release: ChangelogRelease) {
return (release.markdown || releaseToMarkdown(release))
.split("\n")
.map(stripTrailingAttribution)
.join("\n");
}
function escapeHtml(value: string) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
}
function renderMarkdown(release: ChangelogRelease) {
const renderer = new Renderer();
let headingIndex = 0;
renderer.heading = ({ tokens, depth }) => {
const text = renderer.parser.parseInline(tokens);
const id = depth === 3 ? ` id="${sectionId(release.tag, headingIndex++)}"` : "";
return `<h${depth}${id}>${text}</h${depth}>`;
};
renderer.html = ({ text }) => escapeHtml(text);
return marked.parse(releaseDisplayMarkdown(release), { gfm: true, renderer }) as string;
}
// TOC 必须与 renderMarkdown 数的是同一批 h3(跳过代码围栏里的 "### "),
// 否则 parseBody 过滤掉无条目小节后,锚点序号会整体错位。
function buildTocEntries(release: ChangelogRelease, lang: string) {
const markdown = releaseDisplayMarkdown(release);
const titles: string[] = [];
let inFence = false;
for (const line of markdown.split("\n")) {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence;
continue;
}
if (inFence) continue;
const headingMatch = line.match(/^###\s+(.+)/);
if (headingMatch) titles.push(headingMatch[1].trim());
}
return titles.map((title, index) => ({
id: sectionId(release.tag, index),
label: sectionLabels[sectionTypeByTitle[title] ?? ""]?.[lang] || title,
}));
}
function ReleaseCard({ release, lang, isLoading, errorMessage }: { release: ChangelogRelease | null; lang: "en" | "cn"; isLoading: boolean; errorMessage?: string }) {
const text = lang === "cn" ? { publishedOn: "发布于", download: "下载", seeGitHub: "查看 GitHub Release 获取详情" } : { publishedOn: "Published on", download: "Download", seeGitHub: "See GitHub Release for details" };
if (!release) {
return (
<section className="changelog-release changelog-release-loading" aria-busy="true">
<div className="changelog-loading-inner">
<span>{errorMessage || (isLoading ? (lang === "cn" ? "正在加载版本…" : "Loading release…") : text.seeGitHub)}</span>
</div>
</section>
);
}
return (
<details className="changelog-release border-t border-[rgba(173,176,182,0.18)]" open={featured || expanded || undefined}>
<summary className="changelog-release-summary min-h-[72px] cursor-pointer list-none items-center justify-between gap-4 py-4 text-[#e4e7ea]">
<span className="min-w-0">
<strong className="block truncate text-[17px] font-[720]">Release {release.tag}</strong>
<span className="mt-1 block text-xs text-[#71717a]">{formatDate(release.date, lang)}</span>
</span>
<ChevronDown className="changelog-release-chevron shrink-0 text-[#6ea8ff]" size={18} />
</summary>
// 用 section 而非 article:docs 的 article 排版规则是浅色主题,
// 会把这里的链接/行内代码染黑。
<section id={releaseId(release.tag)} className="changelog-release">
<div className="changelog-release-body py-12 max-[760px]:py-8">
<div className="flex items-center justify-between gap-4 mb-8 max-[760px]:items-start max-[760px]:flex-wrap max-[760px]:mb-6">
<div className="flex items-center gap-4 max-[760px]:flex-wrap max-[760px]:gap-2.5">
<span className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-full border border-[rgba(173,176,182,0.25)] text-sm font-semibold text-[#e4e7ea]">
<Tag size={13} className="text-[#6ea8ff]" />
{release.tag.replace("v", "")}
{release.tag.replace(/^v/, "")}
</span>
<span className="text-[15px] text-[#71717a] max-[760px]:text-[13px]">
{t.publishedOn} {formatDate(release.date, lang)}
{text.publishedOn} {formatDate(release.date, lang)}
</span>
</div>
<a href={`https://github.com/t8y2/dbx/releases/tag/${release.tag}`} target="_blank" rel="noopener noreferrer" className="flex min-h-11 items-center gap-1.5 px-4 rounded-full border border-[rgba(173,176,182,0.25)] text-sm text-[#e4e7ea] hover:border-[rgba(173,176,182,0.4)] transition-colors">
{t.download}
<ChevronDown size={14} />
<a href={`https://github.com/t8y2/dbx/releases/tag/${release.tag}`} target="_blank" rel="noopener noreferrer" className="flex min-h-9 items-center px-4 rounded-full border border-[rgba(173,176,182,0.25)] text-sm text-[#e4e7ea] hover:border-[rgba(173,176,182,0.4)] transition-colors">
{text.download}
</a>
</div>
<h2 className="text-[28px] font-[720] text-[#ededf0] mb-10 max-[760px]:text-2xl max-[760px]:mb-7">Release {release.tag}</h2>
{release.sections.map((section, sectionIndex) => (
<div key={sectionIndex} className={sectionIndex > 0 ? "mt-10 max-[760px]:mt-8" : ""}>
<h3 className="text-xl font-bold text-[#ededf0] mb-5 max-[760px]:text-lg max-[760px]:mb-4">{sectionLabels[section.type]?.[lang] || section.title}</h3>
<ul className="space-y-3">
{section.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex gap-3 text-[15px] leading-relaxed text-[#bcc1c9] max-[760px]:text-sm">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-[#52525b] shrink-0" />
<span>
{item.desc ? (
<>
{item.title},{item.desc}
</>
) : (
item.title
)}
</span>
</li>
))}
</ul>
</div>
))}
{release.sections.length === 0 && <p className="text-[15px] text-[#71717a] italic">{t.seeGitHub}</p>}
<div className="changelog-markdown" dangerouslySetInnerHTML={{ __html: renderMarkdown(release) }} />
</div>
</details>
</section>
);
}
export function ChangelogList({ releases, lang }: { releases: ChangelogRelease[]; lang: string }) {
const [count, setCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement>(null);
const isDesktop = useSyncExternalStore(subscribeToDesktop, getDesktopSnapshot, getDesktopServerSnapshot);
const visible = releases.slice(0, count);
const hasMore = count < releases.length;
useEffect(() => {
const node = sentinelRef.current;
if (!node || !hasMore) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setCount((c) => Math.min(c + PAGE_SIZE, releases.length));
}
},
{ rootMargin: "200px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [count, hasMore, releases.length]);
export function ChangelogList({ releaseIndex, selectedTag, release, lang, isLoading, errorMessage, onSelectRelease }: { releaseIndex: ChangelogIndexEntry[]; selectedTag: string; release: ChangelogRelease | null; lang: "en" | "cn"; isLoading: boolean; errorMessage?: string; onSelectRelease: (tag: string) => void }) {
const activeTag = release?.tag || selectedTag || releaseIndex[0]?.tag;
const tocEntries = release ? buildTocEntries(release, lang) : [];
return (
<>
{visible.map((release, index) => (
<ReleaseCard key={release.tag} release={release} lang={lang} featured={index === 0} expanded={isDesktop} />
))}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-12 text-[#71717a] text-sm">
{lang === "cn" ? "加载更多版本…" : "Loading more versions…"}
<div className="changelog-shell">
<aside className="changelog-sidebar changelog-sidebar-left" aria-label={lang === "cn" ? "版本列表" : "Release list"}>
<div className="changelog-sidebar-inner">
<div className="changelog-sidebar-title">
<Tag size={18} strokeWidth={1.8} />
<span>{lang === "cn" ? "版本列表" : "Versions"}</span>
</div>
<nav className="changelog-version-list">
{releaseIndex.map((entry) => (
<button key={entry.tag} type="button" className={`changelog-version-link${entry.tag === selectedTag ? " is-active" : ""}`} onClick={() => onSelectRelease(entry.tag)} aria-current={entry.tag === selectedTag ? "page" : undefined}>
<span>{entry.tag}</span>
</button>
))}
</nav>
</div>
</aside>
<section className="changelog-content" aria-label={lang === "cn" ? "更新日志内容" : "Changelog content"}>
<ReleaseCard release={release} lang={lang} isLoading={isLoading} errorMessage={errorMessage} />
</section>
{release && tocEntries.length > 0 && (
<aside className="changelog-sidebar changelog-sidebar-right" aria-label={lang === "cn" ? "当前版本目录" : "Current release contents"}>
<div className="changelog-sidebar-inner">
<p className="changelog-toc-title">{lang === "cn" ? `在 ${activeTag} 中` : `In ${activeTag}`}</p>
<p className="changelog-toc-subtitle">{lang === "cn" ? "更新内容" : "Release contents"}</p>
<nav className="changelog-toc-list">
{tocEntries.map((entry) => (
<a key={entry.id} href={`#${entry.id}`}>
{entry.label}
</a>
))}
</nav>
</div>
</aside>
)}
</>
</div>
);
}
+87 -5
View File
@@ -1,26 +1,108 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChangelogList } from "@/components/landing/ChangelogList";
import type { ChangelogRelease } from "@/lib/changelog";
import { changelogReleaseUrl, type ChangelogIndexEntry, type ChangelogRelease } from "@/lib/changelog";
import { requestJson } from "@/lib/httpJson";
type ChangelogRuntimeProps = {
lang: "en" | "cn";
initialReleases?: ChangelogRelease[];
index: ChangelogIndexEntry[];
initialRelease: ChangelogRelease | null;
fallbackReleases?: ChangelogRelease[] | null;
};
const text = {
en: {
empty: "No releases found.",
loadError: "Failed to load this release. Please retry or check GitHub Releases.",
},
cn: {
empty: "暂无版本记录。",
loadError: "该版本加载失败,请稍后重试或前往 GitHub Releases 查看。",
},
};
export function ChangelogRuntime({ lang, initialReleases = [] }: ChangelogRuntimeProps) {
if (initialReleases.length === 0) {
export function ChangelogRuntime({ lang, index, initialRelease, fallbackReleases = null }: ChangelogRuntimeProps) {
const [selectedTag, setSelectedTag] = useState(initialRelease?.tag ?? index[0]?.tag ?? "");
const [release, setRelease] = useState<ChangelogRelease | null>(initialRelease);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>();
// tag -> 已加载的版本详情;SSR 注入的首个版本与全量兜底数据都放进同一缓存。
const releaseCacheRef = useRef<Map<string, ChangelogRelease>>(new Map());
const cache = releaseCacheRef.current;
if (initialRelease && !cache.has(initialRelease.tag)) {
cache.set(initialRelease.tag, initialRelease);
}
for (const fallback of fallbackReleases ?? []) {
if (!cache.has(fallback.tag)) cache.set(fallback.tag, fallback);
}
useEffect(() => {
if (!selectedTag) return;
const cached = cache.get(selectedTag);
if (cached) {
setRelease(cached);
setErrorMessage(undefined);
setIsLoading(false);
return;
}
let cancelled = false;
setIsLoading(true);
setErrorMessage(undefined);
setRelease(null);
requestJson<ChangelogRelease>(changelogReleaseUrl(lang, selectedTag))
.then((data) => {
if (cancelled) return;
cache.set(selectedTag, data);
setRelease(data);
})
.catch(() => {
if (cancelled) return;
setErrorMessage(text[lang].loadError);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [selectedTag, lang, cache]);
if (index.length === 0) {
return <p className="text-landing-muted py-12">{text[lang].empty}</p>;
}
return <ChangelogList releases={initialReleases} lang={lang} />;
// 切换版本后把视口拉回该版本内容顶部;否则会停留在上一个版本的滚动位置,
// 右侧目录锚点看起来就像没有对上。
const selectRelease = useCallback(
(tag: string) => {
setSelectedTag(tag);
if (typeof document === "undefined") return;
const content = document.querySelector(".changelog-content");
if (!content) return;
const top = content.getBoundingClientRect().top + window.scrollY - 96;
if (Math.abs(top - window.scrollY) > 8) {
window.scrollTo({ top: Math.max(top, 0), behavior: "smooth" });
}
},
[],
);
return (
<ChangelogList
releaseIndex={index}
selectedTag={selectedTag}
release={release}
lang={lang}
isLoading={isLoading}
errorMessage={errorMessage}
onSelectRelease={selectRelease}
/>
);
}
+69 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { afterEach, test, vi } from "vitest";
import { fetchChangelog } from "./changelog";
import { changelogIndexUrl, changelogReleaseUrl, changelogUrl, fetchChangelog, loadChangelogBootstrap } from "./changelog";
import { isAppReleaseTag } from "./releaseTags";
afterEach(() => {
@@ -16,6 +16,12 @@ test("recognizes only DBX app release tags", () => {
assert.equal(isAppReleaseTag("v0.5.x"), false);
});
test("builds index and per-release URLs under the changelog base", () => {
assert.equal(changelogUrl("cn"), "https://dl.dbxio.com/changelog/releases-cn.json");
assert.equal(changelogIndexUrl("en"), "https://dl.dbxio.com/changelog/index-en.json");
assert.equal(changelogReleaseUrl("cn", "v1.2.3"), "https://dl.dbxio.com/changelog/releases-cn/v1.2.3.json");
});
test("changelog build fails without falling back to GitHub API", async () => {
const requestedUrls = [];
vi.stubGlobal(
@@ -29,3 +35,65 @@ test("changelog build fails without falling back to GitHub API", async () => {
await assert.rejects(fetchChangelog("cn"), /Request failed with status 503/);
assert.deepEqual(requestedUrls, ["https://dl.dbxio.com/changelog/releases-cn.json"]);
});
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
}
test("bootstrap prefers the index and per-release files when published", async () => {
const requestedUrls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input) => {
const url = String(input);
requestedUrls.push(url);
if (url.endsWith("/index-cn.json")) {
return jsonResponse({ updatedAt: "2026-08-26T00:00:00Z", releases: [{ tag: "v1.0.0", name: "v1.0.0", date: "2026-08-20" }] });
}
if (url.endsWith("/releases-cn/v1.0.0.json")) {
return jsonResponse({ tag: "v1.0.0", name: "v1.0.0", date: "2026-08-20", markdown: "### 新功能\n- **A** — B", sections: [] });
}
return jsonResponse({}, 404);
}),
);
const bootstrap = await loadChangelogBootstrap("cn");
assert.deepEqual(bootstrap.index, [{ tag: "v1.0.0", name: "v1.0.0", date: "2026-08-20" }]);
assert.equal(bootstrap.initialRelease?.tag, "v1.0.0");
assert.equal(bootstrap.initialRelease?.markdown, "### 新功能\n- **A** — B");
assert.equal(bootstrap.fallbackReleases, null);
assert.deepEqual(requestedUrls, ["https://dl.dbxio.com/changelog/index-cn.json", "https://dl.dbxio.com/changelog/releases-cn/v1.0.0.json"]);
});
test("bootstrap falls back to the full listing while index files are unpublished", async () => {
const requestedUrls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input) => {
const url = String(input);
requestedUrls.push(url);
if (url.endsWith("/index-cn.json")) {
return jsonResponse({}, 404);
}
if (url.endsWith("/releases-cn.json")) {
return jsonResponse({
updatedAt: "2026-08-26T00:00:00Z",
releases: [
{ tag: "v1.0.0", name: "v1.0.0", date: "2026-08-20", sections: [] },
{ tag: "v0.9.0", name: "v0.9.0", date: "2026-08-01", sections: [] },
],
});
}
return jsonResponse({}, 404);
}),
);
const bootstrap = await loadChangelogBootstrap("cn");
assert.deepEqual(bootstrap.index, [
{ tag: "v1.0.0", name: "v1.0.0", date: "2026-08-20" },
{ tag: "v0.9.0", name: "v0.9.0", date: "2026-08-01" },
]);
assert.equal(bootstrap.initialRelease?.tag, "v1.0.0");
assert.equal(bootstrap.fallbackReleases?.length, 2);
assert.deepEqual(requestedUrls, ["https://dl.dbxio.com/changelog/index-cn.json", "https://dl.dbxio.com/changelog/releases-cn.json"]);
});
+60 -3
View File
@@ -15,6 +15,7 @@ export type ChangelogRelease = {
tag: string;
name: string;
date: string;
markdown?: string;
sections: ChangelogSection[];
};
@@ -23,14 +24,70 @@ export type ChangelogData = {
releases: ChangelogRelease[];
};
export type ChangelogIndexEntry = {
tag: string;
name: string;
date: string;
};
export type ChangelogIndex = {
updatedAt: string;
releases: ChangelogIndexEntry[];
};
export type ChangelogBootstrap = {
index: ChangelogIndexEntry[];
initialRelease: ChangelogRelease | null;
// index-cn.json 与 releases-cn/ 尚未发布到 R2 时,退回全量 releases JSON,
// 并把全量数据交给客户端按需取用,避免逐版本请求 404。
fallbackReleases: ChangelogRelease[] | null;
};
const DEFAULT_BASE_URL = "https://dl.dbxio.com/changelog";
export function changelogUrl(lang: "en" | "cn") {
const baseUrl = (typeof process !== "undefined" && (process.env.NEXT_PUBLIC_CHANGELOG_BASE_URL || process.env.CHANGELOG_BASE_URL)) || DEFAULT_BASE_URL;
function changelogBaseUrl() {
return (typeof process !== "undefined" && (process.env.NEXT_PUBLIC_CHANGELOG_BASE_URL || process.env.CHANGELOG_BASE_URL)) || DEFAULT_BASE_URL;
}
return `${baseUrl}/releases-${lang}.json`;
export function changelogUrl(lang: "en" | "cn") {
return `${changelogBaseUrl()}/releases-${lang}.json`;
}
export function changelogIndexUrl(lang: "en" | "cn") {
return `${changelogBaseUrl()}/index-${lang}.json`;
}
export function changelogReleaseUrl(lang: "en" | "cn", tag: string) {
return `${changelogBaseUrl()}/releases-${lang}/${tag}.json`;
}
export async function fetchChangelog(lang: "en" | "cn"): Promise<ChangelogData> {
return requestJson<ChangelogData>(changelogUrl(lang), { cache: "force-cache" });
}
export async function fetchChangelogIndex(lang: "en" | "cn"): Promise<ChangelogIndex> {
return requestJson<ChangelogIndex>(changelogIndexUrl(lang), { cache: "force-cache" });
}
export async function fetchChangelogRelease(lang: "en" | "cn", tag: string): Promise<ChangelogRelease> {
return requestJson<ChangelogRelease>(changelogReleaseUrl(lang, tag), { cache: "force-cache" });
}
export async function loadChangelogBootstrap(lang: "en" | "cn"): Promise<ChangelogBootstrap> {
try {
const index = await fetchChangelogIndex(lang);
if (index.releases.length > 0) {
const initialRelease = await fetchChangelogRelease(lang, index.releases[0].tag);
return { index: index.releases, initialRelease, fallbackReleases: null };
}
} catch {
// fall through to the full listing
}
const full = await fetchChangelog(lang);
return {
index: full.releases.map(({ tag, name, date }) => ({ tag, name, date })),
initialRelease: full.releases[0] ?? null,
fallbackReleases: full.releases,
};
}
+1
View File
@@ -22,6 +22,7 @@
"fumadocs-mdx": "^14.3.2",
"fumadocs-ui": "^16.8.7",
"lucide-react": "^0.515.0",
"marked": "18.0.4",
"next": "^16.3.0",
"postcss": "^8.5.26",
"react": "^19.2.5",
+39 -3
View File
@@ -5,9 +5,7 @@ settings:
excludeLinksFromLockfile: false
patchedDependencies:
next-themes@0.4.6:
hash: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
path: patches/next-themes@0.4.6.patch
next-themes@0.4.6: bc954c2c679c43ecf5351d4a9d68d9f36a9b8fb2dc4237fdbf57304cae4b0c04
importers:
@@ -40,6 +38,9 @@ importers:
lucide-react:
specifier: ^0.515.0
version: 0.515.0(react@19.2.5)
marked:
specifier: 18.0.4
version: 18.0.4
next:
specifier: ^16.3.0
version: 16.3.0(@types/node@25.6.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -289,89 +290,105 @@ packages:
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.2':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.2':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.2':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.2':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.2':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.2':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.3':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.3':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.3':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.3':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.3':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.3':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.3':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.3':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.3':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
@@ -439,24 +456,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@16.3.0':
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@16.3.0':
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@16.3.0':
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@16.3.0':
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
@@ -914,24 +935,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.4':
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.4':
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.4':
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.4':
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
@@ -1387,24 +1412,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1445,6 +1474,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@18.0.4:
resolution: {integrity: sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==}
engines: {node: '>= 20'}
hasBin: true
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -3205,6 +3239,8 @@ snapshots:
markdown-table@3.0.4: {}
marked@18.0.4: {}
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -1,411 +0,0 @@
# 字段类型映射独立弹窗 + JSON 导入/导出 实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 在"比较架构"配置步骤的"选项"按钮后增加"字段类型映射"按钮,点击打开独立弹窗编辑映射(内部可滚动、底部操作栏始终可达),并支持映射配置的 JSON 导入/导出。
**架构:** 新建 `FieldMappingDialog.vue` 复用 `SchemaDiffDialog` 已有的 overlay 弹窗模式;通过 `SchemaDiffConfigStep` 透传 `open-field-mapping` 事件到 `SchemaDiffDialog`;弹窗内持有本地副本(`editingMappings`),"完成"时 emit `save` 复用现有 `handleFieldMappingsUpdate` 写回 `activeConfig.options.fieldMappings`。导入/导出采用现有 `TagManagementPanel` 的 Blob 下载 + 隐藏 file input 模式。
**技术栈:** Vue 3 `<script setup>` + TypeScript + Tailwind(shadcn-vue 风格)、`@lucide/vue` 图标、`vue-i18n`(`t()`)、`useToast` composable。无 Rust 变更。
---
## 文件结构
- 新建 `apps/desktop/src/components/diff/FieldMappingDialog.vue`
- 职责:独立弹窗容器,渲染 `FieldMappingPanel` + 底部导入/导出/完成栏;持有 `editingMappings` 本地副本;实现 `handleExport` / `handleImport` / `onFileSelected`;深拷贝初始化。
- 修改 `apps/desktop/src/components/diff/SchemaDiffConfigStep.vue`
- 职责:在"选项"按钮后插入"字段类型映射"按钮,新增 `open-field-mapping` emit;保留内嵌 `FieldMappingPanel`。
- 修改 `apps/desktop/src/components/diff/SchemaDiffDialog.vue`
- 职责:新增 `showFieldMappingDialog` ref,挂载 `<FieldMappingDialog>` 并接线 `open` / `save` / `update:open`,传入 `sourceDbType` / `targetDbType`。
- 修改 `apps/desktop/src/i18n/locales/zh-CN.ts`
- 职责:在 `schemaDiff.fieldMapping` 对象内新增 `openFieldMapping` / `import` / `export` / `importError` / `done` 键。
- 修改 `apps/desktop/src/i18n/locales/en.ts`
- 职责:同上英文键。
---
## 任务 1:新建 FieldMappingDialog.vue 容器与导入/导出
**文件:**
- 创建:`apps/desktop/src/components/diff/FieldMappingDialog.vue`
- [ ] **步骤 1:编写组件(script + template + style)**
完整内容如下(注意:`FieldMappingEntry` 来自 `@/types/schemaDiff`;`FieldMappingPanel` 已存在并可复用;`useToast` 提供 `toast(msg, duration?)`):
```vue
<script setup lang="ts">
import { ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Download, Upload, Check, X } from "@lucide/vue";
import FieldMappingPanel from "@/components/diff/FieldMappingPanel.vue";
import type { FieldMappingEntry } from "@/types/schemaDiff";
import { useToast } from "@/composables/useToast";
const props = defineProps<{
open: boolean;
mappings: FieldMappingEntry[];
sourceDbType: string;
targetDbType: string;
}>();
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "save", value: FieldMappingEntry[]): void;
}>();
const { t } = useI18n();
const toast = useToast();
const editingMappings = ref<FieldMappingEntry[]>([]);
const fileInputRef = ref<HTMLInputElement | null>(null);
// 打开时深拷贝当前映射为本地副本(单向数据流,取消不写回)
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
editingMappings.value = props.mappings.map((m) => ({ ...m }));
}
},
{ immediate: true },
);
function handleUpdateMappings(v: FieldMappingEntry[]) {
editingMappings.value = v;
}
function handleExport() {
const blob = new Blob([JSON.stringify(editingMappings.value, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "field-mappings.json";
a.click();
URL.revokeObjectURL(url);
}
function handleImportClick() {
fileInputRef.value?.click();
}
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(String(reader.result)) as unknown;
if (!Array.isArray(parsed)) throw new Error("not an array");
const normalized: FieldMappingEntry[] = parsed.map((item: any) => ({
sourceType: String(item.sourceType ?? ""),
targetType: String(item.targetType ?? ""),
paramStrategy: item.paramStrategy === "strip" || item.paramStrategy === "custom" ? item.paramStrategy : "preserve",
customParams: item.customParams ? String(item.customParams) : undefined,
}));
if (normalized.some((m) => !m.sourceType || !m.targetType)) {
throw new Error("missing sourceType/targetType");
}
editingMappings.value = normalized;
toast(t("diff.fieldMapping.importSuccess"), 2000);
} catch {
toast(t("diff.fieldMapping.importError"), 3000);
} finally {
input.value = "";
}
};
reader.onerror = () => toast(t("diff.fieldMapping.importError"), 3000);
reader.readAsText(file);
}
function handleDone() {
emit("save", editingMappings.value.map((m) => ({ ...m })));
emit("update:open", false);
}
function handleClose() {
emit("update:open", false);
}
</script>
<template>
<div v-if="open" class="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center" @click.self="handleClose">
<div class="bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)] max-h-[80vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium">{{ t("diff.fieldMapping.title") }}</h3>
<Button variant="ghost" size="sm" @click="handleClose">
<X class="w-3.5 h-3.5" />
</Button>
</div>
<FieldMappingPanel
:mappings="editingMappings"
:source-db-type="sourceDbType"
:target-db-type="targetDbType"
@update:mappings="handleUpdateMappings"
/>
<div class="flex items-center justify-between mt-4 pt-3 border-t">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="handleImportClick">
<Upload class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.import") }}
</Button>
<Button variant="outline" size="sm" @click="handleExport">
<Download class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.export") }}
</Button>
</div>
<Button size="sm" @click="handleDone">
<Check class="w-3.5 h-3.5 mr-1" />
{{ t("diff.fieldMapping.done") }}
</Button>
</div>
<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden" @change="onFileSelected" />
</div>
</div>
</template>
```
- [ ] **步骤 2:类型检查确认组件无错误**
运行:`cd apps/desktop && pnpm vue-tsc --noEmit --project tsconfig.json`(或仓库根 `pnpm typecheck`)
预期:该文件无 `FieldMappingDialog` 相关报错(已知 2 个与本次无关的预存错误:`dbAdminSql.ts` / `McpServerStatus.node_path`)。
- [ ] **步骤 3:Commit**
```bash
git add apps/desktop/src/components/diff/FieldMappingDialog.vue
git commit -m "feat(desktop): add FieldMappingDialog with JSON import/export"
```
---
## 任务 2:SchemaDiffConfigStep 增加"字段类型映射"按钮
**文件:**
- 修改:`apps/desktop/src/components/diff/SchemaDiffConfigStep.vue`
- [ ] **步骤 1:在 defineEmits 中新增 open-field-mapping 事件**
定位 `const emit = defineEmits<{ ... }>();` 块,在现有事件(如 `(e: "update:fieldMappings", value: FieldMappingEntry[]): void;`)之后新增一行:
```ts
(e: "open-field-mapping"): void;
```
- [ ] **步骤 2:在"选项"按钮后插入"字段类型映射"按钮**
定位底部操作栏(约 468-488 行):
```html
<!-- Bottom Actions -->
<div class="flex items-center justify-between pt-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" @click="$emit('saveConfig')">
<Save class="w-3.5 h-3.5 mr-1" />
{{ t("diff.saveConfig") }}
</Button>
<Button variant="outline" size="sm" @click="$emit('loadConfig')">
<FolderOpen class="w-3.5 h-3.5 mr-1" />
{{ t("diff.loadConfig") }}
</Button>
<Button variant="outline" size="sm" @click="$emit('showOptions')">
<Settings class="w-3.5 h-3.5 mr-1" />
{{ t("diff.options") }}
</Button>
</div>
```
在 `{{ t("diff.options") }}` 按钮的 `</Button>` 之后、`</div>` 之前,插入:
```html
<Button v-if="showFieldMapping" variant="outline" size="sm" @click="$emit('open-field-mapping')">
<ArrowLeftRight class="w-3.5 h-3.5 mr-1" />
{{ t("diff.openFieldMapping") }}
</Button>
```
(注:`showFieldMapping` 已在 `<script>` 中定义为 `computed`,见 `SchemaDiffConfigStep.vue:66`。`ArrowLeftRight` 图标已在文件顶部 import。)
- [ ] **步骤 3:类型检查**
运行:`pnpm typecheck`
预期:无新增该文件报错。
- [ ] **步骤 4:Commit**
```bash
git add apps/desktop/src/components/diff/SchemaDiffConfigStep.vue
git commit -m "feat(desktop): add open-field-mapping button in config step"
```
---
## 任务 3:SchemaDiffDialog 挂载 FieldMappingDialog 并接线
**文件:**
- 修改:`apps/desktop/src/components/diff/SchemaDiffDialog.vue`
- [ ] **步骤 1:导入组件与图标**
在文件顶部 import 区(`SchemaDiffConfigStep` import 附近,约 11 行)新增:
```ts
import FieldMappingDialog from "@/components/diff/FieldMappingDialog.vue";
```
确认 `Download` / `Upload` / `Check` / `X` 不需要在此文件导入(`FieldMappingDialog` 内部已自带)。
- [ ] **步骤 2:新增 ref 并接线到 SchemaDiffConfigStep**
在 `showOptionsPanel` ref 定义附近(约 70 行 `const showOptionsPanel = ref(false);`)新增:
```ts
const showFieldMappingDialog = ref(false);
```
定位 `<SchemaDiffConfigStep ... >`(约 810-831 行),在其 `@update:field-mappings="handleFieldMappingsUpdate"` 之后新增一行:
```html
@open-field-mapping="showFieldMappingDialog = true"
```
- [ ] **步骤 3:渲染 FieldMappingDialog(放在 showOptionsPanel overlay 之后,约 1055 行 `</DialogContent>` 之前)**
```html
<!-- Field Mapping Dialog Overlay -->
<FieldMappingDialog
:open="showFieldMappingDialog"
:mappings="activeConfig?.options.fieldMappings ?? []"
:source-db-type="sourceDbType"
:target-db-type="targetDbType"
@update:open="showFieldMappingDialog = $event"
@save="handleFieldMappingsUpdate"
/>
```
(注:`sourceDbType` / `targetDbType` 已在 `SchemaDiffDialog` 的 `<script>` 中计算,`handleFieldMappingsUpdate(mappings: FieldMappingEntry[])` 签名见 298 行,直接复用。)
- [ ] **步骤 4:类型检查**
运行:`pnpm typecheck`
预期:无新增报错。
- [ ] **步骤 5:Commit**
```bash
git add apps/desktop/src/components/diff/SchemaDiffDialog.vue
git commit -m "feat(desktop): wire FieldMappingDialog into SchemaDiffDialog"
```
---
## 任务 4:i18n 新增键(zh-CN + en)
**文件:**
- 修改:`apps/desktop/src/i18n/locales/zh-CN.ts`
- 修改:`apps/desktop/src/i18n/locales/en.ts`
- [ ] **步骤 1:zh-CN 新增键**
定位 `schemaDiff.fieldMapping` 对象(约 2444 行 `fieldMapping: {`)。在 `selectPreset: "选择预设...",` 行之后新增:
```ts
openFieldMapping: "字段类型映射",
import: "导入",
export: "导出",
importSuccess: "导入成功",
importError: "导入失败:文件不是合法的字段类型映射 JSON",
done: "完成",
```
- [ ] **步骤 2:en 新增键**
定位 `schemaDiff.fieldMapping` 对象(约 2457 行 `fieldMapping: {`)。在 `selectPreset: "Select preset...",` 行之后新增:
```ts
openFieldMapping: "Field Type Mapping",
import: "Import",
export: "Export",
importSuccess: "Imported successfully",
importError: "Import failed: file is not a valid field type mapping JSON",
done: "Done",
```
- [ ] **步骤 3:类型检查(i18n 为运行时键,typecheck 不校验缺失键,但确认 JSON 结构无语法错误)**
运行:`pnpm typecheck`
预期:无新增报错。
- [ ] **步骤 4:Commit**
```bash
git add apps/desktop/src/i18n/locales/zh-CN.ts apps/desktop/src/i18n/locales/en.ts
git commit -m "feat(desktop): add i18n keys for field mapping dialog"
```
---
## 任务 5:FieldMappingPanel 内嵌滚动兜底
**文件:**
- 修改:`apps/desktop/src/components/diff/FieldMappingPanel.vue`
- [ ] **步骤 1:给映射列表容器加最大高度与滚动**
定位 `<div v-else class="p-3">`(约 100 行,内含映射列表的 `v-else` 分支)。将其改为带 `max-h` + `overflow-auto`,使内嵌面板(非弹窗场景)也能滚动:
```html
<div v-else class="p-3 max-h-[42vh] overflow-auto">
```
(弹窗场景下外层已有 `max-h-[80vh] overflow-auto`,内层再加一层可滚避免双重长列表;内嵌场景下这层是关键兜底。)
- [ ] **步骤 2:类型检查**
运行:`pnpm typecheck`
预期:无新增报错。
- [ ] **步骤 3:Commit**
```bash
git add apps/desktop/src/components/diff/FieldMappingPanel.vue
git commit -m "feat(desktop): make FieldMappingPanel list scrollable as fallback"
```
---
## 自检(计划作者视角)
**规格覆盖度:**
- 独立弹窗(任务 1 + 3)✓
- "选项"后按钮(任务 2)✓
- 单向数据流 / 取消不写回(任务 1 watch + handleClose)✓
- JSON 导出(任务 1 handleExport,参考 TagManagementPanel)✓
- JSON 导入 + 校验(任务 1 onFileSelected)✓
- 弹窗内滚动 + 内嵌兜底(任务 1 max-h-[80vh] + 任务 5)✓
- 配置步骤底部操作栏始终可见(弹窗隔离映射,任务 3)✓
- i18n(任务 4)✓
**占位符扫描:** 无 TODO / 待定 / "补充细节"。所有步骤含完整代码。
**类型一致性:** `FieldMappingEntry` 来自 `@/types/schemaDiff`;`handleFieldMappingsUpdate(mappings: FieldMappingEntry[])` 在任务 3 复用任务 1 emit 的 `save(FieldMappingEntry[])`;`sourceDbType` / `targetDbType` 在 `SchemaDiffDialog` 已存在;i18n 键在任务 4 定义、任务 1 使用,名称一致(`openFieldMapping` / `import` / `export` / `importSuccess` / `importError` / `done`)。
---
## 验收(手动,pnpm dev)
1. 打开"比较架构"弹窗,源/目标选不同类型 → 出现"字段类型映射"按钮。
2. 点按钮 → 独立弹窗打开,渲染映射面板。
3. 弹窗内添加大量映射 → 弹窗内部可滚动,底部"导入/导出/完成"始终可点。
4. 点"导出" → 下载 `field-mappings.json`,内容为 `FieldMappingEntry[]`。
5. 清空映射后点"导入"选该 json → 映射恢复;非法文件 → 报错 toast 且不替换。
6. 点"完成" → 写回配置步骤;关闭弹窗后底部操作栏(保存/加载/选项/开始比较)始终可见可点。
7. 点遮罩 / ✕ → 未"完成"的修改不写回。
@@ -1,542 +0,0 @@
# ClickHouse SQL Syntax Highlighting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a dedicated ClickHouse CodeMirror dialect so ClickHouse keywords, types, built-ins, comments, and table names receive correct editor highlighting.
**Architecture:** Keep DBX's existing CodeMirror/Lezer pipeline and define a ClickHouse dialect by extending `StandardSQL` with a curated ClickHouse vocabulary and lexical options. Route ClickHouse connections to that syntax dialect without widening the existing completion/formatting dialect, and add a ClickHouse semantic adapter selected before the legacy MySQL behavior dialect.
**Tech Stack:** TypeScript, Vue 3, CodeMirror 6 `@codemirror/lang-sql@6.10.0`, Lezer SQL parser, Vitest.
## Global Constraints
- Cover the main CodeMirror query editor and CodeMirror-based DDL/SQL viewers.
- Do not change Shiki-rendered static SQL blocks in AI messages.
- Do not change SQL formatting, execution, completion, snippets, or editor theme colors.
- Use `StandardSQL`, not `MySQL`, as the ClickHouse parser base.
- Preserve ClickHouse's compact `--SELECT 1` line-comment behavior.
- Add no runtime dependency and no persisted-setting migration.
---
## File Map
- Modify `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts`: own the ClickHouse CodeMirror dialect name, vocabulary, lexical configuration, and parser factory selection.
- Modify `apps/desktop/src/lib/database/jdbcDialect.ts`: return the dedicated syntax dialect for native and inferred JDBC ClickHouse connections while retaining the existing three-value behavior dialect.
- Modify `apps/desktop/src/components/editor/QueryEditor.vue`: allow `clickhouse` only for the syntax-dialect prop.
- Modify `apps/desktop/src/components/layout/ContentArea.vue`: type the syntax dialect with the shared CodeMirror dialect type.
- Modify `packages/app-tests/codemirrorSqlDialect.test.ts`: verify vocabulary parser nodes, comments, and database-to-syntax propagation.
- Modify `packages/app-tests/jdbcDialect.test.ts`: verify inferred JDBC ClickHouse syntax selection.
- Modify `apps/desktop/src/lib/sql/semantic/dialect.ts`: own the ClickHouse semantic adapter and prioritize it for ClickHouse database types.
- Create `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts`: verify adapter selection, quoting, and normalization.
### Task 1: Dedicated ClickHouse CodeMirror dialect
**Files:**
- Modify: `packages/app-tests/codemirrorSqlDialect.test.ts`
- Modify: `packages/app-tests/jdbcDialect.test.ts`
- Modify: `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts`
- Modify: `apps/desktop/src/lib/database/jdbcDialect.ts`
- Modify: `apps/desktop/src/components/editor/QueryEditor.vue`
- Modify: `apps/desktop/src/components/layout/ContentArea.vue`
**Interfaces:**
- Produces: `CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse"`.
- Produces: `codeMirrorSqlDialectForConnection(connection): CodeMirrorSqlDialectName`.
- Preserves: `codeMirrorSqlDialect(dbType): "mysql" | "postgres" | "sqlserver"` for completion and formatting behavior.
- Consumes: `createDbxCodeMirrorSqlDialect(langSql, dialectName, databaseType)`.
- [ ] **Step 1: Write failing parser and mapping tests**
Extend `packages/app-tests/codemirrorSqlDialect.test.ts` with:
```ts
import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts";
test("maps ClickHouse connections to the dedicated editor syntax dialect", () => {
assert.equal(codeMirrorSqlDialectForConnection({ db_type: "clickhouse" }), "clickhouse");
assert.equal(
codeMirrorSqlDialectForConnection({
db_type: "jdbc",
connection_string: "jdbc:clickhouse://127.0.0.1:8123/default",
}),
"clickhouse",
);
});
test("classifies ClickHouse-specific syntax", () => {
const dialect = createDbxCodeMirrorSqlDialect(langSql, "clickhouse", "clickhouse");
const sql = `
CREATE TABLE events
(
id UInt64,
created_at DateTime64(3),
category LowCardinality(String),
attributes Map(String, String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(created_at)
ORDER BY id
TTL created_at + INTERVAL 30 DAY
SETTINGS index_granularity = 8192;
SELECT uniqExact(id), argMax(category, created_at)
FROM events
PREWHERE created_at >= now() - INTERVAL 1 DAY
ARRAY JOIN mapKeys(attributes) AS attribute_key
LIMIT 10 BY category
FORMAT JSONEachRow;
`;
for (const keyword of ["ENGINE", "PARTITION", "TTL", "SETTINGS", "PREWHERE", "ARRAY", "FORMAT"]) {
assert.ok(countParsedNodes(dialect, sql, "Keyword", keyword) >= 1, keyword);
}
for (const type of ["UInt64", "DateTime64", "LowCardinality", "Map"]) {
assert.equal(countParsedNodes(dialect, sql, "Type", type), 1, type);
}
for (const builtin of ["toYYYYMM", "uniqExact", "argMax", "mapKeys"]) {
assert.equal(countParsedNodes(dialect, sql, "Builtin", builtin), 1, builtin);
}
assert.equal(countParsedNodes(dialect, "--SELECT 1", "LineComment", "--SELECT 1"), 1);
});
```
Extend `packages/app-tests/jdbcDialect.test.ts` with:
```ts
test("uses dedicated ClickHouse editor syntax for inferred JDBC connections", () => {
const connection = {
db_type: "jdbc" as const,
connection_string: "jdbc:clickhouse://127.0.0.1:8123/default",
};
assert.equal(inferJdbcDialect(connection), "clickhouse");
assert.equal(codeMirrorSqlDialectForConnection(connection), "clickhouse");
});
```
- [ ] **Step 2: Run the tests and verify the red state**
Run:
```bash
pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts
```
Expected: FAIL because `"clickhouse"` is not accepted as a CodeMirror dialect name and ClickHouse connections currently return `"mysql"`.
- [ ] **Step 3: Add the ClickHouse dialect definition**
In `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts`, extend the shared name and add focused vocabulary constants:
```ts
export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse";
const CLICKHOUSE_KEYWORDS = [
"ATTACH",
"DETACH",
"OPTIMIZE",
"SYSTEM",
"KILL",
"ENGINE",
"PARTITION",
"PRIMARY",
"SAMPLE",
"PREWHERE",
"ARRAY",
"GLOBAL",
"FINAL",
"WITH",
"TOTALS",
"ROLLUP",
"CUBE",
"LIMIT",
"BY",
"INTO",
"OUTFILE",
"COMPRESSION",
"FORMAT",
"SETTINGS",
"TTL",
"CODEC",
"MATERIALIZED",
"ALIAS",
"PROJECTION",
"INDEX",
"GRANULARITY",
].join(" ");
const CLICKHOUSE_TYPES = [
"Bool",
"Int8",
"Int16",
"Int32",
"Int64",
"Int128",
"Int256",
"UInt8",
"UInt16",
"UInt32",
"UInt64",
"UInt128",
"UInt256",
"Float32",
"Float64",
"Decimal",
"Decimal32",
"Decimal64",
"Decimal128",
"Decimal256",
"String",
"FixedString",
"Date",
"Date32",
"DateTime",
"DateTime64",
"Time",
"Time64",
"Enum8",
"Enum16",
"UUID",
"IPv4",
"IPv6",
"Array",
"Tuple",
"Map",
"Nested",
"Nullable",
"LowCardinality",
"AggregateFunction",
"SimpleAggregateFunction",
"JSON",
"Object",
"Variant",
"Dynamic",
"Nothing",
].join(" ");
const CLICKHOUSE_BUILTINS = [
"now",
"today",
"toDate",
"toDateTime",
"toDateTime64",
"toYYYYMM",
"count",
"sum",
"avg",
"min",
"max",
"uniq",
"uniqExact",
"argMin",
"argMax",
"groupArray",
"arrayJoin",
"mapKeys",
"mapValues",
"JSONExtract",
"JSONExtractString",
].join(" ");
```
Make `codeMirrorBaseDialect()` select `StandardSQL` for either an explicit
ClickHouse syntax name or `databaseType === "clickhouse"`:
```ts
if (databaseType) {
if (databaseType === "clickhouse") return langSql.StandardSQL;
// Existing database family checks remain in their current order.
}
if (dialectName === "clickhouse") return langSql.StandardSQL;
```
In `createDbxCodeMirrorSqlDialect()`, build ClickHouse-specific spec fields
without changing other dialects:
```ts
const isClickHouse = databaseType === "clickhouse" || dialectName === "clickhouse";
return langSql.SQLDialect.define({
...baseDialect.spec,
keywords: [
baseKeywords,
DBX_COMMON_SQL_KEYWORDS,
isClickHouse ? CLICKHOUSE_KEYWORDS : "",
isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "",
isSqlServer ? SQLSERVER_KEYWORDS : "",
]
.filter(Boolean)
.join(" "),
types: [baseDialect.spec.types || "", isClickHouse ? CLICKHOUSE_TYPES : "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""]
.filter(Boolean)
.join(" ") || undefined,
builtin: [baseDialect.spec.builtin || "", isClickHouse ? CLICKHOUSE_BUILTINS : "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""]
.filter(Boolean)
.join(" ") || undefined,
identifierQuotes: isClickHouse ? '"`' : baseDialect.spec.identifierQuotes,
backslashEscapes: isClickHouse ? true : baseDialect.spec.backslashEscapes,
spaceAfterDashes: isClickHouse ? false : baseDialect.spec.spaceAfterDashes,
doubleDollarQuotedStrings: false,
});
```
- [ ] **Step 4: Route ClickHouse only through the syntax-dialect channel**
In `apps/desktop/src/lib/database/jdbcDialect.ts`, import the type and keep the
existing behavior-dialect function narrow:
```ts
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): CodeMirrorSqlDialectName {
if (isJdbcAseProfile(connection)) return "sqlserver";
const databaseType = effectiveDatabaseTypeForConnection(connection);
if (databaseType === "clickhouse") return "clickhouse";
return codeMirrorSqlDialect(databaseType);
}
```
In `apps/desktop/src/components/editor/QueryEditor.vue`, import the type and
change only the syntax prop:
```ts
import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
syntaxDialect?: CodeMirrorSqlDialectName;
```
In `apps/desktop/src/components/layout/ContentArea.vue`, import the same type and
change only the syntax computed value:
```ts
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
const editorSyntaxDialect = computed<CodeMirrorSqlDialectName>(() => codeMirrorSqlDialectForConnection(props.activeConnection));
```
The existing `editorDialect` remains `"mysql" | "postgres" | "sqlserver"` so
completion, identifier quoting, and formatting behavior stay out of scope.
- [ ] **Step 5: Run focused tests and typecheck**
Run:
```bash
pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts
pnpm typecheck
```
Expected: both test files PASS and `vue-tsc` exits with code 0.
- [ ] **Step 6: Commit the dedicated parser**
```bash
git add apps/desktop/src/lib/editor/codemirrorSqlDialect.ts \
apps/desktop/src/lib/database/jdbcDialect.ts \
apps/desktop/src/components/editor/QueryEditor.vue \
apps/desktop/src/components/layout/ContentArea.vue \
packages/app-tests/codemirrorSqlDialect.test.ts \
packages/app-tests/jdbcDialect.test.ts
git commit -m "feat(editor): add ClickHouse SQL highlighting"
```
### Task 2: ClickHouse semantic table-name adapter
**Files:**
- Create: `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts`
- Modify: `apps/desktop/src/lib/sql/semantic/dialect.ts`
**Interfaces:**
- Produces: `SQL_SEMANTIC_DIALECTS.clickhouse: SqlSemanticDialectAdapter`.
- Preserves: `sqlSemanticDialectFor(options)` input type; ClickHouse is selected by `databaseType`, not by widening completion's three-value dialect.
- Consumes: `SqlSemanticDialectAdapter` and `defaultNormalize`.
- [ ] **Step 1: Write the failing semantic adapter test**
Create `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts`:
```ts
import { describe, expect, it } from "vitest";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
describe("ClickHouse semantic dialect", () => {
it("takes precedence over the legacy MySQL behavior dialect", () => {
const dialect = sqlSemanticDialectFor({
databaseType: "clickhouse",
dialect: "mysql",
});
expect(dialect.id).toBe("clickhouse");
expect(dialect.identifierQuotes).toEqual([
{ open: "`", close: "`" },
{ open: '"', close: '"' },
]);
expect(dialect.normalizeIdentifier("EventName")).toBe("EventName");
expect(dialect.quoteIdentifier("event`name")).toBe("`event``name`");
});
});
```
- [ ] **Step 2: Run the test and verify the red state**
Run:
```bash
pnpm vitest run apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts
```
Expected: FAIL because the returned adapter id is currently `"mysql"`.
- [ ] **Step 3: Implement the minimal ClickHouse semantic adapter**
Add this entry to `SQL_SEMANTIC_DIALECTS` in
`apps/desktop/src/lib/sql/semantic/dialect.ts`:
```ts
clickhouse: {
id: "clickhouse",
identifierQuotes: [
{ open: "`", close: "`" },
{ open: '"', close: '"' },
],
supportsAsForTableAlias: true,
projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true },
normalizeIdentifier: defaultNormalize,
quoteIdentifier: (identifier) => quoteWith(identifier, "`"),
qualifierRole(parts, context) {
if (context === "column") return parts.length >= 2 ? "table" : "table";
if (context === "routine") return parts.length >= 2 ? "package" : "schema";
return parts.length >= 1 ? "schema" : "unknown";
},
},
```
Select it before the explicit three-value behavior dialect:
```ts
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" }): SqlSemanticDialectAdapter {
if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse;
if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect];
// Existing switch remains unchanged.
}
```
- [ ] **Step 4: Run semantic and parser regression tests**
Run:
```bash
pnpm vitest run \
apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts \
apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts \
packages/app-tests/codemirrorSqlDialect.test.ts
```
Expected: all tests PASS.
- [ ] **Step 5: Commit the semantic adapter**
```bash
git add apps/desktop/src/lib/sql/semantic/dialect.ts \
apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts
git commit -m "feat(editor): add ClickHouse semantic SQL dialect"
```
### Task 3: Final verification and PR preparation
**Files:**
- Verify: all files changed by Tasks 1 and 2
- Verify: `docs/superpowers/specs/2026-07-29-clickhouse-syntax-highlighting-design.md`
- Verify: `docs/superpowers/plans/2026-07-29-clickhouse-syntax-highlighting.md`
**Interfaces:**
- Consumes: the dedicated ClickHouse parser and semantic adapter.
- Produces: a reviewed, tested branch ready for a GitHub pull request.
- [ ] **Step 1: Run the full relevant automated checks**
Run:
```bash
pnpm vitest run \
packages/app-tests/codemirrorSqlDialect.test.ts \
packages/app-tests/jdbcDialect.test.ts \
apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts \
apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts \
apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts
pnpm typecheck
```
Expected: every Vitest file passes and typecheck exits with code 0.
- [ ] **Step 2: Review parser output and scope boundaries**
Run:
```bash
git diff main...HEAD --check
git diff main...HEAD --stat
git diff main...HEAD -- \
apps/desktop/src/lib/editor/codemirrorSqlDialect.ts \
apps/desktop/src/lib/database/jdbcDialect.ts \
apps/desktop/src/lib/sql/semantic/dialect.ts
rg -n "clickhouse" apps/desktop/src/lib/sql/sqlHighlighter.ts
```
Expected: no whitespace errors; changes stay in the planned CodeMirror and
semantic paths; `sqlHighlighter.ts` has no new ClickHouse/Shiki modification.
- [ ] **Step 3: Perform a local correctness review**
Check each diff hunk against this exact checklist:
```text
[ ] ClickHouse uses StandardSQL, never MySQL, as its parser base.
[ ] Compact -- comments remain LineComment nodes.
[ ] Keywords, types, and built-ins are stored in separate spec fields.
[ ] Only syntaxDialect accepts the new clickhouse value.
[ ] Existing completion/formatting dialect types remain unchanged.
[ ] databaseType=clickhouse wins over dialect=mysql in semantic selection.
[ ] No dependency, persistence, theme, Shiki, execution, or formatter changes.
```
Expected: every item is satisfied; correct any violation and rerun Step 1.
- [ ] **Step 4: Push and create the pull request**
Run:
```bash
git status --short
git push -u origin codex/clickhouse-syntax-highlighting
gh pr create \
--base main \
--head codex/clickhouse-syntax-highlighting \
--title "feat(editor): add dedicated ClickHouse SQL highlighting" \
--body-file /tmp/dbx-clickhouse-highlighting-pr.md
```
Before the last command, create `/tmp/dbx-clickhouse-highlighting-pr.md` with:
```markdown
## Summary
- add a dedicated ClickHouse CodeMirror SQL dialect
- classify ClickHouse keywords, data types, and common built-in functions
- select a ClickHouse semantic adapter for table-name highlighting
- preserve existing completion, formatting, Shiki, and theme behavior
## Test plan
- `pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts`
- `pnpm typecheck`
```
Expected: the branch is pushed and GitHub returns a new PR URL.
@@ -1,896 +0,0 @@
# ClickHouse Function Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add broad, static ClickHouse function completion with canonical casing, overload-aware and parametric signature help, table functions, and valid aggregate-combinator variants.
**Architecture:** Keep ClickHouse definitions and deterministic combinator logic in `apps/desktop/src/lib/sql/clickhouse/`. Adapt that registry into the existing generic SQL completion provider without changing other database inventories. Extend signature help to return overloads and parameter groups, then render that richer result in the existing CodeMirror tooltip.
**Tech Stack:** TypeScript 6, Vue 3, CodeMirror 6, Vitest 4, pnpm 10.
---
## File Map
**Create**
- `apps/desktop/src/lib/sql/clickhouse/functionTypes.ts` — shared ClickHouse function, signature, category, status, and registry-query types.
- `apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts` — scalar and window-function inventory plus category manifest.
- `apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts` — ordinary and parametric aggregate inventory.
- `apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts` — functions valid as table sources.
- `apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts` — legal suffix transitions and signature transformations.
- `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts` — validation, canonical lookup, prefix search, and lazy combinator generation.
- `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts` — registry integrity and lookup coverage.
- `apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts` — combinator ordering and signature transformations.
- `apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts` — overload and parametric-call parsing.
- `apps/desktop/src/lib/editor/sqlSignatureTooltip.ts` — render overload-aware signature help without coupling registry logic to Vue.
- `apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts` — exercise the real tooltip DOM in happy-dom.
**Modify**
- `apps/desktop/src/lib/sql/sqlCompletion.ts` — ClickHouse registry adapter, table-function context, canonical apply text, and multi-signature resolver.
- `apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts` — ClickHouse completion, table-function, isolation, and regression cases.
- `apps/desktop/src/components/editor/QueryEditor.vue` — render all overloads and active parameters.
**Reference**
- `docs/superpowers/specs/2026-07-30-clickhouse-function-completion-design.md`
- `apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts` — existing dialect reconfiguration regression to keep running.
- <https://clickhouse.com/docs/reference/functions>
- <https://clickhouse.com/docs/reference/functions/aggregate-functions/combinators>
## Task 1: Introduce the ClickHouse Function Model and Validated Registry
**Files:**
- Create: `apps/desktop/src/lib/sql/clickhouse/functionTypes.ts`
- Create: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts`
- Create: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts`
- [ ] **Step 1: Write failing registry-validation tests**
Create `functionRegistry.spec.ts` with real definitions covering canonical lookup, overload preservation, duplicate detection, and invalid preferred indexes:
```ts
import { describe, expect, it } from "vitest";
import { createClickHouseFunctionRegistry } from "@/lib/sql/clickhouse/functionRegistry";
import type { ClickHouseFunctionDefinition } from "@/lib/sql/clickhouse/functionTypes";
const toStartOfDay: ClickHouseFunctionDefinition = {
name: "toStartOfDay",
kind: "regular",
category: "date-time",
signatures: [{ parameterGroups: [["value", "time_zone?"]], returnType: "DateTime" }],
aliases: ["startOfDay"],
};
describe("ClickHouse function registry", () => {
it("looks up canonical names case-insensitively and preserves overloads", () => {
const registry = createClickHouseFunctionRegistry([toStartOfDay]);
expect(registry.get("TOSTARTOFDAY")).toEqual(toStartOfDay);
expect(registry.search("tostart", 20)).toEqual([toStartOfDay]);
expect(registry.search("startof", 20)).toEqual([toStartOfDay]);
});
it("rejects duplicate canonical names case-insensitively", () => {
expect(() => createClickHouseFunctionRegistry([toStartOfDay, { ...toStartOfDay, name: "TOSTARTOFDAY" }])).toThrow(/duplicate/i);
});
it("rejects an invalid preferred signature index", () => {
expect(() => createClickHouseFunctionRegistry([{ ...toStartOfDay, preferredSignature: 2 }])).toThrow(/preferred signature/i);
});
});
```
- [ ] **Step 2: Run the focused test and confirm the RED state**
Run:
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts
```
Expected: FAIL because `functionTypes.ts` and `functionRegistry.ts` do not exist.
- [ ] **Step 3: Add the function types**
Create `functionTypes.ts`:
```ts
export type ClickHouseFunctionKind = "regular" | "aggregate" | "window" | "table";
export type ClickHouseFunctionStatus = "stable" | "experimental" | "deprecated";
export type ClickHouseFunctionCategory =
| "aggregate"
| "array"
| "bitmap"
| "comparison"
| "conversion"
| "date-time"
| "dictionary"
| "encoding"
| "geo"
| "hash"
| "ip"
| "json"
| "map"
| "math"
| "nullable"
| "random"
| "string"
| "table"
| "tuple"
| "url"
| "window"
| "other";
export interface ClickHouseFunctionSignature {
parameterGroups: string[][];
returnType?: string;
}
export interface ClickHouseFunctionDefinition {
name: string;
kind: ClickHouseFunctionKind;
category: ClickHouseFunctionCategory;
signatures: ClickHouseFunctionSignature[];
description?: string;
preferredSignature?: number;
status?: ClickHouseFunctionStatus;
aliases?: string[];
combinators?: boolean;
generated?: boolean;
}
export interface ClickHouseFunctionRegistry {
get(name: string): ClickHouseFunctionDefinition | undefined;
search(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[];
all(): readonly ClickHouseFunctionDefinition[];
}
```
- [ ] **Step 4: Implement strict construction and deterministic prefix search**
Create `functionRegistry.ts` with a private lowercase index. Validate non-empty names, non-empty signatures, non-empty parameter groups, preferred indexes, canonical collisions, and alias collisions:
```ts
import type { ClickHouseFunctionDefinition, ClickHouseFunctionKind, ClickHouseFunctionRegistry } from "./functionTypes";
function definitionKeys(definition: ClickHouseFunctionDefinition): string[] {
return [definition.name, ...(definition.aliases ?? [])].map((name) => name.toLowerCase());
}
function validateDefinition(definition: ClickHouseFunctionDefinition): void {
if (!definition.name.trim()) throw new Error("ClickHouse function name must not be empty");
if (definition.signatures.length === 0) throw new Error(`ClickHouse function ${definition.name} must define a signature`);
if (definition.signatures.some((signature) => signature.parameterGroups.length === 0)) {
throw new Error(`ClickHouse function ${definition.name} must define a parameter group`);
}
const preferred = definition.preferredSignature ?? 0;
if (preferred < 0 || preferred >= definition.signatures.length) {
throw new Error(`ClickHouse function ${definition.name} has an invalid preferred signature`);
}
}
export function createClickHouseFunctionRegistry(definitions: readonly ClickHouseFunctionDefinition[]): ClickHouseFunctionRegistry {
const byKey = new Map<string, ClickHouseFunctionDefinition>();
for (const definition of definitions) {
validateDefinition(definition);
for (const key of definitionKeys(definition)) {
if (byKey.has(key)) throw new Error(`Duplicate ClickHouse function or alias: ${key}`);
byKey.set(key, definition);
}
}
const ordered = [...definitions].sort((left, right) => left.name.localeCompare(right.name));
return {
get: (name) => byKey.get(name.toLowerCase()),
search: (prefix, limit, kind?: ClickHouseFunctionKind) => {
const normalized = prefix.toLowerCase();
return ordered.filter((definition) => (!kind || definition.kind === kind) && definitionKeys(definition).some((key) => key.startsWith(normalized))).slice(0, limit);
},
all: () => ordered,
};
}
```
- [ ] **Step 5: Run the focused test and confirm GREEN**
Run the same Vitest command. Expected: 3 tests PASS.
- [ ] **Step 6: Commit the registry foundation**
```bash
git add apps/desktop/src/lib/sql/clickhouse/functionTypes.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts
git commit -m "feat(sql): add ClickHouse function registry"
```
## Task 2: Add the Regular, Window, and Table Function Inventories
**Files:**
- Create: `apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts`
- Create: `apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts`
- Modify: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts`
- Modify: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts`
- [ ] **Step 1: Add failing inventory-integrity and representative-coverage tests**
Extend `functionRegistry.spec.ts`:
```ts
import { CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST, CLICKHOUSE_REGULAR_FUNCTIONS } from "@/lib/sql/clickhouse/regularFunctions";
import { CLICKHOUSE_TABLE_FUNCTIONS } from "@/lib/sql/clickhouse/tableFunctions";
import { CLICKHOUSE_FUNCTION_REGISTRY } from "@/lib/sql/clickhouse/functionRegistry";
it("keeps the checked-in category manifest and inventory counts aligned", () => {
for (const entry of CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST) {
expect(CLICKHOUSE_REGULAR_FUNCTIONS.filter((definition) => definition.category === entry.category)).toHaveLength(entry.minimumCount);
}
});
it.each([
["arrayMap", "array"],
["toStartOfDay", "date-time"],
["JSONExtractString", "json"],
["cityHash64", "hash"],
["URLHierarchy", "url"],
["lagInFrame", "window"],
] as const)("contains %s with canonical casing and category %s", (name, category) => {
expect(CLICKHOUSE_FUNCTION_REGISTRY.get(name)).toMatchObject({ name, category });
});
it.each(["numbers", "file", "url", "s3", "remote", "postgresql", "mysql"] as const)("contains the %s table function", (name) => {
expect(CLICKHOUSE_TABLE_FUNCTIONS.some((definition) => definition.name === name && definition.kind === "table")).toBe(true);
});
```
- [ ] **Step 2: Run the registry test and verify the missing-inventory failure**
Expected: FAIL because the inventory modules and exported default registry do not exist.
- [ ] **Step 3: Add regular-function helpers and the first documented categories**
In `regularFunctions.ts`, use a small typed helper so data remains compact:
```ts
import type { ClickHouseFunctionCategory, ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes";
const regular = (
name: string,
category: ClickHouseFunctionCategory,
signatures: ClickHouseFunctionSignature[],
options: Partial<ClickHouseFunctionDefinition> = {},
): ClickHouseFunctionDefinition => ({ name, kind: "regular", category, signatures, ...options });
export const CLICKHOUSE_REGULAR_FUNCTIONS: ClickHouseFunctionDefinition[] = [
regular("arrayMap", "array", [{ parameterGroups: [["lambda", "array", "...arrays"]], returnType: "Array" }]),
regular("toStartOfDay", "date-time", [
{ parameterGroups: [["value"]], returnType: "DateTime" },
{ parameterGroups: [["value", "time_zone"]], returnType: "DateTime" },
]),
regular("JSONExtractString", "json", [{ parameterGroups: [["json", "path", "...paths"]], returnType: "String" }]),
regular("cityHash64", "hash", [{ parameterGroups: [["argument", "...arguments"]], returnType: "UInt64" }]),
regular("URLHierarchy", "url", [{ parameterGroups: [["url"]], returnType: "Array(String)" }]),
{ name: "lagInFrame", kind: "window", category: "window", signatures: [{ parameterGroups: [["value", "offset?", "default?"]] }] },
];
```
Continue in category-sized edits using the official 2026-07-30 documentation snapshot. Each definition must include every documented overload needed to distinguish arity or parameter groups. Cover the manifest categories declared in `functionTypes.ts`; omit a category from the manifest only when ClickHouse has no public function page for it.
- [ ] **Step 4: Add a checked-in manifest after each category batch**
Append a manifest whose counts exactly equal the checked-in arrays:
```ts
export const CLICKHOUSE_FUNCTION_CATEGORY_MANIFEST = [
{ category: "array", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "array").length },
{ category: "date-time", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "date-time").length },
{ category: "json", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "json").length },
{ category: "hash", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "hash").length },
{ category: "url", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "url").length },
{ category: "window", minimumCount: CLICKHOUSE_REGULAR_FUNCTIONS.filter((item) => item.category === "window").length },
] as const;
```
Expand this literal as each documented category is added. Do not derive the manifest at runtime; checked-in numeric literals are the reviewable deletion guard. Replace the `.length` expressions above with those final numeric literals before making the test green.
- [ ] **Step 5: Add table-function definitions**
Create `tableFunctions.ts`:
```ts
import type { ClickHouseFunctionDefinition } from "./functionTypes";
export const CLICKHOUSE_TABLE_FUNCTIONS: ClickHouseFunctionDefinition[] = [
{
name: "numbers",
kind: "table",
category: "table",
signatures: [
{ parameterGroups: [["count"]] },
{ parameterGroups: [["offset", "count"]] },
{ parameterGroups: [["offset", "count", "step"]] },
],
},
{ name: "file", kind: "table", category: "table", signatures: [{ parameterGroups: [["path", "format?", "structure?", "compression?"]] }] },
{ name: "url", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format", "structure?", "headers?"]] }] },
{ name: "s3", kind: "table", category: "table", signatures: [{ parameterGroups: [["url", "format?", "structure?", "compression?"]] }] },
{ name: "remote", kind: "table", category: "table", signatures: [{ parameterGroups: [["addresses", "database", "table", "user?", "password?"]] }] },
{ name: "postgresql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password", "schema?"]] }] },
{ name: "mysql", kind: "table", category: "table", signatures: [{ parameterGroups: [["address", "database", "table", "user", "password"]] }] },
];
```
Add all other documented public table functions from the same snapshot, retaining lowercase canonical names where ClickHouse documents them that way.
- [ ] **Step 6: Export the complete direct-function registry**
In `functionRegistry.ts`:
```ts
import { CLICKHOUSE_REGULAR_FUNCTIONS } from "./regularFunctions";
import { CLICKHOUSE_TABLE_FUNCTIONS } from "./tableFunctions";
export const CLICKHOUSE_FUNCTION_REGISTRY = createClickHouseFunctionRegistry([
...CLICKHOUSE_REGULAR_FUNCTIONS,
...CLICKHOUSE_TABLE_FUNCTIONS,
]);
```
- [ ] **Step 7: Run formatting and the focused registry tests after every category batch**
```bash
pnpm exec oxfmt apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts
```
Expected: all registry tests PASS and manifest counts remain literal, non-zero floors.
- [ ] **Step 8: Commit the regular and table inventories**
```bash
git add apps/desktop/src/lib/sql/clickhouse/regularFunctions.ts apps/desktop/src/lib/sql/clickhouse/tableFunctions.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts
git commit -m "feat(sql): add ClickHouse regular function inventory"
```
## Task 3: Add Aggregate Functions and Lazy Combinators
**Files:**
- Create: `apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts`
- Create: `apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts`
- Create: `apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts`
- Modify: `apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts`
- Modify: `apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts`
- [ ] **Step 1: Write failing aggregate and combinator tests**
Create `aggregateCombinators.spec.ts`:
```ts
import { describe, expect, it } from "vitest";
import { generateAggregateCombinatorCandidates } from "@/lib/sql/clickhouse/aggregateCombinators";
import { CLICKHOUSE_FUNCTION_REGISTRY } from "@/lib/sql/clickhouse/functionRegistry";
describe("ClickHouse aggregate combinators", () => {
it("generates If with an appended condition argument", () => {
const sumIf = generateAggregateCombinatorCandidates("sumIf", 20).find((item) => item.name === "sumIf");
expect(sumIf?.signatures[0].parameterGroups).toEqual([["value", "condition"]]);
});
it("allows Array before If and rejects the reverse order", () => {
expect(generateAggregateCombinatorCandidates("uniqArrayIf", 20).some((item) => item.name === "uniqArrayIf")).toBe(true);
expect(generateAggregateCombinatorCandidates("uniqIfArray", 20).some((item) => item.name === "uniqIfArray")).toBe(false);
});
it("preserves parametric aggregate groups for State", () => {
const state = generateAggregateCombinatorCandidates("quantilesTDigestState", 20).find((item) => item.name === "quantilesTDigestState");
expect(state?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]);
});
it("bounds generated results", () => {
expect(generateAggregateCombinatorCandidates("", 7)).toHaveLength(7);
});
});
it("contains ordinary and parametric aggregate definitions", () => {
expect(CLICKHOUSE_FUNCTION_REGISTRY.get("uniqExact")).toMatchObject({ kind: "aggregate" });
expect(CLICKHOUSE_FUNCTION_REGISTRY.get("quantilesTDigest")?.signatures[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]);
});
```
- [ ] **Step 2: Run both ClickHouse test files and confirm RED**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts
```
Expected: FAIL because aggregate modules and generated candidates do not exist.
- [ ] **Step 3: Add the aggregate inventory**
Create `aggregateFunctions.ts` with a compact helper and explicit parameter groups:
```ts
import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes";
const aggregate = (
name: string,
signatures: ClickHouseFunctionSignature[],
options: Partial<ClickHouseFunctionDefinition> = {},
): ClickHouseFunctionDefinition => ({
name,
kind: "aggregate",
category: "aggregate",
signatures,
combinators: true,
...options,
});
export const CLICKHOUSE_AGGREGATE_FUNCTIONS: ClickHouseFunctionDefinition[] = [
aggregate("count", [{ parameterGroups: [[]] }, { parameterGroups: [["expression"]] }]),
aggregate("sum", [{ parameterGroups: [["value"]] }]),
aggregate("uniq", [{ parameterGroups: [["expression", "...expressions"]] }]),
aggregate("uniqExact", [{ parameterGroups: [["expression", "...expressions"]] }]),
aggregate("quantilesTDigest", [{ parameterGroups: [["level", "...levels"], ["expression"]] }]),
];
```
Add every documented ordinary and parametric aggregate from the snapshot. Mark aggregates that reject generic combinators with `combinators: false`; do not invent signatures for undocumented combinations.
- [ ] **Step 4: Implement ordered combinator rules**
In `aggregateCombinators.ts`, model suffix transitions rather than arbitrary permutations:
```ts
import { CLICKHOUSE_AGGREGATE_FUNCTIONS } from "./aggregateFunctions";
import type { ClickHouseFunctionDefinition, ClickHouseFunctionSignature } from "./functionTypes";
type CombinatorName = "Array" | "Map" | "ForEach" | "Distinct" | "If" | "OrDefault" | "OrNull" | "Resample" | "SimpleState" | "State" | "Merge" | "MergeState";
const ORDER: readonly CombinatorName[] = ["Array", "Map", "ForEach", "Distinct", "If", "OrDefault", "OrNull", "Resample", "SimpleState", "State", "Merge", "MergeState"];
function applyIf(signature: ClickHouseFunctionSignature): ClickHouseFunctionSignature {
const groups = signature.parameterGroups.map((group) => [...group]);
const target = groups.length - 1;
groups[target] = [...groups[target], "condition"];
return { ...signature, parameterGroups: groups };
}
```
Add one transformer per documented combinator. Enforce `Array` before `If`, make terminal state/merge combinators stop further suffix expansion where required, and reject cycles. `generateAggregateCombinatorCandidates(prefix, limit)` must traverse only prefixes that can still match the typed text and stop at `limit`.
- [ ] **Step 5: Merge direct aggregates and lazy generated results**
Add direct aggregate definitions to `CLICKHOUSE_FUNCTION_REGISTRY`. Export:
```ts
export function searchClickHouseFunctions(prefix: string, limit: number, kind?: ClickHouseFunctionKind): ClickHouseFunctionDefinition[] {
const direct = CLICKHOUSE_FUNCTION_REGISTRY.search(prefix, limit, kind);
if (kind && kind !== "aggregate") return direct;
const generated = generateAggregateCombinatorCandidates(prefix, Math.max(0, limit - direct.length));
return [...direct, ...generated].slice(0, limit);
}
```
- [ ] **Step 6: Run tests and confirm GREEN**
Run the two focused files. Expected: all registry and combinator tests PASS.
- [ ] **Step 7: Commit aggregate support**
```bash
git add apps/desktop/src/lib/sql/clickhouse/aggregateFunctions.ts apps/desktop/src/lib/sql/clickhouse/aggregateCombinators.ts apps/desktop/src/lib/sql/clickhouse/functionRegistry.ts apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts
git commit -m "feat(sql): add ClickHouse aggregate combinators"
```
## Task 4: Integrate ClickHouse Functions into SQL Completion
**Files:**
- Modify: `apps/desktop/src/lib/sql/sqlCompletion.ts`
- Modify: `apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts`
- [ ] **Step 1: Add failing completion tests**
Extend `sqlCompletion.context.spec.ts`:
```ts
it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => {
const sql = "SELECT tostart";
const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "clickhouse", tables: [], columnsByTable: new Map() });
expect(items.find((item) => item.label === "toStartOfDay")).toMatchObject({ type: "function", apply: "toStartOfDay(${value})" });
});
it("does not leak ClickHouse-only functions to MySQL", () => {
const sql = "SELECT tostart";
const items = buildSqlCompletionItems(sql, sql.length, { databaseType: "mysql", tables: [], columnsByTable: new Map() });
expect(items.some((item) => item.label === "toStartOfDay")).toBe(false);
});
it("suggests only ClickHouse table functions alongside tables after FROM", () => {
const sql = "SELECT * FROM num";
const items = buildSqlCompletionItems(sql, sql.length, {
databaseType: "clickhouse",
tables: [{ name: "number_events", type: "table" }],
columnsByTable: new Map(),
});
expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "numbers", type: "function" }), expect.objectContaining({ label: "number_events", type: "table" })]));
expect(items.some((item) => item.label === "toStartOfDay")).toBe(false);
});
```
Add a cursor-before-existing-parenthesis case:
```ts
it("does not insert a duplicate opening parenthesis before an existing call", () => {
const sql = "SELECT toStart()";
const cursor = "SELECT toStart".length;
const items = buildSqlCompletionItems(sql, cursor, { databaseType: "clickhouse", tables: [], columnsByTable: new Map() });
expect(items.find((item) => item.label === "toStartOfDay")?.apply).toBe("toStartOfDay");
});
```
- [ ] **Step 2: Run the context test and confirm RED**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts
```
Expected: ClickHouse-specific labels are absent.
- [ ] **Step 3: Add cursor lookahead to completion context**
Add `openingParenAfterCursor: boolean` to `SqlCompletionContext`, populated by `/^\s*\(/.test(sql.slice(cursor))`. Preserve it in semantic-context merging.
- [ ] **Step 4: Adapt registry definitions to completion items**
Import `searchClickHouseFunctions`. Add helpers:
```ts
function formatFunctionSignatureApply(definition: ClickHouseFunctionDefinition, omitOpeningParen: boolean): string {
if (omitOpeningParen) return definition.name;
const signature = definition.signatures[definition.preferredSignature ?? 0];
return (
definition.name +
signature.parameterGroups
.map((group) => `(${group.filter((parameter) => !parameter.endsWith("?")).map((parameter) => `\${${parameter}}`).join(", ")})`)
.join("")
);
}
function clickHouseFunctionDetail(definition: ClickHouseFunctionDefinition): string {
const status = definition.status && definition.status !== "stable" ? ` · ${definition.status}` : "";
const overloads = definition.signatures.length > 1 ? ` · ${definition.signatures.length} overloads` : "";
return `ClickHouse · ${definition.category}${overloads}${status}`;
}
```
In `buildFunctionSnippetItems`, use the ClickHouse registry when `databaseType === "clickhouse"`; retain the existing map path for all other database types. Set `info` to `definition.description` when present. Apply a negative boost to experimental/deprecated definitions and a smaller negative boost to generated variants.
- [ ] **Step 5: Add table-function completion only in table contexts**
Inside the `context.suggestTables` branch, append `kind === "table"` results for ClickHouse. Do not relax `exclusiveTableSuggestions` for scalar or aggregate functions.
- [ ] **Step 6: Run ClickHouse and existing database completion tests**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts
```
Expected: ClickHouse tests PASS; existing MySQL and snippet tests remain PASS.
- [ ] **Step 7: Commit completion integration**
```bash
git add apps/desktop/src/lib/sql/sqlCompletion.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts
git commit -m "feat(sql): complete ClickHouse functions"
```
## Task 5: Add Overload-Aware and Parametric Signature Resolution
**Files:**
- Modify: `apps/desktop/src/lib/sql/sqlCompletion.ts`
- Create: `apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts`
- [ ] **Step 1: Write failing ordinary and parametric signature tests**
Create `sqlCompletion.signature.spec.ts`:
```ts
import { describe, expect, it } from "vitest";
import { getSqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion";
describe("ClickHouse signature help", () => {
it("returns every overload and highlights the active ordinary parameter", () => {
const sql = "SELECT toStartOfInterval(ts, ";
const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse");
expect(help?.name).toBe("toStartOfInterval");
expect(help?.overloads.length).toBeGreaterThan(1);
expect(help?.overloads[0].activeGroup).toBe(0);
expect(help?.overloads[0].activeParameter).toBe(1);
});
it("resolves the second parameter group of a parametric aggregate", () => {
const sql = "SELECT quantilesTDigest(0.5, 0.9)(value";
const help = getSqlFunctionSignatureHelp(sql, sql.length, "clickhouse");
expect(help?.name).toBe("quantilesTDigest");
expect(help?.overloads[0]).toMatchObject({ activeGroup: 1, activeParameter: 0 });
expect(help?.overloads[0].parameterGroups).toEqual([["level", "...levels"], ["expression"]]);
});
it("keeps MySQL signature help as one overload and one parameter group", () => {
const sql = "SELECT DATE_ADD(created_at, ";
const help = getSqlFunctionSignatureHelp(sql, sql.length, "mysql");
expect(help?.overloads).toHaveLength(1);
expect(help?.overloads[0].parameterGroups).toEqual([["date", "INTERVAL expr unit"]]);
});
});
```
- [ ] **Step 2: Run the signature test and confirm RED**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts
```
Expected: FAIL because the returned model has no `overloads`.
- [ ] **Step 3: Replace the public signature-help shape**
In `sqlCompletion.ts`:
```ts
export interface SqlFunctionSignatureHelpOverload {
signature: string;
parameterGroups: string[][];
activeGroup: number;
activeParameter: number;
}
export interface SqlFunctionSignatureHelp {
name: string;
overloads: SqlFunctionSignatureHelpOverload[];
activeOverload: number;
}
```
- [ ] **Step 4: Resolve the owning call and active group**
Replace the flat `findActiveFunctionOpenParen` path with a helper returning:
```ts
interface ActiveFunctionCall {
name: string;
activeGroup: number;
groupText: string;
}
```
For a normal unmatched `(`, read the identifier directly before it and return group `0`. If the identifier position contains `)`, find that balanced group's opening parenthesis, read the identifier before the first group, and return group `1`. Count commas with the existing quote- and nesting-aware `countTopLevelCommas`.
- [ ] **Step 5: Convert old database maps into one-overload definitions**
When the database is not ClickHouse, wrap the existing `string[]` as `parameterGroups: [parameters]`. For ClickHouse, find an exact case-insensitive match across `searchClickHouseFunctions(name, 50)`, so lazily generated combinator names and aliases resolve as well as direct registry entries. Format all parameter groups into each overload's `signature`, while using the function spelling from the SQL text as the tooltip name.
Rank overloads that can accept the observed parameter index first; keep source order as the stable tiebreaker. Set `activeOverload` to `0`.
- [ ] **Step 6: Run signature and context tests**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts
```
Expected: all tests PASS.
- [ ] **Step 7: Commit signature resolution**
```bash
git add apps/desktop/src/lib/sql/sqlCompletion.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts
git commit -m "feat(sql): support ClickHouse function overloads"
```
## Task 6: Render Multiple Signatures in the Query Editor
**Files:**
- Create: `apps/desktop/src/lib/editor/sqlSignatureTooltip.ts`
- Create: `apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts`
- Modify: `apps/desktop/src/components/editor/QueryEditor.vue`
- [ ] **Step 1: Add a failing real-DOM tooltip test**
Create `sqlSignatureTooltip.spec.ts`:
```ts
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
describe("SQL signature tooltip", () => {
it("renders every overload and highlights the active parameter in each one", () => {
const dom = createSqlSignatureTooltipDom({
name: "toStartOfInterval",
activeOverload: 0,
overloads: [
{
signature: "toStartOfInterval(value, interval)",
parameterGroups: [["value", "interval"]],
activeGroup: 0,
activeParameter: 1,
},
{
signature: "toStartOfInterval(value, interval, time_zone)",
parameterGroups: [["value", "interval", "time_zone"]],
activeGroup: 0,
activeParameter: 1,
},
],
});
expect(dom.textContent).toContain("1/2");
expect(dom.textContent).toContain("2/2");
expect(dom.textContent).toContain("time_zone");
expect(dom.querySelectorAll("[data-active-parameter='true']")).toHaveLength(2);
});
});
```
- [ ] **Step 2: Run the editor test and confirm RED**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts
```
Expected: FAIL because `sqlSignatureTooltip.ts` does not exist.
- [ ] **Step 3: Implement the overload-aware DOM renderer**
Create `sqlSignatureTooltip.ts`:
```ts
import type { SqlFunctionSignatureHelp } from "@/lib/sql/sqlCompletion";
export function createSqlSignatureTooltipDom(signature: SqlFunctionSignatureHelp | null): HTMLElement {
const dom = document.createElement("div");
dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md";
if (!signature) return dom;
signature.overloads.forEach((overload, overloadIndex) => {
const row = document.createElement("div");
row.className = overloadIndex > 0 ? "mt-1 flex items-center gap-2 font-mono" : "flex items-center gap-2 font-mono";
if (signature.overloads.length > 1) {
const count = document.createElement("span");
count.className = "text-[10px] text-muted-foreground";
count.textContent = `${overloadIndex + 1}/${signature.overloads.length}`;
row.appendChild(count);
}
const call = document.createElement("span");
const name = document.createElement("span");
name.className = "text-muted-foreground";
name.textContent = signature.name;
call.appendChild(name);
overload.parameterGroups.forEach((group, groupIndex) => {
const open = document.createElement("span");
open.className = "text-muted-foreground";
open.textContent = "(";
call.appendChild(open);
group.forEach((parameter, parameterIndex) => {
if (parameterIndex > 0) call.append(", ");
const node = document.createElement("span");
const active = groupIndex === overload.activeGroup && parameterIndex === overload.activeParameter;
node.className = active ? "font-semibold text-foreground" : "text-muted-foreground";
if (active) node.dataset.activeParameter = "true";
node.textContent = parameter;
call.appendChild(node);
});
call.append(")");
});
row.appendChild(call);
dom.appendChild(row);
});
return dom;
}
```
- [ ] **Step 4: Wire the renderer into QueryEditor**
Import `createSqlSignatureTooltipDom`, delete the local `createSignatureDom`, and change the signature extension to:
```ts
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
```
- [ ] **Step 5: Run editor, signature, and context tests**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts
```
Expected: all tests PASS.
- [ ] **Step 6: Commit tooltip rendering**
```bash
git add apps/desktop/src/lib/editor/sqlSignatureTooltip.ts apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/components/editor/QueryEditor.vue
git commit -m "feat(editor): render SQL function overloads"
```
## Task 7: Audit Coverage and Run Full Verification
**Files:**
- Modify only if verification finds an issue: files from Tasks 1–6.
- [ ] **Step 1: Run registry integrity and review category floors**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/clickhouse/functionRegistry.spec.ts apps/desktop/src/lib/__tests__/sql/clickhouse/aggregateCombinators.spec.ts
```
Expected: all tests PASS; every checked-in category floor equals its reviewed inventory count.
- [ ] **Step 2: Run the complete SQL completion test set**
```bash
pnpm exec vitest run apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.snippet.spec.ts apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts
```
Expected: all tests PASS with zero failures.
- [ ] **Step 3: Run TypeScript checking**
```bash
pnpm typecheck
```
Expected: exit code 0 with no Vue or TypeScript errors.
- [ ] **Step 4: Run lint on every changed TypeScript and Vue file**
```bash
pnpm exec oxlint --vue-plugin \
apps/desktop/src/lib/sql/clickhouse \
apps/desktop/src/lib/sql/sqlCompletion.ts \
apps/desktop/src/lib/editor/sqlSignatureTooltip.ts \
apps/desktop/src/components/editor/QueryEditor.vue \
apps/desktop/src/lib/__tests__/sql/clickhouse \
apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts \
apps/desktop/src/lib/__tests__/sql/sqlCompletion.signature.spec.ts \
apps/desktop/src/lib/__tests__/editor/sqlSignatureTooltip.spec.ts \
apps/desktop/src/lib/__tests__/editor/queryEditorSqlSignature.spec.ts
```
Expected: exit code 0 with no lint errors.
- [ ] **Step 5: Inspect the final diff against the approved design**
```bash
git diff --check
git status --short
git log --oneline -7
```
Confirm that:
- no backend or server metadata query was added;
- ClickHouse names retain canonical casing;
- regular, aggregate, window, and table functions are represented;
- overloads and multiple parameter groups are tested;
- invalid combinator order is rejected;
- non-ClickHouse regression tests pass;
- only the planned files changed.
- [ ] **Step 6: Commit any verification-only corrections**
If Step 1–5 required a correction, stage only the corrected planned files and commit:
```bash
git commit -m "fix(sql): complete ClickHouse function verification"
```
If no correction was required, do not create an empty commit.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,309 +0,0 @@
# Documentation Menu at Database Scope Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the "Documentation" context-menu entry out of the object browser's table and view menus and into the sidebar's database and schema context menu, so the entry sits at the scope it actually documents.
**Architecture:** A new `openDocs` action in `useSidebarTreeToolRuntime` writes `connectionStore.docsSource` from the active tree node, exactly as the neighbouring `openDiagram` does. One `items.push` in `buildDatabaseSidebarMenu` exposes it on database and schema nodes. The two `ObjectBrowser.vue` entries and their helper are deleted. No backend, dialog, or viewer code changes.
**Tech Stack:** Vue 3 SFC with `<script setup>`, TypeScript, Pinia (`connectionStore`), vue-i18n, `@lucide/vue` icons, Vitest.
## Global Constraints
- Branch is `fix/docs-menu-database-scope`, cut from `upstream/main` at `29fe57143`. Work on it directly; do not create a worktree.
- Do not modify `VERSION`, `package.json`, or any file under `crates/`. Version numbers belong to upstream's `scripts/release.mjs`.
- Do not add an i18n key. `docs.title` already resolves to "Documentation" in all eight locales, added by PR #5559.
- Icons come from `@lucide/vue`, not `lucide-vue-next`.
- Do not touch either `t("diagram.open")` menu entry.
- Reuse the existing `canOpenDiagram` computed as the visibility gate. Do not add a `docs` key to `DATABASE_PRODUCT_CAPABILITY_KEYS`; that is deliberately out of scope and flagged as a follow-up in the PR description.
---
## File Structure
| File | Responsibility | Change |
|---|---|---|
| `apps/desktop/src/composables/useSidebarTreeToolRuntime.ts` | Sidebar actions that open a tool dialog by writing a `*Source` field on `connectionStore`. | Add `openDocs`; export it. |
| `apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts` | Behavioural cover for `openDocs`. | Create. |
| `apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue` | Builds every sidebar context menu. | Import `BookOpen`, destructure `openDocs`, push one menu item in `buildDatabaseSidebarMenu`. |
| `apps/desktop/src/components/objects/ObjectBrowser.vue` | Object list panel and its per-object context menus. | Delete the docs entries, the `openDocs` helper, and the orphaned `BookOpen` import. |
| `apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts` | Guards the placement so the entry cannot drift back onto table menus. | Create. |
---
## Task 1: Sidebar `openDocs` action
**Files:**
- Modify: `apps/desktop/src/composables/useSidebarTreeToolRuntime.ts:55-64` (insert after `openDiagram`), and the return block at `:132-145`
- Test: `apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts` (create)
**Interfaces:**
- Consumes: `options.activeNode: ShallowRef<TreeNode>` and `options.connectionStore`, both already destructured at the top of `useSidebarTreeToolRuntime`.
- Produces: `openDocs(): void`, returned from `useSidebarTreeToolRuntime`. It sets `connectionStore.docsSource` to `{ connectionId: string; database: string; schema: string | undefined }`. Task 2 binds this to a menu item.
**Why `schema` is passed through untouched:** `DatabaseDocsDialog.vue:93` reads `props.prefillSchema ? [props.prefillSchema] : []`. An `undefined` schema therefore sends an empty schema list, which the collector treats as "everything it finds" — the whole database. A database tree node has no `schema`, a schema node does, so forwarding the field verbatim gives both scopes for free.
**Why no `tableName`:** `openDiagram` passes one because the diagram dialog has a `tableName` prop. `DatabaseDocsDialog` has only `prefillConnectionId`, `prefillDatabase`, and `prefillSchema`. Passing `tableName` would be dead data.
- [ ] **Step 1: Write the failing test**
Create `apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts`.
The composable imports only types plus two pure helpers from `@/lib/sidebar/sidebarExportRuntime`, so no `vi.mock` is needed. `queryStore`, `settingsStore`, and `tableChildObjectName` are unused by `openDocs`; they are passed as minimal stubs because the options object requires them.
```ts
import { shallowRef } from "vue";
import { describe, expect, it } from "vitest";
import type { TreeNode } from "@/types/database";
import { useSidebarTreeToolRuntime } from "@/composables/useSidebarTreeToolRuntime";
function setup(node: Partial<TreeNode>) {
const activeNode = shallowRef({ id: "n-1", label: "node", children: [], ...node } as TreeNode);
const connectionStore = { docsSource: null as unknown };
const runtime = useSidebarTreeToolRuntime({
activeNode,
connectionStore: connectionStore as never,
queryStore: {} as never,
settingsStore: {} as never,
tableChildObjectName: () => "",
});
return { connectionStore, runtime };
}
describe("useSidebarTreeToolRuntime openDocs", () => {
it("documents the whole database when invoked on a database node", () => {
const { connectionStore, runtime } = setup({ type: "database", label: "shop", connectionId: "conn-1", database: "shop" });
runtime.openDocs();
// An absent schema is what makes the collector document every schema.
expect(connectionStore.docsSource).toEqual({ connectionId: "conn-1", database: "shop", schema: undefined });
});
it("narrows to a single schema when invoked on a schema node", () => {
const { connectionStore, runtime } = setup({ type: "schema", label: "public", connectionId: "conn-1", database: "shop", schema: "public" });
runtime.openDocs();
expect(connectionStore.docsSource).toEqual({ connectionId: "conn-1", database: "shop", schema: "public" });
});
it("does nothing when the node carries no database", () => {
const { connectionStore, runtime } = setup({ type: "connection", label: "local", connectionId: "conn-1" });
runtime.openDocs();
expect(connectionStore.docsSource).toBeNull();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts`
Expected: FAIL. All three cases error with `runtime.openDocs is not a function`.
- [ ] **Step 3: Write the implementation**
In `apps/desktop/src/composables/useSidebarTreeToolRuntime.ts`, insert immediately after the closing brace of `openDiagram` (currently line 64):
```ts
function openDocs() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
connectionStore.docsSource = {
connectionId: node.connectionId,
database: node.database,
// A database node has no schema, and an absent schema is what tells the
// collector to document every schema in the database.
schema: node.schema,
};
}
```
Then add `openDocs,` to the returned object, keeping the existing alphabetical order — between `openDiagram,` and `openFieldLineage,`:
```ts
return {
openAllDatabasesExport,
openDataCompare,
openDatabaseExport,
openDatabaseSearch,
openDiagram,
openDocs,
openFieldLineage,
openScheduledBackups,
openSchemaDiff,
openSqlFileExecution,
openStructureEditor,
openTableImport,
openTransfer,
};
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts`
Expected: PASS, 3 tests.
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/composables/useSidebarTreeToolRuntime.ts apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts
git commit -m "feat(docs): add a sidebar action opening documentation for a node"
```
---
## Task 2: Move the menu entry
**Files:**
- Modify: `apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue:13` (icon import), `:312` (destructure), `:4204-4206` (menu push)
- Modify: `apps/desktop/src/components/objects/ObjectBrowser.vue:9` (icon import), `:1466-1474` (helper), `:2666-2671` and `:2721-2726` (menu entries)
- Test: `apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts` (create)
**Interfaces:**
- Consumes: `openDocs()` from Task 1, reached through the existing `useSidebarTreeToolRuntime(...)` destructure at `SidebarTreeRuntimeHost.vue:312`.
- Produces: no new exports.
**Why the single push covers both node types:** line 4204 sits inside `buildDatabaseSidebarMenu` (lines 4147-4259), whose outer guard is `node.type === "database" || node.type === "schema"`. The other `t("diagram.open")` push, at line 4483, is in `buildObjectSidebarMenu` and must be left alone.
**On the placement test:** this repository contains both mounted component specs and source-text specs. A mounted test is impractical for a 4700-line SFC whose menus are assembled from dozens of store-backed computeds, and the behaviour that matters — what `openDocs` writes — is already covered by Task 1. The spec below is a deliberately narrow placement guard: it asserts which file registers `docs.title`, nothing about rendering. Do not extend it into a substitute for behavioural cover.
- [ ] **Step 1: Write the failing test**
Create `apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts`:
```ts
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
function source(relative: string): string {
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), "utf8");
}
describe("documentation menu placement", () => {
it("registers the entry in the sidebar database menu", () => {
// The docs viewer documents a database or a schema, so it belongs on the
// tree nodes that name one.
expect(source("../../sidebar/SidebarTreeRuntimeHost.vue")).toContain('t("docs.title"), action: openDocs');
});
it("keeps the entry off per-object menus", () => {
// openDocs never read the row's table: a table-level entry advertised a
// scope the viewer cannot render.
expect(source("../ObjectBrowser.vue")).not.toContain("docs.title");
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts`
Expected: FAIL, both cases. The first fails because `SidebarTreeRuntimeHost.vue` has no `docs.title`; the second fails because `ObjectBrowser.vue` still has two.
- [ ] **Step 3: Register the entry in the sidebar**
In `apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue`:
Add `BookOpen,` as the first name in the `@lucide/vue` import block, immediately after `import {` on line 13. The block is not alphabetically ordered, so position is a matter of readability only:
```ts
import {
BookOpen,
Database,
ChevronsDown,
```
Add `openDocs` to the destructure on line 312, keeping alphabetical order:
```ts
const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabaseSearch, openDiagram, openDocs, openFieldLineage, openScheduledBackups, openSchemaDiff, openSqlFileExecution, openStructureEditor, openTableImport, openTransfer } = useSidebarTreeToolRuntime({
```
Replace lines 4204-4206:
```ts
if (canOpenDiagram.value) {
items.push({ label: t("diagram.open"), action: openDiagram, icon: Network });
items.push({ label: t("docs.title"), action: openDocs, icon: BookOpen });
}
```
- [ ] **Step 4: Remove the object browser entries**
In `apps/desktop/src/components/objects/ObjectBrowser.vue`:
Delete `BookOpen,` from the icon import on line 9. It has no other use in the file.
Delete the `openDocs` helper, lines 1466-1474 inclusive, together with the blank line that followed it:
```ts
function openDocs(row: ObjectBrowserRow) {
// The docs viewer documents the whole schema rather than one object, so the
// row only supplies which schema to collect.
connectionStore.docsSource = {
connectionId: props.connection.id,
database: props.database,
schema: row.schema || selectedSchema.value,
};
}
```
In `getTableMenuItems`, collapse lines 2666-2671 back to the single-entry form:
```ts
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
```
In `getViewMenuItems`, collapse lines 2721-2726 to exactly the same single line.
- [ ] **Step 5: Run the placement test to verify it passes**
Run: `npx vitest run apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts`
Expected: PASS, 2 tests.
- [ ] **Step 6: Typecheck**
Run: `pnpm typecheck`
Expected: no errors. This is what catches a stale `BookOpen` reference or a mistyped `openDocs` in the destructure. An unused-import error here means step 4 removed a menu entry but left the icon, or the reverse.
- [ ] **Step 7: Run the surrounding suites**
Run: `npx vitest run apps/desktop/src/composables apps/desktop/src/components/objects apps/desktop/src/components/sidebar`
Expected: PASS. No existing spec asserts on the docs menu entries, so nothing should need updating. If a sidebar spec fails on menu-item counts or ordering, that spec is the source of truth for the menu's shape — read it before changing it.
- [ ] **Step 8: Commit**
```bash
git add apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue apps/desktop/src/components/objects/ObjectBrowser.vue apps/desktop/src/components/objects/__tests__/docsMenuPlacement.spec.ts
git commit -m "fix(docs): move Documentation to the database context menu"
```
---
## Manual verification
Automated cover stops at "the right store field gets the right value". Confirm the dialog end of the wire once, by hand:
```bash
pnpm dev
```
1. Right-click a PostgreSQL database node in the connections sidebar. "Documentation" appears directly below "View Diagram", with a book icon.
2. Click it. The viewer opens listing tables from **every** schema in that database, not just `public`.
3. Close it, right-click a schema node, pick "Documentation". Only that schema's tables are listed.
4. Open the object browser for the same database and right-click a table. There is no "Documentation" entry; "View Diagram" is still there.
5. Right-click a Redis or MongoDB connection's database node. No "Documentation" entry, because `supportsSchemaDiagram` is false for those drivers — the same visibility set that shipped in v0.5.77.
## Pull request
Target `t8y2/dbx`, base `main`. The description should state that PR #5559's entry point never used the row's table (`ObjectBrowser.vue:1466` read only `row.schema`), so this is a placement correction rather than a feature change, and should flag the follow-up the spec records: documentation visibility currently rides on `supportsSchemaDiagram`, and a dedicated `docs` capability key in `database-drivers.manifest.json` is worth considering separately.
@@ -1,169 +0,0 @@
# SQL Formatter Configuration Design
## Summary
DBX will add a lightweight SQL formatter configuration experience. Users can tune formatter options in a settings window, import and export a small JSON configuration file, and use a capable JSON editor inside that window. The editor shortcuts are part of the configuration window experience only; they are not stored in the SQL formatter configuration file.
## Goals
- Let users customize SQL formatting without replacing the existing `sql-formatter` dependency.
- Keep startup and idle memory low by loading formatter and advanced JSON editor code only when needed.
- Support importing and exporting a dedicated SQL formatter configuration file.
- Provide a comfortable JSON configuration editor in the SQL formatter settings window.
- Make editor shortcuts work consistently on Windows, Linux, and macOS.
- Validate imported configuration before applying it to stored editor settings.
## Non-Goals
- Do not introduce Monaco, Prettier, WASM parsers, or multiple formatter engines.
- Do not turn this into a full editor profile export system.
- Do not store configuration-window shortcuts in the SQL formatter configuration file.
- Do not run SQL formatting automatically while typing.
- Do not implement per-connection formatter profiles in the first version.
## Current Context
The current formatter path is intentionally small:
- `apps/desktop/src/lib/sqlFormatter.ts` imports `format` from `sql-formatter`.
- `formatSqlText(sql, dialect)` maps the DBX dialect to the formatter language.
- The only current formatting option is `keywordCase: "upper"`.
- `QueryEditor.vue` calls `formatSqlText()` only when the user triggers SQL formatting.
- Editor settings already live in `settingsStore.editorSettings` and are persisted through `localStorage`.
This design keeps that structure and adds a typed formatter settings object to the existing editor settings.
## Configuration File
The imported and exported file is a dedicated SQL formatter config:
```json
{
"version": 1,
"formatter": "sql-formatter",
"options": {
"keywordCase": "upper",
"functionCase": "preserve",
"dataTypeCase": "preserve",
"indentStyle": "spaces",
"tabWidth": 2,
"linesBetweenQueries": 1,
"expressionWidth": 80,
"semicolonNewline": false
}
}
```
`version` enables future migrations. `formatter` must be `sql-formatter` in the first version. `options` contains only validated SQL formatter options. Unknown top-level fields are ignored during import. Unknown option fields are rejected with a validation message so users can find mistakes in hand-edited files.
## Stored Settings
Add `sqlFormatter` to `EditorSettings`:
```ts
interface SqlFormatterSettings {
keywordCase: "upper" | "lower" | "preserve";
functionCase: "upper" | "lower" | "preserve";
dataTypeCase: "upper" | "lower" | "preserve";
indentStyle: "spaces" | "tabs";
tabWidth: 2 | 4;
linesBetweenQueries: 0 | 1 | 2;
expressionWidth: 50 | 80 | 120;
semicolonNewline: boolean;
}
```
Normalization clamps every field to a known value. Missing fields fall back to defaults. Existing users get the current behavior by default: uppercase keywords and two-space readable formatting.
The exact mapping to `sql-formatter` options should be confirmed against the installed `sql-formatter@15.8.0` type definitions during implementation. Unsupported options are hidden rather than simulated with fragile string rewriting.
## Settings Window
The SQL formatter settings window has two modes:
- Form mode: select controls for common options such as keyword case, indentation, line spacing, and expression width.
- Advanced JSON mode: a JSON editor for the same configuration envelope used by import and export.
Both modes edit the same local draft. Applying settings normalizes and persists the draft. Canceling closes the dialog without touching stored settings.
The window includes:
- Import config
- Export config
- Restore defaults
- Validate
- Apply
Import reads the file once, validates it, updates the local draft, and shows validation errors without applying partial invalid state. Export writes the normalized draft as pretty JSON.
## JSON Editor Shortcuts
The advanced JSON editor should reuse CodeMirror instead of adding a heavier editor. It supports common editing actions with platform-aware shortcuts:
| Action | Windows/Linux | macOS |
| --- | --- | --- |
| Undo | `Ctrl+Z` | `Cmd+Z` |
| Redo | `Ctrl+Y`, `Ctrl+Shift+Z` | `Cmd+Shift+Z` |
| Find | `Ctrl+F` | `Cmd+F` |
| Replace | `Ctrl+H` | `Cmd+Alt+F` or an in-window button |
| Select all | `Ctrl+A` | `Cmd+A` |
| Indent more | `Tab` | `Tab` |
| Indent less | `Shift+Tab` | `Shift+Tab` |
| Duplicate current line | `Ctrl+D` | `Cmd+D` |
| Delete current line | `Ctrl+Shift+K` | `Cmd+Shift+K` |
| Move line up/down | `Alt+Up/Down` | `Option+Up/Down` |
| Copy line up/down | `Shift+Alt+Up/Down` | `Shift+Option+Up/Down` |
| Format config JSON | `Shift+Alt+F` | `Shift+Option+F` |
| Apply config | `Ctrl+S` | `Cmd+S` |
Most of these actions come from CodeMirror's existing commands and keymaps. `Duplicate current line` can use `copyLineDown`. `Indent less` can use `indentLess`. The JSON editor uses platform-sensitive display labels, while the command mapping uses CodeMirror's `Mod` abstraction where appropriate.
These shortcuts are local to the SQL formatter configuration window. They do not change the main SQL editor shortcut settings and are not written into exported formatter config files.
## Low-Memory Strategy
- Keep `sql-formatter` as the only SQL formatting engine.
- Lazy-load `sql-formatter` when the user formats SQL or opens the formatter settings preview.
- Lazy-load CodeMirror JSON support only when the user opens Advanced JSON mode.
- Store only a small normalized settings object, not generated formatter state.
- Format only on explicit user action.
- Add a size guard for SQL text. Very large SQL should prompt before formatting or skip formatting with a clear message.
- Avoid background workers in the first version unless formatting proves to block the UI on realistic input.
## Data Flow
1. User opens SQL formatter settings.
2. The window copies `settingsStore.editorSettings.sqlFormatter` into a local draft.
3. Form controls or JSON editor update the draft.
4. Validation normalizes the draft and reports errors.
5. Apply persists the normalized settings through `settingsStore.updateEditorSettings()`.
6. SQL formatting calls `formatSqlText(sql, dialect, sqlFormatterSettings)`.
7. `formatSqlText()` maps DBX settings to `sql-formatter` options and returns formatted SQL.
## Error Handling
- Blank SQL remains unchanged.
- Invalid JSON in Advanced mode shows a local validation error and disables Apply.
- Invalid imported files do not mutate stored settings.
- Unsupported formatter names are rejected.
- Unsupported option values are rejected with field-specific messages.
- Formatter runtime errors keep the existing `formatError` event path and toast behavior.
## Testing
Add focused tests for:
- Default SQL formatter settings preserve current formatting behavior.
- Settings normalization clamps invalid values to defaults.
- Exported config is stable and contains only allowed fields.
- Imported config accepts valid files and rejects invalid files.
- `formatSqlText()` maps settings to formatter options.
- Blank SQL remains unchanged.
- Advanced JSON editor keymap includes `Shift+Tab`, duplicate line, delete line, move line, copy line, and apply shortcuts.
Manual verification should include:
- Windows/Linux shortcut behavior using `Ctrl`.
- macOS shortcut behavior using `Cmd`.
- Import/export round trip.
- No large formatter/editor code loaded before formatting or opening Advanced JSON mode, checked through the built bundle chunks.
@@ -1,247 +0,0 @@
# GridFS Manager Filter And Sort Design
**Date:** 2026-07-06
## Goal
Extend the existing GridFS manager and bucket browser so MongoDB users can filter and sort GridFS data without leaving the dedicated GridFS workflow.
This work covers three user-facing improvements:
- add query-style filter and sort controls to the GridFS file list
- add filter and sort controls to the GridFS bucket manager
- make the GridFS sidebar node more visually distinct from regular Mongo collections
## Background
The dedicated GridFS browser shipped on 2026-07-03 and already provides:
- a single `GridFS` sidebar entry per Mongo database
- a database-level manager page for bucket CRUD
- a bucket-level file browser for upload, download, and delete
Issue #2589 reports that the new Mongo manager flow still lacks filtering. The current UI shows static tables only:
- `MongoGridFsBrowser.vue` lists bucket summaries with no filter or sort controls
- `MongoBucketBrowser.vue` lists bucket files with a fixed default ordering
- the backend `list_gridfs_files` API always runs an empty `find({})` with a built-in descending upload date sort
The goal of this iteration is to keep the dedicated GridFS UX, but make it feel closer to the existing Mongo document query experience.
## Current State
### File browser
`MongoBucketBrowser.vue` currently:
- loads every file in the selected bucket
- shows a table with metadata columns
- supports upload, download, delete, refresh
- has no filter input
- has no user-controlled sort
- has no query preview
### Bucket manager
`MongoGridFsBrowser.vue` currently:
- loads every GridFS bucket summary for the database
- shows bucket name, file count, and total size
- supports create, delete, open, refresh
- has no filter input
- has no user-controlled sort
### Sidebar
`TreeItem.vue` currently renders:
- `mongo-gridfs` and `mongo-buckets` with `Archive` plus amber styling
- `mongo-bucket` with a very similar `Archive` plus lighter amber styling
This makes the GridFS entry readable, but not especially distinct from other MongoDB tree nodes.
## Requirements
### Functional
- users can filter GridFS files within a bucket
- users can sort GridFS files within a bucket
- users can filter GridFS buckets within a database
- users can sort GridFS buckets within a database
- file-list sorting works both from explicit sort input and column-header sort actions
- the GridFS sidebar entry has a clearer dedicated visual treatment
### Behavioral
- existing upload, download, delete, create, and open flows keep working
- existing callers that omit new query parameters retain current behavior
- invalid filter or sort input produces visible errors rather than silent fallback
## Chosen Approach
### Query model
Use one consistent principle across both GridFS pages:
- execute filter and sort on the server
- keep frontend controls visually aligned with the document query UI
- only reuse document-query semantics where the underlying data shape supports it cleanly
### Bucket file browser
The file browser should closely mirror Mongo document query behavior.
Frontend:
- add `filter` and `sort` text inputs to `MongoBucketBrowser.vue`
- show a query preview string for the effective GridFS file query
- trigger reload on Enter, refresh click, and table-header sort changes
- map header sort actions back into the `sort` input, matching `DocumentBrowser.vue`
Backend:
- extend the GridFS file list API to accept optional `filter` and `sort` strings
- parse them with the same Mongo document parsing helpers already used by document queries
- run the query against `<bucket>.files` as `find(filter).sort(sort)`
- preserve the existing default sort of `uploadDate desc, _id desc` when no sort is provided
Preview format:
- use `db.getCollection("<bucket>.files").find(...).sort(...).skip(0).limit(...)` style output
- the preview exists for transparency and consistency, even if the file browser continues returning a plain row list instead of a paged document result object
### Bucket manager
The bucket manager should adopt the same interaction style, but not pretend bucket summaries are raw Mongo documents.
Frontend:
- add a lightweight `filter` input for bucket-name matching
- add a sort control that behaves like the existing query toolbar and table-header sorting
- keep the manager table focused on `name`, `fileCount`, and `totalBytes`
Backend:
- extend the bucket list API to accept optional filter and sort parameters
- filter bucket summaries by normalized bucket name match
- sort bucket summaries by one of:
- `name`
- `fileCount`
- `totalBytes`
- default to `name asc` when no explicit sort is provided
This gives the manager page service-side filtering and sorting while staying honest about the underlying data model, which is aggregated summary data rather than a directly queried collection.
### Sidebar icon treatment
Keep the existing icon family but make the top-level GridFS entry visually stand apart.
- `mongo-gridfs`: keep `Archive` but switch to a more distinctive cool color so it no longer blends with ordinary Mongo tree items
- `mongo-bucket`: keep the same icon family with a related but softer shade
The goal is recognition, not a broad tree redesign.
## Architecture
### Backend layers
Update the GridFS query path across all transport layers:
- `crates/dbx-core/src/db/mongo_driver.rs`
- `crates/dbx-core/src/document_ops.rs`
- `src-tauri/src/commands/document_cmd.rs`
- `crates/dbx-web/src/routes/document_store.rs`
- `apps/desktop/src/lib/backend/tauri.ts`
- `apps/desktop/src/lib/backend/http.ts`
The backend remains backward compatible by treating all new parameters as optional.
### Frontend layers
Update the dedicated GridFS views:
- `apps/desktop/src/components/document/MongoBucketBrowser.vue`
- `apps/desktop/src/components/document/MongoGridFsBrowser.vue`
Reuse document-query support where it fits:
- `apps/desktop/src/lib/app/documentStoreProvider.ts`
Update the sidebar presentation in:
- `apps/desktop/src/components/sidebar/TreeItem.vue`
## Data Flow
### File browser
1. User opens a GridFS bucket tab.
2. `MongoBucketBrowser.vue` builds the current `filter` and `sort` state.
3. Frontend sends the new parameters through the shared document-store API.
4. Rust applies the filter and sort to `<bucket>.files`.
5. The returned file list renders in the table and side detail panel.
6. If the user clicks a sortable header, the UI rewrites the `sort` input and reloads.
### Bucket manager
1. User opens the `GridFS` manager tab.
2. `MongoGridFsBrowser.vue` sends current manager filter and sort state.
3. Rust collects bucket summaries, filters them, sorts them, and returns the list.
4. The manager table and summary pane update in place.
## Error Handling
- invalid Mongo-style file filter input should surface as a visible page error
- invalid file sort input should surface as a visible page error
- unsupported bucket-manager sort fields should return a clear backend error
- empty filter input should behave like no filter
- missing sort input should fall back to the page default sort
- write operations continue to use the existing read-only safeguards
## Testing
### Frontend
Add or update Vitest coverage for:
- GridFS query preview formatting in `documentStoreProvider`
- request assembly for GridFS file filter and sort loading
- request assembly for GridFS bucket filter and sort loading
- sidebar icon and color mapping for `mongo-gridfs` and `mongo-bucket`
### Backend
Add Rust coverage for:
- GridFS file filter parsing
- default file sort fallback
- explicit file sort handling
- bucket summary filtering
- bucket summary sorting by supported fields
## Scope Boundaries
### Included now
- service-side GridFS file filtering
- service-side GridFS file sorting
- service-side GridFS bucket filtering
- service-side GridFS bucket sorting
- query-style toolbar inputs for the file browser
- visually improved GridFS sidebar node styling
### Not included now
- pagination contract changes for GridFS APIs
- turning bucket summaries into full Mongo document queries
- advanced bucket filter builders
- drag-and-drop upload improvements
- inline file editing or file preview
## Success Criteria
- users can narrow GridFS files with Mongo-style filter input
- users can sort GridFS files from either toolbar input or table headers
- users can narrow and sort GridFS bucket summaries from the manager page
- the dedicated GridFS sidebar node is easier to distinguish at a glance
- existing GridFS CRUD flows keep working without regression
@@ -1,167 +0,0 @@
# 字段类型映射:独立弹窗 + 导入/导出
日期:2026-07-16
状态:已批准(待实现)
## 背景 / 问题
在"比较架构"配置步骤(`SchemaDiffConfigStep.vue`)中,字段类型映射面板
(`FieldMappingPanel`)直接内嵌在配置步骤里,没有内部滚动。当用户添加大量
映射条目时,面板会把底部的操作栏(保存配置 / 加载配置 / 选项 / 开始比较)
顶出视口,导致这些按钮无法点击、看不到。
用户要求:在"选项"按钮后面增加一个"字段类型映射"按钮,点击打开一个独立的
页面/弹窗来配置映射;并且该弹窗需要支持以 JSON 格式导入、导出映射配置,
参考现有导出功能。
## 目标
1. 字段类型映射添加再多,配置步骤底部的操作栏始终可见、可点击。
2. 提供独立弹窗编辑映射,体验与现有"选项"弹窗一致。
3. 弹窗内支持映射配置的 JSON 导入与导出。
## 非目标(YAGNI)
- 不新增映射列表内的搜索/过滤框(后续可加)。
- 不改动 Rust 侧逻辑(`FieldMapping` / `generate_schema_sync_sql` 已在上一个
会话修复)。
- 不改动配置整体的保存/加载/历史逻辑。
## 方案
采用方案 A:在"选项"按钮后增加一个"字段类型映射"按钮,点击打开一个叠加
Dialog(overlay),与现有"选项"弹窗(`SchemaDiffDialog` 的 `showOptionsPanel`)
同一风格。
### 1. 组件结构
- **新建 `apps/desktop/src/components/diff/FieldMappingDialog.vue`**
- 复用 `SchemaDiffDialog` 的 overlay 模式:`absolute inset-0 bg-background/80
backdrop-blur-sm z-50 flex items-center justify-center`,内层
`bg-card border rounded-lg shadow-lg w-[760px] max-w-[calc(100vw-2rem)]
max-h-[80vh] overflow-auto p-4`。
- 内部渲染 `FieldMappingPanel`(`source-db-type` / `target-db-type` /
`:mappings` / `@update:mappings`)。
- 底部操作栏:导入 / 导出 / 完成(✕ 关闭在右上角)。
- Props:`open: boolean`、`mappings: FieldMappingEntry[]`、
`sourceDbType: string`、`targetDbType: string`。
- Emits:`update:open(boolean)`、`save(FieldMappingEntry[])`。
- 打开时把 `mappings` 深拷贝为本地 `editingMappings`(单向数据流,取消不写回)。
- **改动 `apps/desktop/src/components/diff/SchemaDiffConfigStep.vue`**
- 在"选项"按钮(`@show-options`)之后、同一 `<div class="flex items-center gap-2">`
内,插入"字段类型映射"按钮:`v-if="showFieldMapping"`,
`@click="$emit('open-field-mapping')"`,文案 `t('diff.openFieldMapping')`,
图标 `ListChecks` 或 `ArrowLeftRight`。
- 保留现有内嵌 `<FieldMappingPanel>`(行 429)作为快捷入口,行为不变。
- `defineEmits` 新增 `(e: "open-field-mapping"): void;`。
- **改动 `apps/desktop/src/components/diff/SchemaDiffDialog.vue`**
- 新增 `const showFieldMappingDialog = ref(false);`
- `<SchemaDiffConfigStep>` 增加 `@open-field-mapping="showFieldMappingDialog = true"`。
- 在 `showOptionsPanel` overlay 之后,新增 `<FieldMappingDialog>`:
- `:open="showFieldMappingDialog"`
- `:mappings="activeConfig?.options.fieldMappings ?? []"`
- `:source-db-type="sourceDbType"`、`:target-db-type="targetDbType"`
(沿用 `SchemaDiffConfigStep` 中既有的 source/target db type 取值方式)
- `@update:open="showFieldMappingDialog = $event"`
- `@save="handleFieldMappingsUpdate"`(复用现有写回函数,签名一致:
`(v: FieldMappingEntry[]) => void`)
### 2. 数据流(单向,与 Options 一致)
打开弹窗 → 用当前 `fieldMappings` 深拷贝为本地 `editingMappings`
(`JSON.parse(JSON.stringify(...))` 或 `map` 拷贝)→ 弹窗内增删改只动本地副本
→ 点"完成" → `emit('save', editingMappings)` → `handleFieldMappingsUpdate`
写回 `activeConfig.options.fieldMappings` → 关闭弹窗。
点遮罩 / ✕ 仅关闭,不写回(取消)。
### 3. 导入 / 导出(JSON)
参考现有 `TagManagementPanel.handleExport`(Blob 下载)与
`useSchemaDiffConfig`(JSON 解析校验)。
- **导出**(`FieldMappingDialog` 内 `handleExport`):
```ts
const blob = new Blob([JSON.stringify(editingMappings, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "field-mappings.json";
a.click();
URL.revokeObjectURL(url);
```
- **导入**(`FieldMappingDialog` 内 `handleImport` + 隐藏 file input):
- `<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden" @change="onFileSelected" />`
- 读取文本 → `JSON.parse` → 校验为数组且每项含 `sourceType` / `targetType` /
`paramStrategy`(缺 `paramStrategy` 默认 `"preserve"`,缺 `customParams`
默认 `undefined`)→ 替换本地 `editingMappings`。
- 解析失败或结构不合法:`toast(t('diff.fieldMapping.importError'))` 并报错,
不替换。
- **JSON 结构**:即 `FieldMappingEntry[]`:
```json
[
{ "sourceType": "VARCHAR", "targetType": "character", "paramStrategy": "preserve" },
{ "sourceType": "DECIMAL", "targetType": "numeric", "paramStrategy": "custom", "customParams": "(10,2)" }
]
```
与运行时 / Rust 侧 `FieldMapping` 对齐,可被 `generate_schema_sync_sql`
直接消费。
### 4. 滚动与可达性(根本修复)
- 弹窗内部 `max-h-[80vh] overflow-auto`:映射再多也在弹窗内滚动,绝不撑出视口。
- 同时给 `FieldMappingPanel` 的映射列表容器(`v-else` 分支根 `div`)加
`max-h-[40vh] overflow-auto` 作为内嵌兜底,保证即便内嵌面板也滚动。
### 5. i18n
复用现有 `diff.fieldMapping.*` 键。新增(zh-CN / en,其余语言按现有
`exportConfig`/`importConfig` 模式补,本次至少保证 zh-CN + en):
- `diff.openFieldMapping`:打开字段类型映射弹窗的按钮文案
(zh-CN: "字段类型映射";en: "Field Type Mapping")
- `diff.fieldMapping.import`:导入(zh-CN: "导入";en: "Import")
- `diff.fieldMapping.export`:导出(zh-CN: "导出";en: "Export")
- `diff.fieldMapping.importError`:导入失败提示
(zh-CN: "导入失败:文件不是合法的字段类型映射 JSON";
en: "Import failed: file is not a valid field type mapping JSON")
- `diff.fieldMapping.done`:完成按钮(zh-CN: "完成";en: "Done")
位置:zh-CN.ts 的 `schemaDiff.fieldMapping` 对象内(约 2444 行);
en.ts 的 `schemaDiff.fieldMapping` 对象内(约 2457 行)。
### 6. 测试 / 验收
纯前端改动,无 Rust 逻辑变更。手动验收(`pnpm dev`):
1. 打开"比较架构"弹窗,源/目标选择不同类型(触发 `showFieldMapping`)。
2. 点"选项"后的"字段类型映射"按钮 → 弹出独立弹窗。
3. 在弹窗内添加大量映射,确认弹窗内部可滚动、底部"导入/导出/完成"始终可点。
4. 点"导出" → 下载 `field-mappings.json`,内容正确。
5. 清空映射后点"导入",选择刚导出的 json → 映射恢复。
6. 点"完成" → 映射写回配置步骤;关闭弹窗后配置步骤底部操作栏(保存/加载/
选项/开始比较)始终可见可点。
7. 点遮罩 / ✕ 关闭弹窗 → 本地未"完成"的修改不写回。
类型检查:`pnpm typecheck`(仅关注本次改动文件无新增错误;已知 2 个与本次无关的
预存错误:`dbAdminSql.ts` / `McpServerStatus.node_path`)。
## 受影响文件
- 新建 `apps/desktop/src/components/diff/FieldMappingDialog.vue`
- 改 `apps/desktop/src/components/diff/SchemaDiffConfigStep.vue`
- 改 `apps/desktop/src/components/diff/SchemaDiffDialog.vue`
- 改 `apps/desktop/src/i18n/locales/zh-CN.ts`
- 改 `apps/desktop/src/i18n/locales/en.ts`
- (可选,非必须)其他语言 locale 的 `schemaDiff.fieldMapping` 补键
## 风险 / 注意
- `FieldMappingPanel` 现有 `space-y-3` 列表容器加上 `max-h` + `overflow-auto`
后,嵌套在弹窗的 `overflow-auto` 里可能产生双重滚动条;需实测,优先让弹窗
外层滚动,内层列表仅在高度受限时滚动。
- 导入校验要宽松:允许缺省 `customParams` / `paramStrategy`,避免严格校验把
合法旧配置拒掉。
@@ -1,133 +0,0 @@
# Connection List Alphabetical Sort Design
**Date:** 2026-07-21
## Goal
Add a connection-list sort control to the desktop sidebar. Users can view saved
connections by name in ascending or descending alphabetical order, without
destroying their existing group and drag-and-drop layout.
## Background
The source repository was searched before this change. Issue #72, "连接分组和拖拽排序",
is closed because DBX already supports groups and manual drag sorting. No issue
or announced plan covers alphabetical ordering of connection names.
The current sidebar builds its root tree from the persisted `SidebarLayout`.
That layout captures manual root and group ordering. Rewriting it for an
alphabetical view would silently discard a user's manual arrangement.
## Requirements
- provide ascending and descending connection-name ordering from the sidebar
- retain a way to return to the saved manual order
- sort connections both at the root and inside each connection group
- keep connection groups themselves in their saved order
- do not mutate the persisted drag-and-drop layout when changing display sort
- remember the selected sort mode across application restarts
- keep search, selection, expand/collapse state, and virtualized rendering
working
## Chosen Approach
### Sort mode
Add `sidebarConnectionSortMode` to editor settings with three values:
- `manual` — existing persisted drag-and-drop order (default)
- `asc` — connection names from A to Z
- `desc` — connection names from Z to A
The setting is normalized on load and saved through the existing editor-settings
store, so old profiles safely default to `manual`.
### Display transformation
Create a pure sidebar utility that produces a display-only tree:
- it recursively visits only `connection-group` nodes
- at every such level, it sorts only sibling nodes whose type is `connection`
- it preserves the positions and order of group nodes and any non-connection
nodes
- it copies only the group branches whose child order changes; stored tree nodes
remain unmodified
- it uses an `Intl.Collator` with numeric, case-insensitive comparison; names
that compare equally retain their manual relative order
`ConnectionTree.vue` uses this transformed tree before it applies the existing
search filter and flattening flow. This means all of the existing selection and
virtualization logic continues to operate on the currently visible order.
### User interface
Add a compact sort dropdown next to the existing sidebar locate and filter
controls. It exposes:
- Manual order
- Name: A–Z
- Name: Z–A
The active mode has an accessible label and visible selected state. New labels
are added to every supported locale, with fallback coverage for any locale that
does not yet provide a translation.
Manual drag-and-drop is disabled while either alphabetical sort is active. This
avoids a misleading interaction in which a successful drag immediately appears
to be undone by the active display sort. Switching back to `manual` restores
the untouched stored layout and drag behavior.
## Files And Boundaries
- `apps/desktop/src/lib/sidebar/connectionListSort.ts` — pure ordering utility
and its Vitest coverage
- `apps/desktop/src/stores/settingsStore.ts` — typed default, normalization,
and persistence update for the mode
- `apps/desktop/src/components/sidebar/ConnectionTree.vue` — dropdown,
display-tree integration, and drag guard
- `apps/desktop/src/i18n/locales/*.ts` — sort-control labels
No Rust backend, connection storage schema, or sidebar-layout persistence code
changes. Sorting is entirely a frontend presentation preference.
## Error Handling And Compatibility
- missing, invalid, or legacy setting values normalize to `manual`
- empty trees and groups remain unchanged
- duplicate or equivalent names remain stable in their manual order
- filtering continues to run after display sorting, so disconnected-connection
filtering has no new special case
## Testing
- unit-test ascending and descending ordering
- verify groups retain their relative order while root and nested connections
sort independently
- verify manual mode returns original references/order and input trees are not
mutated
- test editor-settings default, normalization, and persisted update behavior
- run targeted Vitest tests, frontend typecheck, Rust fast check, and local
`make dev-fast` native startup
## Scope Boundaries
Included:
- connection-name alphabetical ascending/descending display sorting
- remembered sort mode
- grouped connection support
Not included:
- sorting databases, schemas, tables, or other tree objects
- replacing manual drag ordering
- changing connection configuration or the persisted sidebar layout format
- per-group independent sort preferences
## Success Criteria
- a user can choose A–Z or Z–A from the sidebar and see all connections sorted
accordingly within their current groups
- manual ordering can be restored without data loss
- the chosen mode survives restart
- native local startup completes with the control present and functional
@@ -1,380 +0,0 @@
# Query Result SQL INSERT Async Background Export
Date: 2026-07-25
Author: dbx team
Status: Draft
## Problem
点击"导出当前结果集全部数据 SQL INSERT"后,UI 会冻结数十秒甚至一分钟(4 万行 ~20s,10 万行 ~1min+),然后才弹出保存对话框。用户以为功能坏了。
## Root Cause
当前 `exportSql()` 对 query-result 场景走 `resultToExport()` → 全量数据先拉到前端内存 → `formatSqlInsert()` 通过 Tauri IPC 发给 Rust 生成 INSERT → 再回前端 → `saveTextFile()` 写入。整个过程阻塞 UI 线程,保存对话框出现在最后。
**对比 CSV/XLSX/TXT 的后台流式导出**(右键表名导出、工具栏导出 query result):
1. 先弹保存对话框(立即响应)
2. 注册后台任务(进度显示在 toolbar popover)
3. Rust 端流式分页读取 DB → 直接写入文件
4. 完成后 toast 通知
## Design
### 方案
在 `export_query_result_core_inner()` 的通用 JDBC 分页循环中添加 `"sql"` 格式支持。复用 `build_export_insert_statements()`,按 100 行一批 flush 到文件。
**不碰** Postgres/MySQL/ClickHouse/SQL Server 四个原生流式路径——SQL 格式只走分页循环(原生路径检测到 `format == "sql"` 返回 `false` 后回退)。
前端 `exportSql()` 增加 Step 2:先尝试 `exportQueryResultSqlViaBackend()` 走后台流式导出,失败/Web 才回退到当前本地导出。
### 修复的 Blocker
1. **复用 `buildQueryResultExportRequest`**(`queryResultExportRequest` callback),不手撸 request 对象。保留 `rowLimit`、`totalRows`、`timeoutSecs`、`useAgentCursor`、`setupSql`、`clientSessionId`、`executionId`、`keysetOptimizationEnabled` 等字段。
2. **`export_table_name` 兜底**:Rust 侧当 `export_table_name` 为空时回退到 `"query_result"`,纯 `SELECT` 查询不再报错。
3. **启动失败处理**:`startQueryResultExport()` rejecting 时将后台任务标记为 `Error`,清理 cancel handler,防止任务卡在 `Running` 状态。
## Files Changed
| File | What | Lines |
|------|------|-------|
| `crates/dbx-core/src/query_result_export.rs` | 格式守卫放松 + 分页循环 SQL 分支 + 新字段 + helper | ~80 |
| `apps/desktop/src/composables/useDataGridExport.ts` | 新增 `exportQueryResultSqlViaBackend`,`exportSql()` 增加 Step 2 | ~50 |
| `apps/desktop/src/components/grid/DataGrid.vue` | `queryResultExportRequest` prop 类型加 `"sql"` 和两个 new fields | ~5 |
| `apps/desktop/src/components/layout/ContentArea.vue` | 同 DataGrid.vue 类型更新 | ~3 |
| `apps/desktop/src/stores/queryStore.ts` | `BuildQueryResultExportRequestOptions` 加 `exportTableName`、`exportColumnTypes` | ~5 |
| `packages/app-tests/useDataGridExport.test.ts` | 测试适配(TXT/SQL Web fallback) | ~5 |
**Total: ~148 lines, zero new files, zero new dependencies.**
## Detailed Design
### 1. Rust: `QueryResultExportRequest` — new optional fields
File: `crates/dbx-core/src/query_result_export.rs`
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_time_format: Option<String>,
// -- new fields --
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export_table_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export_column_types: Option<Vec<String>>,
}
```
### 2. Rust: format guard
Relax the existing guard to accept `"sql"`:
```rust
if format != "csv" && format != "xlsx" && format != "txt" && format != "sql" {
return Err(format!("Unsupported streaming query-result export format: {format}"));
}
```
### 3. Rust: effective_row_limit
SQL format has no XLSX row cap:
```rust
fn effective_row_limit(format: &str, request: &QueryResultExportRequest) -> Option<usize> {
if format == "xlsx" {
Some(request.row_limit.map_or(XLSX_MAX_DATA_ROWS, |limit| limit.min(XLSX_MAX_DATA_ROWS)))
} else {
request.row_limit
}
}
```
### 4. Rust: native stream skip
原生流式路径(Postgres/MySQL/ClickHouse/SQL Server)只支持 CSV/TXT/XLSX。在 `export_query_result_core_inner` 中跳过它们对 `"sql"` 的调用,改为直接走通用分页循环:
```rust
// Before: 对每种格式都尝试原生流
// After: SQL 格式跳过原生流路径
if format != "sql" {
if try_export_postgres_query_result_stream(state, request, &format, cancel_token.clone(), on_progress).await? { return Ok(()); }
if try_export_sqlserver_query_result_stream(state, request, &format, cancel_token.clone(), on_progress).await? { return Ok(()); }
if try_export_mysql_query_result_stream(state, request, &format, cancel_token.clone(), on_progress).await? { return Ok(()); }
if try_export_clickhouse_query_result_stream(state, request, &format, cancel_token.clone(), on_progress).await? { return Ok(()); }
}
```
1 行变更(在原有 `if` 外包装一个 `format != "sql"` 条件),避免了修改 4 个原生函数的 60+ 行。
### 5. Rust: pagination loop — `"sql"` branch
Inserted alongside existing CSV/TXT branch. Uses a `pending_rows` buffer that flushes via `build_export_insert_statements` every 100 rows.
**New imports:**
```rust
use std::mem;
use crate::database_export::{build_export_insert_statements, BuildExportInsertStatementsOptions};
```
**New constant:**
```rust
const SQL_INSERT_BATCH_SIZE: usize = 100;
```
**New helper:**
```rust
fn sql_insert_column_types(request: &QueryResultExportRequest, column_types: &[String]) -> Vec<Option<String>> {
request.export_column_types
.as_ref()
.map(|types| types.iter().map(|t| if t.is_empty() { None } else { Some(t.clone()) }).collect())
.unwrap_or_else(|| vec![None; column_types.len()])
}
```
**New variables** (alongside existing `columns`/`column_types`):
```rust
let mut sql_file: Option<BufWriter<File>> = None;
let mut pending_rows: Vec<Vec<Value>> = Vec::new();
let mut sql_insert_col_types: Vec<Option<String>> = Vec::new();
```
**Initialize types** (inside existing `if columns.is_empty()` block):
```rust
if columns.is_empty() {
columns = result.columns.clone();
column_types = result.column_types.clone();
sql_insert_col_types = sql_insert_column_types(request, &column_types);
}
```
**Write branch** (after the `if format == "csv" || format == "txt"` block):
```rust
} else if format == "sql" {
if sql_file.is_none() {
sql_file = Some(BufWriter::new(
File::create(&request.file_path)
.map_err(|e| format!("Failed to create SQL file: {e}"))?,
));
}
pending_rows.extend(formatted_rows);
if pending_rows.len() >= SQL_INSERT_BATCH_SIZE {
let col_types = sql_insert_col_types.clone();
let table_name = request.export_table_name
.as_deref()
.filter(|n| !n.trim().is_empty())
.unwrap_or("query_result");
let stmts = build_export_insert_statements(BuildExportInsertStatementsOptions {
database_type: Some(request.database_type),
schema: request.schema.clone(),
table_name: Some(table_name.to_string()),
qualified_table_name: None,
columns: columns.clone(),
column_types: col_types,
column_extras: Vec::new(),
rows: mem::take(&mut pending_rows),
batch_size: Some(SQL_INSERT_BATCH_SIZE),
})?;
let file = sql_file.as_mut().unwrap();
for stmt in &stmts {
writeln!(file, "{stmt}").map_err(|e| format!("Failed to write SQL: {e}"))?;
}
}
}
```
**Post-loop flush** (alongside CSV/TXT flush):
```rust
} else if format == "sql" {
if !pending_rows.is_empty() {
let col_types = sql_insert_col_types.clone();
let table_name = request.export_table_name
.as_deref()
.filter(|n| !n.trim().is_empty())
.unwrap_or("query_result");
let stmts = build_export_insert_statements(BuildExportInsertStatementsOptions {
database_type: Some(request.database_type),
schema: request.schema.clone(),
table_name: Some(table_name.to_string()),
qualified_table_name: None,
columns: columns.clone(),
column_types: col_types,
column_extras: Vec::new(),
rows: mem::take(&mut pending_rows),
batch_size: Some(SQL_INSERT_BATCH_SIZE),
})?;
for stmt in &stmts {
writeln!(sql_file.as_mut().unwrap(), "{stmt}")
.map_err(|e| format!("Failed to write SQL: {e}"))?;
}
}
if let Some(file) = sql_file.as_mut() {
file.flush().map_err(|e| format!("Failed to flush SQL file: {e}"))?;
}
}
```
**File open output**: the `File::create` overwrites by design — same behavior as all other export formats.
### 6. Frontend: Types
**queryStore.ts:**
```typescript
interface BuildQueryResultExportRequestOptions {
exportId: string;
filePath: string;
format: "csv" | "xlsx" | "txt" | "sql"; // ← "sql" 新增
includeSqlSheet?: boolean;
exportTableName?: string; // ← new
exportColumnTypes?: Array<string | null | undefined>; // ← new
}
```
**DataGrid.vue prop type:**
```typescript
queryResultExportRequest?: (options: {
exportId: string;
filePath: string;
format: "csv" | "xlsx" | "txt" | "sql"; // ← "sql" 新增
includeSqlSheet?: boolean;
exportTableName?: string; // ← new
exportColumnTypes?: Array<string | null | undefined>; // ← new
}) => Promise<api.QueryResultExportRequest | undefined>;
```
### 6. Frontend: `exportQueryResultSqlViaBackend`
New function in `useDataGridExport.ts`.
**新增 import** (文件头部):
```typescript
import { useExportTracker } from "@/composables/useExportTracker";
```
**新增 destructure** (在 `useDataGridExport` 函数体开头):
```typescript
const { addTask, updateTableExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler } = useExportTracker();
```
```typescript
async function exportQueryResultSqlViaBackend(rowIds?: number[]): Promise<boolean> {
// Guard: 仅用于 query-result、无完整结果、桌面端
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) return false;
if (hasCompleteLocalResult?.value) return false;
if (!isTauriRuntime()) return false; // Web fall through → local export
// 1. 保存对话框 FIRST(立即响应)
let outputPath = exportFileName("query-result", "sql");
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: outputPath,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) return true;
outputPath = path as string;
// 2. 通过 buildQueryResultExportRequest 构建完整 request
const exportId = uuid();
const request = await queryResultExportRequest({
exportId,
filePath: outputPath,
format: "sql",
exportTableName: tableMeta.value?.tableName,
exportColumnTypes: columnTypes.value?.map(
t => t ?? null
) as Array<string | null | undefined> | undefined,
});
if (!request) throw new Error("Unable to build query result export request");
// 3. 注册后台任务 + cancel handler
registerTaskCancelHandler(exportId, () => api.cancelQueryResultExport(exportId, request.executionId));
addTask(tableMeta.value?.tableName || "Query Result", "sql", outputPath, exportId);
try {
await api.startQueryResultExport(request, (progress) => {
updateTableExportTask(exportId, progress);
if (progress.status === "Done") {
toast(t("grid.exported"));
}
});
} catch (e) {
// 4. 启动失败 → 标记 Error(修复 Blocker 3)
updateTableExportTask(exportId, {
exportId,
tableName: tableMeta.value?.tableName || "Query Result",
rowsExported: 0,
totalRows: null,
status: "Error" as const,
errorMessage: e?.message || String(e),
});
throw e;
} finally {
unregisterTaskCancelHandler(exportId);
}
return true;
}
```
### 7. Frontend: `exportSql()` — new middle step
```typescript
async function exportSql(rowIds?: number[]) {
await runExclusiveExport(async () => {
try {
// Step 1: table-data context — 已有后台表导出(不变)
if (await exportFullTableDataViaBackend("sql", rowIds)) return;
// Step 2: query-result context — NEW 后台流式导出
if (await exportQueryResultSqlViaBackend(rowIds)) return;
// Step 3: fallback — 本地导出(Web 和异常场景)
const result = await resultToExport(rowIds, undefined, true, false);
const exportData = sqlInsertExportData(result);
const content = await formatSqlInsert({ ... });
await saveTextFile(content, exportFileName(..., "sql", ...), "SQL", "sql");
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
}
```
## User Experience Flow
```
1. 用户点击 "导出当前结果集全部数据 SQL INSERT"
2. 系统保存对话框立即弹出 ← 无冻结
3. 用户选择路径 → 点击保存
4. 后台任务指示器在 toolbar 出现(带进度条)
5. Export 分页流式执行:DB → 分批读取 → build INSERT → 写入文件
6. 完成 → toast "已导出"
或
失败 → 后台任务显示 Error 状态
```
## Backward Compatibility
- 新增字段均为 `Option` + `skip_serializing_if`,现有 caller 无损
- SQL INSERT 输出格式与现有 `build_export_insert_statements` 完全一致(batch 从 1→100,语义等价)
- 纯查询兜底表名 `"query_result"`,导出文件内容与之前不同(之前失败),属于 bugfix 而非 regression
- Web 端保持不变,仍走本地导出(Blob download)
## Error Handling
| Scenario | Behavior |
|----------|----------|
| `startQueryResultExport` 拒绝启动 | catch 中标记 task Error + 清理 cancel handler |
| 文件创建失败 | 错误传播到前端,task 标记 Error |
| 磁盘满 | `std::io::Error` → 游标中断 → task Error |
| 用户取消 | CancellationToken → OnProgress(Cancelled) → 清理文件 |
| SQL 表单 export_table_name 为空 | Rust 回退到 `"query_result"` |
@@ -1,141 +0,0 @@
# ClickHouse SQL Syntax Highlighting Design
**Date:** 2026-07-29
## Goal
Give ClickHouse connections a dedicated SQL highlighting dialect in DBX's
CodeMirror query editors and DDL viewers. ClickHouse keywords, data types, and
common built-in functions should receive the same semantic colors that the
existing editor themes already assign to those token classes.
## Background
DBX currently has separate dialect-selection paths for SQL formatting and SQL
highlighting:
- `sqlFormatter.ts` maps ClickHouse to the formatter's native `clickhouse`
language.
- `jdbcDialect.ts` maps ClickHouse to the generic `mysql` CodeMirror dialect
name.
- `codemirrorSqlDialect.ts` does not recognize the ClickHouse database type and
therefore selects `StandardSQL` as its parser base.
- the semantic table-name overlay receives the explicit `mysql` dialect and
consequently applies MySQL rules to ClickHouse SQL.
This explains why formatting can look correct while highlighting does not.
Editor themes already distinguish keywords, types, functions, strings, and
identifiers; the parser is producing incomplete or incorrect token classes.
## Scope
Included:
- the main CodeMirror query editor
- CodeMirror-based DDL and SQL viewers that use the shared DBX dialect factory
- ClickHouse keyword, data-type, built-in-function, comment, and table-name
classification
- focused parser and propagation tests
Not included:
- Shiki-rendered static SQL blocks in AI messages
- SQL formatting, execution, completion, or snippet behavior
- a complete ClickHouse grammar or query validator
- theme color changes
## Chosen Approach
Add `clickhouse` as a first-class DBX CodeMirror dialect name. Define its
CodeMirror dialect from `StandardSQL`, then extend the dialect specification
with ClickHouse-specific keywords, data types, and common built-in functions.
`StandardSQL` is the safer parser base than `MySQL` because ClickHouse and MySQL
do not share all lexical behavior. In particular, DBX already tests that
ClickHouse accepts `--` comments without the whitespace requirement used by the
CodeMirror MySQL dialect.
The vocabulary will be curated from the official ClickHouse SQL reference and
kept as named constants next to the existing PostgreSQL and SQL Server
extensions. Representative coverage includes:
- clauses and DDL terms such as `PREWHERE`, `ARRAY JOIN`, `ENGINE`,
`PARTITION BY`, `SAMPLE BY`, `TTL`, `SETTINGS`, `CODEC`, and `FORMAT`
- types such as `UInt64`, `DateTime64`, `LowCardinality`, `Nullable`, `Map`,
`Tuple`, `Nested`, `AggregateFunction`, and `SimpleAggregateFunction`
- common functions such as `toYYYYMM`, `uniqExact`, `argMax`, `groupArray`,
`arrayJoin`, and `JSONExtractString`
This is vocabulary-driven syntax highlighting, not validation. Unknown future
functions will still parse as callable identifiers instead of breaking editor
input.
## Data Flow
1. A ClickHouse connection resolves to the new `clickhouse` CodeMirror dialect
name in `jdbcDialect.ts`.
2. QueryEditor and DDL viewers pass that name and the ClickHouse database type
into `createDbxCodeMirrorSqlDialect()`.
3. The dialect factory selects the dedicated ClickHouse specification.
4. CodeMirror's Lezer SQL parser assigns keyword, type, built-in, comment, and
identifier tags.
5. Existing editor themes render those tags with their current colors.
6. The semantic overlay selects a ClickHouse adapter for table-name
decorations instead of inheriting MySQL behavior accidentally.
## Semantic Adapter
Add a ClickHouse semantic dialect adapter rather than silently treating
ClickHouse as MySQL. It will support ClickHouse's backtick and double-quote
identifier quoting and use the project's existing default identifier
normalization unless a verified ClickHouse-specific rule is required.
This adapter is limited to tokenization and table-reference decoration. It does
not change query execution or attempt to model every ClickHouse alias-resolution
extension.
## Compatibility And Failure Behavior
- Existing MySQL, PostgreSQL, SQL Server, Oracle, SQLite, and generic database
mappings remain unchanged.
- ClickHouse keeps its current `--SELECT 1` line-comment behavior.
- An unlisted ClickHouse keyword or function remains readable as an identifier;
highlighting degrades locally without blocking editing.
- All changes remain frontend-only and require no persisted-setting migration.
## Testing
Use test-driven development:
1. Add parser tests that initially fail under the current StandardSQL fallback.
2. Assert representative ClickHouse terms produce keyword, type, and built-in
parser nodes.
3. Retain and strengthen the line-comment regression test.
4. Assert the JDBC database-type mapping returns `clickhouse`.
5. Assert QueryEditor and DDL viewers pass the dedicated dialect through the
shared factory.
6. Add semantic-dialect coverage for ClickHouse identifier quoting and
selection.
7. Run focused tests, the relevant desktop test suite, and frontend typecheck.
## Alternatives Considered
### Extend the MySQL dialect
This is smaller but preserves known lexical mismatches and continues to make
ClickHouse behavior depend on unrelated MySQL changes.
### Integrate the ClickHouse lexer or WebAssembly
ClickHouse exposes native query-highlighting capabilities, including recent
lexer/WASM work. That route could eventually provide higher fidelity, but it
adds runtime, bundle-size, and CodeMirror syntax-tree integration costs that are
disproportionate to this focused highlighting fix.
## Success Criteria
- ClickHouse-specific clauses, types, and common functions are visually
differentiated in query and DDL editors.
- ClickHouse no longer resolves to MySQL or generic StandardSQL by accident.
- existing dialect behavior and editor themes do not regress.
- the implementation is covered by focused parser, mapping, and semantic tests.
@@ -1,301 +0,0 @@
# ClickHouse Function Completion Design
Date: 2026-07-30
## Summary
DBX currently provides database-specific static function completion for MySQL, PostgreSQL, SQLite, SQL Server, and Manticore Search. It also loads stored routines dynamically for selected databases. ClickHouse receives only the small common SQL function set, so native functions such as `toStartOfDay`, `uniqExact`, and `JSONExtractString` are absent.
This change adds a static, ClickHouse-specific function registry with broad official-function coverage, overload-aware signature help, ClickHouse parametric aggregate support, table-function completion, and aggregate combinator generation. It does not query the connected ClickHouse server.
## Goals
- Provide broad completion coverage for documented ClickHouse regular, aggregate, window, and table functions.
- Preserve canonical ClickHouse casing when inserting camelCase function names.
- Support multiple signatures per function and highlight the active parameter.
- Support ClickHouse functions with multiple parameter groups, such as `quantilesTDigest(levels...)(expression)`.
- Generate valid aggregate-combinator completions without storing an unbounded Cartesian product.
- Keep existing MySQL, PostgreSQL, SQLite, SQL Server, and Manticore Search completion behavior unchanged.
- Keep the ClickHouse inventory maintainable and isolated from the already large generic SQL completion module.
## Non-goals
- Query `system.functions`, `system.user_defined_functions`, or any other server metadata table.
- Complete server-defined UDFs in the first version.
- Treat operators, keywords, or data types as functions.
- Include undocumented internal functions without a stable user-facing interface.
- Migrate every existing database function inventory to the new representation in the same change.
## Existing Design to Reuse
`sqlCompletion.ts` already provides:
- database-specific function filtering;
- prefix matching and ranking;
- function snippets with CodeMirror placeholders;
- function-versus-keyword de-duplication;
- a signature tooltip driven by the cursor's active argument;
- regression coverage proving that database-specific functions do not leak across dialects.
The existing `DATABASE_FUNCTION_SIGNATURES` maps demonstrate the desired database isolation, but their `Map<string, string[]>` value type represents only one flat signature. ClickHouse requires a richer model because overloads and two-stage parameter lists are common.
The dynamic stored-routine path used by MySQL, PostgreSQL, SQL Server, and Oracle is not reused for built-in ClickHouse functions. Static built-in completion and dynamic stored-routine completion solve different problems.
## Architecture
Add a ClickHouse-specific directory:
```text
apps/desktop/src/lib/sql/clickhouse/
functionTypes.ts
regularFunctions.ts
aggregateFunctions.ts
tableFunctions.ts
aggregateCombinators.ts
functionRegistry.ts
```
Responsibilities:
- `functionTypes.ts` defines function, overload, parameter-group, category, and lifecycle-status types.
- `regularFunctions.ts` contains documented scalar functions, organized into readable category sections.
- `aggregateFunctions.ts` contains ordinary and parametric aggregate functions.
- `tableFunctions.ts` contains functions valid in table-target contexts such as `FROM` and `JOIN`.
- `aggregateCombinators.ts` defines suffix compatibility, ordering, signature transformations, and lazy generation.
- `functionRegistry.ts` merges definitions, validates lookup keys, performs case-insensitive lookup, preserves canonical names, and exposes prefix queries.
- `sqlCompletion.ts` adapts registry results into the existing generic completion item type and uses the richer signatures for tooltip rendering.
The ClickHouse module contains data and deterministic transformation logic only. It does not depend on Vue, CodeMirror, a connection, or backend APIs.
## Function Model
The shared model is:
```ts
interface SqlFunctionDefinition {
name: string;
category: SqlFunctionCategory;
signatures: SqlFunctionSignature[];
description?: string;
preferredSignature?: number;
aggregate?: boolean;
status?: "stable" | "experimental" | "deprecated";
aliases?: string[];
}
interface SqlFunctionSignature {
parameterGroups: string[][];
returnType?: string;
}
```
One parameter group represents an ordinary function:
```ts
{
name: "toStartOfInterval",
category: "date-time",
signatures: [
{
parameterGroups: [["value", "INTERVAL x unit"]],
returnType: "DateTime"
},
{
parameterGroups: [["value", "INTERVAL x unit", "time_zone"]],
returnType: "DateTime"
}
]
}
```
Multiple groups represent a parametric aggregate:
```ts
{
name: "quantilesTDigest",
category: "aggregate",
aggregate: true,
signatures: [
{
parameterGroups: [
["level", "...levels"],
["expression"]
]
}
]
}
```
`preferredSignature` selects the inserted snippet. When it is omitted, the first signature is preferred. Optional arguments remain visible in alternate signatures but are excluded from the default insertion when a shorter common form exists.
## Inventory Scope
The static inventory follows the public ClickHouse function reference and records the documentation snapshot date in source comments. It covers:
- regular scalar functions, including array, string, date/time, JSON, Map, Tuple, URL, IP, mathematical, hashing, encoding, conversion, Nullable, dictionary, geography, and related documented categories;
- regular and parametric aggregate functions;
- window functions;
- table functions;
- documented aliases with canonical insertion names;
- experimental and deprecated functions, marked with status and ranked below stable functions.
The official ClickHouse documentation separates regular, aggregate, table, window, and user-defined functions. The first version covers the first four static categories and excludes user-defined functions:
- <https://clickhouse.com/docs/reference/functions>
- <https://clickhouse.com/docs/reference/functions/aggregate-functions/combinators>
Every function must have an official name and an accurate parameter-group shape. A concise description is optional. When absent, the completion detail falls back to `ClickHouse · <category>` so full internationalization coverage is not required to ship the inventory.
The checked-in inventory also contains a category manifest that records the documentation pages used for the 2026-07-30 snapshot and the number of definitions in each category. Integrity tests use those checked-in category counts as minimum floors, so removing a documented batch requires an intentional manifest update rather than silently reducing coverage.
## Completion Behavior
Matching is case-insensitive, while labels and inserted text retain canonical ClickHouse casing. For example, `tostart` can match and insert `toStartOfDay(${value})`.
Each completion item displays:
- canonical function name;
- `ClickHouse` and the function category;
- the preferred signature;
- overload count when greater than one;
- experimental or deprecated status;
- a concise description when available.
Accepting a completion:
- inserts the preferred signature with CodeMirror placeholders;
- avoids adding a second opening parenthesis when the user already typed one;
- inserts `function()` for zero-argument functions;
- preserves multiple parameter groups for parametric functions;
- continues to use existing ranking history and prefix scoring.
ClickHouse-specific signatures override common SQL signatures for names shared with the common registry.
## Table Functions
Table functions are a distinct function kind. In ClickHouse table-target contexts, including `FROM` and `JOIN`, the provider includes matching table functions alongside tables and views. It does not include ordinary scalar or aggregate functions in those exclusive contexts.
Table-function completions use the same overload and placeholder model as regular functions. This requires a narrow exception to the existing `exclusiveTableSuggestions` path rather than globally enabling all functions after `FROM`.
## Signature Help
The generic signature-help result is extended from one flat signature to multiple overloads containing one or more parameter groups.
The resolver:
1. finds the function call that owns the cursor;
2. determines the active parameter group;
3. counts top-level commas only within that group;
4. ranks signatures that can accept the observed argument count first;
5. returns every matching overload for display;
6. highlights the active parameter in each displayed overload.
For `quantilesTDigest(0.5, 0.9)(value)`, the resolver recognizes both pairs of parentheses as one function call. It can distinguish the level list from the expression list.
Ordinary existing database functions are adapted as one overload with one parameter group, preserving their current behavior.
## Aggregate Combinators
Aggregate combinators are generated lazily from aggregate definitions. The rules describe:
- which base aggregates accept a combinator;
- valid suffix order;
- how parameter groups change;
- how return metadata changes;
- whether a combinator can be followed by another combinator.
The initial rule set covers the documented combinators, including `If`, `Array`, `Map`, `SimpleState`, `State`, `Merge`, `MergeState`, `ForEach`, `Distinct`, `OrDefault`, `OrNull`, and `Resample`.
Generation follows official ordering constraints. For example, `Array` precedes `If`, producing `uniqArrayIf`, while `uniqIfArray` is not generated. `If` appends a condition argument to the data-argument group. State and merge combinators transform the expected input or output shape rather than merely renaming the function.
The registry generates candidates only for the active prefix and enforces a deterministic maximum candidate count. It does not materialize every possible suffix permutation at module initialization.
## Ranking and Lifecycle Status
Stable direct functions rank above generated combinator variants when match quality is equal. Exact prefix and exact label matches continue to receive the existing strong boosts.
Generated variants remain competitive once the typed prefix includes their suffix. Experimental and deprecated entries receive a modest negative boost and a visible status label, but remain discoverable.
Aliases de-duplicate against canonical functions case-insensitively. An alias can match the typed prefix, but accepting it inserts the canonical ClickHouse function name and casing.
## Validation and Failure Handling
The static path has no network failures. Registry validation catches authoring problems:
- duplicate case-insensitive names;
- aliases colliding with canonical names;
- empty signature lists;
- empty parameter groups where they are not meaningful;
- out-of-range preferred signature indexes;
- invalid lifecycle status;
- illegal combinator ordering;
- combinator cycles;
- aliases that do not belong to a canonical definition.
Tests fail on invalid inventory data. At runtime, an isolated malformed entry is skipped rather than breaking the entire completion popup, while development builds may log a diagnostic.
## Testing Strategy
Implementation follows test-driven development: add one failing behavioral test, confirm the expected failure, add the minimum implementation, and keep the focused suite green before proceeding.
### Registry integrity
- canonical names and case-insensitive lookup keys are unique;
- every definition has at least one valid signature;
- preferred signature indexes are valid;
- every declared category has representative coverage;
- an expected inventory-size floor guards against accidental bulk deletion.
### Completion behavior
- `toStart` returns canonical camelCase ClickHouse functions;
- completion inserts the preferred placeholder template;
- an already typed `(` is not duplicated;
- common functions use ClickHouse-specific signatures;
- ClickHouse-only names do not appear for MySQL or PostgreSQL.
### Multiple signatures
- all overloads are available to signature help;
- overload ranking reacts to top-level comma count;
- active parameters are highlighted correctly;
- parametric aggregate calls distinguish the first and second parameter groups.
### Combinators
- valid examples such as `sumIf`, `uniqArrayIf`, and `quantilesState` are generated;
- invalid suffix orders such as `uniqIfArray` are not generated;
- `If` adds the condition parameter;
- generation respects its deterministic result bound.
### Table functions
- ClickHouse table functions appear in `FROM` and `JOIN`;
- scalar and aggregate functions remain excluded from exclusive table contexts;
- ordinary table and view completion continues to work.
### Regression coverage
- current MySQL function suggestions and special insertion templates remain unchanged;
- PostgreSQL, SQLite, SQL Server, and Manticore Search functions remain isolated;
- existing snippet, keyword, column, table, and routine completion tests remain green.
Final verification includes the focused SQL completion tests, frontend type checking, and the repository's relevant frontend test suite.
## Success Criteria
- A ClickHouse query can autocomplete representative functions from every supported category using canonical casing.
- Multiple overloads and current-parameter highlighting work for ordinary and parametric functions.
- Valid aggregate combinator variants are discoverable without invalid-order noise or eager combinatorial expansion.
- Table functions are suggested only where a table source is valid.
- No server query is required for built-in completion.
- Existing database-specific completion behavior remains covered by passing regression tests.
## Future Work
Possible follow-up work, intentionally excluded from this implementation:
- optional discovery of SQL or executable UDFs from ClickHouse system tables;
- a generated inventory pipeline sourced from an official machine-readable index;
- migration of other database function maps to the overload-aware registry;
- localized descriptions for the complete ClickHouse inventory.
@@ -1,171 +0,0 @@
# Data Transfer: Tree Object Selection and Transfer Modes Design
Date: 2026-08-01
## Summary
The data transfer dialog (`apps/desktop/src/components/transfer/DataTransferDialog.vue`) currently lets the user pick tables from a flat checkbox list and copy table **data** between connections. Non-table objects (views, stored procedures, functions, triggers, sequences, events) are only copied for PostgreSQL→PostgreSQL transfers, and then only as an all-or-nothing schema sweep performed after the table data copy.
This change turns the table picker into a tree that groups selectable objects by type (tables, views, stored procedures, functions, triggers, sequences, events), and adds an explicit transfer mode: **structure and data** (default), **structure only**, and **data only**. Non-table object DDL copying is implemented for PostgreSQL and PG-family databases (Kingbase, GaussDB, KWDB, OpenGauss), MySQL, Dameng, and Oracle. Cross-database-family object DDL copy is out of scope for the first version.
## Goals
- Select transfer content via a tree grouped by object type, with per-group select-all and object-level checkboxes.
- Support three transfer modes: structure and data, structure only, data only.
- Copy non-table object DDL for PostgreSQL/PG-family, MySQL, Dameng, and Oracle, within the same database family.
- Skip objects that already exist on the target side (by name), and report the skip count on completion.
- Preserve the existing table data transfer behavior (append/overwrite/upsert write modes, FK dependency ordering, ownership preview, target-table-name case handling).
## Non-goals
- Cross-family non-table object DDL transfer (e.g., MySQL view → PostgreSQL view). The tree disables non-table groups with a hint in that case.
- Object transfer for databases outside the supported family set (SQLite, ClickHouse, SQL Server, MongoDB, Hive, Spark, etc.).
- Editing object DDL inside the transfer dialog (users use the object source editor for that).
- Differential/sync behavior beyond "skip existing" (no replacement of changed objects).
## Existing Design to Reuse
- `crates/dbx-core/src/transfer.rs` — table data transfer core: `TransferRequest`, `transfer_table`, FK dependency sorting (`sort_tables_by_fk_dependency`), progress reporting (`TransferProgress`), DDL generation (`generate_create_table_ddl`), column type mapping (`map_column_type`).
- `crates/dbx-core/src/transfer.rs:5020` — `transfer_postgres_schema_objects`: existing PostgreSQL schema object DDL copy (views, materialized views, procedures, functions, sequences, triggers, types, packages, synonyms + ownership/grants/policies). Reused and extended with per-object selection filtering.
- `crates/dbx-core/src/object_source_sql.rs` — `build_executable_object_source_statements`, `ObjectSourceKind`, `EditableObjectSourceSqlInput`: per-database object source DDL machinery (already used by the PG object transfer).
- `crates/dbx-web/src/routes/transfer.rs` — transfer orchestration route, progress channel, per-object failure handling.
- `apps/desktop/src/lib/backend/api.ts` — `listObjects(connectionId, database, schema, objectTypes?, filter?, limit?, offset?, catalog?)` returning `ObjectInfo[]`; `TransferRequest` types.
- `apps/desktop/src/lib/database/databaseObjectCapabilities.ts` — per-database sidebar object kind lists.
## Design
### 1. Backend protocol (`crates/dbx-core/src/transfer.rs`, `crates/dbx-web/src/routes/transfer.rs`)
Extend `TransferRequest`:
```rust
#[serde(rename_all = "camelCase")]
pub enum TransferContent {
#[default]
StructureAndData,
StructureOnly,
DataOnly,
}
#[serde(rename_all = "SCREAMING_SNAKE_CASE")] // matches SidebarObjectKind conventions
pub enum TransferObjectKind {
Table, View, MaterializedView, Procedure, Function, Trigger, Sequence, Event,
}
pub struct TransferObjectSelection {
pub object_type: TransferObjectKind,
pub names: Vec<String>,
}
```
New fields on `TransferRequest`:
- `content: TransferContent` — defaults to `StructureAndData`. Replaces the `create_table` boolean (`create_table == (content != DataOnly)`); the field is kept for wire compatibility but the frontend stops sending it.
- `objects: Vec<TransferObjectSelection>` — non-table object selections. The table selection stays in the existing `tables: Vec<String>` field (also mirrored as a `TransferObjectKind::Table` entry when the tree sends it).
Validation (in the route, before transfer starts):
- `DataOnly` with non-empty `objects` → error "仅数据模式不传输非表对象".
- `StructureOnly` with empty `tables` and empty `objects` → error (nothing to do).
- Non-table objects are rejected when source and target database families differ (see family rules below).
- Non-table object names are validated like table names (non-empty, no NUL bytes).
`TransferMode` (append/overwrite/upsert) is unchanged; it only takes effect when the content includes data (`StructureAndData` or `DataOnly`).
### 2. Database family rules
| Family | Database types | Non-table object DDL support |
|---|---|---|
| mysql | mysql | yes (views, procedures, functions, triggers, events) |
| postgres | postgres, kingbase, gaussdb, kwdb, opengauss | yes |
| oracle | oracle, dameng, oceanbase-oracle | yes (views, procedures, functions, triggers, sequences) |
| other | everything else | no — tree groups disabled, backend rejects |
`is_same_transfer_family(source, target)` — a new pure function in `transfer.rs`, unit-tested. Cross-family transfers keep table data copy working exactly as today, with non-table groups disabled in the UI.
Object type availability per family (drives which tree groups render):
| Object kind | mysql | postgres family | oracle/dameng |
|---|---|---|---|
| TABLE | ✓ | ✓ | ✓ |
| VIEW | ✓ | ✓ | ✓ |
| MATERIALIZED_VIEW | ✗ | ✓ | ✓ |
| PROCEDURE | ✓ | ✓ | ✓ |
| FUNCTION | ✓ | ✓ | ✓ |
| TRIGGER | ✓ | ✓ | ✓ |
| SEQUENCE | ✗ (MariaDB only, first version: no) | ✓ | ✓ |
| EVENT | ✓ | ✗ | ✗ |
### 3. Object listing (backend)
Extend the existing `listObjects` path (per-database object listing in `crates/dbx-core/src/schema.rs` and the agent-side listing) so the transfer tree can fetch objects grouped by type:
- **PostgreSQL family**: already supported kinds (TABLE, VIEW, MATERIALIZED_VIEW, PROCEDURE, FUNCTION, SEQUENCE) via `pg_catalog`; add TRIGGER listing (`pg_trigger` join `pg_class`/`pg_namespace`, names of triggers in the schema).
- **MySQL**: add TRIGGER (`information_schema.TRIGGERS` filtered by `EVENT_OBJECT_SCHEMA`) and EVENT (`information_schema.EVENTS`); VIEW/PROCEDURE/FUNCTION listing already exists.
- **Oracle/Dameng**: add TRIGGER and SEQUENCE (`ALL_OBJECTS` filtered by `OBJECT_TYPE`); VIEW/PROCEDURE/FUNCTION listing already exists (Dameng already has `SYS.SYSOBJECTS`-based listing).
The `Event` kind is transfer-only: it is added to the backend `ObjectSourceKind`/listing mapping but not to the sidebar object capability lists (the sidebar does not gain new node types in this change).
### 4. Non-table object DDL copy (backend)
New `transfer_objects` flow in `crates/dbx-core/src/transfer.rs`, invoked from the route after table data transfer and after the existing PG schema-dependency step. Execution order:
1. tables (existing flow, FK sorted)
2. sequences
3. views, materialized views
4. functions
5. procedures
6. triggers
7. events
DDL fetching per family:
- **PostgreSQL family**: reuse `get_postgres_schema_object_sources_for_transfer` + `get_postgres_materialized_view_sources_for_transfer` + new `get_postgres_trigger_sources_for_transfer`-style queries, filtered by the selected names. Existing schema rewrite (`rewrite_postgres_routine_schema`) and `build_executable_object_source_statements` apply. The current "transfer everything in the schema" behavior becomes "transfer only selected objects"; when the request carries no object selection the step is skipped entirely (no behavior surprise for table-only transfers).
- **MySQL**: VIEW/PROCEDURE/FUNCTION DDL via `SHOW CREATE` (existing `mysql_object_source_sql`); TRIGGER DDL assembled from `information_schema.TRIGGERS` (`CREATE TRIGGER` with ACTION_TIMING/EVENT_MANIPULATION/EVENT_OBJECT_TABLE/ACTION_STATEMENT — the per-table trigger query at `crates/dbx-core/src/db/mysql.rs:4239` is the basis); EVENT DDL assembled from `information_schema.EVENTS`; strip `DEFINER=` clauses; rewrite the schema qualifier from source schema to target schema when they differ.
- **Oracle/Dameng**: fetch DDL via `DBMS_METADATA.GET_DDL('<TYPE>', '<name>')` (the existing object-source machinery already implements per-type metadata DDL for these databases; reuse those code paths where possible); rewrite schema qualifiers; execute per statement.
Target-side existence check, per object, immediately before executing its DDL:
- **PostgreSQL family**: `SELECT 1 FROM pg_class/pg_proc ...` by schema+name.
- **MySQL**: `information_schema` lookups (VIEWS, ROUTINES, TRIGGERS, EVENTS).
- **Oracle/Dameng**: `ALL_OBJECTS` (or `SYS.SYSOBJECTS` for Dameng) by schema+name.
Existing objects are skipped and counted; the count is included in the final summary progress message ("skipped N existing objects"). Failed objects follow the existing pattern: recorded in the failure list, transfer continues, final status reflects the failures.
Progress: reuse `TransferProgress` with `table = "schema object: <name>"` (already the convention used by the PG object transfer). The `DataOnly` mode skips the whole object-DDL step.
### 5. Frontend (`apps/desktop/src/components/transfer/DataTransferDialog.vue`)
- Replace the flat table list with a tree:
- Top-level groups rendered from the source database family capability table (tables, views, materialized views, procedures, functions, triggers, sequences, events as applicable).
- Group header checkbox = select all / clear group; per-object checkboxes; expand/collapse per group; a search filter box filters object names across groups.
- Groups that cannot be transferred (cross-family, unsupported kind, or `DataOnly` mode) are rendered disabled with a tooltip explaining why.
- Table selection stays in `tables`; non-table selections serialize into `objects`.
- Transfer mode selector: 结构和数据 / 仅结构 / 仅数据 (i18n keys in the `transfer.*` namespace).
- `DataOnly`: non-table groups disabled ("仅数据模式不传输非表对象"); table group enabled.
- `StructureOnly`: data write mode (append/overwrite/upsert) selector hidden/disabled; ownership preview and target-table-name case options only apply when data is copied (ownership preview only relevant with `StructureAndData`; keep the existing behavior for that mode).
- The `createTable` checkbox is removed.
- Load object lists per group from `listObjects` with the per-kind object type filter; loading states per group; error states surface inline.
### 6. Error handling
- Validation errors from the route are shown inline in the dialog (existing error display pattern).
- Per-object DDL failures: existing `failed_tables` mechanism extended to carry object entries; final status `Error` with the failure list, transfer continues for remaining objects.
- Cancellation: existing `transfer_id` cancellation checks are threaded through the object-DDL loop (mirroring the PG object transfer loop).
### 7. Testing
Backend unit tests (`crates/dbx-core/src/transfer.rs` test module + `crates/dbx-core/tests/live_postgres_transfer.rs`):
- `is_same_transfer_family` matrix.
- Request validation (DataOnly + objects, cross-family objects).
- Per-family object DDL statement generation (MySQL `SHOW CREATE` handling with DEFINER stripping; schema rewriting).
- Target existence check SQL per family.
- Live PG transfer: object filtering by selection, skip-existing behavior, `StructureOnly` (no data rows copied), `DataOnly` (no DDL executed).
Frontend tests (`apps/desktop/src/components/transfer/__tests__/`):
- Tree group rendering per database family.
- Group select-all, object toggle, search filtering.
- Disable rules: `DataOnly` mode, cross-family, unsupported kinds.
- Request serialization (`tables` + `objects` + `content`).
@@ -1,388 +0,0 @@
# Database Documentation and DBML Export Design
Date: 2026-08-02
## Summary
DBX can inspect a schema but cannot document it. There is no way to produce a browsable, shareable description of a database, and no occurrence of DBML anywhere in the repository.
This change adds a **documentation subsystem** built around one idea: `SchemaSnapshot` is the product, and every output is a serializer over it. `crates/dbx-core/src/docs/` collects a normalized snapshot of a relational schema, merges it with user-authored notes and table groups, and serializes it to two artifacts — a `.dbml` interchange file and a self-contained HTML documentation site in the style of [dbdocs.io](https://dbdocs.io/Holistics/Ecommerce).
The documentation UI is a **standalone Vue application that makes no backend calls**. The same component tree renders inside DBX as a dialog and inside an exported single-file HTML page. Generation is available headless through `dbx docs` and `dbx dbml`, so documentation can be regenerated in CI and schema drift can fail a build.
Schema versioning (a Changelog tab and per-table "Last Updated") is explicitly deferred, but the snapshot is version-stamped and storable from day one so that adding it later is additive rather than a refactor.
## Goals
- Collect a normalized `SchemaSnapshot` for any relational engine DBX already supports, reusing the existing `schema::providers` metadata layer.
- Serialize the snapshot to valid DBML, including `TableGroup` blocks, `Enum` blocks, and `Ref` cardinality.
- Render dbdocs-style documentation: project overview, per-group table index with inline markdown notes, per-table pages with columns/indexes/bidirectional relationships, an ER diagram with group containers, and client-side search.
- Let users author table notes, column notes, project notes, and **table groups** in DBX, persisted in a human-readable, git-committable file.
- Export a self-contained HTML file that works over `file://` with no network access.
- Provide `dbx docs` and `dbx dbml` CLI verbs producing byte-identical output to the desktop app.
- Degrade visibly, never silently, when an engine cannot supply metadata.
## Non-goals
- **Schema versioning / Changelog / "Last Updated"** — deferred. Live databases mostly cannot report a DDL timestamp (PostgreSQL has none; MySQL's `information_schema.tables.update_time` is NULL for most InnoDB tables), so this is a derivative of stored snapshot history, not of metadata. Section 12 defines the seam.
- **DBML import / parsing.** The serializer is one-way. A `.dbml` file is an output, never a source of truth.
- **Non-relational engines** (MongoDB, Redis, Elasticsearch, InfluxDB, HBase). DBML has no vocabulary for them and schema inference from documents is a separate feature.
- **Non-table objects** — triggers, procedures, functions, sequences, packages, grants, partitions. `ObjectInfo` exists for these but none map to DBML and dbdocs does not document them. `format_version` makes adding them additive.
- **Write-back to the database.** DBX never issues `COMMENT ON` as a side effect of documentation.
- **Publishing/hosting.** DBX writes a file; distributing it is the user's business.
## Existing Design to Reuse
- `crates/dbx-core/src/types.rs:57-141` — `TableInfo`, `ColumnInfo`, `IndexInfo`, `ForeignKeyInfo`. These are serde mirrors of the TS interfaces in `apps/desktop/src/types/database.ts` and are **composed directly** into the snapshot rather than re-declared.
- `crates/dbx-core/src/schema/providers/native.rs` — `list_databases`, `list_schemas`, `list_tables`, `get_columns`, `list_foreign_keys`, `list_indexes`, already dispatching across every supported engine.
- `crates/dbx-core/src/schema/normalization.rs` — identifier normalization; the home for per-engine case folding of annotation keys.
- `crates/dbx-core/src/table_structure_sql/dialect.rs` — `capabilities_for(database_type).comment`, the authority on whether an engine supports comments at all.
- `crates/dbx-core/src/schema_diff.rs:772` — `RenameCandidate { source_name, target_name, score, column_jaccard, type_similarity }` plus `jaccard_similarity` and `column_type_similarity`. Reused to *suggest* re-mapping orphaned notes after a rename.
- `crates/dbx-core/src/database_export.rs` — the established progress-callback and cancellation shape.
- `apps/desktop/src/lib/diagram/erDiagram.ts` — `layoutDiagramTables` and `diagramZoom.ts`. This module imports **only types**, so the standalone docs bundle can import it without pulling in DBX runtime code. `buildDiagramRelationships` / `foreignKeySourceCardinality` are the reference implementation for the Rust port in Section 3.
- `apps/desktop/src/lib/export/diagramSvgExport.ts` — existing SVG serialization for diagram export.
- `apps/desktop/src/styles/globals.css:1310+` — the DBX token palette (strictly achromatic, `oklch(… 0 0)`), from which the docs theme subset is derived.
- `marked@18` and `shiki@4` are already dependencies. **No new dependency is required by this design.**
## Design
### 1. Snapshot model (`crates/dbx-core/src/docs/snapshot.rs`)
```rust
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo};
#[derive(Serialize, Deserialize)]
pub struct SchemaSnapshot {
pub format_version: u32, // 1
pub project: ProjectMeta,
pub tables: Vec<DocTable>,
pub relationships: Vec<Relationship>,
pub groups: Vec<TableGroup>,
pub enums: Vec<DocEnum>,
pub warnings: Vec<SnapshotWarning>,
}
pub struct ProjectMeta {
pub name: String,
pub database_type: String,
pub database: Option<String>,
pub schemas: Vec<String>,
pub generated_at: String, // RFC3339
pub note: Option<String>, // markdown, the landing page
}
pub struct DocTable {
pub schema: Option<String>,
pub name: String,
pub kind: TableKind, // Table | View | MaterializedView
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKeyInfo>,
pub group_id: Option<String>,
pub note: Option<String>,
pub note_source: NoteSource, // Database | Local | None
pub shadowed_note: Option<String>, // db comment a local note overrides
pub column_notes: BTreeMap<String, ColumnNote>,
pub estimated_rows: Option<i64>,
pub view_definition: Option<String>,
}
pub struct TableGroup {
pub id: String, // slug, stable across renames
pub name: String,
pub hue: u16, // 0-359 — see Section 4
pub note: Option<String>,
}
pub struct Relationship {
pub id: String,
pub name: Option<String>,
pub from: FieldRef,
pub to: FieldRef,
pub cardinality: Cardinality,
pub on_update: Option<String>,
pub on_delete: Option<String>,
}
pub struct FieldRef { pub schema: Option<String>, pub table: String, pub column: String }
pub struct ColumnNote { pub note: String, pub source: NoteSource, pub shadowed: Option<String> }
pub struct DocEnum { pub schema: Option<String>, pub name: String,
pub values: Vec<String>, pub note: Option<String>,
pub synthesized: bool } // true for MySQL inline ENUM(...)
pub enum TableKind { Table, View, MaterializedView }
pub enum NoteSource { Database, Local, None }
pub enum Cardinality { OneToOne, ManyToOne }
pub enum SnapshotWarning {
TableSkipped { table: String, reason: String },
NoForeignKeyMetadata { engine: String },
CommentsUnsupported { engine: String },
OrphanedNotes { count: usize },
DbmlOmitted { table: String, item: String, reason: String },
}
```
Four decisions are embedded here:
- **`format_version`** guards viewer/data skew. The exported HTML pins a snapshot beside a viewer built from the same commit, but an in-app viewer may load a snapshot produced by an older DBX. One integer prevents a class of blank-render bugs and makes future additions additive.
- **`note_source` and `shadowed_note`** exist so the UI can show *where* prose came from. When a local note shadows a database comment, both are available; the original is shown on hover. Without this, a DBA later writing a better `COMMENT ON` would have their improvement silently hidden forever.
- **`warnings`** is the honest-degradation channel (Section 10).
- **Enums are first-class** because DBML has native `enum` blocks. `ColumnInfo.enum_values` is already populated for MySQL inline `ENUM(...)`; PostgreSQL named enums are collected from `pg_type`/`pg_enum`. MySQL inline enums are synthesized into a named enum per `table_column`. This is the one place the snapshot normalizes rather than passes through.
### 2. Collection (`crates/dbx-core/src/docs/collector.rs`)
v1 fetches through the **existing** provider functions, fanned out with a bounded semaphore (8 concurrent). No new per-engine SQL.
This is deliberately the naive-but-correct version: it works for every supported relational engine on day one because it reuses code already proven against all of them. A 400-table schema costs roughly 1200 queries — bounded, cancellable, and measurable.
The optimization path is targeted rather than speculative: PostgreSQL and MySQL can collect all columns, FKs and indexes for a schema in three `information_schema` sweeps instead of 3×N. That belongs in a `collector::bulk` specialization behind the same interface, added only where a benchmark under `scripts/bench/` shows it matters.
Progress is reported through an `impl Fn(DocsProgress)` callback and cancellation via a checked `AtomicBool`, matching `database_export.rs`.
### 3. Relationship inference (`crates/dbx-core/src/docs/relations.rs`)
A port of the data half of `erDiagram.ts`. The split is deliberate:
- **Data logic moves to Rust** — given foreign keys and unique indexes, is an edge 1:1 or N:1? This is the `foreignKeySourceCardinality` check: a relationship is `OneToOne` only when the source columns contain a unique key, otherwise `ManyToOne`.
- **Layout stays in the renderer** — `layoutDiagramTables` is a view concern and remains in TypeScript, imported by the docs bundle.
This keeps the port to roughly 100 lines rather than a rewrite of the diagram feature. `SchemaDiagramDialog.vue` is **not modified** by this change; once `relations.rs` is proven it becomes the obvious candidate to consume the same inference, removing the duplicate.
### 4. Annotations and table groups (`crates/dbx-core/src/docs/annotations.rs`)
**The notes file is the store** — not a cache, not an export. `dbx.db` persists exactly one thing per connection: the path to this file.
```jsonc
// default: <app-data>/docs-notes/<connection-id>.json
// overridable per connection to e.g. ./docs/dbx-docs.json inside a repository
{
"format_version": 1,
"project": { "name": "Ecommerce", "note": "# Overview\n\nMarkdown." },
"groups": [
{ "id": "order-management", "name": "Order Management", "hue": 28,
"note": "Everything from checkout to carrier handoff." }
],
"tables": {
"core.orders": {
"group": "order-management",
"note": "One row per completed checkout.",
"columns": { "status": { "note": "State machine; only moves forward." } }
}
}
}
```
This shape is required by the CI goal. If notes lived only in `dbx.db`, `dbx docs` in CI would render documentation with every note missing — polished pages, empty of the prose that justifies them. As a file, notes are also reviewable in pull requests, which is arguably better than `COMMENT ON` (invisible to code review). `DesktopSettings.saved_sql_sync_dir` is the existing precedent for a user-chosen path.
**Table groups** are cross-schema domain groupings, authored by the user, stored here alongside notes, and serialized to DBML `TableGroup` blocks. A table belongs to at most one group; unassigned tables render under `(no group)`. Grouping is documentation, not configuration — it never affects what is collected.
**Group colour is stored as a hue integer (0–359), not a hex string.** Lightness and chroma are fixed per theme in CSS and only the hue varies per group:
```css
.group { --h: 28; }
/* light */ --group-c: oklch(0.55 0.15 var(--h)); --group-tint: oklch(0.965 0.022 var(--h));
/* dark */ --group-c: oklch(0.76 0.13 var(--h)); --group-tint: oklch(0.27 0.038 var(--h));
```
Storing a hex colour the dbdocs way would force either an unreadable dark mode (pastels tuned for a white page turn to mud on `rgb(19 20 22)`) or two colours per group that the user must choose twice. One hue guarantees legible contrast on both grounds by construction, and converts to hex on DBML export.
**Group editor UI.** Groups are created and edited in a dialog reached from the group header on the wiki index, the group badge on a table page, and the diagram group popover. Fields: name, note (markdown), colour, and member tables.
The colour control is **12 curated swatches plus a full 0–359 hue slider** — every hue is reachable, but lightness and chroma remain theme-controlled, so no selection can produce an illegible group. The dialog renders a **two-ground preview**, showing the group chrome on a forced-light and a forced-dark tile simultaneously, because the user may be editing in one theme while a colleague reads the exported HTML in the other.
A native hex/RGB picker is deliberately not offered. It would let a user choose a colour that disappears against one of the two grounds, and the only remedies are to render something different from what the picker showed, or to ship an unreadable group. Hue-plus-fixed-lightness makes the illegible state unrepresentable rather than merely discouraged.
On save, the group is written to the notes file. Since colour is one integer, a group's full definition is a four-field object and diffs cleanly in review.
**Keys and renames.** Keys are `schema.table` and `schema.table.column`, case-folded per engine (PostgreSQL lower, Oracle upper, MySQL per `lower_case_table_names`) in `schema/normalization.rs`.
When a table or column is renamed its note orphans. **User prose is never silently deleted.** Orphans remain in the file, the collector emits `SnapshotWarning::OrphanedNotes { count }`, and the dialog shows an "Unmatched notes" panel. For each orphan, `schema_diff::RenameCandidate` is used to **suggest** the most likely new owner along with its confidence score; the user confirms, re-maps manually, or deletes. Suggestion plus confirmation, never automatic re-attachment — a wrong auto-guess would attach misleading documentation to the wrong column, which is worse than an honest orphan.
**Merge precedence** is `local_annotation ?? database_comment`, recorded in `note_source`. On engines where `capabilities_for(db_type).comment == false` (SQLite, ClickHouse) the annotation layer is the only prose channel, and the UI states this once rather than offering an inert "read from database" affordance.
### 5. DBML serialization (`crates/dbx-core/src/docs/dbml.rs`)
A hand-written serializer, roughly 250 lines. The `@dbml/core` npm package is unusable from Rust and the grammar is small enough not to warrant a dependency.
```dbml
Project ecommerce {
database_type: 'PostgreSQL'
Note: '''Generated by DBX on 2026-08-02'''
}
Enum order_status { pending shipped cancelled }
TableGroup order_management [color: #c2410c] {
core.orders
core.order_items
Note: '''Everything from checkout to carrier handoff.'''
}
Table core.orders {
id integer [pk, increment]
user_id integer [not null, note: 'Owning customer']
status order_status [not null, default: 'pending']
placed_at timestamptz [default: `now()`]
Indexes {
(user_id, placed_at) [name: 'idx_orders_user_placed']
}
Note: '''One row per completed checkout.'''
}
Ref fk_orders_user: core.orders.user_id > core.users.id [delete: restrict]
```
Rules:
- **Types pass through verbatim.** DBML does not validate type names, so `timestamptz`, `jsonb` and `numeric(10,2)` survive. Where `data_type` arrives bare, precision is reconstructed from `character_maximum_length` / `numeric_precision` / `numeric_scale`.
- **Cardinality comes from `relations.rs`**: `>` for many-to-one, `-` for one-to-one.
- **Schema qualification is conditional** — bare names for a single-schema snapshot (it reads better), fully qualified for multi-schema, since DBML has no `search_path`.
- **Escaping** — identifiers quoted when not `[A-Za-z_][A-Za-z0-9_]*`; notes always in `'''triple quotes'''` so apostrophes cannot break the file; expression defaults in backticks, literals in single quotes.
- **Group colour** is converted from hue to hex at the theme-neutral lightness.
**DBML is lossy and this is documented in the output.** It cannot express check constraints, partial or filtered indexes (`IndexInfo.filter`), included columns, collations, or generated columns. Composite foreign keys are emitted in the grouped `(a, b) > (c, d)` form, whose support across dbdocs/dbdiagram is uneven. Every omission produces a `SnapshotWarning`, and the HTML documentation renders the omitted item with a "not in DBML" marker.
The asymmetry is intentional: **DBML is the interchange format; the HTML documentation is the complete record.** Treating DBML as a source of truth would silently drop constraints.
### 6. Docs viewer (`apps/desktop/src/docs/`)
```
main.ts — standalone entry; reads window.__DBX_DOCS__, mounts
DocsApp.vue — root; hash routing
components/
DocsShell.vue — sidebar + main
DocsSidebar.vue — Group by: Schemas | Table Groups switch, trees, enums
DocsSearch.vue — ⌘K palette, client-side fuzzy over tables/columns/groups
WikiIndex.vue — stats, project note, per-group table index
TablePage.vue — orchestrates the sections below
ColumnTable.vue — columns grid; inline note editing when !readonly
IndexList.vue RelationshipList.vue GroupHeader.vue EnumPage.vue
GroupEditorDialog.vue — name/note/hue/members; rendered only when !readonly
ErdView.vue — pan/zoom SVG with group containers; reuses layoutDiagramTables()
NoteMarkdown.vue — marked → sanitized HTML
WarningBanner.vue — renders snapshot.warnings
__tests__/ — fixture snapshots in, rendered output asserted
```
**The contract: this directory makes no backend calls.** No `api.*`, no Tauri, no Pinia stores. Snapshot in, DOM out. This is what allows the same code to run inside an HTML file on a machine that has never installed DBX, and what makes the entire documentation UI testable without a database.
Editing is expressed as a `readonly` prop — `true` in exported HTML, `false` in-app. Components emit `@update:note`, `@update:group` and `@update:table-group`; `DatabaseDocsDialog.vue` performs the writes. `GroupEditorDialog.vue` is not rendered at all when `readonly`, so the exported HTML never ships an editor that cannot save.
Six further decisions:
- **Hash routing** (`#/table/core.orders`), not the History API, which breaks over `file://`. Also makes every table deep-linkable.
- **Raw HTML is stripped from markdown.** `marked` removed `sanitize` in v5 and DOMPurify is not in the tree. Notes come partly from `COMMENT ON` values living in the database, which is not always under the document author's control, and the output is a file people forward to colleagues. `marked` is configured to escape raw HTML: markdown formatting works, `<script>` and `<img onerror>` do not.
- **The information architecture follows dbdocs**: the Wiki tab is a scrolling per-group index whose Table Notes column renders **full markdown inline** (headings, prose, code blocks), with table detail pages behind it — not a bare list of links.
- **Theme tokens are a curated subset**, not `globals.css` (2362 lines). The viewer has its own Tailwind v4 entry scanning only `src/docs/**` plus roughly 25 hand-picked tokens matching DBX's variable names, so it inherits live tokens in-app and ships its own values (with `prefers-color-scheme` and a manual toggle) when exported.
- **No Shiki in the exported bundle.** View definitions render in a plain `<pre>`. Shiki's grammars would dominate the artifact weight for a feature nobody generates documentation to obtain.
- **i18n uses a `docs` namespace only** — roughly 40 keys, imported directly, never `i18n/locales/en.ts` wholesale, or the standalone bundle inherits the whole application translation table. All 8 locales ship, selected from `navigator.language`, with a switcher.
### 7. Static HTML export (`crates/dbx-core/src/docs/html.rs`)
Output is `HTML shell + inlined CSS + inlined viewer bundle + window.__DBX_DOCS__ = {snapshot}`.
The viewer bundle is produced by `pnpm build:docs-viewer` into `crates/dbx-core/src/docs/assets/docs-viewer.js`, and **that artifact is committed to git**. `cargo build` never invokes Vite; it only `include_str!`s what is present.
This is the one piece of build coupling in the design, accepted deliberately:
- Rust-only contributors can still `cargo build` without a Node toolchain.
- The artifact is reviewable in diffs.
- A CI job rebuilds the bundle and fails if the committed copy is stale.
The cost is a generated file in the tree that must be regenerated when the viewer changes. The CI check is what makes that safe.
**Budget: under 400 KB uncompressed** for a self-contained page documenting a 50-table schema, asserted by test (Section 11). If the real figure lands materially above this, the correct response is to question the Vue-in-a-file approach, not to ship something sluggish.
### 8. Desktop integration (`apps/desktop/src/components/docs/DatabaseDocsDialog.vue`)
The dialog owns all I/O: connection/database/schema selection, invoking the collector, progress and cancellation, note and group writes, the orphan re-map panel, and the two export actions. It mounts `<DocsApp :snapshot :readonly="false">`.
Entry point follows `SchemaDiagramDialog.vue` — a dialog reached from the connection context menu and the toolbar, not a new tab kind.
### 9. CLI (`crates/dbx-cli/src/main.rs`)
```
dbx docs <connection> --out <dir-or-file> [--schema s] [--database d]
[--notes path] [--tables a,b] [--open]
dbx dbml <connection> [--out path] [--schema s] [--database d] [--notes path]
```
`dbx docs` writes a self-contained `index.html`. `dbx dbml` writes to `--out` or stdout so it pipes.
The existing `--format table|json|csv` flag is **not** extended with `html|dbml`. That flag means "how do I tabulate result rows"; overloading it would invite `dbx query --format dbml`. Two verbs, one output each, matching the existing `dbx schema list` / `dbx schema describe` style.
Target CI shape:
```yaml
- run: dbx dbml prod --notes docs/dbx-docs.json --out docs/schema.dbml
- run: git diff --exit-code docs/schema.dbml # migration drift fails the build
```
### 10. Error handling
| Situation | Behavior | Rationale |
| --- | --- | --- |
| Notes file absent | Proceed with empty notes | First CI run, or a user who has not annotated yet. Not an error. |
| Notes file malformed | **Hard fail**, non-zero exit | User prose is at stake. Proceeding would render apparently-complete docs while discarding writing. |
| One table's metadata fails | `SnapshotWarning::TableSkipped`, continue | A permission gap on one table must not kill a 400-table build. |
| Engine lacks FK metadata | Warning banner; ERD with no edges | ClickHouse, Doris, StarRocks. Honest degradation over a blank page. |
| Engine lacks comment support | Stated once in the UI | SQLite, ClickHouse — via `capabilities_for()`. |
| Orphaned notes after rename | Warning + re-map panel with suggestions | Section 4. |
| DBML-inexpressible construct | Warning + "not in DBML" marker in HTML | Section 5. |
| Connection failure | Existing error path | Already solved. |
| Stale committed viewer bundle | CI job fails | Guards the Section 7 build coupling. |
The through-line: **fail loudly when user-authored content is at risk; degrade visibly when the database simply cannot answer.**
### 11. Testing
**Rust**
- DBML snapshot tests following `crates/dbx-core/src/sql_dialect/snapshots/`.
- `relations.rs`: FK with unique source → `OneToOne`; without → `ManyToOne`; composite FK grouping; self-referencing FK.
- `annotations.rs`: merge precedence; per-engine key folding; orphan detection; `RenameCandidate` suggestion ranking; group assignment and hue round-trip through DBML hex.
- Live-engine tests as `crates/dbx-core/tests/live_postgres_docs_snapshot.rs`, matching the existing `live_postgres_query_result_export.rs` convention.
**Frontend** (`packages/app-tests/`, following `erDiagram.test.ts` and `diagramSvgExport.test.ts`)
- Every docs component rendered against **fixture snapshot JSON** — no database, no Tauri. This is the payoff of the Section 6 contract.
- `NoteMarkdown` tested explicitly against `<script>`, `<img onerror=…>` and `javascript:` hrefs.
- **Self-containment test**: generate HTML from a fixture and assert zero `http://` / `https://` in any `src`, `href` or `@import`. This catches a stray CDN font before it breaks someone's offline documentation.
- **Artifact size assertion** against the 400 KB budget, so bloat fails a test rather than accumulating.
- Group colour contrast: assert computed `--group-c` meets the contrast floor against both grounds **across all 360 hues**, not only the 12 curated swatches — the slider makes every hue reachable, so the guarantee must hold for every one of them.
- `GroupEditorDialog` is absent from the rendered tree when `readonly` is true.
### 12. Deferred: the versioning seam
The Changelog tab and per-table "Last Updated" are out of scope, but the design must not preclude them. Three constraints carry forward:
1. `SchemaSnapshot` is `Serialize + Deserialize` and carries `format_version` and `generated_at`, so it is already storable as a version record.
2. `crates/dbx-core/src/schema_diff.rs` (8728 lines) already computes `TableDiff` / `ColumnDiff` / `ForeignKeyDiff` between two schemas. A future Changelog is a storage and UI problem, not an algorithm problem.
3. The docs viewer's tab strip reserves the Changelog slot.
No storage, retention policy, or snapshot history is built in this change.
## Build Order
Each step is independently verifiable; steps 1–2 have standalone value if work stops there.
1. `SchemaSnapshot` + `collector.rs` + `relations.rs` — pure data, fully testable, no UI.
2. `dbml.rs` + `dbx dbml` — first shippable user value, small surface.
3. `annotations.rs` — notes, groups, merge, orphan handling.
4. Docs viewer against fixture snapshots — UI with no backend at all.
5. `DatabaseDocsDialog.vue` — wire into DBX.
6. `html.rs` + committed bundle + `dbx docs` — the build coupling last, once the UI has settled.
## Versioning and Documentation
- Version bump `0.5.73 → 0.6.0` (feature, pre-1.0) in `package.json`.
- Changelog entry through the existing CDN JSON flow (`crates/dbx-core/src/changelog.rs`), not a root `CHANGELOG.md` — this repository does not use one.
- A feature page in the `docs/` Next.js site.
- A `docs` i18n namespace across all 8 locales (`en`, `es`, `it`, `ja`, `ko`, `pt-BR`, `zh-CN`, `zh-TW`).
## Validated Mock
The UI was prototyped and approved before this spec was written. The throwaway mock covered: the wiki index with inline markdown notes, a table page with the shadowed-note marker, table groups across sidebar/index/diagram/search, the `Group by` switch, group containers in the ERD, the DBML-lossiness banner, and both themes. It is not part of the implementation.
@@ -1,218 +0,0 @@
# Database documentation in DBX — design
**Part 3b of the database-documentation feature.** Parts 1, 2 and 3a are complete and merged into
`feature/docs-snapshot-dbml`: `SchemaSnapshot` collection with a DBML serializer and a `dbx dbml`
verb, a file-backed annotation store for notes and table groups, and a read-only Vue viewer tested
against a real backend-generated fixture.
This part makes the viewer reachable and editable inside DBX. The standalone HTML export, the
`dbx docs` verb and hash routing are deliberately **not** here — they are Part 3c.
## Goal
A user opens a connection's documentation from the tree, reads a dbdocs.io-style wiki of their
schema, and edits it in place: table and column notes, table groups, and each group's colour.
Everything they write persists to a JSON notes file that can live inside their repository.
## Scope
**In scope**
- `DatabaseDocsDialog.vue`, mounted through the existing dialog pattern
- Backend reachability: the desktop frontend currently cannot call the docs collector at all
- Editing: table notes, column notes, project note, group create/rename/delete/assign, group hue
- Debounced autosave to the notes file, with a visible saved/failed indicator
- `EnumPage.vue`
- An i18n `docs` namespace across all 8 locales, with a parity guard
- A link from the docs viewer to the existing `SchemaDiagramDialog`
**Out of scope (Part 3c)**
- The standalone Vite bundle and self-contained HTML export
- The `dbx docs` CLI verb
- Hash routing (`#/table/public.orders`) — its purpose is surviving a `file://` load
- A minimal read-only ER renderer for the export
- Inlining the Geist woff2 at `apps/desktop/public/fonts/geist-latin-wght-normal.woff2`
**Explicitly not built**: a second ER diagram. DBX already ships `SchemaDiagramDialog.vue`
(51.6 KB) with custom relationships, join-SQL generation and zoom. In-app, the docs viewer links
to it. Only the export needs its own minimal renderer, because that dialog reaches into stores and
the backend and therefore cannot be inlined.
## Architecture
### The viewer stays pure; the dialog owns all I/O
Part 3a's contract test forbids `apps/desktop/src/docs/**/*.vue` from referencing `@/lib/backend`,
`@tauri-apps`, `invoke(`, `fetch(`, or any store. Editing preserves that rule: components emit
edit intents upward, and only `DatabaseDocsDialog.vue` — which lives outside `src/docs/` — talks to
the backend.
This is not ceremony. It is what allows Part 3c to bundle the identical components into an HTML
file that runs on a machine with no DBX installed.
```
connectionStore.docsSource set by the connection-tree context menu
→ useDialogSources.ts watcher mirrors the existing diagramSource pattern
→ showDocsDialog + prefills
→ DatabaseDocsDialog.vue owns: collect, load notes, save notes, autosave
│ props: snapshot, annotations ↓ emits: update:note, update:group, …
└─ DocsApp.vue Part 3a, unchanged apart from readonly=false
```
### Four backend operations, five touchpoints each
`apps/desktop/src/lib/backend/api.ts` is a facade that lazily resolves either `tauri.ts` or
`http.ts` via `isTauriRuntime()` and re-exports each function through `forward()`. Every operation
therefore exists twice, once per transport. There is currently **no docs entry in `api.ts` at
all** — Part 1's collector is reachable from the CLI, MCP and the web route, but never from the
desktop frontend.
| Operation | Returns | Existing | New |
|---|---|---|---|
| `collectDocsSnapshot` | raw `SchemaSnapshot` | web route | Tauri command, both transports, `api.ts` forward |
| `loadDocsAnnotations` | `AnnotationFile \| null` | `load_annotations` | path resolution, command, routes, forward |
| `applyDocsAnnotations` | applied `SchemaSnapshot` | `apply_annotations` | command, routes, forward |
| `saveDocsAnnotations` | `()` | — | **`save_annotations` does not exist** |
### Why `applyDocsAnnotations` is a separate call
The viewer renders a snapshot with annotations already applied, but edits target the
`AnnotationFile`. After an edit the displayed snapshot is stale.
Applying the edit optimistically in TypeScript would mean reimplementing `apply_annotations`,
including the `shadowedNote` rule where a local note displaces a database `COMMENT ON` and
preserves it underneath. A second implementation of that rule in a second language is exactly the
drift this feature has repeatedly suffered from.
Instead, `collect` returns the **raw** snapshot, and `applyDocsAnnotations(raw, file)` is a pure
Rust call with no database access. The dialog keeps the raw snapshot from open and re-derives on
demand. One implementation of the rule, in the language where it is already tested.
While the user types, nothing is re-derived: the editor binds to the raw note text in the
`AnnotationFile`, not to rendered output. The round trip happens on blur, not per keystroke.
### Notes file location
`config.docs_notes_path` when set; otherwise `<app_data>/docs-notes/<connection_id>.json`.
The default makes the feature work with no setup. The override is the point: pointing it at a file
inside a repository is what lets schema documentation be code-reviewed and committed alongside the
schema it describes.
`docs_notes_path` already exists on `ConnectionConfig`, `ConnectionConfigData` and the `From` impl
(Part 2). Nothing reads it yet.
### `save_annotations` must be atomic
Write to a temporary file in the same directory, fsync, then rename. A partial write destroys prose
a human typed, and `load_annotations` errors loudly on malformed JSON — so a torn write becomes
"your notes file is corrupt" the next time the dialog opens.
A failed save must surface in the UI. A silently swallowed write failure is the worst outcome this
feature can produce.
## Editing
### Edits are pure transformations of the `AnnotationFile`
`apps/desktop/src/docs/annotationEdits.ts` — every function takes an `AnnotationFile` and returns a
new one, with no I/O:
- `setProjectNote(file, note)`
- `setTableNote(file, tableKey, note)`
- `setColumnNote(file, tableKey, column, note)`
- `setTableGroup(file, tableKey, groupId | null)`
- `upsertGroup(file, group)` — create or update name, hue, note
- `removeGroup(file, groupId)` — also clears the reference from every table that used it
Empty or whitespace-only notes remove the entry rather than storing an empty string, so the file
does not accumulate dead keys.
**Deleting a group clears its references.** `docsIndex.ts` already drops a dangling `groupId` when
rendering, so the viewer degrades correctly either way — but the file should not carry references
to a group that no longer exists.
### Components
All thin; every decision lives in `annotationEdits.ts` or an existing Part 3a module.
- **`NoteEditor.vue`** — renders `renderNote(...)` output until clicked, then swaps to a raw
markdown textarea. Reused for table, column, group and project notes. The `v-html` contract rule
applies here as everywhere: only `renderNote` output may be bound.
- **`GroupEditor.vue`** — name field plus the hue picker from the approved mock: 12 swatches, a
360° slider, and a preview showing the colour on light and dark grounds simultaneously. The
two-ground preview is why the hue-only model exists, so it belongs in front of the user while
they choose.
- **`GroupPicker.vue`** — assigns a table to a group; a select plus "New group…".
- **`EnumPage.vue`** — a `DocEnum`'s values, its note, and the columns using it. The reverse lookup
from enum to using columns is a tested function in `docsIndex.ts`, not template logic.
### `readonly` returns
Part 3a removed the `readonly` prop as YAGNI, since nothing read it. Part 3b reintroduces it
because Part 3c renders the identical components with editing disabled.
### Autosave
Debounced ~500 ms after the last edit, owned by the dialog. A small indicator shows saved / saving
/ failed. Failure is visible and the in-memory state is retained so the user can retry rather than
losing what they wrote.
## i18n
A `docs` namespace added to all 8 locales: `en`, `es`, `it`, `ja`, `ko`, `pt-BR`, `zh-CN`, `zh-TW`.
`withEnglishFallback` deep-merges `en` underneath every locale, so a key missing from `ja.ts`
silently renders English with no error, no warning, and no failing test. Translation completeness
is therefore unobservable at runtime.
**A key-parity test is required**: import the raw locale modules — not the merged output — and
assert every locale declares the same key set under `docs` as `en` does. Scoped to the new
namespace only; parity across the existing 315 KB of keys is out of scope and would fail
immediately.
Part 3a hardcoded English strings in `docsWarnings.ts`. Those move into the namespace, which is a
breaking change to that module: `describeWarning(warning)` becomes
`describeWarning(warning, translate)`, and its existing tests change with it.
It takes a **translator function** rather than importing `useI18n` directly, and that is
deliberate. `useI18n()` throws without a provided instance, which is exactly the Part 3c
standalone-export case — a bare HTML file with no Vue app bootstrapped around it. Passing the
translator in keeps `docsWarnings.ts` a pure module the export can call with an English identity
function. Importing vue-i18n inside `src/docs/` would make the export impossible.
## Testing
| Layer | Approach |
|---|---|
| `annotationEdits.ts` | Pure unit tests — the bulk of the coverage |
| `save_annotations` | Rust: atomicity; a failed write leaves the previous file intact |
| Path resolution | Rust: override wins; default when unset |
| Components | The Part 3a static SFC contract test, extended to the new files |
| i18n | Key parity across 8 locales for the `docs` namespace |
| Dialog | Autosave debounce; the failure indicator |
**Every new test gets the deliberate-break treatment**: break the behaviour, watch the named test
fail, restore, report the failure message. In Part 3a this caught a vacuous ranking test, a CSS
ordering bug that let a whole theme's colours be deleted silently, and a capability warning that
contradicted its own data. It is the highest-yield practice carried forward.
### Known gap: `DocEnum` drift is unguarded
The conformance fixture is generated from `keycloak`, which declares no PostgreSQL enum types, so
`snapshot.enums` is empty and `DocEnum` field renames are not detectable. `EnumPage` tests use
in-memory snapshots, which prove the rendering logic but not the Rust↔TS boundary.
`fixtureConformance.spec.ts` carries a self-correcting assertion that fails the day the fixture
source gains an enum, with the exact `pinShape` call to restore. Part 3c is the natural time to
close this, since an exportable demo wants an enum-bearing schema anyway.
## Success criteria
- A user opens documentation from the connection tree, reads it, edits notes and group colours, and
the changes survive closing and reopening the dialog.
- Pointing `docs_notes_path` at a repository file produces a diff a colleague can review.
- `apps/desktop/src/docs/**/*.vue` still makes zero backend calls, enforced by the contract test.
- All 8 locales declare the same `docs` keys, enforced by a test.
- A failed save is visible to the user and does not discard their work.
@@ -1,302 +0,0 @@
# Standalone documentation export — design
**Part 3c of the database-documentation feature.** Parts 1, 2, 3a and 3b are complete on
`feature/docs-snapshot-dbml` and open upstream as PR #5559: `SchemaSnapshot` collection with a DBML
serializer and a `dbx dbml` verb, a file-backed annotation store, a pure Vue viewer, and that
viewer mounted in DBX with autosaved editing.
This part makes the documentation leave the application: one self-contained HTML file, produced by
a `dbx docs` verb and by a button in the app, that opens from `file://` on a machine with no DBX
installed.
## Goal
A user runs `dbx docs prod --out schema.html` in CI, or clicks **Export HTML** in the docs dialog,
and gets a single file they can commit, attach to a ticket, or email to a colleague. It opens with
no network, no server and no install. Deep links into it survive a reload, and the reader chooses
their own language.
## Scope
**In scope**
- A standalone Vite bundle of the existing viewer, built to one JS and one CSS
- `crates/dbx-core/src/docs/export.rs` — `to_standalone_html(snapshot, annotations, lang)`
- The `dbx docs` CLI verb, mirroring `dbx dbml`'s flags
- A Tauri command and an **Export HTML** button in `DatabaseDocsDialog.vue`
- Hash routing in the export shell, so `schema.html#/table/public.orders` is a link
- A language switcher over all 8 bundled `docs` namespaces
- `SchemaDiagram.vue` — a minimal read-only ER renderer
- A staleness guard so the committed bundle cannot drift from its sources
**Out of scope**
- Editing in the export. It is read-only; `readonly` exists on `DocsApp` for exactly this.
- Zoom, pan controls, drag, custom relationships or join-SQL in the diagram — that is what the
full `SchemaDiagramDialog` is for.
- The `latin-ext` font subset. The latin subset is inlined; `latin-ext` doubles the font cost to
serve characters most schemas never use, and the fallback is graceful.
- Persisting the reader's language choice. See "Language" below.
## Decisions taken before design
| Question | Decision |
|---|---|
| How does the built bundle reach the Rust binary? | Committed build artefact in `crates/dbx-core/assets/`, embedded with `include_str!`, with a staleness guard |
| Where does the ER renderer live? | `src/docs/`, with the host choosing the affordance |
| What language is the export? | All 8 namespaces bundled, switchable by the reader |
| Is there an in-app export button? | Yes — same Rust function, two callers |
| How is a stale bundle detected? | Content hash against a manifest, derived from the build |
| How is the payload embedded? | base64 |
## Architecture
### Three pieces, each with one job
```
apps/desktop/src/docs-export/ the export shell (browser)
main.ts mounts ExportApp, decodes the payload
ExportApp.vue hash routing, language switcher, readonly=true, diagram="inline"
export.css @source narrowed to ../docs/** + inlined @font-face
pnpm build:docs-export vite.docs-export.config.ts
→ one JS, one CSS, font as data URI, no code splitting
→ crates/dbx-core/assets/docs-export.{js,css,manifest.json}
crates/dbx-core/src/docs/export.rs to_standalone_html(snapshot, annotations, lang)
→ include_str! the two assets, base64 the payload, emit one HTML file
```
### Why the bundle is committed rather than built by cargo
Rust cannot run Vite, and `dbx docs` must work from a plain `cargo install` on a machine with no
Node. A `build.rs` that shells out to pnpm would break exactly that case, and neither existing
`build.rs` invokes an external toolchain.
There is partial precedent: `crates/dbx-core/src/ai_pi_agent_cli.rs:24` embeds
`include_str!("../assets/pi-mcp-bridge.mjs")`, and `crates/dbx-core/assets/` already carries a
54.8 KB driver manifest. The caveat is honest and belongs in the PR: `pi-mcp-bridge.mjs` is
hand-written, so the precedent covers committing a *source* asset, not a *build artefact*. The
staleness guard is what makes the difference acceptable.
### Data flow
`dbx docs` collects a snapshot exactly as `dbx dbml` does — same collector, same `--schema`,
`--database`, `--tables`, `--notes` flags, same warnings on stderr through the `Display` impl. It
then calls `to_standalone_html`. The Tauri command calls the identical function.
The snapshot is passed **with annotations already applied**, because `apply_annotations` is Rust
and the export has no Rust at runtime. The raw `AnnotationFile` also travels, for the same reason
the dialog needs it: `snapshot.groups` holds resolved `TableGroup`s, while `annotations.groups`
holds the `GroupAnnotation` records that carry each group's hue.
### The payload is base64
The exported page contains user-authored Markdown. A note containing the literal text `</script>`
— entirely plausible in a schema document that discusses HTML — would terminate the script element
early and inject the remainder as markup.
base64 removes the problem structurally rather than procedurally: the alphabet is `A-Za-z0-9+/=`,
so no escaping rule exists to forget, and the property is auditable by inspection instead of by
reviewing every serialization path. It costs +33% on the JSON only.
The alternatives were considered and rejected. A `<script type="application/json">` in which every
`<` is rewritten as its JSON unicode escape (backslash, `u`, `003c`) is what SSR frameworks do and
is sound, but its correctness depends on that rewrite being applied on every path. A JS string literal needs four independent escaping rules —
quotes, backslashes, `</script>`, and U+2028/U+2029, which `JSON.stringify` emits raw because they
are valid JSON but which were line terminators inside JS string literals until ES2019.
The cost of base64 is that the embedded schema is no longer readable in a text editor. That is
accepted.
### Styling: `globals.css` cannot be reused
Two facts decide this.
`apps/desktop/src/styles/globals.css:40` is `@source "../**/*.{vue,ts,tsx,js,jsx,html}"`. Tailwind
v4 scans the entire application, so importing `globals.css` into the export would emit every
utility used anywhere in DBX. The 76 KB of source is not the cost; the generated utility surface
is. `@source` is additive — there is no way to un-source it.
Its four `@font-face` blocks use absolute `/fonts/…` URLs, which under `file://` resolve against
the filesystem root and fail silently to a system font.
So `export.css` is its own entry: `@source` narrowed to `../docs/**`, and `@font-face` redeclared
with the woff2 inlined as a data URI (~38 KB base64).
### One shared-file change: `tokens.css`
The viewer's utilities resolve against tokens — `--background`, `--foreground`,
`--muted-foreground`, `--border`, `--ring` — defined in `globals.css` from line 131, interleaved
with roughly 2300 lines of application-specific rules. A narrowed `@source` still needs those
tokens.
The token blocks move to `apps/desktop/src/styles/tokens.css`, imported by **both** `globals.css`
and `export.css`. One definition, no drift. Copying the values into the export instead is exactly
the duplication that produced this feature's recurring defects.
**This is a real risk and the plan must treat it as one.** It edits a 76 KB stylesheet the whole
application depends on, on an already-large branch. The move is mechanical — a contiguous block
plus one `@import` — but the cascade order between `:root` and `.dark` must be preserved exactly.
A test asserts every custom property defined at `:root` before the extraction still resolves after
it.
## The export shell
### Hash routing stays out of the shared tree
`DocsApp` owns navigation internally today: `activeKey`, `activeEnumName`, and a computed
`view: "index" | "table" | "enum"`. It gains an **optional** `v-model:route`:
```ts
type DocsRoute =
| { kind: "index" }
| { kind: "table"; key: string }
| { kind: "enum"; name: string }
| { kind: "diagram" };
```
Absent, `DocsApp` behaves exactly as it does today and the dialog is untouched. Present, the shell
controls navigation and mirrors `location.hash`.
`{ kind: "diagram" }` is only reachable when `diagram="inline"`. Under `diagram="external"` the
route resolves to the index, so a hash of `#/diagram` in the dialog's context cannot render a view
that host has deliberately not enabled.
This separation is not stylistic. DBX has **no router at all** — `vue-router` is not a dependency,
and the only `location.hash` reference in the frontend is a debug logger at
`lib/backend/debugLog.ts:214`. A `DocsApp` that wrote to the URL would hijack the host
application's address bar.
Hash grammar, with `encodeURIComponent` on the identifier so a table named `a/b` round-trips:
```
#/ index
#/table/public.orders table page
#/enum/order_status enum page
#/diagram ER diagram
```
Hash routing rather than `pushState` because the target is `file://`, where `pushState` is
unusable but a fragment survives a reload and makes `schema.html#/table/public.orders` a link
worth pasting.
**Parsing is defensive by contract.** An unparseable hash, or one naming a table or enum absent
from the payload, resolves to the index. A stale deep link into a since-dropped table is the
expected case, not an exotic one, and must never produce a blank page.
### Language
All 8 `docs` namespaces are bundled. They total 15,197 bytes of source across
`apps/desktop/src/i18n/locales/docs/{en,es,it,ja,ko,pt-BR,zh-CN,zh-TW}.ts` — roughly 3 KB gzipped,
negligible against the bundle. The payload carries an initial `lang` from `--lang` or the app's
current locale; the switcher rebuilds `translate` from the bundled namespace.
**No persistence.** Under `file://` every document shares one opaque origin, so `localStorage`
would leak one export's preference into an unrelated one. A single-session document is the honest
model.
**Missing keys fall back to English.** The parity test guarantees all 8 namespaces agree, so this
should never fire; it exists so an artefact opened offline degrades to English rather than
rendering a raw key. This does not repeat the Part 3b hazard where a fallback masked drift — there
the fallback replaced the guard, here the guard runs in CI and the fallback is a runtime backstop.
The switcher is worth having because **the reader is usually not the exporter**: the file gets
sent to someone else, and upstream's largest user base does not read English.
### The ER renderer
`SchemaDiagram.vue` lives in `src/docs/` and is added to the contract test's expected component
list. It imports `layoutDiagramTables` from `@/lib/diagram/erDiagram` — permitted, since only
`@/lib/backend` is forbidden, and that module has exactly one import which is `import type`,
making it fully bundleable.
So the grid layout is reused, not reinvented. What is genuinely new is **edge routing**, which
exists nowhere in the codebase today: a straight line between two cards' centres, clipped at each
card's border so it terminates on the edge rather than under the box. Crossings are tolerated.
Deliberately minimal: SVG at natural size inside an `overflow: auto` container, because scrolling
*is* panning. No zoom, no drag, no custom relationships, no join-SQL. Group colours come from
`groupStyle(hue)` so the diagram agrees with the wiki, and clicking a table navigates to its page —
which under hash routing makes the diagram a usable table of contents.
The host chooses the affordance. `DatabaseDocsDialog` passes `diagram="external"` and keeps its
existing button to the full `SchemaDiagramDialog`; the export passes `diagram="inline"`. No screen
shows both, and neither host gets a worse diagram than it could have.
## The staleness guard
`pnpm build:docs-export` emits `docs-export.js`, `docs-export.css` and `docs-export.manifest.json`
into `crates/dbx-core/assets/`.
**The manifest is derived from Rollup's module graph, not from a hand-written glob.** This is the
single most important detail in this section. `SchemaDiagram.vue` imports `erDiagram.ts`, which
lives outside `src/docs/` — a glob of `src/docs/**` would miss it and the guard would pass while
the artefact was stale. That is the same defect shape that has now bitten this feature three times:
`vue-i18n` absent from the contract test's forbidden list, `DocEnum` unpinned by an enum-free
fixture, `.docs-ground-light` absent from the CSS selector list. **A guard that enumerates its
targets silently excludes everything added later.**
So the build script writes the manifest from every repo file that actually entered the bundle,
plus the resolved versions of the node_modules packages it pulled in.
This closes the loop on new files, and the reason should be stated in the plan rather than left
implied: a new module can only enter the bundle by being imported, which means editing an existing
file, which changes that file's hash. The one hole is `import.meta.glob`, which would add modules
without editing an importer — the viewer does not use it, and the plan says so explicitly.
The Rust test recomputes the hashes and fails with the regeneration command. It must skip when the
crate is consumed from a published package where `apps/desktop/` does not exist — and that skip is
itself a hazard, since a vacuous skip in CI would silently disable the guard. The skip therefore
keys off a repository-only marker (`pnpm-workspace.yaml`): marker present, the test runs; marker
absent, the crate is genuinely packaged and there is nothing to check.
## Error handling
- `--out` omitted writes to stdout, matching `dbx dbml`. A 400 KB HTML in a terminal is unpleasant,
but consistency and `dbx docs prod | gzip > docs.html.gz` are worth more than a special case.
- `--lang xx` fails with the list of valid locales rather than falling back. A typo should be loud,
the same reasoning that made `--notes` an explicit flag in Part 2.
- A missing or malformed notes file behaves exactly as `dbx dbml` does, through the existing
`require_notes_file`.
## Testing
| Layer | Approach |
|---|---|
| `to_standalone_html` | Rust: base64 round-trip; a note containing `</script>` survives intact |
| Self-containedness | Rust: the emitted HTML contains no `http://`, `https://`, `url(/`, or external `src`/`href` |
| Route parsing | TS: pure parse/format — unknown table resolves to index, encoded identifiers, empty hash |
| Language | TS: the translate factory picks the right namespace; unknown lang falls back to English |
| Edge clipping | TS: the line-to-card-border intersection is a pure function |
| Contract | `SchemaDiagram.vue` added to the expected component list |
| Token extraction | Every custom property defined at `:root` before the move still resolves after it |
| Staleness | The manifest test, verified by touching a source file and watching it fail |
**Every new test gets the deliberate-break treatment**: break the behaviour, watch the named test
fail, restore, report the failure message. Across Parts 3a and 3b this caught a vacuous ranking
test, a CSS ordering bug that let a whole theme's colours be deleted silently, a capability warning
that contradicted its own data, and an atomicity test that was vacuous because of the fix
instruction I had written myself.
### The test worth attempting but not promising
A `happy-dom` smoke test that loads the generated HTML, executes the inline bundle, and asserts a
known table name reaches the DOM is the only test that proves the artefact *works* rather than
merely looks right. `happy-dom ^20.10.6` is already a dependency; Playwright and Puppeteer are not.
happy-dom can execute scripts, but running a full Vue bundle in it is not guaranteed. If it works
it is the most valuable test in this part. If it does not, the plan says so plainly and falls back
to the structural self-containedness test plus opening the file in a real browser over `file://` —
the method already used to verify the viewer for PR #5559's screenshots.
## Success criteria
- `dbx docs prod --out schema.html` produces one file that opens from `file://` with no network, no
server and no DBX installed.
- `schema.html#/table/public.orders` survives a reload and can be pasted to a colleague.
- A reader switches language without re-exporting.
- The in-app button and the CLI produce byte-identical output for the same inputs, because they
call the same function.
- The committed bundle cannot drift from its sources without a test failing.
- `apps/desktop/src/docs/**/*.vue` still makes zero backend calls, enforced by the contract test.
@@ -1,150 +0,0 @@
# Documentation Menu at Database Scope Design
**Date:** 2026-08-07
## Goal
Move the "Documentation" context-menu entry from the object browser's table and
view menus to the sidebar's database and schema context menu, so the entry
appears at the scope it actually documents.
## Background
PR #5559 ("feat(docs): database documentation viewer and DBML export") shipped in
v0.5.77. It added the only entry point for the docs viewer to
`ObjectBrowser.vue`, in the table menu and the view menu, nested inside the
existing `canOpenDiagram` branch alongside "View Diagram".
That placement misrepresents what the action does. `openDocs(row)` in
`ObjectBrowser.vue:1466` reads only `row.schema`; the row's table is discarded.
`DatabaseDocsDialog.vue` accepts `prefillConnectionId`, `prefillDatabase`, and
`prefillSchema` — there is no table prop, so the viewer cannot deep-link to a
table even in principle. A user who right-clicks `orders` and picks
"Documentation" gets documentation for the whole schema.
The docs collector was designed for the wider scope. `DatabaseDocsDialog.vue:91`
carries the comment "An absent schema means 'everything the collector finds'",
and `connectionStore.docsSource.schema` is already optional. Database-scoped
documentation is the case the backend was built for and is currently
unreachable from the UI.
Users therefore look for the entry on the database node in the connections
sidebar, where "View Diagram" already sits, and do not find it.
## Requirements
- offer "Documentation" on sidebar database nodes, documenting the whole database
- offer "Documentation" on sidebar schema nodes, documenting that schema only
- remove the entry from the object browser's table and view menus
- keep the set of drivers that expose the entry identical to v0.5.77
- change no backend, dialog, or viewer code
- leave the "View Diagram" entries in both menus untouched
## Non-Goals
- deep-linking the viewer to a single table (would need a new dialog prop and a
viewer route change; a separate change if it is ever wanted)
- introducing a `docs` driver capability key (see Gating below)
- any change to `crates/dbx-core`
## Design
### Sidebar action
`useSidebarTreeToolRuntime.ts` gains `openDocs`, mirroring `openDiagram` at
line 55:
```ts
function openDocs() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
connectionStore.docsSource = {
connectionId: node.connectionId,
database: node.database,
schema: node.schema,
};
}
```
`node.schema` carries the whole behaviour. It is `undefined` on a database node,
so `DatabaseDocsDialog.vue:93` (`props.prefillSchema ? [props.prefillSchema] : []`)
sends an empty schema list and the collector documents the entire database. On a
schema node it is set, and the collector is limited to that schema.
`openDiagram` also passes `tableName`; `openDocs` does not. The docs dialog has
no corresponding prop, so passing it would be dead data.
### Menu registration
One `items.push` in `SidebarTreeRuntimeHost.vue`, immediately after the
"View Diagram" push at line 4205:
```ts
if (canOpenDiagram.value) {
items.push({ label: t("diagram.open"), action: openDiagram, icon: Network });
items.push({ label: t("docs.title"), action: openDocs, icon: BookOpen });
}
```
Line 4205 sits inside `buildDatabaseSidebarMenu` (lines 4147-4259), whose guard
is `node.type === "database" || node.type === "schema"`. A single push therefore
covers both node types. The other "View Diagram" push, at line 4483, belongs to
`buildObjectSidebarMenu` and is not touched.
`openDocs` is destructured from `useSidebarTreeToolRuntime` at line 312, and
`BookOpen` is added to the existing `@lucide/vue` icon import (lines 13-57).
### Object browser removal
`ObjectBrowser.vue` loses the docs entry from the table menu (line 2669) and the
view menu (line 2724), the `openDocs` function (line 1466), and the now-orphaned
`BookOpen` import (line 9). The `canOpenDiagram` ternaries collapse back to the
single-element form they had before PR #5559.
### Gating
The new entry reuses the sidebar's existing `canOpenDiagram` computed
(line 3386): `!!activeNode.value.database && supportsSchemaDiagram(currentDatabaseType())`.
This is a deliberate compromise rather than a principled gate. Documentation and
diagrams are separate features; a driver that supported one but not the other
would be misrepresented. The principled alternative is a `docs` key in
`DATABASE_PRODUCT_CAPABILITY_KEYS`, but that list is backed by
`crates/dbx-core/assets/database-drivers.manifest.json`, shared with the Rust
side, and would require a per-support-level default plus per-driver overrides
across every driver.
Reusing the diagram gate keeps this change's visible behaviour identical to
v0.5.77 except for *where* the entry appears, which is the point of the change.
A capability key is worth proposing separately; it is called out in the PR
description rather than bundled here.
## Testing
New spec `apps/desktop/src/composables/__tests__/useSidebarTreeToolRuntime.docs.spec.ts`,
following the `shallowRef` node plus stub-store pattern of the neighbouring
`useSidebarTreeExportRuntime.spec.ts`:
- database node: `docsSource` receives `connectionId` and `database`, and
`schema` is `undefined`
- schema node: `schema` is propagated
- node without `database`: `docsSource` is left untouched
No i18n work is required. `docs.title` was added to all eight locales by
PR #5559, so this change introduces no locale-drift exposure.
Verification:
```
pnpm typecheck
npx vitest run apps/desktop/src/composables apps/desktop/src/components/objects
```
## Delivery
Branch `fix/docs-menu-database-scope`, cut from `upstream/main` at `29fe57143`.
One commit, then a PR to `t8y2/dbx`.
No `VERSION` or `CHANGELOG.md` bump: this repository has no changelog, and
`package.json` version is managed by upstream's `scripts/release.mjs`. Version
numbers belong to the maintainer's release process, not to a contribution PR.