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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -710,10 +710,10 @@
- **适用范围**:`crates/core/src/{window_close,popup_window,popup_lifecycle}.rs`、`crates/core/src/popup_window_tests.rs`,以及所有 `open_reusable_popup_window` 调用点(更新对话框、代理设置、表导入/导出、SQL 运行/转储、比较窗口、离线扩展包对话框)。

- **标题**:剪贴板 TSV 必须成对转义,否则「复制整行后粘贴丢字段」(issue #355)
- **触发信号**:复制一行/一块表格数据,粘贴后列错位或少了若干字段,而剪贴板里看着数据是全的;典型诱因是某个字段里含 `\t`、`\n`、`\r` 或 `"`(多行文本、JSON、带引号的备注)。
- **触发信号**:复制一行/一块表格数据,粘贴后列错位或少了若干字段,而剪贴板里看着数据是全的;典型诱因是某个字段里含 `\t`、`\n`、`\r` 或 `"`(多行文本、JSON、带引号的备注)。反向症状同样要警惕:复制一个 JSON 字段,粘到外部编辑器变成 `"{""a"":1}"` —— 剪贴板内容与库里存的不一致(#355 的修复在 `cdad30638` 引入,应用内往返是对称的所以自家测试全绿,破坏的是粘到应用外的内容)。
- **根因 / 约束**:可编辑表格(`one_ui::edit_table`)与结果集(`db_view::table_data`)共用同一套剪贴板格式「`\t` 分列、`\n` 分行」,但两侧各自手写:复制侧 `join("\t")` 原样写值,粘贴侧 `text.lines()` + `split('\t')` 裸切。值本身含分隔符时,复制那一刻就已经在剪贴板上被切成多个字段,粘贴侧无从还原——字段被挤到旁边的列或下一行,观感就是「丢了字段」。CSV 导出那条路早有 RFC 4180 引号转义,说明剪贴板链路只是没跟上。
- **正确做法**:编解码收敛到 `one_ui::edit_table::tsv`(`escape_tsv_field` / `encode_tsv_row(s)` / `parse_tsv_rows`),四个读写点全走它:`EditTableState::action_copy` / `action_paste`(Ctrl+C/V)与 `CopyFormatter::format_tsv` / `results_delegate` 右键粘贴。规则:字段含 `\t`/`\n`/`\r`/`"` 才加引号并双写内部 `"`(普通值保持原样,粘到 Excel 也是标准格式);**只有整段文本含 `\t`、换行或 `""` 时才按引号规则解转义**,这样从别处复制单值(JSON 片段、`C:\new\test` 这类路径)仍是原样写入,不会被误当引号字段抹掉引号。`\r\n` 算一个换行、末尾换行不补空行,与旧的 `str::lines()` 行为对齐(否则「复制一行」粘回来会多一行)。已知遗留:值恰为字面量 `\N` 与 SQL NULL 的标记仍无法区分,粘贴侧没有 NULL 语义,这次**没有**动它。
- **验证方式**:`cargo test -p one-ui --lib tsv`(编解码单测:往返、引号/换行/制表符/回车/空值,加 `clipboard_sites_use_the_shared_codec` 契约测)、`cargo test -p db_view --lib copy_format`(`clipboard_tsv_round_trip_is_lossless` 走真实 `CopyFormatter::format(Tsv, ..)` → `parse_tsv_rows`:按旧规则先跑会精确复现「字段含制表符 → 4 列」「字段含换行 → 多一行且值错位」,改完转绿;`table_data_clipboard_sites_use_the_shared_codec` 用 `include_str!` 钉住两个右侧读写点不许退回 `split('\t')`)。回归面:`cargo test -p db_view --lib`(884)、`cargo test -p one-ui --lib`(114)、`cargo clippy -p one-ui -p db_view --all-targets`、`cargo check -p main`。
- **正确做法**:编解码收敛到 `one_ui::edit_table::tsv`(`escape_tsv_field` / `encode_tsv_row(s)` / `parse_tsv_rows`),四个读写点全走它:`EditTableState::action_copy` / `action_paste`(Ctrl+C/V)与 `CopyFormatter::format_tsv` / `results_delegate` 右键粘贴。包裹规则**只认结构上真正冲突的字符**:字段含 `\t`/`\n`/`\r` **或以 `"` 开头**时才加引号并双写内部 `"`,其余原样——TSV 里 `"` 只有出现在字段开头才有语法意义(与 Excel 语义一致),不在开头的引号是普通字符,给 JSON 包一层只会让剪贴板和存储值不一致;开头的 `"` 必须包裹,否则解码侧无法和「引号包裹的字段」区分。**不要**再把 `"` 整体塞回触发集合(`json_values_copy_verbatim` / `clipboard_tsv_copies_json_cell_verbatim` 会转红)。**只有整段文本含 `\t`、换行或 `""` 时才按引号规则解转义**,这样从别处复制单值(JSON 片段、`C:\new\test` 这类路径)仍是原样写入,不会被误当引号字段抹掉引号。`\r\n` 算一个换行、末尾换行不补空行,与旧的 `str::lines()` 行为对齐(否则「复制一行」粘回来会多一行)。已知遗留:值恰为字面量 `\N` 与 SQL NULL 的标记仍无法区分,粘贴侧没有 NULL 语义;含 `\t`/换行的单元格(例如美化过的 JSON)仍会被包裹,这是行列保真的必要代价,粘到应用外还带引号属于已知行为。CSV 导出(`escape_csv_field`)继续按严格 RFC 4180,不受此规则影响。
- **验证方式**:`cargo test -p one-ui --lib tsv`(编解码单测:往返、引号/换行/制表符/回车/空值、JSON 原样复制,加 `clipboard_sites_use_the_shared_codec` 契约测)、`cargo test -p db_view --lib copy_format`(`clipboard_tsv_round_trip_is_lossless` 走真实 `CopyFormatter::format(Tsv, ..)` → `parse_tsv_rows`:按旧规则先跑会精确复现「字段含制表符 → 4 列」「字段含换行 → 多一行且值错位」,改完转绿;`clipboard_tsv_copies_json_cell_verbatim` 断言多列与单格复制出的 JSON 等于原文;`table_data_clipboard_sites_use_the_shared_codec` 用 `include_str!` 钉住两个右侧读写点不许退回 `split('\t')`)。变异验证:把条件改成只 `contains(SEPARATORS)` → `escapes_only_when_needed` + `round_trip_keeps_empty_null_marker_and_quotes` 转红;改回 `contains(['\t','\n','\r','"'])` → `json_values_copy_verbatim` 转红,两个分支都有测试守着。回归面:`cargo test -p db_view --lib`(941)、`cargo test -p one-ui --lib`(127)、`cargo clippy -p one-ui -p db_view --all-targets`、`cargo check -p main`。
- **适用范围**:`crates/one_ui/src/edit_table/{tsv.rs,tsv_tests.rs,state.rs,mod.rs}`、`crates/db_view/src/table_data/{copy_format.rs,results_delegate.rs}`;任何新增「写/读表格剪贴板」的调用点都必须走这套编解码,不要各自 `join("\t")` / `split('\t')`(`results_delegate` 的「复制列名」是给 SQL 用的,故意不转义)。

- **标题**:拖拽分隔条的抓取区必须骑在边界两侧,细线别被 block 布局丢到盒子起点(issue #333)
Expand Down
37 changes: 37 additions & 0 deletions crates/db_view/src/table_data/copy_format_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,14 @@ fn clipboard_tsv_round_trip_is_lossless() {
Some("say \"hi\"".into()),
],
),
(
"字段是 JSON",
vec![
Some("1".into()),
Some(r#"{"name":"张三","age":1}"#.into()),
Some("tail".into()),
],
),
(
"含空值与 SQL NULL",
vec![None, Some(String::new()), Some("\\N".into())],
Expand Down Expand Up @@ -614,6 +622,35 @@ fn clipboard_tsv_escapes_fields_with_separators() {
);
}

/// 表数据里复制 JSON 字段(Ctrl/Cmd+C 与右键「复制」)时,剪贴板内容必须等于存储
/// 原文;两侧再套一层引号会让粘到应用外的内容和库里的值不一致。
#[test]
fn clipboard_tsv_copies_json_cell_verbatim() {
const JSON: &str = r#"{"name":"张三","age":1}"#;
let columns = vec!["id".into(), "payload".into()];
let metadata = TableMetadata::new("t").with_columns(columns.clone());

let data = vec![vec![Some("1".into()), Some(JSON.into())]];
assert_eq!(
format!("1\t{JSON}"),
CopyFormatter::format(
CopyFormat::Tsv,
CopyFormatContext::new(&data, &columns, &metadata),
)
);

// 只复制这一个单元格时同样是原文。
let single = vec![vec![Some(JSON.into())]];
let one_column = vec!["payload".into()];
let single_metadata = TableMetadata::new("t").with_columns(one_column.clone());
let clipboard = CopyFormatter::format(
CopyFormat::Tsv,
CopyFormatContext::new(&single, &one_column, &single_metadata),
);
assert_eq!(JSON, clipboard);
assert_eq!(vec![vec![JSON.to_string()]], parse_tsv_rows(&clipboard));
}

/// 查询结果里的表名是 `库.表` 的拼接形式,复制出来的 SQL 必须逐段引用,
/// 否则会得到 `ads.tbl` 这种被粘在一个引号对里的非法名字。
#[test]
Expand Down
23 changes: 14 additions & 9 deletions crates/one_ui/src/edit_table/tsv.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
//! 剪贴板 TSV 编解码(issue #355)。
//!
//! 可编辑表格与查询结果共用同一套剪贴板格式:`\t` 分列、`\n` 分行。但单元格的值
//! 本身可能含 `\t`、`\n`、`\r` 或 `"`,原样写进剪贴板后,粘贴时会被当成分隔符
//! 切走——现象正是「剪贴板里的数据是全的,粘进去少了几个字段」。
//! 本身可能含 `\t`、`\n`、`\r`,原样写进剪贴板后,粘贴时会被当成分隔符切走——现象
//! 正是「剪贴板里的数据是全的,粘进去少了几个字段」。
//!
//! 所以编码时按 RFC 4180 的做法转义:字段含上述字符就用 `"` 包裹、内部 `"` 双写;
//! 解码对称还原。只有整段文本含 `\t`、换行或 `""` 时才按引号规则解转义,这样从
//! 别处复制一段单值(JSON 片段、带反斜杠的路径)粘进单元格仍然是原样写入。
//! 所以编码时按 RFC 4180 的做法转义:字段含 `\t`、`\n`、`\r` 或以 `"` 开头时用 `"`
//! 包裹、内部 `"` 双写;解码对称还原。只在结构上必要时才包裹——TSV 里 `"` 只有出现在
//! 字段开头才有语法意义,所以 JSON 这类「开头是 `{`、内部带引号」的值原样进剪贴板,
//! 粘到应用外仍是存储原文。同理,解码时只有整段文本含 `\t`、换行或 `""` 才按引号规则
//! 解转义,这样从别处复制一段单值(JSON 片段、带反斜杠的路径)粘进单元格仍是原样写入。
//!
//! 分隔与换行沿用旧行为:`\r\n` 算一个换行,末尾换行不多产生一个空行。
//!
//! 已知遗留:值恰好等于字面量 `\N` 时,与 SQL NULL 的剪贴板标记仍无法区分
//! (复制侧把 NULL 写成 `\N`,粘贴侧没有 NULL 语义)。

/// 触发加引号转义的字符:列分隔、行分隔与引号本身。
const QUOTE_TRIGGERS: [char; 4] = ['\t', '\n', '\r', '"'];
/// 会改变剪贴板行列结构的字符:列分隔与行分隔。
const SEPARATORS: [char; 3] = ['\t', '\n', '\r'];

/// 编码单个字段:含 `\t`、`\n`、`\r` 或 `"` 时加引号并把内部 `"` 双写。
/// 编码单个字段:含分隔符或以 `"` 开头时加引号并把内部 `"` 双写,其余原样。
///
/// 开头的 `"` 必须包裹,否则解码时会和「引号包裹的字段」混淆;不在开头的 `"` 是普通
/// 字符(与 Excel 的 TSV 语义一致),包一层反而让复制结果和存储值不一致。
pub fn escape_tsv_field(field: &str) -> String {
if field.contains(QUOTE_TRIGGERS) {
if field.contains(SEPARATORS) || field.starts_with('"') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
Expand Down
15 changes: 15 additions & 0 deletions crates/one_ui/src/edit_table/tsv_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ fn escapes_only_when_needed() {
assert_eq!("\"\"\"quoted\"\"\"", escape_tsv_field("\"quoted\""));
}

/// 复制 JSON 字段时剪贴板必须等于存储原文:引号只有在字段开头才有语法意义,
/// 值中间的 `"` 不构成需要包裹的冲突。
#[test]
fn json_values_copy_verbatim() {
const JSON: &str = r#"{"name":"张三","tags":["a","b"]}"#;

assert_eq!(JSON, escape_tsv_field(JSON));
assert_eq!(r#"say "hi""#, escape_tsv_field(r#"say "hi""#));
assert_eq!(format!("1\t{JSON}"), encode_tsv_row(["1", JSON]));
assert_eq!(
vec![vec!["1".to_string(), JSON.to_string()]],
round_trip(&[vec!["1", JSON]])
);
}

#[test]
fn single_value_without_separators_stays_verbatim() {
assert_eq!(vec![vec!["\"json 片段\""]], parse_tsv_rows("\"json 片段\""));
Expand Down
Loading