diff --git a/__tests__/commands-format.test.ts b/__tests__/commands-format.test.ts index c73c927..117486a 100644 --- a/__tests__/commands-format.test.ts +++ b/__tests__/commands-format.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { sanitizeRows } from "../commands/query"; import { formatRelationsList } from "../commands/relations"; import { formatFavoriteList } from "../commands/favorites"; @@ -114,9 +115,18 @@ describe("formatFavoriteList", () => { it("truncates long sql and appends description line", () => { const longSql = "SELECT * FROM " + "x".repeat(100); const out = formatFavoriteList([fav({ sql: longSql, description: "很长的一段说明" })]); - expect(out).toContain("..."); + expect(out).toContain("…"); expect(out).toContain("很长的一段说明"); }); + + it("中文收藏名与中文 SQL 按显示宽度对齐(不按码元)", () => { + // 中文名 "最近订单" 显示宽 8 列——若按码元 padEnd(18) 只补到 10 列, + // dbTag 起点会比 ASCII 名提前 8 列,列错位。 + const out = formatFavoriteList([fav({}), fav({ id: 2, name: "orders", database: "" })]); + const lines = out.split("\n").filter((l) => l.startsWith(" #")); + const tagStarts = lines.map((l) => visibleWidth(l.slice(0, l.indexOf("[")))); + expect(new Set(tagStarts).size).toBe(1); + }); }); // ====== formatEntry / entryToItem ====== @@ -147,6 +157,20 @@ describe("formatEntry / entryToItem", () => { expect(formatEntry(long, 8)).toContain("…"); }); + it("中文 SQL 按显示宽度截断/补齐——整行宽度与 ASCII 行一致", () => { + const cjk: HistoryEntry = { + ...entry, + sql: "SELECT * FROM 产品表 WHERE 产品名称 LIKE '%智能音箱%' AND 状态='在售' ORDER BY 创建时间 DESC", + }; + const out = formatEntry(cjk, 0); + // SQL 列固定 52 显示列:按码元 slice/padEnd 会撑到 ~104 列, + // 挤掉右侧的行数/耗时列(SelectList 按宽度截断后丢失)。 + expect(out).toContain("…"); + expect(out).toContain("100行"); + expect(out).toContain("0.010s"); + expect(visibleWidth(out)).toBe(visibleWidth(formatEntry(entry, 0))); + }); + it("converts to a SelectItem keyed by id", () => { const item = entryToItem(entry, 3); expect(item.value).toBe("42"); diff --git a/__tests__/display-width.test.ts b/__tests__/display-width.test.ts index 976e2f2..e2d3423 100644 --- a/__tests__/display-width.test.ts +++ b/__tests__/display-width.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import { visibleWidth } from "@earendil-works/pi-tui"; -import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; +import { + padToDisplayWidth, + truncateToDisplayWidth, + wrapToDisplayWidth, +} from "../formatting/display-width"; describe("truncateToDisplayWidth", () => { it("短文本原样返回", () => { @@ -42,3 +46,50 @@ describe("padToDisplayWidth", () => { expect(padToDisplayWidth("A".repeat(20), 10)).toBe("A".repeat(20)); }); }); + +describe("wrapToDisplayWidth", () => { + it("短文本不换行", () => { + expect(wrapToDisplayWidth("hello", 20)).toEqual(["hello"]); + }); + + it("空字符串返回单个空行", () => { + expect(wrapToDisplayWidth("", 10)).toEqual([""]); + }); + + it("ASCII 按列切分,每段 ≤ w 列", () => { + const lines = wrapToDisplayWidth("A".repeat(25), 10); + expect(lines).toEqual(["A".repeat(10), "A".repeat(10), "A".repeat(5)]); + }); + + it("中文按显示宽度切分(每字 2 列),不劈开字符", () => { + // 10 列 = 5 个汉字;13 个汉字 → [5 字, 5 字, 3 字] + const s = "产品名称订单详情编号测试好"; + const lines = wrapToDisplayWidth(s, 10); + expect(lines.map((l) => visibleWidth(l))).toEqual([10, 10, 6]); + expect(lines.join("")).toBe(s); + }); + + it("emoji(代理对)不被劈开", () => { + const s = "ab📦cd"; + const lines = wrapToDisplayWidth(s, 4); + expect(lines.join("")).toBe(s); + for (const l of lines) { + expect(visibleWidth(l)).toBeLessThanOrEqual(4); + } + // 📦 占 2 列且完整落在某一行,不产生替换符 + expect(lines.some((l) => l.includes("📦"))).toBe(true); + }); + + it("w < 1 时原样返回(退化兜底)", () => { + expect(wrapToDisplayWidth("abc", 0)).toEqual(["abc"]); + }); + + it("混合中英文时每段 ≤ w 列且拼接还原", () => { + const s = "UPDATE 产品表 SET 名称='新名称' WHERE id=12345"; + const lines = wrapToDisplayWidth(s, 12); + expect(lines.join("")).toBe(s); + for (const l of lines) { + expect(visibleWidth(l)).toBeLessThanOrEqual(12); + } + }); +}); diff --git a/commands/favorites.ts b/commands/favorites.ts index 04451cb..34bb15c 100644 --- a/commands/favorites.ts +++ b/commands/favorites.ts @@ -6,6 +6,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c import type { DatabaseWorkspaceService } from "../state/workspace"; import type { FavoriteEntry } from "../history/store"; import { READONLY_SQL_RE } from "../connection/sql-policy"; +import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; import { executeAndDisplay } from "./query"; // ── 列表格式化 ───────────────────────────────────────────── @@ -21,10 +22,13 @@ export function formatFavoriteList(entries: FavoriteEntry[], currentDb?: string) const lines = [`═══ 收藏查询 ${scope} — ${entries.length} 条 ═══`, ""]; for (const e of entries) { - const sql = e.sql.length > 55 ? e.sql.slice(0, 52) + "..." : e.sql; + // 名称/库标签/SQL 都按显示宽度对齐——中文名按码元 padEnd 会列错位 + const sql = truncateToDisplayWidth(e.sql, 55); const dbTag = e.database ? `[${e.database}]` : "[🌐 全局]"; - const desc = e.description ? ` — ${e.description.slice(0, 30)}` : ""; - lines.push(` #${String(e.id).padStart(3)} ${e.name.padEnd(18)}${dbTag.padEnd(14)}${sql}`); + const desc = e.description ? ` — ${truncateToDisplayWidth(e.description, 30)}` : ""; + lines.push( + ` #${String(e.id).padStart(3)} ${padToDisplayWidth(e.name, 18)}${padToDisplayWidth(dbTag, 14)}${sql}`, + ); if (desc) lines.push(` ${desc}`); } @@ -112,8 +116,8 @@ async function handleFavoriteList( } const labels = entries.map((e) => { - const sql = e.sql.length > 40 ? e.sql.slice(0, 37) + "..." : e.sql.padEnd(40); - return `#${String(e.id).padStart(3)} ${e.name.padEnd(18)} ${sql}`; + const sql = padToDisplayWidth(truncateToDisplayWidth(e.sql, 40), 40); + return `#${String(e.id).padStart(3)} ${padToDisplayWidth(e.name, 18)} ${sql}`; }); const choice = await ctx.ui.select("选择一个收藏", labels); diff --git a/commands/history.ts b/commands/history.ts index 14fb476..d2f78f9 100644 --- a/commands/history.ts +++ b/commands/history.ts @@ -16,6 +16,7 @@ import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; import type { DatabaseWorkspaceService } from "../state/workspace"; import type { HistoryEntry } from "../history/store"; +import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; import { withLoader } from "./utils"; import { executeAndDisplay } from "./query"; @@ -23,11 +24,13 @@ import { executeAndDisplay } from "./query"; export function formatEntry(entry: HistoryEntry, index: number): string { const time = entry.createdTime.replace("T", " ").slice(5, 19); // "MM-DD HH:MM:SS" - const sql = entry.sql.length > 52 ? entry.sql.slice(0, 49) + "…" : entry.sql; + // SQL 列按显示宽度截断/补齐——中文 SQL 按码元 slice/padEnd 会让 + // 行宽翻倍,后续列(行数/耗时)被 SelectList 截掉、列错位。 + const sql = padToDisplayWidth(truncateToDisplayWidth(entry.sql, 52), 52); return [ String(index + 1).padStart(2), time, - sql.padEnd(52), + sql, `${entry.rowCount}行`.padStart(5), entry.elapsed, ].join(" "); @@ -98,7 +101,7 @@ export async function handleHistory( case "🗑 删除": { const ok = await ctx.ui.confirm( "确认删除", - `SQL: ${selected.sql.length > 60 ? selected.sql.slice(0, 57) + "…" : selected.sql}`, + `SQL: ${truncateToDisplayWidth(selected.sql, 60)}`, ); if (ok) { ws.deleteHistory(selected.id); @@ -137,7 +140,7 @@ async function showHistorySelector( const selectList = new SelectList(items, Math.min(items.length, 12), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", theme.bold(t)), - description: (t) => theme.fg("dim", t.slice(0, 80)), + description: (t) => theme.fg("dim", truncateToDisplayWidth(t, 80)), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); diff --git a/commands/mutate-confirm.ts b/commands/mutate-confirm.ts index eb1d987..1871af3 100644 --- a/commands/mutate-confirm.ts +++ b/commands/mutate-confirm.ts @@ -4,13 +4,17 @@ * 显示带颜色编码的操作类型、连接/数据库上下文和缺少 WHERE 的警告。 * 用户按 Enter 确认或 Esc 取消。非 TUI 模式回退到 ctx.ui.confirm()。 * + * SQL 框宽度在 render(w) 时按真实渲染宽度自适应:pi-tui overlay 的 + * 默认宽度是 min(80, termWidth),构造期写死宽度会让长 SQL 行被 + * Text 组件折行、框线崩坏。长 SQL 在框内按显示宽度软换行(不截断, + * 确认破坏性操作需要看到完整 WHERE),行数超限时折叠为省略提示。 */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, Text, Spacer, matchesKey, Key, visibleWidth } from "@earendil-works/pi-tui"; import type { MutationApprovalRequest } from "../state/workspace"; -import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; +import { padToDisplayWidth, wrapToDisplayWidth } from "../formatting/display-width"; type StyleColor = "success" | "warning" | "error"; @@ -23,6 +27,13 @@ const OP_STYLE: Record = { const DEFAULT_WARNING = "该操作将永久修改数据,无法撤销"; +/** SQL 框内容宽度下限——超窄终端的兜底,防止框宽坍缩为负。 */ +const MIN_SQL_CONTENT = 10; +/** SQL 框内容宽度上限——宽终端下的美学上限,避免框体铺满全屏。 */ +const MAX_SQL_CONTENT = 88; +/** SQL 物理行数上限,超出部分折叠为一条省略提示。 */ +const MAX_SQL_LINES = 12; + /** * 显示变更确认对话框,用户确认时返回 true。 * @@ -49,103 +60,125 @@ export async function showMutationConfirm( const result = await ctx.ui.custom( (_tui, theme, _kb, done) => { - const container = new Container(); - - // ── 上边框 ── - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); - container.addChild(new Spacer(0)); - - // ── 标题 ── - container.addChild(new Text(theme.fg("accent", theme.bold(" ⚠️ 数据修改确认")), 2, 0)); - container.addChild(new Spacer(0)); - - // ── 元信息 ── - const opLabel = `${style.icon} ${params.operation}`; - container.addChild(new Text(` 操作类型: ${theme.fg(style.color, opLabel)}`, 2, 0)); - container.addChild( - new Text(` 目标数据库:${params.database} @ ${params.connectionId}`, 2, 0), - ); - container.addChild(new Spacer(0)); - - // ── SQL 框 ── - const sqlLines = params.sql.split("\n"); - const maxSqlLen = Math.max(...sqlLines.map((l) => l.length)); - const boxInnerWidth = Math.min(maxSqlLen + 2, 78); - - // 上边缘 - container.addChild(new Text(theme.fg(style.color, ` ┌${"─".repeat(boxInnerWidth)}┐`), 2, 0)); - - // 空填充行 - container.addChild( - new Text( - `${theme.fg(style.color, " │")}${" ".repeat(boxInnerWidth)}${theme.fg(style.color, "│")}`, - 2, - 0, - ), - ); - - // SQL 内容——按显示宽度截断/补齐(中文/emoji 占 2 列, - // 按码元 slice/padEnd 会让中文 SQL 行超宽)。 - for (const line of sqlLines) { - const trimmed = line.trim(); - const maxContent = boxInnerWidth - 2; - const display = - visibleWidth(trimmed) > maxContent - ? truncateToDisplayWidth(trimmed, maxContent - 1) + "…" - : padToDisplayWidth(trimmed, maxContent); + // 宽度变化(含首次渲染、终端 resize)时重建内容。 + let lastWidth = -1; + let container = new Container(); + + const build = (w: number): void => { + container = new Container(); + + // ── 上边框 ── + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Spacer(0)); + + // ── 标题 ── + container.addChild(new Text(theme.fg("accent", theme.bold(" ⚠️ 数据修改确认")), 2, 0)); + container.addChild(new Spacer(0)); + + // ── 元信息 ── + const opLabel = `${style.icon} ${params.operation}`; + container.addChild(new Text(` 操作类型: ${theme.fg(style.color, opLabel)}`, 2, 0)); + container.addChild( + new Text(` 目标数据库:${params.database} @ ${params.connectionId}`, 2, 0), + ); + container.addChild(new Spacer(0)); + + // ── SQL 框 ── + // 内容预算 = w − Text 左右 margin(2+2) − 框线 " │ "/" │" (4+2), + // 保证框体任何一行都不超过 Text 的折行宽度 w−4。 + const logicalLines = params.sql.split("\n").map((l) => l.trim()); + const maxLineWidth = Math.max(...logicalLines.map((l) => visibleWidth(l))); + const availContent = Math.max(MIN_SQL_CONTENT, w - 10); + const contentWidth = Math.min( + Math.max(maxLineWidth, MIN_SQL_CONTENT), + MAX_SQL_CONTENT, + availContent, + ); + const boxInnerWidth = contentWidth + 2; + + const addBoxLine = (content: string) => { + container.addChild( + new Text( + `${theme.fg(style.color, " │ ")}${content}${theme.fg(style.color, " │")}`, + 2, + 0, + ), + ); + }; + + // 上边缘 + container.addChild( + new Text(theme.fg(style.color, ` ┌${"─".repeat(boxInnerWidth)}┐`), 2, 0), + ); + + // 空填充行 + addBoxLine(" ".repeat(contentWidth)); + + // SQL 内容——按显示宽度软换行为多条物理行(中文/emoji 占 2 列, + // 按码元切会让中文 SQL 行超宽折行);软换行保证每条物理行 + // ≤ contentWidth,只需右补齐即可让框线对齐。 + const physicalLines = logicalLines.flatMap((l) => wrapToDisplayWidth(l, contentWidth)); + const hiddenCount = Math.max(0, physicalLines.length - MAX_SQL_LINES); + const shownLines = physicalLines.slice(0, physicalLines.length - hiddenCount); + for (const line of shownLines) { + addBoxLine(padToDisplayWidth(line, contentWidth)); + } + if (hiddenCount > 0) { + // 先按显示宽度补齐再上色——display-width 工具只接受纯文本 + const note = padToDisplayWidth(`… 省略 ${hiddenCount} 行`, contentWidth); + addBoxLine(theme.fg("dim", note)); + } + + // 空填充行 + addBoxLine(" ".repeat(contentWidth)); + + // 下边缘 + container.addChild( + new Text(theme.fg(style.color, ` └${"─".repeat(boxInnerWidth)}┘`), 2, 0), + ); + + container.addChild(new Spacer(0)); + + // ── 警告 ── container.addChild( new Text( - `${theme.fg(style.color, " │ ")}${display}${theme.fg(style.color, " │")}`, + params.warning + ? ` ${theme.fg("warning", warningText)}` + : ` ${theme.fg("dim", `⚠️ ${warningText}`)}`, 2, 0, ), ); - } - - // 空填充行 - container.addChild( - new Text( - `${theme.fg(style.color, " │")}${" ".repeat(boxInnerWidth)}${theme.fg(style.color, "│")}`, - 2, - 0, - ), - ); - - // 下边缘 - container.addChild(new Text(theme.fg(style.color, ` └${"─".repeat(boxInnerWidth)}┘`), 2, 0)); - - container.addChild(new Spacer(0)); - - // ── 警告 ── - container.addChild( - new Text( - params.warning - ? ` ${theme.fg("warning", warningText)}` - : ` ${theme.fg("dim", `⚠️ ${warningText}`)}`, - 2, - 0, - ), - ); - - container.addChild(new Spacer(0)); - - // ── 按键提示 ── - container.addChild( - new Text( - ` ${theme.fg("accent", "Enter 确认执行")} ${theme.fg("dim", "Esc 取消")}`, - 2, - 0, - ), - ); - - container.addChild(new Spacer(0)); - - // ── 下边框 ── - container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + container.addChild(new Spacer(0)); + + // ── 按键提示 ── + container.addChild( + new Text( + ` ${theme.fg("accent", "Enter 确认执行")} ${theme.fg("dim", "Esc 取消")}`, + 2, + 0, + ), + ); + + container.addChild(new Spacer(0)); + + // ── 下边框 ── + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + }; return { - render: (w: number) => container.render(w), - invalidate: () => container.invalidate(), + render: (w: number) => { + if (w !== lastWidth) { + build(w); + lastWidth = w; + } + return container.render(w); + }, + invalidate: () => { + lastWidth = -1; + container.invalidate(); + }, handleInput: (data: string) => { if (matchesKey(data, Key.enter)) { done(true); diff --git a/commands/related-browser.ts b/commands/related-browser.ts index c48a401..a2b6ede 100644 --- a/commands/related-browser.ts +++ b/commands/related-browser.ts @@ -195,7 +195,9 @@ export async function openRelatedBrowser( (tui, theme, _kb, done) => { const nav = createBrowserNav(cache.related.length); let scroll = 0; - /** 最近一次渲染的 overlay 宽度——build 用它计算表格布局宽度。 */ + /** 最近一次渲染的 overlay 宽度——build 用它计算表格布局宽度。 + * 初始 80 只是占位:首帧 render(w) 拿到真实宽度后会触发重建, + * 否则窄终端下首屏按过期宽度截断的表格行会被 Text 折行。 */ let lastWidth = 80; // Box 提供不透明背景(selectedBg:两主题下均为中性蓝灰, @@ -289,7 +291,12 @@ export async function openRelatedBrowser( return { render: (w) => { - lastWidth = w; + // 宽度变化(含首帧拿到真实 overlay 宽度)时重建内容—— + // build 内的表格布局与截断宽度都依赖 lastWidth + if (w !== lastWidth) { + lastWidth = w; + build(); + } return box.render(w); }, invalidate: () => { diff --git a/commands/relations.ts b/commands/relations.ts index 3176b07..c75f12b 100644 --- a/commands/relations.ts +++ b/commands/relations.ts @@ -7,6 +7,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { DatabaseWorkspaceService } from "../state/workspace"; import type { StoredRelation } from "../types"; +import { padToDisplayWidth } from "../formatting/display-width"; import { pickTableFuzzy, withLoader } from "./utils"; // ── 列表格式化 ───────────────────────────────────────────── @@ -66,7 +67,7 @@ async function handleRelationsList( const src = `${r.table}.${r.column}`; const ref = `${r.refTable}.${r.refColumn}`; const cond = r.condition ? ` [${r.condition}]` : ""; - return `#${String(r.id).padStart(3)} ${src.padEnd(24)} → ${ref} (${r.relationType})${cond}`; + return `#${String(r.id).padStart(3)} ${padToDisplayWidth(src, 24)} → ${ref} (${r.relationType})${cond}`; }); const choice = await ctx.ui.select("选择一个关系", labels); @@ -175,7 +176,7 @@ async function handleRelationsRemove( const labels = rows.map((r) => { const src = `${r.table}.${r.column}`; const ref = `${r.refTable}.${r.refColumn}`; - return `#${String(r.id).padStart(3)} ${src.padEnd(24)} → ${ref} (${r.relationType})`; + return `#${String(r.id).padStart(3)} ${padToDisplayWidth(src, 24)} → ${ref} (${r.relationType})`; }); const choice = await ctx.ui.select("选择要删除的关系", labels); diff --git a/formatting/display-width.ts b/formatting/display-width.ts index 2a31e64..bfae975 100644 --- a/formatting/display-width.ts +++ b/formatting/display-width.ts @@ -33,3 +33,29 @@ export function truncateToDisplayWidth(s: string, max: number): string { export function padToDisplayWidth(s: string, w: number): string { return s + " ".repeat(Math.max(0, w - visibleWidth(s))); } + +/** + * 纯文本按显示宽度软换行——超宽行切成多段,每段 ≤ w 列。 + * + * 按码点迭代(for..of),不会劈开代理对(emoji);宽字符在 + * 行尾放不下时挪到下一行。单个字符比 w 还宽时原样放出(调用方 + * 的宽度预算退化为 0 的兜底)。空字符串返回 [""]。 + */ +export function wrapToDisplayWidth(s: string, w: number): string[] { + if (w < 1) return [s]; + const lines: string[] = []; + let cur = ""; + let curW = 0; + for (const ch of s) { + const cw = visibleWidth(ch); + if (curW + cw > w && cur !== "") { + lines.push(cur); + cur = ""; + curW = 0; + } + cur += ch; + curW += cw; + } + lines.push(cur); + return lines; +}