diff --git a/.claude/context-summary-macos-icon.md b/.claude/context-summary-macos-icon.md new file mode 100644 index 000000000..60624607c --- /dev/null +++ b/.claude/context-summary-macos-icon.md @@ -0,0 +1,52 @@ +## 项目上下文摘要(macos-icon) +生成时间:2026-03-17 14:00:00 +0800 + +### 1. 相似实现分析 +- **实现1**: `script/bundle-macos.sh:30-40` + - 模式:macOS 打包时直接把 `resources/macos/OnetCli.icns` 拷入 `.app` + - 可复用:`.app` 资源目录和 `Info.plist` 生成逻辑 + - 需注意:如果 `OnetCli.icns` 过期,打包不会自动从 `logo.svg` 更新 + +- **实现2**: `.github/workflows/release.yml:115-121` + - 模式:CI 在 macOS 上执行 `bundle-macos.sh` 和 `bundle-macos-dmg.sh` + - 可复用:当前发布链完全依赖仓库脚本,不依赖额外打包工具 + - 需注意:只要修正本地脚本,CI 发布链会同步生效 + +- **实现3**: `logo.svg:53-55` + - 模式:图标视觉源是 SVG,圆角背景位于透明画布上 + - 可复用:`logo.svg` 可以直接作为 `icns` 的单一真源 + - 需注意:不同系统渲染链对 SVG 透明角处理不一致,Quick Look 会把角烘成白底 + +### 2. 项目约定 +- **命名约定**: 脚本文件使用 kebab-case,资源文件沿用 `OnetCli.icns` +- **文件组织**: macOS 资源位于 `resources/macos/`,打包脚本位于 `script/` +- **代码风格**: Shell 脚本使用 `set -euo pipefail`,路径通过 `SCRIPT_DIR/PROJECT_DIR` 计算 +- **导入/依赖**: 优先使用系统自带的 `sips` 与 `iconutil` + +### 3. 可复用组件清单 +- `script/bundle-macos.sh`: 现有 `.app` 打包入口 +- `script/bundle-macos-dmg.sh`: 现有 `.dmg` 打包入口 +- `resources/macos/Info.plist`: App bundle 元数据 +- `logo.svg`: 图标源文件 + +### 4. 测试策略 +- **验证方式**: 本地脚本执行 + `iconutil` 解包 + 像素级透明度检查 +- **关键检查**: + - `generate-macos-icon.sh` 能输出合法的 `OnetCli.icns` + - `iconutil -c iconset` 能反解成功 + - 反解后的 `icon_512x512.png` 四角 alpha 为 0 + +### 5. 依赖和集成点 +- **外部依赖**: `/usr/bin/sips`、`/usr/bin/iconutil` +- **内部依赖**: `bundle-macos.sh` 依赖新的图标生成脚本 +- **集成方式**: 打包前自动重建 `resources/macos/OnetCli.icns` + +### 6. 技术选型理由 +- **为什么用这个方案**: 直接用仓库现有 `logo.svg` 作为真源,消除手工维护旧 `.icns` 的偏差 +- **优势**: 本地和 CI 行为一致;不需要引入第三方图形工具 +- **风险**: `sips` 从 SVG 渲染出的母图是 512,再放大生成 1024 规格;对当前简洁图标足够,但复杂图标可能需要更高精度渲染链 + +### 7. 关键风险点 +- **边界条件**: `qlmanage` 会把透明角渲染成白底,不能用于生成 `.icns` +- **兼容性**: 该脚本依赖 macOS 自带工具,只适合在 macOS 运行 +- **工具说明**: 当前会话没有 `desktop-commander`、`context7`、`github.search_code`,本次使用本地源码检索和系统命令完成分析与验证 diff --git a/.claude/context-summary-table-data-horizontal-scroll.md b/.claude/context-summary-table-data-horizontal-scroll.md new file mode 100644 index 000000000..0cbe49f73 --- /dev/null +++ b/.claude/context-summary-table-data-horizontal-scroll.md @@ -0,0 +1,64 @@ +## 项目上下文摘要(table-data-horizontal-scroll) +生成时间:2026-03-17 14:56:57 +0800 + +### 1. 相似实现分析 +- **实现1**: `crates/ui/src/table/state.rs:435` + - 模式:通用表格 `set_selected_cell` 在设置活动单元格时直接同步滚动句柄,保证键盘导航和可视区域一致。 + - 可复用:选中单元格与滚动同步应该在同一条状态链路里完成,而不是依赖间接副作用。 + - 需注意:通用表格没有 `EditTable` 的多选区兼容层,因此不能原样照搬,只能借鉴“显式滚动”思路。 + +- **实现2**: `crates/one_ui/src/edit_table/state.rs:377` + - 模式:`EditTable` 的键盘导航统一经过 `select_cell_for_navigation`,当前仅显式做了纵向 `scroll_to_item(..., Center)`,横向滚动依赖 `select_cell -> sync_legacy_selection -> scroll_to_col` 的间接调用。 + - 可复用:可以在导航专用路径里显式补齐横向滚动,避免依赖旧兼容层的副作用。 + - 需注意:当前用户已确认上下移动没有问题,问题集中在左右移动后的横向可视区域同步。 + +- **实现3**: `crates/one_ui/src/edit_table/state.rs:1639` + - 模式:列宽拖拽时使用 `horizontal_scroll_handle.set_offset` 直接调整水平滚动偏移。 + - 可复用:说明 `EditTable` 已有“直接写入滚动偏移”的先例,可复用于键盘导航后的横向可见性保障。 + - 需注意:该逻辑依赖 `bounds` 与 `col_group.bounds` 的实时位置。 + +- **实现4**: `crates/ui/src/virtual_list.rs:248` + - 模式:`VirtualListScrollHandle::scroll_to_item` 在水平方向会根据目标项边界修正 `scroll_offset.x`,前提是正确写入目标列索引。 + - 可复用:当列边界尚未可用时,仍可作为回退方案。 + - 需注意:这套逻辑属于 defer/prepaint 机制,调用方必须在状态变更后保持一次刷新通知。 + +### 2. 项目约定 +- **命名约定**: Rust 方法和字段使用 `snake_case`,类型使用 `PascalCase`。 +- **文件组织**: 表格导航和滚动状态集中在 `crates/one_ui/src/edit_table/state.rs`。 +- **代码风格**: 最小改动、优先复用既有滚动句柄,不引入新的状态字段。 +- **导入顺序**: 沿用文件既有顺序,不做无关重排。 + +### 3. 可复用组件清单 +- `crates/one_ui/src/edit_table/state.rs`: `scroll_to_col`、`select_cell_for_navigation` +- `crates/one_ui/src/edit_table/state.rs`: `scroll_table_by_col_resizing`(直接写入水平偏移) +- `crates/ui/src/table/state.rs`: `set_selected_cell` +- `crates/ui/src/virtual_list.rs`: `VirtualListScrollHandle::scroll_to_item` + +### 4. 测试策略 +- **测试框架**: Rust 内置 `cargo test` +- **测试模式**: 以 `one-ui` 和 `db_view` 包级回归测试为主 +- **参考文件**: + - `crates/one_ui/src/edit_table/state.rs` + - `crates/ui/src/table/state.rs` + - `crates/ui/src/virtual_list.rs` +- **覆盖要求**: + - `EditTable` 改动不破坏现有单元格导航与选择逻辑 + - `one-ui`、`db_view` 包级测试通过 + - 无法自动覆盖的 GUI 左右移动冒烟需在验证报告中留痕 + +### 5. 依赖和集成点 +- **外部依赖**: `gpui` 的 `UniformListScrollHandle` 与 `VirtualListScrollHandle` +- **内部依赖**: `EditTableState -> render_table_row/render_table_header -> track_scroll` +- **集成方式**: 键盘左右移动更新活动单元格后,应显式写入水平滚动目标列 +- **配置来源**: `row_number_enabled`、`fixed_left_cols_count`、`col_fixed` + +### 6. 技术选型理由 +- **为什么用这个方案**: `EditTable` 的水平滚动容器使用 `overflow_hidden`,`scroll_to_item` 可能无法驱动横向偏移。改为基于 `col_group.bounds` 与表格视口计算最小偏移量,直接 `set_offset`,更符合现有拖拽滚动模式。 +- **优势**: 修改范围小,只影响 `EditTableState` 的滚动同步逻辑,且保留 `scroll_to_item` 作为 bounds 不可用时的回退。 +- **劣势和风险**: 依赖 `bounds` 的实时性;初次渲染或列宽尚未测量时仍可能需要下一帧刷新才能准确对齐,需要桌面环境冒烟确认。 + +### 7. 关键风险点 +- **并发问题**: 本次不新增异步任务,不涉及重入更新。 +- **边界条件**: 需要兼容行号列、固定列和普通滚动列三种索引情况。 +- **性能瓶颈**: 仅在活动列越界时写入一次水平偏移,不引入额外渲染或数据请求。 +- **工具说明**: 仓库规范要求优先使用 `desktop-commander`、`context7`、`github.search_code`、`sequential-thinking`,但当前会话未提供这些工具;本次改用本地源码检索与结构化分析替代,并在日志中留痕。 diff --git a/.claude/context-summary-table-data-sort-crash.md b/.claude/context-summary-table-data-sort-crash.md new file mode 100644 index 000000000..d8f87b1e2 --- /dev/null +++ b/.claude/context-summary-table-data-sort-crash.md @@ -0,0 +1,63 @@ +## 项目上下文摘要(table-data-sort-crash) +生成时间:2026-03-17 14:03:28 +0800 + +### 1. 相似实现分析 +- **实现1**: `crates/one_ui/src/edit_table/state.rs:1676` + - 模式:`EditTableState` 在表头点击时先更新本地 `ColumnSort` 状态,再把排序动作委托给 delegate。 + - 可复用:现有排序状态机 `Default -> Descending -> Ascending -> Default` 和行号列偏移换算。 + - 需注意:delegate 回调发生在 `EditTableState` 自身的 `update` 闭包内,若回调里再次更新同一实体会触发重入保护。 + +- **实现2**: `crates/ui/src/table/state.rs:934` + - 模式:通用表格也采用“先切换表头状态,再调用 delegate”的两段式处理。 + - 可复用:说明 `one_ui::edit_table` 的排序骨架与通用表格保持一致,问题不在排序状态机本身。 + - 需注意:业务 delegate 必须避免在回调里同步回写当前表格实体。 + +- **实现3**: `crates/db_view/src/table_data/data_grid.rs:370` + - 模式:`DataGrid::apply_column_sort` 会先更新 `filter_editor` 的 `ORDER BY`,然后调用 `load_data_with_clauses` 触发 `self.table.update(...)` 刷新数据。 + - 可复用:排序后的真实查询链路已经完整,修复时应保留这条链路。 + - 需注意:该方法内部会同步更新 `EditTableState`,因此不能在 `EditTableState` 正在更新时直接调用。 + +- **实现4**: `crates/ui/src/dock/dock.rs:190` + - 模式:项目内已有在实体更新期间通过 `window.defer(cx, ...)` 延后关联实体更新的写法。 + - 可复用:`window.defer` 适合把“当前事件引发的二次更新”延后到下一拍,规避 GPUI 的重入更新限制。 + - 需注意:延后闭包里应使用克隆后的实体句柄,并处理实体已释放的情况。 + +### 2. 项目约定 +- **命名约定**: Rust 函数与字段使用 `snake_case`,类型使用 `PascalCase`。 +- **文件组织**: 表格通用状态在 `one_ui/edit_table`,业务排序/加载逻辑在 `db_view/table_data`。 +- **导入顺序**: 先标准库,再本地模块和外部 crate;沿用文件当前风格,不做无关重排。 +- **代码风格**: 最小改动、早返回、优先复用既有事件循环与异步调度机制。 + +### 3. 可复用组件清单 +- `crates/one_ui/src/edit_table/state.rs`: `EditTableState::perform_sort` +- `crates/db_view/src/table_data/data_grid.rs`: `DataGrid::apply_column_sort` +- `crates/db_view/src/table_data/results_delegate.rs`: `EditorTableDelegate::perform_sort` +- `crates/ui/src/dock/dock.rs`: `window.defer(cx, ...)` 延后更新模式 + +### 4. 测试策略 +- **测试框架**: Rust 内置 `cargo test` +- **测试模式**: 以 `db_view` 排序相关单元测试和受影响文件编译/格式验证为主 +- **参考文件**: + - `crates/db_view/src/table_data/data_grid.rs` 现有排序 SQL 单元测试 + - `crates/db_view/src/table_data/results_delegate.rs` 现有排序解析单元测试 +- **覆盖要求**: + - 排序 SQL 生成行为不回归 + - 排序子句回填表头图标行为不回归 + - `db_view` 包级测试通过,确认本次延后更新未破坏数据加载链路 + +### 5. 依赖和集成点 +- **外部依赖**: `gpui` 的实体更新与 `window.defer` 事件循环模型 +- **内部依赖**: `EditTableState -> EditorTableDelegate -> DataGrid -> TableFilterEditor -> load_data_with_clauses` +- **集成方式**: 表头点击触发 delegate 排序,再由 `DataGrid` 更新 `ORDER BY` 并重新查询 +- **配置来源**: `DataGridConfig.usage`、`DataGridConfig.database_type` + +### 6. 技术选型理由 +- **为什么用这个方案**: 根因是同步重入更新,不是排序 SQL 或查询逻辑错误;因此最小修复应只调整回调时机。 +- **优势**: 只改 `results_delegate` 一处,不影响通用表格状态机和已有查询流程。 +- **劣势和风险**: 排序动作会延后一拍执行,理论上会比同步触发多一个事件循环 tick,但对用户无可感知影响。 + +### 7. 关键风险点 +- **并发问题**: 若实体在 defer 执行前已销毁,必须允许更新安全失败。 +- **边界条件**: 点击不可排序列、缺少 `data_grid` 句柄、列索引越界时应继续早返回。 +- **性能瓶颈**: 本次不改变服务端排序与重新查询策略,不新增额外请求。 +- **工具说明**: 仓库规范要求优先使用 `desktop-commander`、`context7`、`github.search_code`、`sequential-thinking`,但当前会话未提供这些工具;本次使用本地源码检索与结构化分析替代,并在日志中留痕。 diff --git a/.claude/context-summary-table-data-sort.md b/.claude/context-summary-table-data-sort.md new file mode 100644 index 000000000..78ea76ba7 --- /dev/null +++ b/.claude/context-summary-table-data-sort.md @@ -0,0 +1,62 @@ +## 项目上下文摘要(table-data-sort) +生成时间:2026-03-17 12:37:28 +0800 + +### 1. 相似实现分析 +- **实现1**: `crates/ui/src/table/state.rs:937` + - 模式:通用表格组件在表头点击后切换 `ColumnSort`,并把排序动作委托给 delegate。 + - 可复用:`perform_sort` 的状态切换顺序 `Default -> Descending -> Ascending -> Default`。 + - 需注意:UI 层只负责切图标和分发事件,不直接处理业务查询。 + +- **实现2**: `crates/one_ui/src/edit_table/state.rs:1676` + - 模式:可编辑表格复用通用表格的排序状态机,但会处理行号列偏移。 + - 可复用:`delegate_col_ix` 映射逻辑和表头排序图标渲染。 + - 需注意:如果业务 delegate 不实现 `perform_sort`,点击只会改本地状态,不会触发数据刷新。 + +- **实现3**: `crates/db_view/src/table_data/data_grid.rs:347` + - 模式:表格数据浏览统一通过 `load_data_with_clauses` 读取 `WHERE` 和 `ORDER BY` 编辑器内容,再下发 `TableDataRequest`。 + - 可复用:`with_order_by_clause` 请求链路和加载后刷新 delegate 的流程。 + - 需注意:排序真正生效必须把表头事件同步到 `filter_editor.order_by_editor`。 + +- **实现4**: `crates/db_view/src/table_data/results_delegate.rs:384` + - 模式:结果委托会在 `update_data` 时把每一列设为 `sortable()`。 + - 可复用:列元数据、列名和数据类型都已经在 delegate 中维护,无需新增状态对象。 + - 需注意:`update_data` 会重建列定义,因此排序后的表头状态需要在刷新后回填。 + +### 2. 项目约定 +- **命名约定**: Rust 方法和函数使用 `snake_case`,类型使用 `PascalCase`。 +- **文件组织**: 事件分发在 `data_grid.rs`,数据行为在 `results_delegate.rs`,筛选输入在 `filter_editor.rs`。 +- **导入顺序**: 先本模块 `crate::...`,再外部 crate,最后标准库。 +- **代码风格**: 早返回、最小范围 helper、通过现有 delegate/编辑器组件串联行为。 + +### 3. 可复用组件清单 +- `crates/db_view/src/table_data/filter_editor.rs`: `TableFilterEditor::get_order_by_clause` +- `crates/db_view/src/table_data/data_grid.rs`: `load_data_with_clauses` +- `crates/db_view/src/table_data/results_delegate.rs`: `update_data` +- `crates/db/src/manager.rs`: `DbManager::get_plugin` +- `crates/db/src/plugin.rs`: `DatabasePlugin::quote_identifier` + +### 4. 测试策略 +- **测试框架**: Rust 内置 `cargo test` +- **测试模式**: 以纯函数单元测试 + `db_view` 包级回归测试为主 +- **参考文件**: `crates/db_view/src/sql_editor_view.rs`、`crates/db_view/src/table_designer_tab.rs` 中已有纯函数测试模式 +- **覆盖要求**: + - 表头排序生成方言正确的 `ORDER BY` + - 排序子句能解析回表头图标状态 + - `db_view` 整包测试通过,避免影响现有编辑/导出/SQL 结果逻辑 + +### 5. 依赖和集成点 +- **外部依赖**: `db::DbManager` / `DatabasePlugin` 用于数据库方言引用符处理 +- **内部依赖**: `EditTableState -> EditorTableDelegate -> DataGrid -> TableFilterEditor` +- **集成方式**: 表头点击触发 delegate `perform_sort`,由 `DataGrid` 写入 `ORDER BY` 编辑器并重新查询 +- **配置来源**: `DataGridConfig.database_type` / `DataGridConfig.usage` + +### 6. 技术选型理由 +- **为什么用这个方案**: 仓库已经有 `ORDER BY` 编辑器和数据库插件方言能力,直接复用能避免重复拼 SQL。 +- **优势**: 事件链短、数据库方言正确、和现有筛选/分页请求保持一致。 +- **劣势和风险**: 只回填首个排序列的图标;复杂手写 `ORDER BY` 子句不会完整映射到多列表头状态。 + +### 7. 关键风险点 +- **并发问题**: 排序会触发重新加载,若用户在加载中再次点击可能出现重复请求;当前沿用既有加载流程。 +- **边界条件**: 列名包含关键字、空格、引号时必须使用数据库插件做标识符引用。 +- **性能瓶颈**: 排序基于服务端重新查询,不在前端做大表本地排序。 +- **工具说明**: 当前会话没有 `desktop-commander`、`context7`、`github.search_code`,本次使用本地源码检索和既有 crate 实现完成上下文分析。 diff --git a/.claude/context-summary-table-data-tab-navigation.md b/.claude/context-summary-table-data-tab-navigation.md new file mode 100644 index 000000000..bedd901d8 --- /dev/null +++ b/.claude/context-summary-table-data-tab-navigation.md @@ -0,0 +1,57 @@ +## 项目上下文摘要(table-data-tab-navigation) +生成时间:2026-03-17 11:54:39 +0800 + +### 1. 相似实现分析 +- **实现1**: `crates/db_view/src/table_data/data_grid.rs:182` + - 模式:表记录页使用 `one_ui::edit_table::EditTableState` 作为核心表格状态。 + - 可复用:`DataGrid` 只订阅 `EditTableEvent`,真正的键盘行为应落在 `EditTableState` 和 delegate 输入构建上。 + - 需注意:这里不是 `crates/ui/src/table`,不能直接假设已有 Tab 键逻辑可用。 + +- **实现2**: `crates/one_ui/src/edit_table/state.rs:1048` + - 模式:`EditTableState` 已有上下左右对应的动作处理,但当前实现主要按“整行/整列”更新 `selected_row/selected_col`。 + - 可复用:现成的 `set_selected_cell`、`editing_cell`、`commit_cell_edit` 生命周期。 + - 需注意:当前 `action_select_prev_col/action_select_next_col` 没有按单元格模式移动,也未正确处理行号列偏移。 + +- **实现3**: `crates/ui/src/table/state.rs:619` + - 模式:通用 `ui::Table` 已经在单元格模式下,把方向键与 `tab/shift-tab` 统一映射到“同一行/列内移动单元格”。 + - 可复用:按单元格模式优先、否则回退到行/列选择模式的分支写法。 + - 需注意:这是仓库内最接近目标行为的标准样板。 + +- **实现4**: `crates/db_view/src/table_data/results_delegate.rs:1236` + - 模式:表格编辑器由 delegate 按字段类型创建 `InputState` / 日期时间选择器,并用订阅处理 `PressEnter` / `Blur`。 + - 可复用:在 cell editor 内通过订阅提交编辑;必要时配合外层表格动作完成“提交后切换单元格”。 + - 需注意:普通文本编辑器当前使用 `multi_line(true).rows(1)`,会把 `Tab` 解释为缩进而非导航。 + +### 2. 项目约定 +- **命名约定**: Rust 类型使用 `PascalCase`,函数和字段使用 `snake_case` +- **文件组织**: 表格通用交互放在 `crates/one_ui/src/edit_table/`,业务侧输入策略放在 `crates/db_view/src/table_data/results_delegate.rs` +- **导入顺序**: 先标准库,再外部依赖,最后本地模块;同层 `use` 维持紧凑分组 +- **代码风格**: 行为修复优先复用现有状态机和 action handler,不新增平行实现 + +### 3. 可复用组件清单 +- `crates/one_ui/src/edit_table/state.rs`: `set_selected_cell`、`commit_cell_edit`、`editing_cell` +- `crates/ui/src/table/state.rs`: 单元格模式键盘导航参考实现 +- `crates/db_view/src/table_data/results_delegate.rs`: 单元格编辑输入创建与提交订阅模式 +- `crates/one_ui/src/edit_table/mod.rs`: `EditTable` 级别键盘绑定入口 + +### 4. 测试策略 +- **测试框架**: Rust 内置单元测试(`#[cfg(test)]` / `#[test]`) +- **参考文件**: `crates/one_ui/src/edit_table/selection.rs:292` +- **覆盖要求**: 本次至少做包级编译/单测验证;图形界面的 Tab 导航需要桌面环境手工冒烟 + +### 5. 依赖和集成点 +- **外部依赖**: `gpui::KeyBinding`、`gpui_component::input::{InputState, IndentInline, OutdentInline}` +- **内部依赖**: `EditTableState`、`EditorTableDelegate::build_input` +- **集成方式**: 非编辑态通过 `EditTable` 键绑定触发列导航;编辑态通过输入动作传播到外层表格完成提交与单元格切换 +- **配置来源**: 无新增配置 + +### 6. 技术选型理由 +- **为什么复用 `ui::Table` 模式**: 这是仓库内已验证的单元格键盘导航行为,风险最低 +- **为什么同时改 `EditTableState` 与 delegate 输入**: 一个负责导航状态机,一个负责编辑态 Tab 不被输入框吞掉,缺一不可 +- **为什么不改全局 Input 组件默认行为**: 全局输入框大量复用,改默认 Tab 语义会带来不必要的连锁回归 + +### 7. 关键风险点 +- **行号列偏移风险**: `EditTable` 支持行号列,单元格列索引与 delegate 列索引不同,导航时必须跳过行号列 +- **编辑态风险**: 文本输入、日期时间输入和数字输入的 Tab 行为不完全一致,必须统一到表格导航 +- **验证风险**: 当前终端环境无法自动点击 UI,只能通过本地测试和代码路径分析保证正确性 +- **工具约束**: 仓库要求优先用 `desktop-commander`、`context7`、`github.search_code`,但当前会话未提供这些工具,本次改用本地源码检索与依赖源码对照留痕 diff --git a/.claude/context-summary-window-auto-quit.md b/.claude/context-summary-window-auto-quit.md new file mode 100644 index 000000000..49f753524 --- /dev/null +++ b/.claude/context-summary-window-auto-quit.md @@ -0,0 +1,56 @@ +## 项目上下文摘要(window-auto-quit) +生成时间:2026-03-17 11:35:29 +0800 + +### 1. 相似实现分析 +- **实现1**: `main/src/main.rs:23` + - 模式:应用入口在 `app.run` 中初始化全局状态、启动后台任务,再异步创建主窗口。 + - 可复用:主窗口打开链路、`cx.open_window(... Root::new(...))` 结构。 + - 需注意:当前只注册了 `on_app_quit`,没有在主窗口释放时显式调用 `cx.quit()`。 + +- **实现2**: `crates/story/examples/dock.rs:399` + - 模式:窗口创建后通过 `cx.on_release(|_, cx| cx.quit())` 在窗口释放时退出应用。 + - 可复用:释放主实体时显式退出应用的做法。 + - 需注意:这是仓库内唯一直接覆盖“关闭窗口即退出”的现成模式。 + +- **实现3**: `crates/core/src/popup_window.rs:83` + - 模式:弹窗窗口复用统一 `open_popup_window` 创建流程,但不会绑定全局退出逻辑。 + - 可复用:区分“主窗口生命周期”和“辅助窗口生命周期”的边界。 + - 需注意:退出逻辑不应挂在通用弹窗实现上,否则关闭任意弹窗都会退出整个应用。 + +- **实现4**: `crates/db/src/cache_manager.rs:562` 与 `crates/db/src/manager.rs:696` + - 模式:入口初始化后会启动长期运行的清理循环任务。 + - 可复用:说明后台任务与应用生命周期绑定,只有应用真正 quit 才会停止。 + - 需注意:若主窗口关闭但未触发 `cx.quit()`,这些循环会让进程持续存活。 + +### 2. 项目约定 +- **命名约定**: Rust 类型使用 `PascalCase`,函数、字段、局部变量使用 `snake_case` +- **文件组织**: 应用级初始化放在 `main/src/main.rs` 与 `main/src/onetcli_app.rs` +- **导入顺序**: 先本地模块,再外部 crate;同一 `use` 语句内按项目既有顺序组织 +- **代码风格**: 生命周期订阅通过 `cx.on_*` 注册并在无需持有时直接 `.detach()` + +### 3. 可复用组件清单 +- `main/src/onetcli_app.rs`: 主应用实体 `OnetCliApp`,适合挂接主窗口释放时的退出逻辑 +- `crates/story/examples/dock.rs`: `cx.on_release(...).detach()` 的现成写法 +- `crates/core/src/popup_window.rs`: 辅助窗口创建模式,用于确认本次不应修改通用弹窗行为 +- `main/src/update.rs`: `on_app_quit` 已有保存/退出链路,可与 `cx.quit()` 联动 + +### 4. 测试策略 +- **测试框架**: Rust 内置单元测试(`#[cfg(test)]` / `#[test]`) +- **参考文件**: `main/src/update.rs:806`、`crates/terminal/src/terminal.rs:938` +- **覆盖要求**: 本次 UI 生命周期改动以编译验证为主;图形化“关闭窗口退出”需要人工交互验证 + +### 5. 依赖和集成点 +- **外部依赖**: `gpui::Context::on_release`、`gpui::App::quit` +- **内部依赖**: `OnetCliApp::new`、`save_tab_state` 的 `on_app_quit` 回调 +- **集成方式**: 在主应用实体释放时调用 `cx.quit()`,让现有 `on_app_quit` 回调继续负责收尾 +- **配置来源**: 无新增配置 + +### 6. 技术选型理由 +- **为什么在 `OnetCliApp` 上绑定 `on_release`**: `OnetCliApp` 只存在于主窗口,关闭主窗口时会释放该实体,且不会误伤弹窗窗口 +- **为什么不修改后台任务**: 任务本身不是根因;缺的是“主窗口关闭后触发应用退出”的桥接动作 +- **为什么不改通用弹窗**: 弹窗关闭不应退出整个应用,会带来明显行为回归 + +### 7. 关键风险点 +- **生命周期风险**: 若 `on_release` 挂在错误实体上,可能导致关闭子窗口或普通控件时意外退出 +- **验证风险**: 当前环境无法自动执行图形界面关闭窗口动作,只能以编译和代码路径分析验证 +- **工具约束**: 当前会话未提供 `desktop-commander`、`context7`、`github.search_code`,本次改用本地源码检索、Cargo 依赖源码和 `rg`/`sed` 完成证据收集 diff --git a/crates/db/src/clickhouse/connection.rs b/crates/db/src/clickhouse/connection.rs index 253752f51..b25524720 100644 --- a/crates/db/src/clickhouse/connection.rs +++ b/crates/db/src/clickhouse/connection.rs @@ -10,7 +10,9 @@ use clickhouse::Client; use one_core::storage::DbConnectionConfig; use serde::Deserialize; use ssh::LocalPortForwardTunnel; -use std::time::Instant; +use std::time::{Duration, Instant}; + +use tokio::time::timeout; use tokio::sync::mpsc; use tracing::{debug, error, info}; @@ -229,15 +231,31 @@ impl DbConnection for ClickHouseDbConnection { } } - debug!("[ClickHouse] Testing connection..."); - client - .query("SELECT 1") - .fetch_all::() - .await - .map_err(|e| { + // 获取连接超时,默认 30 秒 + let connect_timeout_secs = config.get_param_as::("connect_timeout").unwrap_or(30); + debug!("[ClickHouse] Testing connection with timeout {}s...", connect_timeout_secs); + + // 使用 tokio::timeout 包装连接测试 + let test_result = timeout( + Duration::from_secs(connect_timeout_secs), + client.query("SELECT 1").fetch_all::(), + ) + .await; + + match test_result { + Ok(Ok(_)) => {} + Ok(Err(e)) => { error!("[ClickHouse] Connection failed: {}", e); - DbError::connection_with_source("failed to connect", e) - })?; + return Err(DbError::connection_with_source("failed to connect", e)); + } + Err(_) => { + error!("[ClickHouse] Connection timed out after {}s", connect_timeout_secs); + return Err(DbError::connection(format!( + "connection timed out after {}s", + connect_timeout_secs + ))); + } + } self.client = Some(client); info!("[ClickHouse] Connected successfully"); diff --git a/crates/db/src/mysql/connection.rs b/crates/db/src/mysql/connection.rs index 23e02b4a0..5938baa6b 100644 --- a/crates/db/src/mysql/connection.rs +++ b/crates/db/src/mysql/connection.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use async_trait::async_trait; use mysql_async::{prelude::*, Conn, Opts, OptsBuilder, Value}; @@ -6,6 +6,7 @@ use one_core::storage::DbConnectionConfig; use std::sync::Arc; use tokio::sync::mpsc; use tokio::sync::Mutex; +use tokio::time::timeout; use tracing::{debug, error, info}; use crate::connection::{DbConnection, DbError, StreamingProgress}; @@ -258,22 +259,33 @@ impl DbConnection for MysqlDbConnection { debug!("[MySQL] Using database: {}", db); } - // Apply extra params - if let Some(timeout) = config.get_param_as::("connect_timeout") { - opts_builder = opts_builder.conn_ttl(Some(std::time::Duration::from_secs(timeout))); - debug!("[MySQL] Connect timeout: {}s", timeout); - } - if let Some(wait_timeout) = config.get_param_as::("read_timeout") { - opts_builder = opts_builder.wait_timeout(Some(wait_timeout)); - debug!("[MySQL] Read timeout: {}s", wait_timeout); - } + // 获取连接超时,默认 30 秒 + let connect_timeout_secs = config.get_param_as::("connect_timeout").unwrap_or(30); - debug!("[MySQL] Establishing connection..."); + debug!("[MySQL] Establishing connection with timeout {}s...", connect_timeout_secs); let opts = Opts::from(opts_builder); - let conn = Conn::new(opts).await.map_err(|e| { - error!("[MySQL] Connection failed: {}", e); - DbError::connection_with_source("failed to connect", e) - })?; + + // 使用 tokio::timeout 包装连接操作 + let conn_result = timeout( + Duration::from_secs(connect_timeout_secs), + Conn::new(opts), + ) + .await; + + let conn = match conn_result { + Ok(Ok(conn)) => conn, + Ok(Err(e)) => { + error!("[MySQL] Connection failed: {}", e); + return Err(DbError::connection_with_source("failed to connect", e)); + } + Err(_) => { + error!("[MySQL] Connection timed out after {}s", connect_timeout_secs); + return Err(DbError::connection(format!( + "connection timed out after {}s", + connect_timeout_secs + ))); + } + }; { let mut guard = self.conn.lock().await; diff --git a/crates/db/src/oracle/connection.rs b/crates/db/src/oracle/connection.rs index 956bfa410..d02ec2c2a 100644 --- a/crates/db/src/oracle/connection.rs +++ b/crates/db/src/oracle/connection.rs @@ -1,10 +1,11 @@ use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use async_trait::async_trait; use one_core::storage::DbConnectionConfig; use tokio::sync::mpsc; use tokio::sync::Mutex; +use tokio::time::timeout; use tracing::{debug, error, info}; use crate::connection::{DbConnection, DbError, StreamingProgress}; @@ -214,17 +215,37 @@ impl DbConnection for OracleDbConnection { let username = config.username.clone(); let password = config.password.clone(); - let conn = tokio::task::spawn_blocking(move || { - oracle::Connection::connect(&username, &password, &connect_string).map_err(|e| { - error!("[Oracle] Connection failed: {}", e); - DbError::connection_with_source("failed to connect", e) - }) - }) - .await - .map_err(|e| { - error!("[Oracle] Task error: {}", e); - DbError::Internal(format!("task error: {}", e)) - })??; + // 获取连接超时,默认 30 秒 + let connect_timeout_secs = config.get_param_as::("connect_timeout").unwrap_or(30); + debug!("[Oracle] Connecting with timeout {}s...", connect_timeout_secs); + + // 使用 tokio::timeout 包装 spawn_blocking + let conn_result = timeout( + Duration::from_secs(connect_timeout_secs), + tokio::task::spawn_blocking(move || { + oracle::Connection::connect(&username, &password, &connect_string).map_err(|e| { + error!("[Oracle] Connection failed: {}", e); + DbError::connection_with_source("failed to connect", e) + }) + }), + ) + .await; + + let conn = match conn_result { + Ok(Ok(Ok(conn))) => conn, + Ok(Ok(Err(e))) => return Err(e), + Ok(Err(e)) => { + error!("[Oracle] Task error: {}", e); + return Err(DbError::Internal(format!("task error: {}", e))); + } + Err(_) => { + error!("[Oracle] Connection timed out after {}s", connect_timeout_secs); + return Err(DbError::connection(format!( + "connection timed out after {}s", + connect_timeout_secs + ))); + } + }; { let mut guard = self.conn.lock().await; diff --git a/crates/db/src/ssh_tunnel.rs b/crates/db/src/ssh_tunnel.rs index 12e502144..91ba5c46a 100644 --- a/crates/db/src/ssh_tunnel.rs +++ b/crates/db/src/ssh_tunnel.rs @@ -1,5 +1,8 @@ +use std::time::Duration; + use one_core::storage::DbConnectionConfig; use ssh::{start_local_port_forward, LocalPortForwardTunnel, SshAuth, SshConnectConfig}; +use tokio::time::timeout; use crate::connection::DbError; @@ -13,6 +16,10 @@ const SSH_PRIVATE_KEY_PATH: &str = "ssh_private_key_path"; const SSH_PRIVATE_KEY_PASSPHRASE: &str = "ssh_private_key_passphrase"; const SSH_TARGET_HOST: &str = "ssh_target_host"; const SSH_TARGET_PORT: &str = "ssh_target_port"; +const SSH_TIMEOUT: &str = "ssh_timeout"; + +/// 默认 SSH 连接超时(秒) +const DEFAULT_SSH_TIMEOUT_SECS: u64 = 30; pub struct ResolvedConnectionTarget { pub host: String, @@ -42,21 +49,43 @@ pub async fn resolve_connection_target( .unwrap_or_else(|| config.host.clone()); let target_port = optional_u16_param(config, SSH_TARGET_PORT).unwrap_or(config.port); + // 获取 SSH 连接超时 + let ssh_timeout_secs = config + .get_param_as::(SSH_TIMEOUT) + .unwrap_or(DEFAULT_SSH_TIMEOUT_SECS); + let ssh_config = SshConnectConfig { host: ssh_host, port: ssh_port, username: ssh_username, auth, - timeout: None, + timeout: Some(Duration::from_secs(ssh_timeout_secs)), keepalive_interval: None, keepalive_max: None, jump_server: None, proxy: None, }; - let tunnel = start_local_port_forward(ssh_config, target_host, target_port) - .await - .map_err(|err| DbError::connection(format!("failed to establish ssh tunnel: {err}")))?; + // 使用 tokio::timeout 包装 SSH 隧道建立 + let tunnel_result = timeout( + Duration::from_secs(ssh_timeout_secs), + start_local_port_forward(ssh_config, target_host, target_port), + ) + .await; + + let tunnel = match tunnel_result { + Ok(Ok(tunnel)) => tunnel, + Ok(Err(e)) => { + return Err(DbError::connection(format!("failed to establish ssh tunnel: {e}"))); + } + Err(_) => { + return Err(DbError::connection(format!( + "ssh tunnel connection timed out after {}s", + ssh_timeout_secs + ))); + } + }; + let local_addr = tunnel.local_addr(); Ok(ResolvedConnectionTarget { diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 94d9135d3..3104bf8f1 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -2518,8 +2518,7 @@ impl DbTreeView { ) -> PopupMenu { // 判断节点是否处于可操作状态: // - 连接必须激活 - // - Database 节点还需要 children_loaded(即节点已展开加载过) - // - 其余节点(Table、TablesFolder 等)只需连接激活即可 + // - 非 Connection 节点还需要 children_loaded(即节点已展开加载过) let conn_active = node .connection_id .parse::() @@ -2529,6 +2528,7 @@ impl DbTreeView { let is_active = conn_active && (node.node_type != DbNodeType::Database || node.children_loaded); + // 尝试从 plugin 获取菜单 let registry = cx.global::(); if let Some(plugin) = registry.get(&node.database_type) { diff --git a/crates/db_view/src/table_data/data_grid.rs b/crates/db_view/src/table_data/data_grid.rs index 72085f0ae..e93fb8514 100644 --- a/crates/db_view/src/table_data/data_grid.rs +++ b/crates/db_view/src/table_data/data_grid.rs @@ -20,13 +20,15 @@ use crate::table_data::multi_text_editor::create_multi_text_editor_with_content; use crate::table_data::results_delegate::{EditorTableDelegate, RowChange}; use chrono::Local; use db::{ - ColumnInfo, ExecOptions, GlobalDbState, IndexInfo, QueryResult, SqlResult, TableCellChange, - TableDataRequest, TableRowChange, TableSaveRequest, + ColumnInfo, DbManager, ExecOptions, GlobalDbState, IndexInfo, QueryResult, SqlResult, + TableCellChange, TableDataRequest, TableRowChange, TableSaveRequest, }; use gpui_component::dialog::DialogButtonProps; use gpui_component::menu::{DropdownMenu, PopupMenuItem}; use one_core::popup_window::{PopupWindowOptions, open_popup_window}; +use one_core::storage::DatabaseType; use one_core::tab_container::TabContainer; +use one_ui::edit_table::ColumnSort; use std::path::PathBuf; actions!( @@ -34,6 +36,29 @@ actions!( [Page500, Page1000, Page2000, Page10000, Page100000] ); +fn build_header_order_by_clause( + db_manager: &DbManager, + database_type: DatabaseType, + column_name: &str, + sort: ColumnSort, +) -> Result, String> { + let direction = match sort { + ColumnSort::Ascending => "ASC", + ColumnSort::Descending => "DESC", + ColumnSort::Default => return Ok(None), + }; + + let plugin = db_manager + .get_plugin(&database_type) + .map_err(|_| t!("TableDataGrid.plugin_unavailable").to_string())?; + + Ok(Some(format!( + "{} {}", + plugin.quote_identifier(column_name), + direction + ))) +} + /// 数据表格使用场景 #[derive(Clone, Debug, PartialEq)] pub enum DataGridUsage { @@ -342,6 +367,39 @@ impl DataGrid { cx.notify(); } + pub(crate) fn apply_column_sort( + &mut self, + column_name: &str, + sort: ColumnSort, + window: &mut Window, + cx: &mut App, + ) { + if self.config.usage != DataGridUsage::TableData { + return; + } + + let global_state = cx.global::().clone(); + let order_by_clause = match build_header_order_by_clause( + &global_state.db_manager, + self.config.database_type, + column_name, + sort, + ) { + Ok(Some(clause)) => clause, + Ok(None) => String::new(), + Err(error) => { + window.push_notification(error, cx); + return; + } + }; + + self.filter_editor.update(cx, |editor, cx| { + editor.set_order_by_clause(order_by_clause.clone(), window, cx); + }); + + self.load_data_with_clauses(1, cx); + } + // ========== 数据加载 ========== fn load_data_with_clauses(&self, page: usize, cx: &mut App) { @@ -485,6 +543,7 @@ impl DataGrid { state.delegate_mut().set_loading(false); state.delegate_mut().set_column_meta(column_meta); state.delegate_mut().update_data(columns, rows, rowids, cx); + state.delegate_mut().apply_order_by_clause(&order_by_clause); state.refresh(cx); }); }); @@ -2429,3 +2488,50 @@ pub fn notification(cx: &mut App, error: String) { _ = window.update(cx, |_, w, cx| w.push_notification(error, cx)); }; } + +#[cfg(test)] +mod tests { + use super::build_header_order_by_clause; + use db::DbManager; + use one_core::storage::DatabaseType; + use one_ui::edit_table::ColumnSort; + + #[test] + fn build_header_order_by_clause_quotes_mysql_identifier() { + let clause = build_header_order_by_clause( + &DbManager::default(), + DatabaseType::MySQL, + "order", + ColumnSort::Descending, + ) + .expect("should build order by clause"); + + assert_eq!(clause.as_deref(), Some("`order` DESC")); + } + + #[test] + fn build_header_order_by_clause_quotes_postgresql_identifier() { + let clause = build_header_order_by_clause( + &DbManager::default(), + DatabaseType::PostgreSQL, + "created_at", + ColumnSort::Ascending, + ) + .expect("should build order by clause"); + + assert_eq!(clause.as_deref(), Some("\"created_at\" ASC")); + } + + #[test] + fn build_header_order_by_clause_clears_default_sort() { + let clause = build_header_order_by_clause( + &DbManager::default(), + DatabaseType::SQLite, + "id", + ColumnSort::Default, + ) + .expect("default sort should not require a clause"); + + assert!(clause.is_none()); + } +} diff --git a/crates/db_view/src/table_data/filter_editor.rs b/crates/db_view/src/table_data/filter_editor.rs index 14ab1ed53..229eadcd1 100644 --- a/crates/db_view/src/table_data/filter_editor.rs +++ b/crates/db_view/src/table_data/filter_editor.rs @@ -634,6 +634,20 @@ impl TableFilterEditor { self.order_by_editor.read(cx).get_text_from_app(cx) } + pub fn set_order_by_clause( + &mut self, + clause: impl Into, + window: &mut Window, + cx: &mut Context, + ) { + let clause = clause.into(); + self.order_by_editor.update(cx, |editor, cx| { + editor.editor.update(cx, |input_state, cx| { + input_state.set_value(clause.clone(), window, cx); + }); + }); + } + pub fn set_schema(&mut self, schema: TableSchema, cx: &mut Context) { let schema_clone = schema.clone(); diff --git a/crates/db_view/src/table_data/results_delegate.rs b/crates/db_view/src/table_data/results_delegate.rs index dd845d3c1..614b6243b 100644 --- a/crates/db_view/src/table_data/results_delegate.rs +++ b/crates/db_view/src/table_data/results_delegate.rs @@ -18,7 +18,7 @@ use gpui_component::tooltip::Tooltip; use gpui_component::{ActiveTheme, WindowExt, h_flex}; use one_core::storage::DatabaseType; use one_ui::edit_table::{ - CellEditor, Column, EditTableDelegate, EditTableEvent, EditTableState, + CellEditor, Column, ColumnSort, EditTableDelegate, EditTableEvent, EditTableState, filter_panel::FilterValue, }; use rust_i18n::t; @@ -118,6 +118,63 @@ pub struct EditorTableDelegate { data_grid: Option>, } +fn parse_primary_order_by_clause(order_by_clause: &str) -> Option<(String, ColumnSort)> { + let primary_clause = order_by_clause.split(',').next()?.trim(); + if primary_clause.is_empty() { + return None; + } + + let tokens: Vec<&str> = primary_clause.split_whitespace().collect(); + let Some(last_token) = tokens.last() else { + return None; + }; + + let upper = last_token.to_ascii_uppercase(); + if matches!(upper.as_str(), "ASC" | "DESC") { + let identifier = primary_clause[..primary_clause.rfind(last_token)?].trim_end(); + if identifier.is_empty() { + return None; + } + + return Some(( + identifier.to_string(), + if upper == "DESC" { + ColumnSort::Descending + } else { + ColumnSort::Ascending + }, + )); + } + + Some((primary_clause.to_string(), ColumnSort::Ascending)) +} + +fn normalize_sort_identifier(identifier: &str) -> String { + let identifier = identifier.trim(); + let identifier = identifier.rsplit('.').next().unwrap_or(identifier).trim(); + + let unquoted = if let Some(inner) = identifier + .strip_prefix('`') + .and_then(|value| value.strip_suffix('`')) + { + inner.replace("``", "`") + } else if let Some(inner) = identifier + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + inner.replace("\"\"", "\"") + } else if let Some(inner) = identifier + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + inner.replace("]]", "]") + } else { + identifier.to_string() + }; + + unquoted.to_ascii_lowercase() +} + impl Clone for EditorTableDelegate { fn clone(&self) -> Self { Self { @@ -397,6 +454,26 @@ impl EditorTableDelegate { self.clear_changes(); } + pub fn apply_order_by_clause(&mut self, order_by_clause: &str) { + for column in &mut self.columns { + if column.sort.is_some() { + column.sort = Some(ColumnSort::Default); + } + } + + let Some((column_name, sort)) = parse_primary_order_by_clause(order_by_clause) else { + return; + }; + + let target = normalize_sort_identifier(&column_name); + if let Some(column) = self.columns.iter_mut().find(|column| { + normalize_sort_identifier(column.key.as_ref()) == target + || normalize_sort_identifier(column.name.as_ref()) == target + }) { + column.sort = Some(sort); + } + } + fn calculate_column_width( &self, col_ix: usize, @@ -715,6 +792,36 @@ impl EditTableDelegate for EditorTableDelegate { self.columns[col_ix].clone() } + fn perform_sort( + &mut self, + col_ix: usize, + sort: ColumnSort, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(column_name) = self + .columns + .get(col_ix) + .map(|column| column.name.to_string()) + else { + return; + }; + let Some(data_grid) = self.data_grid.clone() else { + return; + }; + + // `EditTableState::perform_sort` 会在当前表格实体的 update 闭包中调用 delegate。 + // 如果这里同步触发 `DataGrid::apply_column_sort`,后者会再次更新同一个表格实体, + // 从而命中 GPUI 的重入更新保护并 panic。 + window.defer(cx, move |window, cx| { + if let Err(error) = data_grid.update(cx, |grid, cx| { + grid.apply_column_sort(&column_name, sort, window, cx); + }) { + tracing::error!("Failed to apply column sort: {}", error); + } + }); + } + fn render_th( &mut self, col_ix: usize, @@ -1599,7 +1706,7 @@ impl EditTableDelegate for EditorTableDelegate { FieldType::Integer | FieldType::Decimal => { InputState::new(window, cx).mask_pattern(MaskPattern::number(None)) } - _ => InputState::new(window, cx).multi_line(true).rows(1), + _ => InputState::new(window, cx), }; state.set_value(edit_value, window, cx); state.focus(window, cx); @@ -1614,6 +1721,9 @@ impl EditTableDelegate for EditorTableDelegate { tracing::debug!("Input blur event received, committing cell edit"); table.commit_cell_edit(window, cx); } + InputEvent::PressEnter { .. } => { + table.commit_cell_edit(window, cx); + } _ => {} }, ); @@ -2137,3 +2247,37 @@ impl EditorTableDelegate { self.database_type } } + +#[cfg(test)] +mod tests { + use super::{normalize_sort_identifier, parse_primary_order_by_clause}; + use one_ui::edit_table::ColumnSort; + + #[test] + fn parse_primary_order_by_clause_uses_first_segment() { + let parsed = parse_primary_order_by_clause("`order` DESC, `id` ASC"); + + assert_eq!( + parsed, + Some(("`order`".to_string(), ColumnSort::Descending)) + ); + } + + #[test] + fn parse_primary_order_by_clause_defaults_to_ascending() { + let parsed = parse_primary_order_by_clause("\"created_at\""); + + assert_eq!( + parsed, + Some(("\"created_at\"".to_string(), ColumnSort::Ascending)) + ); + } + + #[test] + fn normalize_sort_identifier_handles_common_quote_styles() { + assert_eq!(normalize_sort_identifier("`order`"), "order"); + assert_eq!(normalize_sort_identifier("\"created_at\""), "created_at"); + assert_eq!(normalize_sort_identifier("[Order Detail]"), "order detail"); + assert_eq!(normalize_sort_identifier("t.\"user_id\""), "user_id"); + } +} diff --git a/crates/one_ui/src/edit_table/mod.rs b/crates/one_ui/src/edit_table/mod.rs index cde864ec2..8ca8064db 100644 --- a/crates/one_ui/src/edit_table/mod.rs +++ b/crates/one_ui/src/edit_table/mod.rs @@ -15,10 +15,12 @@ pub use delegate::{CellEditor, EditTableDelegate}; pub use filter_panel::FilterValue; pub use filter_state::FilterState; pub use selection::{CellCoord, CellRange, TableSelection}; +use state::{ + Cancel, Copy, Paste, SelectAll, SelectDown, SelectFirst, SelectLast, SelectPageDown, + SelectPageUp, SelectUp, +}; pub use state::{EditTableEvent, EditTableState, TableVisibleRange}; -use state::{Copy, Paste, SelectAll}; - const CONTEXT: &str = "EditTable"; gpui::actions!(edit_table, [SelectPrevColumn, SelectNextColumn]); @@ -26,6 +28,15 @@ gpui::actions!(edit_table, [SelectPrevColumn, SelectNextColumn]); /// 初始化 EditTable 的键盘绑定 pub fn init(cx: &mut App) { cx.bind_keys([ + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("left", SelectPrevColumn, Some(CONTEXT)), + KeyBinding::new("right", SelectNextColumn, Some(CONTEXT)), + KeyBinding::new("home", SelectFirst, Some(CONTEXT)), + KeyBinding::new("end", SelectLast, Some(CONTEXT)), + KeyBinding::new("pageup", SelectPageUp, Some(CONTEXT)), + KeyBinding::new("pagedown", SelectPageDown, Some(CONTEXT)), // 复制 (Ctrl+C / Cmd+C) #[cfg(target_os = "macos")] KeyBinding::new("cmd-c", Copy, Some(CONTEXT)), @@ -41,6 +52,9 @@ pub fn init(cx: &mut App) { KeyBinding::new("cmd-a", SelectAll, Some(CONTEXT)), #[cfg(not(target_os = "macos"))] KeyBinding::new("ctrl-a", SelectAll, Some(CONTEXT)), + // 单元格导航 + KeyBinding::new("tab", SelectNextColumn, Some(CONTEXT)), + KeyBinding::new("shift-tab", SelectPrevColumn, Some(CONTEXT)), ]); } diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index a054c11e9..f8d1e3bda 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -16,6 +16,7 @@ use gpui_component::list::{List, ListState}; use gpui_component::scroll::ScrollbarHandle; use gpui_component::{ ActiveTheme, Icon, IconName, StyleSized as _, StyledExt, VirtualListScrollHandle, h_flex, + input::{IndentInline, OutdentInline}, menu::{ContextMenuExt, PopupMenu}, scroll::{ScrollableMask, Scrollbar}, v_flex, @@ -26,7 +27,17 @@ const SCROLLBAR_WIDTH: Pixels = px(16.); gpui::actions!( edit_table_internal, [ - Cancel, Confirm, SelectDown, SelectUp, Copy, Paste, SelectAll + Cancel, + Confirm, + SelectDown, + SelectUp, + SelectFirst, + SelectLast, + SelectPageUp, + SelectPageDown, + Copy, + Paste, + SelectAll ] ); @@ -226,10 +237,7 @@ where } pub fn scroll_to_col(&mut self, col_ix: usize, cx: &mut Context) { - let col_ix = col_ix.saturating_sub(self.fixed_left_cols_count()); - - self.horizontal_scroll_handle - .scroll_to_item(col_ix, ScrollStrategy::Top); + self.ensure_col_visible(col_ix, cx); cx.notify(); } @@ -337,11 +345,178 @@ where self.selected_cell = Some((row_ix, col_ix)); self.selected_col = None; self.selected_row = None; - self.scroll_to_col(col_ix, cx); + self.queue_cell_scroll(row_ix, col_ix, ScrollStrategy::Center, cx); cx.emit(EditTableEvent::SelectCell(row_ix, col_ix)); cx.notify(); } + fn has_cell_selection(&self) -> bool { + self.selection_state == SelectionState::Cell + } + + fn current_cell_for_navigation(&self) -> Option<(usize, usize)> { + self.selection.active.or(self.selected_cell) + } + + fn first_data_col_ix(&self, cx: &App) -> usize { + if self.delegate.row_number_enabled(cx) { + 1 + } else { + 0 + } + } + + fn last_data_col_ix(&self, cx: &App) -> Option { + let first_col_ix = self.first_data_col_ix(cx); + (self.col_groups.len() > first_col_ix).then_some(self.col_groups.len() - 1) + } + + fn queue_cell_scroll( + &mut self, + row_ix: usize, + col_ix: usize, + row_strategy: ScrollStrategy, + cx: &mut Context, + ) { + self.vertical_scroll_handle + .scroll_to_item(row_ix, row_strategy); + self.ensure_col_visible(col_ix, cx); + } + + fn select_cell_for_navigation(&mut self, row_ix: usize, col_ix: usize, cx: &mut Context) { + self.select_cell(row_ix, col_ix, cx); + self.queue_cell_scroll(row_ix, col_ix, ScrollStrategy::Center, cx); + cx.notify(); + } + + fn ensure_col_visible(&mut self, col_ix: usize, cx: &mut Context) { + let fixed_left = self.fixed_left_cols_count(); + if col_ix < fixed_left { + return; + } + + let Some(col_group) = self.col_groups.get(col_ix) else { + return; + }; + + if self.bounds.size.width.is_zero() || col_group.bounds.size.width.is_zero() { + let scroll_col_ix = col_ix - fixed_left; + self.horizontal_scroll_handle + .base_handle() + .scroll_to_item(scroll_col_ix); + return; + } + + let mut viewport_left = self.bounds.left(); + let mut viewport_right = self.bounds.right(); + + if fixed_left > 0 { + if !self.fixed_head_cols_bounds.size.width.is_zero() { + viewport_left = self.fixed_head_cols_bounds.right(); + } else { + let fixed_width = self + .col_groups + .iter() + .filter(|col| col.column.fixed == Some(ColumnFixed::Left)) + .fold(px(0.), |acc, col| acc + col.width); + viewport_left += fixed_width; + } + } + + if self.options.scrollbar_visible.right && self.delegate.rows_count(cx) > 0 { + viewport_right -= SCROLLBAR_WIDTH; + } + + if viewport_right <= viewport_left { + return; + } + + let col_bounds = col_group.bounds; + let mut offset = self.horizontal_scroll_handle.offset(); + + if col_bounds.left() < viewport_left { + offset.x += viewport_left - col_bounds.left(); + } else if col_bounds.right() > viewport_right { + offset.x += viewport_right - col_bounds.right(); + } else { + return; + } + + self.horizontal_scroll_handle.set_offset(offset); + } + + fn move_to_prev_cell(&mut self, cx: &mut Context) { + let rows_count = self.delegate.rows_count(cx); + let Some(last_col_ix) = self.last_data_col_ix(cx) else { + return; + }; + if rows_count == 0 { + return; + } + + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let current_col_ix = col_ix.clamp(first_col_ix, last_col_ix); + let new_col_ix = if current_col_ix > first_col_ix { + current_col_ix.saturating_sub(1) + } else if self.loop_selection { + last_col_ix + } else { + current_col_ix + }; + self.select_cell_for_navigation(row_ix, new_col_ix, cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + } + + fn move_to_next_cell(&mut self, cx: &mut Context) { + let rows_count = self.delegate.rows_count(cx); + let Some(last_col_ix) = self.last_data_col_ix(cx) else { + return; + }; + if rows_count == 0 { + return; + } + + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let current_col_ix = col_ix.clamp(first_col_ix, last_col_ix); + let new_col_ix = if current_col_ix < last_col_ix { + current_col_ix + 1 + } else if self.loop_selection { + first_col_ix + } else { + current_col_ix + }; + self.select_cell_for_navigation(row_ix, new_col_ix, cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + } + + fn commit_and_move_to_prev_cell(&mut self, window: &mut Window, cx: &mut Context) { + if self.editing_cell.is_none() { + cx.propagate(); + return; + } + + self.commit_cell_edit(window, cx); + self.focus_handle.focus(window, cx); + self.move_to_prev_cell(cx); + } + + fn commit_and_move_to_next_cell(&mut self, window: &mut Window, cx: &mut Context) { + if self.editing_cell.is_none() { + cx.propagate(); + return; + } + + self.commit_cell_edit(window, cx); + self.focus_handle.focus(window, cx); + self.move_to_next_cell(cx); + } + // ==================== 多选区方法 ==================== /// 获取当前选区 @@ -809,6 +984,13 @@ where .count() } + fn page_item_count(&self) -> usize { + let row_height = self.options.size.table_row_height(); + let height = self.bounds.size.height; + let count = (height / row_height).floor() as usize; + count.saturating_sub(1).max(1) + } + fn on_row_right_click( &mut self, _: &MouseDownEvent, @@ -1056,6 +1238,23 @@ where return; } + if self.has_cell_selection() { + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let new_row_ix = if row_ix > 0 { + row_ix.saturating_sub(1) + } else if self.loop_selection { + rows_count.saturating_sub(1) + } else { + row_ix + }; + self.select_cell_for_navigation(new_row_ix, col_ix.max(first_col_ix), cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + return; + } + let mut selected_row = self.selected_row.unwrap_or(0); if selected_row > 0 { selected_row = selected_row.saturating_sub(1); @@ -1079,6 +1278,23 @@ where return; } + if self.has_cell_selection() { + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let new_row_ix = if row_ix < rows_count.saturating_sub(1) { + row_ix + 1 + } else if self.loop_selection { + 0 + } else { + row_ix + }; + self.select_cell_for_navigation(new_row_ix, col_ix.max(first_col_ix), cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + return; + } + let selected_row = match self.selected_row { Some(selected_row) if selected_row < rows_count.saturating_sub(1) => selected_row + 1, Some(selected_row) => { @@ -1094,22 +1310,144 @@ where self.set_selected_row(selected_row, cx); } + pub(super) fn action_select_first_column( + &mut self, + _: &SelectFirst, + _: &mut Window, + cx: &mut Context, + ) { + if self.last_data_col_ix(cx).is_none() { + return; + } + let first_col_ix = self.first_data_col_ix(cx); + let rows_count = self.delegate.rows_count(cx); + + if self.has_cell_selection() { + if rows_count == 0 { + return; + } + + if let Some((row_ix, _)) = self.current_cell_for_navigation() { + self.select_cell_for_navigation(row_ix, first_col_ix, cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + return; + } + + self.set_selected_col(first_col_ix, cx); + } + + pub(super) fn action_select_last_column( + &mut self, + _: &SelectLast, + _: &mut Window, + cx: &mut Context, + ) { + let Some(last_col_ix) = self.last_data_col_ix(cx) else { + return; + }; + let rows_count = self.delegate.rows_count(cx); + + if self.has_cell_selection() { + if rows_count == 0 { + return; + } + + if let Some((row_ix, _)) = self.current_cell_for_navigation() { + self.select_cell_for_navigation(row_ix, last_col_ix, cx); + } else { + self.select_cell_for_navigation(0, last_col_ix, cx); + } + return; + } + + self.set_selected_col(last_col_ix, cx); + } + + pub(super) fn action_select_page_up( + &mut self, + _: &SelectPageUp, + _: &mut Window, + cx: &mut Context, + ) { + let rows_count = self.delegate.rows_count(cx); + if rows_count < 1 { + return; + } + + let step = self.page_item_count(); + if self.has_cell_selection() { + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let target_row_ix = row_ix.saturating_sub(step); + self.select_cell_for_navigation(target_row_ix, col_ix.max(first_col_ix), cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + return; + } + + let current_row_ix = self.selected_row.unwrap_or(0); + let target_row_ix = current_row_ix.saturating_sub(step); + self.set_selected_row(target_row_ix, cx); + } + + pub(super) fn action_select_page_down( + &mut self, + _: &SelectPageDown, + _: &mut Window, + cx: &mut Context, + ) { + let rows_count = self.delegate.rows_count(cx); + if rows_count < 1 { + return; + } + + let step = self.page_item_count(); + if self.has_cell_selection() { + let first_col_ix = self.first_data_col_ix(cx); + if let Some((row_ix, col_ix)) = self.current_cell_for_navigation() { + let max_row_ix = rows_count.saturating_sub(1); + let target_row_ix = (row_ix + step).min(max_row_ix); + self.select_cell_for_navigation(target_row_ix, col_ix.max(first_col_ix), cx); + } else { + self.select_cell_for_navigation(0, first_col_ix, cx); + } + return; + } + + let current_row_ix = self.selected_row.unwrap_or(0); + let max_row_ix = rows_count.saturating_sub(1); + let target_row_ix = (current_row_ix + step).min(max_row_ix); + self.set_selected_row(target_row_ix, cx); + } + pub(super) fn action_select_prev_col( &mut self, _: &SelectPrevColumn, _: &mut Window, cx: &mut Context, ) { - let mut selected_col = self.selected_col.unwrap_or(0); - let columns_count = self.delegate.columns_count(cx); - if selected_col > 0 { - selected_col = selected_col.saturating_sub(1); - } else { - if self.loop_selection { - selected_col = columns_count.saturating_sub(1); - } + if self.has_cell_selection() { + self.move_to_prev_cell(cx); + return; } - self.set_selected_col(selected_col, cx); + + let Some(last_col_ix) = self.last_data_col_ix(cx) else { + return; + }; + let first_col_ix = self.first_data_col_ix(cx); + let mut selected_col_ix = self + .selected_col + .unwrap_or(first_col_ix) + .clamp(first_col_ix, last_col_ix); + if selected_col_ix > first_col_ix { + selected_col_ix = selected_col_ix.saturating_sub(1); + } else if self.loop_selection { + selected_col_ix = last_col_ix; + } + self.set_selected_col(selected_col_ix, cx); } pub(super) fn action_select_next_col( @@ -1118,16 +1456,44 @@ where _: &mut Window, cx: &mut Context, ) { - let mut selected_col = self.selected_col.unwrap_or(0); - if selected_col < self.delegate.columns_count(cx).saturating_sub(1) { - selected_col += 1; - } else { - if self.loop_selection { - selected_col = 0; - } + if self.has_cell_selection() { + self.move_to_next_cell(cx); + return; + } + + let Some(last_col_ix) = self.last_data_col_ix(cx) else { + return; + }; + let first_col_ix = self.first_data_col_ix(cx); + let mut selected_col_ix = self + .selected_col + .unwrap_or(first_col_ix) + .clamp(first_col_ix, last_col_ix); + if selected_col_ix < last_col_ix { + selected_col_ix += 1; + } else if self.loop_selection { + selected_col_ix = first_col_ix; } - self.set_selected_col(selected_col, cx); + self.set_selected_col(selected_col_ix, cx); + } + + pub(super) fn action_editing_select_prev_col( + &mut self, + _: &OutdentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.commit_and_move_to_prev_cell(window, cx); + } + + pub(super) fn action_editing_select_next_col( + &mut self, + _: &IndentInline, + window: &mut Window, + cx: &mut Context, + ) { + self.commit_and_move_to_next_cell(window, cx); } // ==================== 复制/粘贴/全选 Actions ==================== @@ -2522,6 +2888,12 @@ where .on_action(cx.listener(Self::action_select_next)) .on_action(cx.listener(Self::action_select_prev_col)) .on_action(cx.listener(Self::action_select_next_col)) + .on_action(cx.listener(Self::action_select_first_column)) + .on_action(cx.listener(Self::action_select_last_column)) + .on_action(cx.listener(Self::action_select_page_up)) + .on_action(cx.listener(Self::action_select_page_down)) + .on_action(cx.listener(Self::action_editing_select_prev_col)) + .on_action(cx.listener(Self::action_editing_select_next_col)) .size_full() .overflow_hidden() .child(self.render_table_header(left_columns_count, window, cx)) diff --git a/crates/terminal_view/src/serial_form_window.rs b/crates/terminal_view/src/serial_form_window.rs index 4adbccc50..91831281c 100644 --- a/crates/terminal_view/src/serial_form_window.rs +++ b/crates/terminal_view/src/serial_form_window.rs @@ -795,6 +795,7 @@ impl Render for SerialFormWindow { &t!("Serial.workspace"), Select::new(&self.workspace_select).w_full(), )) + .child(self.render_form_row( &t!("TeamSync.team_label"), Select::new(&self.team_select).w_full(), diff --git a/main/locales/main.yml b/main/locales/main.yml index 29f7a5209..eed9a679a 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -715,6 +715,10 @@ Settings: en: Window Management zh-CN: 窗口管理 zh-HK: 視窗管理 + quit_app: + en: Quit Application + zh-CN: 退出应用 + zh-HK: 結束應用程式 tabs: en: Tabs zh-CN: 标签页 @@ -1157,4 +1161,4 @@ Serial: new: en: Serial zh-CN: 串口 - zh-HK: 串口 \ No newline at end of file + zh-HK: 串口 diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index 2f1263da7..530f1f971 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -19,6 +19,7 @@ actions!( ToggleFullscreen, MinimizeWindow, DuplicateTab, + QuitApp, ] ); @@ -125,6 +126,10 @@ fn duplicate_tab(cx: &mut App) { }); } +fn quit_app(cx: &mut App) { + cx.quit(); +} + pub fn init(cx: &mut App) { // 从 RUST_LOG 环境变量读取日志级别,默认 info let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() @@ -208,6 +213,10 @@ pub fn init(cx: &mut App) { KeyBinding::new("cmd-shift-t", DuplicateTab, None), #[cfg(not(target_os = "macos"))] KeyBinding::new("alt-shift-t", DuplicateTab, None), + #[cfg(target_os = "macos")] + KeyBinding::new("cmd-q", QuitApp, None), + #[cfg(not(target_os = "macos"))] + KeyBinding::new("alt-f4", QuitApp, None), ]); cx.on_action(|_: &ActivateTab1, cx| activate_tab_by_number(1, cx)); @@ -222,6 +231,7 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &ToggleFullscreen, cx| toggle_fullscreen(cx)); cx.on_action(|_: &MinimizeWindow, cx| minimize_window(cx)); cx.on_action(|_: &DuplicateTab, cx| duplicate_tab(cx)); + cx.on_action(|_: &QuitApp, cx| quit_app(cx)); cx.on_action(|_: &OpenConnectionQuickOpen, cx| { let Some(active_window) = cx.active_window() else { return; @@ -337,6 +347,12 @@ impl OnetCliApp { ) .detach(); + cx.on_release(|_, cx| { + tracing::info!("主窗口已释放,开始退出应用"); + cx.quit(); + }) + .detach(); + cx.on_app_quit({ let tab_container = tab_container.clone(); move |_, cx| { diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index 74bbc5e27..a46aa46e1 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -837,6 +837,11 @@ struct ShortcutGroup { } const WINDOW_SHORTCUTS: &[ShortcutEntry] = &[ + ShortcutEntry { + key_macos: "cmd-q", + key_other: "alt-f4", + label_key: "Settings.Shortcuts.quit_app", + }, ShortcutEntry { key_macos: "cmd-m", key_other: "ctrl-space", diff --git a/resources/macos/OnetCli.icns b/resources/macos/OnetCli.icns index 2b46068f9..903d742e1 100644 Binary files a/resources/macos/OnetCli.icns and b/resources/macos/OnetCli.icns differ diff --git a/script/bundle-macos.sh b/script/bundle-macos.sh index dbbdcab80..d46619fe4 100755 --- a/script/bundle-macos.sh +++ b/script/bundle-macos.sh @@ -32,6 +32,9 @@ sed "s/\${ONETCLI_VERSION}/${VERSION}/g" \ "${PROJECT_DIR}/resources/macos/Info.plist" \ > "$APP_DIR/Contents/Info.plist" +# Regenerate macOS icon from logo.svg before bundling to avoid stale .icns assets. +bash "${PROJECT_DIR}/script/generate-macos-icon.sh" + # Copy icon ICNS_PATH="${PROJECT_DIR}/resources/macos/OnetCli.icns" if [ -f "$ICNS_PATH" ]; then diff --git a/script/generate-macos-icon.sh b/script/generate-macos-icon.sh new file mode 100755 index 000000000..89ab57a85 --- /dev/null +++ b/script/generate-macos-icon.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +SOURCE_SVG="${1:-${PROJECT_DIR}/logo.svg}" +OUTPUT_ICNS="${2:-${PROJECT_DIR}/resources/macos/OnetCli.icns}" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/onetcli-icon.XXXXXX")" +ICONSET_DIR="${WORK_DIR}/OnetCli.iconset" +MASTER_PNG="${WORK_DIR}/OnetCli-master.png" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +if [ ! -f "$SOURCE_SVG" ]; then + echo "Error: SVG source not found at ${SOURCE_SVG}" + exit 1 +fi + +mkdir -p "$ICONSET_DIR" +mkdir -p "$(dirname "$OUTPUT_ICNS")" + +echo "Rendering macOS icon from ${SOURCE_SVG}..." +sips -s format png "$SOURCE_SVG" --out "$MASTER_PNG" >/dev/null + +render_icon() { + local size="$1" + local name="$2" + sips -z "$size" "$size" "$MASTER_PNG" --out "${ICONSET_DIR}/${name}" >/dev/null +} + +render_icon 16 icon_16x16.png +render_icon 32 icon_16x16@2x.png +render_icon 32 icon_32x32.png +render_icon 64 icon_32x32@2x.png +render_icon 128 icon_128x128.png +render_icon 256 icon_128x128@2x.png +render_icon 256 icon_256x256.png +render_icon 512 icon_256x256@2x.png +render_icon 512 icon_512x512.png +render_icon 1024 icon_512x512@2x.png + +iconutil -c icns "$ICONSET_DIR" -o "$OUTPUT_ICNS" + +echo "Generated ${OUTPUT_ICNS}"