Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion __tests__/commands-format.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 ======
Expand Down Expand Up @@ -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");
Expand Down
53 changes: 52 additions & 1 deletion __tests__/display-width.test.ts
Original file line number Diff line number Diff line change
@@ -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("短文本原样返回", () => {
Expand Down Expand Up @@ -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);
}
});
});
14 changes: 9 additions & 5 deletions commands/favorites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ── 列表格式化 ─────────────────────────────────────────────
Expand All @@ -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}`);
}

Expand Down Expand Up @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions commands/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,21 @@ 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";

// ── 格式化 ───────────────────────────────────────────────────

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(" ");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
});
Expand Down
Loading
Loading