diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 3a8b796ff..fed9b5542 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -8,7 +8,7 @@ 的布局组件,不要在业务页面里重新拼一套拖动逻辑。拖动过程中要保证界面 稳定、面板不会缩成不可用的尺寸,也不能因为鼠标移动就重建整个页面。 macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互行为和基本 -约束必须一致。 +约束必须一致。Windows 分隔条使用无高亮的间隙和调整光标,macOS 保留原生风格高亮;这项视觉差异不改变尺寸与性能约束。 ## 问题 @@ -32,8 +32,13 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 中,避免每次拖动都重新计算整个功能页面。 - 为面板设置最小尺寸、最大尺寸和可用空间约束。窗口缩放、面板隐藏和 面板恢复后,都不能产生负尺寸或布局溢出。 -- 交互应与现有 Git、编辑器和工具窗口保持一致,包括悬停/拖动高亮、 - 平台习惯的调整光标、帮助文本和无障碍标签。 +- 同一平台的分隔条交互应保持一致。Windows 工作台、文件导航和 Git Log 列边缘使用 + 无高亮间隙以及 `ew-resize` / `ns-resize` 光标,避免连续面板边界出现额外蓝色线条; + macOS 的 `SplitHandleView` 保留悬停/拖动高亮。两端仍须保留帮助文本、无障碍标签、 + 尺寸限制和拖动结束后持久化。不要为了视觉统一删除这些交互约束。 +- Windows Git Log 列宽复用 `utils/document-resize-session.ts`,每帧只写局部 CSS 变量, + 结束或卸载时提交一次宽度并释放监听。列的最小和最大宽度限制后,空间不足通过 + 表格横向滚动处理,不挤掉提交说明。 - 如果尺寸需要在重启后保留,只在拖动结束时通过现有布局持久化机制保存 最终尺寸,不要在拖动过程中持续写入持久化存储。 - Windows 编辑器标签栏的滚轮处理和活动标签可见性判断必须使用内层可滚动 @@ -78,6 +83,11 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 外框还包含固定操作按钮,其宽度不等于标签列表可视宽度。读取外框的滚动 范围会漏掉内层溢出;因此保留外框用于拖动命中判断,滚动使用独立的内层引用。 +### 强制两端使用同一种分隔条高亮 + +不采用。两端的窗口呈现方式不同,Windows 无描边面板依靠间隙和调整光标识别边界, +macOS 继续使用既有原生风格。视觉差异仅限这些分隔条,不放宽生命周期、性能或可访问性约束。 + ## 后果 - 收益:拖动行为更稳定,面板不会轻易出现负尺寸或溢出,页面重建范围更小。 @@ -94,7 +104,9 @@ macOS 和 Windows 可以用不同的 UI 技术实现,但用户看到的交互 - Windows 标签滚轮行为测试: `cd windows/tauri && bun test src/features/tabs/hooks/use-tab-wheel-scroll.test.tsx` Windows CI 显式运行整个 `src/features/tabs` 测试目录,并生成逐项计时报告。 -- 代码审查时确认拖动不会导致高频整页重绘 +- Git Log 列宽、日期和布局测试由 Windows CI 的 Git Log 验证步骤运行并输出逐项计时报告。 +- 代码审查时确认拖动不会导致高频整页重绘;Windows 人工验证间隙无高亮、光标正确, + macOS 人工确认 `SplitHandleView` 高亮保持不变。 ## 适用范围 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 3f1ec085a..34e2b1806 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -280,6 +280,15 @@ jobs: node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-execution-journal.json -- src/features/git/services/git-execution-journal.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Git Log previews, dates, columns, and workbench layout + shell: pwsh + run: | + $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-preview.json -- src/features/git/hooks/use-commit-file-preview.test.tsx + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-log-layout.json -- src/features/git/hooks/use-git-log-column-resize.test.tsx src/features/git/components/log/git-log-date-cell.test.tsx src/features/git/components/log/git-commit-file-tree.test.ts src/features/git/utils/git-log-columns.test.ts src/features/git/utils/git-log-date.test.ts src/features/git/utils/multi-file-diff.test.ts src/utils/document-resize-session.test.ts src/features/layout/components/plugin-activity-rail.test.ts src/features/layout/components/workbench-panel-frame.test.ts src/features/layout/config/item-order.test.ts src/features/keymaps/defaults/default-keymaps.git-log.test.ts src/features/terminal/stores/terminal.store.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test workspace Git commit adapters and staging shell: pwsh run: | diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 767f5eb16..28efd0fc9 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -33,7 +33,7 @@ git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证, git-multi-repository-change-groups,版本控制,Git,多仓库变更折叠分组,已实现,待验证,已实现,待验证,Git,在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。,两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。,macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift; macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift; macos/Sources/LitheGitModule/Models/GitModels.swift; rust/lithe-core/src/git/mod.rs,windows/tauri/src/features/git/components/status/git-status-panel.tsx; windows/tauri/src/features/git/components/git-commit-panel.tsx; windows/tauri/src/features/git/components/git-workspace-commit-review.tsx; windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts; windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts; windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx; windows/tauri/src-tauri/src/platform.rs; shared/fixtures/git/workspace-commit-workflow-v1.json git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer -git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.ts; windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git @@ -65,7 +65,7 @@ run-java-long-classpath,运行与调试,运行配置,超长 Java 类路径自动 debug-breakpoints,运行与调试,调试器,启动调试与断点,已实现,待验证,部分实现,待验证,Debug,设置、命中、禁用和重新定位断点,确认调试会话生命周期。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug debug-state,运行与调试,调试器,变量、异常与断开策略,已实现,待验证,部分实现,待验证,Debug,验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。,Windows 真实调试产品链路仍在 #466 跟进。,macos/Sources/Lithe/Views/Debug; shared/fixtures/debug,windows/tauri/src/features/debugger; shared/fixtures/debug terminal-shell,工作台,终端,Shell 发现与配置,已实现,待验证,已实现,待验证,Terminal,验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。,,macos/Sources/Lithe/Views/Terminal; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Services,windows/tauri/src/features/terminal; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/lib/default-shell-options.ts; windows/tauri/src-tauri -terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实现,待验证,Terminal,创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal +terminal-sessions,工作台,终端,多会话与标签,已实现,待验证,已实现,待验证,Terminal,创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal terminal-interaction,工作台,终端,复制粘贴、搜索与调整大小,已实现,待验证,已实现,待验证,Terminal,验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。,,macos/Sources/Lithe/Views/Terminal,windows/tauri/src/features/terminal terminal-long-command-input,工作台,终端,Windows Shell 超长命令输入,平台专属,不适用,已实现,待验证,Terminal,在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。,仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。,macos/Sources/Lithe/Views/Terminal,windows/tauri/crates/terminal/src/connection.rs; windows/tauri/src-tauri/src/terminal.rs markdown-rendering,编辑器,Markdown,Markdown 预览与富文本渲染,已实现,待验证,已实现,待验证,Editor,使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。,,macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift; macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift,windows/tauri/src/features/editor/markdown @@ -88,6 +88,7 @@ plugin-extension-management,扩展,插件与主题,插件、语言服务与主 onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导;在 macOS 深色与浅色模式检查欢迎页布局、颜色和交互,确认默认及最小内容尺寸均为 800×650、可向屏幕可用区域放大,左下角齿轮菜单采用设置页下拉框配色并可进入设置与检查更新。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette workbench-compact-menu-bar,工作台,命令与布局,紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区,平台专属,不适用,已实现,待验证,Workbench,在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。,,macos/Sources/Lithe/LitheApp.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx; windows/tauri/src/features/window/components/window-menu-bar.tsx; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts; windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx +workbench-settings-entry,工作台,命令与布局,从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口,已实现,待验证,已实现,待验证,Workbench,在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。,,macos/Sources/Lithe/Models/LitheAction.swift; macos/Sources/Lithe/Views/App/SettingsView.swift,windows/tauri/src/features/window/components/title-bar/title-bar.tsx; windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/config/item-order.ts window-controls,工作台,窗口管理,标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏,已实现,待验证,已实现,待验证,Workbench,在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/RootView.swift,windows/tauri/src/features/window/components/title-bar/window-controls.tsx; windows/tauri/src/features/command-palette/constants/window-actions.tsx; windows/tauri/src/features/window/utils/window-actions.ts workbench-panes,工作台,命令与布局,分栏、面板与工具窗布局,已实现,待验证,已实现,待验证,Workbench,打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。,,macos/Sources/Lithe/Views/Workbench; macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift; macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift; macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift; macos/Sources/Lithe/Views/App/RootView.swift; macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Theme/LitheTheme.swift; macos/Resources/IDEAIcons/expui; macos/Resources/IDEAIcons/terminal,windows/tauri/src/features/panes; windows/tauri/src/features/layout workbench-activity-tooltips,工作台,工作台布局,活动栏图标的稳定悬停说明,已实现,待验证,已实现,待验证,Workbench,逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。,,macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx; windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx; windows/tauri/src/features/layout/components/plugin-activity-rail.tsx diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 37a0ad01b..27818a50e 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:116 -- macOS:实现:✅ 102 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 105 待验证,— 11 不适用 -- Windows:实现:✅ 98 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 109 待验证,— 7 不适用 +- 功能项:117 +- macOS:实现:✅ 103 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 4 平台专属;验证:✔️ 0 已验证,🔍 106 待验证,— 11 不适用 +- Windows:实现:✅ 99 已实现,🟡 11 部分实现,❌ 5 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 110 待验证,— 7 不适用 ## 实现状态定义 @@ -116,7 +116,7 @@ | Git | **多仓库变更折叠分组**
git-multi-repository-change-groups | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift`、`macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift`、`macos/Sources/LitheGitModule/Models/GitModels.swift`、`rust/lithe-core/src/git/mod.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/components/status/git-status-panel.tsx`、`windows/tauri/src/features/git/components/git-commit-panel.tsx`、`windows/tauri/src/features/git/components/git-workspace-commit-review.tsx`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts`、`windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts`、`windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx`、`windows/tauri/src-tauri/src/platform.rs`、`shared/fixtures/git/workspace-commit-workflow-v1.json` | Git | 在同一工作区打开多个 Git 仓库,确认变更按仓库折叠分组;只剩一个仓库有变更时仍显示仓库名。仓库级和文件级勾选与 Git 暂存区同步;子模块只有未提交文件时显示提示,不能勾选未变化的引用。 | 两端直接消费共享 Rust Core 的计划、依赖排序、执行结果与重试。平台负责真实暂存勾选、确认与结果界面、认证取消和项目生命周期。原生界面实测待平台验收。 | | Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | | | Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭;在 Windows 单文件工作区 Diff 中点击折叠条展开省略的未改动代码,确认 Diff 保持打开且每个修改块仍可单独暂存。 | | -| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | +| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.ts`、`windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | @@ -175,18 +175,19 @@
-工作台 · 12 个能力点 +工作台 · 13 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | 终端 | **Shell 发现与配置**
terminal-shell | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/lib/default-shell-options.ts`、`windows/tauri/src-tauri` | Terminal | 验证设置页默认 Shell 选项与终端「新建终端」菜单检测到的 Shell 一致、可选 Git Bash 并生效,以及环境变量、工作目录和不可用 Shell 的提示。 | | -| 终端 | **多会话与标签**
terminal-sessions | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。 | | +| 终端 | **多会话与标签**
terminal-sessions | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。 | | | 终端 | **复制粘贴、搜索与调整大小**
terminal-interaction | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/terminal` | Terminal | 验证复制粘贴、终端搜索、调整面板大小和高频输入不丢失。 | | | 终端 | **Windows Shell 超长命令输入**
terminal-long-command-input | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/Views/Terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/crates/terminal/src/connection.rs`、`windows/tauri/src-tauri/src/terminal.rs` | Terminal | 在 Windows cmd.exe 和 PowerShell 中执行超过 Shell 单行限制的 IDE 多行命令,确认脚本执行、原始命令内容和终端关闭后的临时文件清理。 | 仅在 IDE 后端为 cmd.exe/PowerShell 的超长多行文本创建一次性脚本;不修改系统 Shell 配置。 | | 本地历史 | **快照列表与 Diff**
history-snapshots | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 | | | 本地历史 | **恢复、删除与持久化**
history-restore | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/History` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/local-history` | Local History | 验证恢复、删除、应用重启后持久化和失败回滚。 | | | 命令与布局 | **命令面板与全局动作**
command-palette | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift`、`macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/command-palette` | Workbench | 搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。 | | | 命令与布局 | **紧凑菜单栏可在标题栏左侧原位展开完整水平菜单,并在点击外部或按 Escape 后收起,不遮挡工作区**
workbench-compact-menu-bar | 🧩 平台专属
— 不适用
`macos/Sources/Lithe/LitheApp.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/window/components/title-bar/title-project-menu.tsx`、`windows/tauri/src/features/window/components/window-menu-bar.tsx`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.ts`、`windows/tauri/src/features/window/hooks/use-compact-menu-bar-dismissal.test.tsx` | Workbench | 在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。 | | +| 命令与布局 | **从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口**
workbench-settings-entry | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/LitheAction.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/title-bar.tsx`、`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/config/item-order.ts` | Workbench | 在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。 | | | 窗口管理 | **标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏**
window-controls | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/RootView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/window/components/title-bar/window-controls.tsx`、`windows/tauri/src/features/command-palette/constants/window-actions.tsx`、`windows/tauri/src/features/window/utils/window-actions.ts` | Workbench | 在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 | | | 命令与布局 | **分栏、面板与工具窗布局**
workbench-panes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench`、`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift`、`macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift`、`macos/Sources/Lithe/Views/App/RootView.swift`、`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Theme/LitheTheme.swift`、`macos/Resources/IDEAIcons/expui`、`macos/Resources/IDEAIcons/terminal` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/panes`、`windows/tauri/src/features/layout` | Workbench | 打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 | | | 工作台布局 | **活动栏图标的稳定悬停说明**
workbench-activity-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx`、`windows/tauri/src/features/layout/components/footer/footer-tab-control.tsx`、`windows/tauri/src/features/layout/components/plugin-activity-rail.tsx` | Workbench | 逐一悬停左上导航、左下工具入口及右侧通知/插件/Maven,快速切换图标并移出活动栏;确认始终只显示当前图标说明,面板更新和滚动后不残留旧提示,禁用入口可显示说明,短文字自适应且提示不超出窗口边缘。 | | diff --git a/rust/lithe-core/src/git/graph.rs b/rust/lithe-core/src/git/graph.rs index e7577ee7b..b70a84c96 100644 --- a/rust/lithe-core/src/git/graph.rs +++ b/rust/lithe-core/src/git/graph.rs @@ -369,6 +369,7 @@ mod tests { author_name: "Lithe Test".to_string(), author_email: "test@example.com".to_string(), date: "2026-09-04".to_string(), + date_utc_offset_minutes: None, subject: hash.to_string(), decorations: decorations.to_string(), } diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 04d1c9c94..4d79da81e 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -2,7 +2,8 @@ use super::{ command_value, execute_git_readonly_with_environment, git_process, parse_commit, - parse_reference, readonly_command, validate_root, GitCommandRequest, INTERNAL_REF_PREFIX, + parse_reference, readonly_command, validate_root, GitCommandRequest, GIT_COMMIT_DATE_ARGUMENT, + INTERNAL_REF_PREFIX, }; use crate::protocol::{ cancellation, CoreError, ErrorCode, GitCommitResponse, GitHistoryCursorCloseResponse, @@ -340,7 +341,7 @@ fn offset_history_page( offset.to_string(), "-n".to_string(), limit.saturating_add(1).to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let output = readonly_command(GitCommandRequest { @@ -479,7 +480,7 @@ impl HistorySession { format!("--decorate-refs-exclude={INTERNAL_REF_PREFIX}*"), "-n".to_string(), MAX_HISTORY_COMMITS.to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let mut process = git_process(); diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index df263141b..d00d9c518 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -72,6 +72,10 @@ use std::time::Duration; const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; const INTERNAL_REF_PREFIX: &str = "refs/lithe/"; +/// Commit date argument for every `parse_commit` producer. The trailing `%z` +/// carries the author's UTC offset, which `parse_commit` splits off so the +/// displayed `date` keeps its `%Y/%m/%d %H:%M` shape. +const GIT_COMMIT_DATE_ARGUMENT: &str = "--date=format:%Y/%m/%d %H:%M %z"; const DEFAULT_REPOSITORY_SCAN_MAX_DIRECTORIES: usize = usize::MAX; const DEFAULT_REPOSITORY_SCAN_MAX_DEPTH: usize = usize::MAX; static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -2036,7 +2040,7 @@ fn read_commit_log( "--decorate=short".to_string(), "-n".to_string(), (limit.saturating_add(1)).to_string(), - "--date=format:%Y/%m/%d %H:%M".to_string(), + GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); let commit_output = readonly_command(GitCommandRequest { @@ -2067,7 +2071,7 @@ pub fn commit(request: GitCommitRequest) -> Result Option { if columns.len() < 8 { return None; } + let (date, date_utc_offset_minutes) = split_commit_date_offset(columns[5]); Some(GitCommitResponse { hash: columns[0].to_string(), short_hash: columns[1].to_string(), parent_hashes: columns[2].split_whitespace().map(String::from).collect(), author_name: columns[3].to_string(), author_email: columns[4].to_string(), - date: columns[5].to_string(), + date: date.to_string(), + date_utc_offset_minutes, subject: columns[6].to_string(), decorations: columns[7].to_string(), }) } +/// Splits a `GIT_COMMIT_DATE_ARGUMENT` value into the displayed local date and +/// the author's UTC offset in minutes. Values without a valid `±HHMM` suffix are +/// kept whole with no offset so unexpected Git output stays visible. +fn split_commit_date_offset(value: &str) -> (&str, Option) { + let Some((date, zone)) = value.rsplit_once(' ') else { + return (value, None); + }; + let bytes = zone.as_bytes(); + if bytes.len() != 5 + || !matches!(bytes[0], b'+' | b'-') + || !zone[1..].bytes().all(|b| b.is_ascii_digit()) + { + return (value, None); + } + let hours: i32 = zone[1..3].parse().unwrap_or(0); + let minutes: i32 = zone[3..5].parse().unwrap_or(0); + let sign = if bytes[0] == b'-' { -1 } else { 1 }; + (date, Some(sign * (hours * 60 + minutes))) +} + fn validate_revision(value: &str) -> Result<(), CoreError> { if value.trim().is_empty() || value.starts_with('-') || value.contains('\0') { return Err(CoreError::new( @@ -6780,9 +6806,9 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, - pathspec_batches, simplified_canonical_path, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_commit, + parse_diff, pathspec_batches, simplified_canonical_path, structured_diff_from_output, + DiffEntry, GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ CoreError, ErrorCode, GitCommitResponse, GitHistoryPageResponse, GitHistoryResponse, @@ -7241,6 +7267,25 @@ mod tests { ); } + #[test] + fn parse_commit_splits_the_author_utc_offset_from_the_displayed_date() { + let line = + |date: &str| format!("h\u{1f}s\u{1f}\u{1f}A\u{1f}a@x\u{1f}{date}\u{1f}msg\u{1f}"); + + let east = parse_commit(&line("2026/09/30 21:05 +0800")).expect("commit should parse"); + assert_eq!(east.date, "2026/09/30 21:05"); + assert_eq!(east.date_utc_offset_minutes, Some(480)); + + let west = parse_commit(&line("2026/09/30 08:05 -0330")).expect("commit should parse"); + assert_eq!(west.date, "2026/09/30 08:05"); + assert_eq!(west.date_utc_offset_minutes, Some(-210)); + + // Output without a recognizable zone stays visible unchanged instead of being trimmed. + let legacy = parse_commit(&line("2026/09/30 08:05")).expect("commit should parse"); + assert_eq!(legacy.date, "2026/09/30 08:05"); + assert_eq!(legacy.date_utc_offset_minutes, None); + } + #[test] fn history_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( @@ -7278,6 +7323,7 @@ mod tests { author_name: "Lithe Test".into(), author_email: "test@example.invalid".into(), date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), subject: "Initial commit".into(), decorations: "HEAD -> feature/recent".into(), }], @@ -7347,6 +7393,7 @@ mod tests { author_name: "Lithe Test".into(), author_email: "test@example.invalid".into(), date: "2026/08/30 12:00".into(), + date_utc_offset_minutes: Some(480), subject: "Initial commit".into(), decorations: "HEAD -> feature/recent".into(), }], @@ -7386,6 +7433,7 @@ mod tests { author_name: "Lithe Developer".into(), author_email: "developer@lithe.local".into(), date: "2026/08/31 10:30".into(), + date_utc_offset_minutes: Some(-300), subject: "Add push preview".into(), decorations: "HEAD -> feature/core".into(), }], diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index dc03e484a..55f834617 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -626,7 +626,12 @@ pub struct GitCommitResponse { pub parent_hashes: Vec, pub author_name: String, pub author_email: String, + /// Author date as `YYYY/MM/DD HH:MM` in the author's own time zone. pub date: String, + /// Author's UTC offset in minutes (east positive) for `date`; `null` when + /// Git did not report one. Hosts compare it with the local offset before + /// presenting relative times. + pub date_utc_offset_minutes: Option, pub subject: String, pub decorations: String, } diff --git a/scripts/verify-windows-boundaries.sh b/scripts/verify-windows-boundaries.sh index 6465443d5..729a729fb 100755 --- a/scripts/verify-windows-boundaries.sh +++ b/scripts/verify-windows-boundaries.sh @@ -10,9 +10,12 @@ if find windows \ exit 1 fi +# The exemption pattern accepts backslash separators (ripgrep emits them on +# Windows) and starts with a character class so Git Bash does not rewrite a +# leading-slash argument into a Windows path. direct_imports=$(rg -l 'from "@tauri-apps/api/core"' windows/tauri/src \ --glob '*.ts' --glob '*.tsx' | \ - rg -v '/(core/lithe-core-client|platform/tauri-core)\.ts$' || true) + rg -v '[/\\](core[/\\]lithe-core-client|platform[/\\]tauri-core)\.ts$' || true) if [[ -n "$direct_imports" ]]; then echo "Frontend modules must use @/platform/tauri-core:" >&2 echo "$direct_imports" >&2 diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index d31596308..ec1df40ee 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -418,7 +418,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | | `git.history` | Return the legacy combined reference snapshot and first bounded commit page | | `git.references` | Return deterministic refs, recent local branches, ahead/behind state, and effective Git identity without scanning commit history | -| `git.historyPage` | Return one bounded commit page, parent hashes, decorations, and an opaque continuation cursor | +| `git.historyPage` | Return one bounded commit page, parent hashes, decorations, author dates with their UTC offset (`dateUtcOffsetMinutes`, east positive, `null` when unknown), and an opaque continuation cursor | | `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | | `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | | `git.commit` | Return one structured commit by revision | diff --git a/shared/fixtures/git/history-page-response-v1.json b/shared/fixtures/git/history-page-response-v1.json index 8d8f14bdf..3fd5d9581 100644 --- a/shared/fixtures/git/history-page-response-v1.json +++ b/shared/fixtures/git/history-page-response-v1.json @@ -7,6 +7,7 @@ "authorName": "Lithe Test", "authorEmail": "test@example.invalid", "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, "subject": "Initial commit", "decorations": "HEAD -> feature/recent" } diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index a5e303309..4efa5904a 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -51,6 +51,7 @@ "authorName": "Lithe Test", "authorEmail": "test@example.invalid", "date": "2026/08/30 12:00", + "dateUtcOffsetMinutes": 480, "subject": "Initial commit", "decorations": "HEAD -> feature/recent" } diff --git a/shared/fixtures/git/push-preview-v1.json b/shared/fixtures/git/push-preview-v1.json index 49125b431..9a11e7246 100644 --- a/shared/fixtures/git/push-preview-v1.json +++ b/shared/fixtures/git/push-preview-v1.json @@ -19,6 +19,7 @@ "authorName": "Lithe Developer", "authorEmail": "developer@lithe.local", "date": "2026/08/31 10:30", + "dateUtcOffsetMinutes": -300, "subject": "Add push preview", "decorations": "HEAD -> feature/core" } diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index 08f610fe4..a6b65474f 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -945,13 +945,15 @@ "windows/tauri/src/features/git", "windows/tauri/src/features/git/utils/git-graph-layout.ts", "windows/tauri/src/features/git/utils/git-graph-colors.ts", - "windows/tauri/src/features/git/utils/git-graph-layout.test.ts" + "windows/tauri/src/features/git/utils/git-graph-layout.test.ts", + "windows/tauri/src/features/git/hooks/use-commit-file-preview.ts", + "windows/tauri/src/features/git/hooks/use-commit-file-preview.test.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列对与本机同一时区的提交显示相对时间(刚刚、N 分钟前、今天/昨天 + 12 小时制,中文为上午/下午),其他时区或未知时区的提交只显示日期,鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 区段及提交标识;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。" }, { "id": "git-rebase-stash", @@ -1742,7 +1744,7 @@ "verificationStatus": "pending" }, "owner": "Terminal", - "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态。" + "verification": "创建多个终端会话并切换、重命名、关闭,确认子进程生命周期和资源清理;Windows 横向标签栏的新建终端与 Shell 配置按钮紧跟最后一个标签,标签溢出时按钮保持可见;标签栏右侧最小化按钮隐藏终端面板且会话继续运行,重新打开时恢复为停靠状态;Windows 打开底部终端面板时默认通栏(与 IDEA 一致):面板位于左右两条活动栏之间并横跨侧边栏与编辑器下方(活动栏保持通高,底部的活动栏按钮位置不上移),侧边栏让出纵向空间,且仍可通过面板菜单切换为编辑器宽度。" }, { "id": "terminal-interaction", @@ -2275,6 +2277,31 @@ "owner": "Workbench", "verification": "在 Windows 工作台点击标题栏左端菜单按钮,确认汉堡按钮由“文件”菜单项取代,“文件”项处于选中状态且其下拉菜单立即展开;菜单与标题栏同高,不覆盖编辑区域,项目/分支控件在展开时隐藏。检查常驻品牌标识与下方活动栏图标列对齐且不可选中,并确认项目菜单显示当前项目图标(无自定义图标时显示项目缩写)。点击其他菜单项时菜单条保持展开并执行动作;点击工作区、标题栏空白和右侧控件或按 Escape 后收起;按 Escape 后确认焦点返回菜单入口;窄窗口确认菜单在标题栏内水平滚动且不挤压窗口控件。macOS 保持系统菜单栏行为。" }, + { + "id": "workbench-settings-entry", + "area": "工作台", + "group": "命令与布局", + "capability": "从工作台 chrome 打开设置:Windows 点击标题栏搜索按钮右侧的齿轮,macOS 通过应用菜单及面板内入口", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Models/LitheAction.swift", + "macos/Sources/Lithe/Views/App/SettingsView.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/window/components/title-bar/title-bar.tsx", + "windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx", + "windows/tauri/src/features/layout/config/item-order.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Workbench", + "verification": "在 Windows 打开工作区,确认左侧活动栏底部不再显示设置齿轮,活动栏右键“在活动栏中显示”列表不包含设置项;点击标题栏右上搜索按钮右侧的齿轮打开设置对话框并再次关闭;携带旧版设置文件(活动栏排序或隐藏列表含 settings)启动后确认遗留条目被清理且活动栏显示正常。macOS 通过应用菜单和各面板入口打开设置保持不变。" + }, { "id": "window-controls", "area": "工作台", diff --git a/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx b/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx index 3d5765100..8418712a1 100644 --- a/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-navigator-sidebar.tsx @@ -366,7 +366,7 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ document.addEventListener("pointermove", handlePointerMove); document.addEventListener("pointerup", handlePointerUp); - document.body.style.cursor = "col-resize"; + document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; }, [navigatorLayout.width, resizeTo], @@ -462,7 +462,7 @@ export const FileNavigatorSidebar = memo(function FileNavigatorSidebar({ )}
{isResizing ? ( -
+
) : null} ); diff --git a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx index bc82e74a0..2eda5beca 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx @@ -340,6 +340,7 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ const [isFileTreeVisible, setIsFileTreeVisible] = useState(true); const [fileNavigatorViewMode, setFileNavigatorViewMode] = useState("tree"); const isWorkingTree = multiDiff.commitHash === "working-tree"; + const showsFileList = !isWorkingTree && !multiDiff.hideFileList; const isWorkingTreeBuffer = activeBuffer?.path === "diff://working-tree/all-files"; const isActiveMultiDiff = activeBuffer?.type === "diff" && activeBuffer.diffData === multiDiff; const isRefreshingRef = useRef(false); @@ -798,7 +799,7 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ > - {!isWorkingTree ? ( + {showsFileList ? ( - {!isWorkingTree && isFileTreeVisible ? ( + {showsFileList && isFileTreeVisible ? ( { test("counts every descendant file in a nested directory", () => { @@ -18,4 +18,33 @@ describe("GitCommitFileTree", () => { expect(root?.type).toBe("branch"); expect(root ? countCommitFileTreeLeaves(root) : 0).toBe(3); }); + + test("shows the file count beside folder names and no status letter on file rows", async () => { + const css = await Bun.file( + new URL("../../../file-explorer/styles/file-explorer-tree.css", import.meta.url), + ).text(); + const source = await Bun.file(new URL("./git-commit-file-tree.tsx", import.meta.url)).text(); + + // No CSS targets a Git-specific tree class, so the shared file-tree styling applies unchanged. + expect(source).not.toContain("git-commit-file-tree"); + // The count rides in the label's description slot so it sits right after the folder name. + expect(source).toContain("git.log.fileCountOne"); + expect(source).not.toContain("trailing="); + expect(source).not.toContain("{file.status}"); + // No trailing span remains, so the shared `:last-child` flex rule must not be overridden. + expect(css).not.toContain(".git-commit-file-tree .file-tree-row > span:last-child"); + }); + + test("picks the first file in the order the tree renders, not input order", () => { + const files: GitCommitFile[] = [ + { path: "windows/tauri/src/b.ts", status: "M" }, + { path: "docs/development/a.md", status: "M" }, + { path: "shared/c.json", status: "M" }, + ]; + + const first = getFirstCommitFilePath(files); + expect(first).not.toBeNull(); + expect(files.map((file) => file.path)).toContain(first as string); + expect(getFirstCommitFilePath([])).toBeNull(); + }); }); diff --git a/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx index f9f5bcc78..7f7cd5515 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx @@ -15,7 +15,6 @@ import { } from "@/features/sidebar/lib/path-tree"; import { useTranslation } from "@/i18n/locale-provider"; import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel"; -import { cn } from "@/utils/cn"; import type { GitCommitFile } from "../../types/git.types"; import { getCommitFileStatusColorClassName } from "../../utils/git-file-status-visuals"; @@ -24,6 +23,25 @@ export function countCommitFileTreeLeaves(node: PathTreeNode): nu return node.children.reduce((count, child) => count + countCommitFileTreeLeaves(child), 0); } +/** Path of the first file in the order the tree renders, so previews match what the user sees. */ +export function getFirstCommitFilePath(files: readonly GitCommitFile[]): string | null { + const firstLeaf = (nodes: readonly PathTreeNode[]): string | null => { + for (const node of nodes) { + if (node.type === "leaf") return node.path; + const nested = firstLeaf(node.children); + if (nested) return nested; + } + return null; + }; + + return firstLeaf( + buildPathTree([...files], { + getPath: (file) => file.path, + getKey: (file) => file.path, + }), + ); +} + function FileNode({ node, depth, @@ -51,6 +69,7 @@ function FileNode({ : { branch: node, label: node.name }; const branch = compacted.branch; const expanded = !collapsed.has(branch.path); + const fileCount = countCommitFileTreeLeaves(branch); return (
@@ -73,9 +92,11 @@ function FileNode({ /> ) : null } - trailing={ - - {countCommitFileTreeLeaves(branch)} + description={ + + {t(fileCount === 1 ? "git.log.fileCountOne" : "git.log.filesCount", { + count: fileCount, + })} } title={branch.path} @@ -123,11 +144,6 @@ function FileNode({ /> ) : null } - trailing={ - - {file.status} - - } title={t("git.log.openFileDiff")} className="h-full py-0.5" style={{ height: presentation.rowHeight }} diff --git a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx index ef8760b07..fb1e2bccc 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx @@ -14,7 +14,7 @@ import { resolveGitCommitSelectionDiff, type GitCommitSelectionDiff, } from "../../utils/git-commit-selection-diff"; -import { GitCommitFileTree } from "./git-commit-file-tree"; +import { GitCommitFileTree, getFirstCommitFilePath } from "./git-commit-file-tree"; type FilesLoadState = "idle" | "loading" | "ready" | "failed"; @@ -25,6 +25,8 @@ export function GitCommitInspector({ onOpenDiff, onOpenRangeDiff, onOpenSelectionDiff, + onPreviewFile, + previewRequest, }: { repoPath: string | null; commit: GitCommit | null; @@ -38,6 +40,10 @@ export function GitCommitInspector({ selection: Extract, filePath?: string, ) => void; + /** Shows only one file's diff in the editor preview tab. */ + onPreviewFile: (selection: GitCommitSelectionDiff, filePath: string) => void; + /** Bumped when the user selects a commit; the first file is previewed once its files load. */ + previewRequest: number; }) { const { t } = useTranslation(); const [files, setFiles] = useState([]); @@ -47,10 +53,28 @@ export function GitCommitInspector({ const inspectorPanelLayout = useGitLogPreferencesStore.use.inspectorPanelLayout(); const { setInspectorPanelLayout } = useGitLogPreferencesStore.use.actions(); const selectionDiff = useMemo(() => resolveGitCommitSelectionDiff(commits), [commits]); + // The selection `files` were loaded for; guards against previewing the previous commit's files + // during the render in which a new selection has arrived but its files have not loaded yet. + const [loadedSelection, setLoadedSelection] = useState(null); + const handledPreviewRequestRef = useRef(previewRequest); + + // Selecting a commit previews its first file, like the IDEA Git log. Only explicit user + // selections bump `previewRequest`, so opening the Log never steals the editor on its own. + useEffect(() => { + if (!selectionDiff || loadedSelection !== selectionDiff) return; + if (handledPreviewRequestRef.current === previewRequest) return; + handledPreviewRequestRef.current = previewRequest; + + const firstPath = getFirstCommitFilePath(files); + if (!firstPath) return; + setSelectedPath(firstPath); + onPreviewFile(selectionDiff, firstPath); + }, [files, loadedSelection, onPreviewFile, previewRequest, selectionDiff]); useEffect(() => { const requestId = ++requestIdRef.current; setFiles([]); + setLoadedSelection(null); setSelectedPath(null); if (!repoPath || !selectionDiff) { setLoadState("idle"); @@ -84,6 +108,7 @@ export function GitCommitInspector({ return; } setFiles(loadedFiles); + setLoadedSelection(selectionDiff); setLoadState("ready"); }); @@ -93,7 +118,7 @@ export function GitCommitInspector({ }, [repoPath, selectionDiff]); return ( -
+
-
+
{t("git.log.commitFiles")} {loadState === "loading" @@ -148,13 +173,12 @@ export function GitCommitInspector({ { + setSelectedPath(path); + if (selectionDiff) onPreviewFile(selectionDiff, path); + }} onOpen={(path) => { - if (selectionDiff?.kind === "commit") onOpenDiff(selectionDiff.commit, path); - else if (selectionDiff?.kind === "range") onOpenRangeDiff(selectionDiff, path); - else if (selectionDiff?.kind === "selection") { - onOpenSelectionDiff(selectionDiff, path); - } + if (selectionDiff) onPreviewFile(selectionDiff, path); }} /> )} diff --git a/windows/tauri/src/features/git/components/log/git-commit-table.tsx b/windows/tauri/src/features/git/components/log/git-commit-table.tsx index 656a53b00..d5d2c2161 100644 --- a/windows/tauri/src/features/git/components/log/git-commit-table.tsx +++ b/windows/tauri/src/features/git/components/log/git-commit-table.tsx @@ -27,6 +27,7 @@ import { import { Button } from "@/ui/button"; import { cn } from "@/utils/cn"; import { useTranslation } from "@/i18n/locale-provider"; +import { useGitLogColumnResize } from "../../hooks/use-git-log-column-resize"; import { type GitLogFilterScope, useGitLogPreferencesStore, @@ -39,6 +40,8 @@ import { selectedCommitsInHistoryOrder, } from "../../utils/git-history-selection"; import { GitGraphRow } from "./git-graph-row"; +import { GitLogColumnResizeHandle } from "./git-log-column-resize-handle"; +import { GitLogDateCell } from "./git-log-date-cell"; import { isGitHeadCommit } from "../../utils/git-history-message"; const ROW_HEIGHT = 30; @@ -121,6 +124,7 @@ export function GitCommitTable({ estimateSize: () => ROW_HEIGHT, overscan: 14, }); + const { columnStyle, activeColumn, startResize } = useGitLogColumnResize(scrollRef); useLayoutEffect(() => { const element = scrollRef.current; @@ -202,7 +206,7 @@ export function GitCommitTable({ return (
-
+
-
- {t("git.log.commit")} - {t("git.log.author")} - {t("git.log.date")} -
-
{visibleRows.length === 0 ? ( commits.length === 0 && emptyState ? ( @@ -278,7 +277,10 @@ export function GitCommitTable({ ) ) : ( <> -
+
{virtualizer.getVirtualItems().map((virtualRow) => { const row = visibleRows[virtualRow.index]; const isSelected = selectedCommitHashes.has(row.commit.hash); @@ -323,12 +325,19 @@ export function GitCommitTable({ title={t("git.log.openDiffHint")} > - - {row.commit.author} - - - {row.commit.date} - +
+ + {row.commit.author} + + +
+
+ + +
{hasMultipleContextCommits ? ( @@ -460,6 +469,8 @@ export function GitCommitTable({ )}
+ {/* Shields the rows from hover and the trailing click while a column is being dragged. */} + {activeColumn ?
: null}
); } diff --git a/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx b/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx new file mode 100644 index 000000000..acf831294 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-column-resize-handle.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "@/i18n/locale-provider"; +import type { GitLogColumn } from "../../utils/git-log-columns"; + +/** + * Drag handle on the left edge of an author or date cell. Every row renders one, so the whole + * column edge is draggable. It reaches 4px outside the edge and 8px (the cell's text inset) into + * the cell, so the resize cursor appears right up to the text; it stays unstyled (no highlight) + * like the app's other splitters. + */ +export function GitLogColumnResizeHandle({ + column, + onStartResize, +}: { + column: GitLogColumn; + onStartResize: (column: GitLogColumn, event: React.PointerEvent) => void; +}) { + const { t } = useTranslation(); + + return ( +
onStartResize(column, event)} + // A drag ends with a click on a common ancestor; keep clicks off the row's own handlers. + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + /> + ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx b/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx new file mode 100644 index 000000000..1daa382e8 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-date-cell.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { GitLogDateCell } from "./git-log-date-cell"; + +// The commit is authored in the machine's own zone so relative labels apply. +const commitTime = new Date(2026, 8, 10, 15, 0); +const localOffset = -commitTime.getTimezoneOffset(); + +let restoreDom: () => void; +let container: HTMLElement; +let root: Root; +const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousActEnvironment: boolean | undefined; + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + setSystemTime(new Date(2026, 8, 10, 15, 5)); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render( + +
+ +
+
, + ); + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + setSystemTime(); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + restoreDom(); +}); + +test("relative date refreshes when the pointer enters the row, not on its own", async () => { + const row = container.querySelector('[data-testid="row"]') as HTMLElement; + expect(row.textContent).toBe("5 minutes ago"); + + setSystemTime(new Date(2026, 8, 10, 15, 20)); + expect(row.textContent).toBe("5 minutes ago"); + + await act(async () => { + row.dispatchEvent(new Event("mouseenter")); + }); + expect(row.textContent).toBe("20 minutes ago"); +}); diff --git a/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx b/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx new file mode 100644 index 000000000..c0bc63814 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-date-cell.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { formatGitLogDate } from "../../utils/git-log-date"; + +/** + * Commit date cell of a Git log row. Relative labels ("5 minutes ago", "Today") are computed + * against the time the row was rendered and refreshed when the pointer enters the row, instead + * of re-rendering the whole table on a timer. + */ +export function GitLogDateCell({ + date, + utcOffsetMinutes, +}: { + date: string; + utcOffsetMinutes: number | undefined; +}) { + const { t } = useTranslation(); + const cellRef = useRef(null); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const row = cellRef.current?.closest("[data-git-commit-index]"); + if (!row) return; + const refresh = () => setNow(new Date()); + row.addEventListener("mouseenter", refresh); + return () => row.removeEventListener("mouseenter", refresh); + }, []); + + return ( + + {formatGitLogDate(date, t, utcOffsetMinutes, now)} + + ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx index 879a60371..d95edc8c3 100644 --- a/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx @@ -25,7 +25,7 @@ export function GitLogTitleBar({ const { t } = useTranslation(); return ( -
+
{t("workbench.gitLog")} diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 5f50d16c1..736e11ba8 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -93,6 +93,7 @@ export function GitLogToolWindow() { if (!rootFolderPath || availableRepoPaths.length > 0) return; void syncWorkspaceRepositories(rootFolderPath); }, [availableRepoPaths.length, rootFolderPath, syncWorkspaceRepositories]); + const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); const openSettingsDialog = useUIState((state) => state.openSettingsDialog); const pendingReferenceSelectionRef = useRef(null); @@ -112,6 +113,7 @@ export function GitLogToolWindow() { const pullWorkflow = useGitPullWorkflow({ repoPath: repoPath ?? "", refresh }); const [selectedCommit, setSelectedCommit] = useState(null); const [selectedCommitHashes, setSelectedCommitHashes] = useState>(new Set()); + const [previewRequest, setPreviewRequest] = useState(0); const [isReferenceOperating, setIsReferenceOperating] = useState(false); const [showFetchOptions, setShowFetchOptions] = useState(false); const [showRemoteManager, setShowRemoteManager] = useState(false); @@ -199,12 +201,17 @@ export function GitLogToolWindow() { const { isLoadingCommitDiff, viewCommitDiff, + previewCommitFileDiff, viewCommitRangeDiff, viewCommitSelectionDiff, viewBranchDiff, viewReferenceWorkingTreeDiff, } = useGitDiffActions({ activeRepoPath: repoPath, + commitPreviewScope: + isBottomPaneVisible && panel === "log" && selectedCommits.length > 0 + ? JSON.stringify(selectedCommits.map((commit) => commit.hash)) + : null, gitFileByPath: emptyGitFileByPath, workingTreeDiffEntriesByScope: emptyWorkingTreeEntries, commitByHash, @@ -230,6 +237,7 @@ export function GitLogToolWindow() { ? commit.hash : visibleCommitHashes.find((hash) => result.selected.has(hash)); setSelectedCommit(activeHash ? (commitByHash.get(activeHash) ?? null) : null); + setPreviewRequest((request) => request + 1); }; const selectCommitForContextMenu = (commit: GitCommit) => { @@ -735,6 +743,8 @@ export function GitLogToolWindow() { repoPath={repoPath} commit={activeSelectedCommit} commits={selectedCommits} + previewRequest={previewRequest} + onPreviewFile={previewCommitFileDiff} onOpenDiff={openDiff} onOpenRangeDiff={(range, filePath) => { if (isLoadingCommitDiff) return; diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx index 86e496d84..60ce88db5 100644 --- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -405,7 +405,7 @@ function GitReferenceToolbar({ return (
{primaryActions.map((action) => renderAction(action))}
@@ -419,7 +419,7 @@ function GitReferenceToolbar({ return (
{visibleActions.map((action) => renderAction(action))}
@@ -1022,7 +1022,7 @@ export function GitReferenceTree({ ); return ( -
+
- ) : ( - - - - - {t("git.workingTreeClean")} - - )} + ) : null} ({ + hash, + shortHash: hash, + parentHashes: [], + message: hash, + author: "Test", + date: "", + decorations: "", +}); +const diff = (additions: number, path = "a.ts"): GitDiff => ({ + file_path: path, + is_new: false, + is_deleted: false, + is_renamed: false, + lines: [], + additions, + deletions: 0, +}); +let preview: ReturnType; +let root: Root | undefined; +let container: HTMLElement; +let restoreDom: () => void; +let previousAct: boolean | undefined; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let published: MultiFileDiff[]; +let releases: Array<() => void>; +let inFlight: Promise[]; +let loadCommit: ReturnType>; +let loadRange: ReturnType>; +const openPreview = (_path: string, _name: string, data: MultiFileDiff) => { + published.push(data); +}; + +function Harness({ scope = "A", repo = "C:/repo" }: { scope?: string | null; repo?: string }) { + preview = useCommitFilePreview(repo, scope, openPreview); + return null; +} +async function render(scope: string | null, repo = "C:/repo") { + await act(async () => { + root!.render(); + }); +} +function pendingDiff() { + let resolve!: (diffs: GitDiff[]) => void; + const promise = new Promise((done) => { + resolve = done; + }); + releases.push(() => resolve([])); + return { promise, resolve }; +} +function start(hash = "A") { + const task = preview({ kind: "commit", commit: commit(hash) }, "a.ts"); + inFlight.push(task); + return task; +} + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + published = []; + releases = []; + inFlight = []; + loadCommit = spyOn(api, "getCommitDiff").mockResolvedValue([diff(1)]); + loadRange = spyOn(api, "getRefDiff").mockResolvedValue([diff(2)]); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await render("A"); +}); +afterEach(async () => { + try { + await act(async () => { + root?.unmount(); + }); + // Resolve every controlled request even when an assertion fails. + for (const release of releases) release(); + await Promise.all(inFlight); + } finally { + loadCommit.mockRestore(); + loadRange.mockRestore(); + container.remove(); + actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; + restoreDom(); + } +}); + +for (const reason of [ + "deselected", + "hidden", + "new-selection", + "new-repository", + "unmounted", +] as const) { + test(`does not publish a pending preview after ${reason}`, async () => { + const pending = pendingDiff(); + loadCommit.mockReturnValue(pending.promise); + const task = start(); + if (reason === "unmounted") { + await act(async () => { + root!.unmount(); + }); + root = undefined; + } else if (reason === "new-repository") { + await render("A", "C:/other"); + } else { + await render(reason === "new-selection" ? "B" : null); + } + pending.resolve([diff(1)]); + await task; + expect(published).toEqual([]); + }); +} + +test("a later file click supersedes an earlier request and only opens once", async () => { + const first = pendingDiff(); + const second = pendingDiff(); + loadCommit.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const older = start("A"); + const newer = start("B"); + second.resolve([diff(2)]); + await newer; + first.resolve([diff(1)]); + await older; + expect(published.map((data) => data.totalAdditions)).toEqual([2]); +}); + +test("disconnected commits retain every matching revision and its label", async () => { + loadCommit + .mockResolvedValueOnce([diff(3), diff(20, "other.ts")]) + .mockResolvedValueOnce([diff(7)]); + await preview({ kind: "selection", commits: [commit("C"), commit("A")] }, "a.ts"); + expect(published).toHaveLength(1); + expect(published[0].files.map((entry) => entry.additions)).toEqual([3, 7]); + expect(published[0].fileLabels).toEqual(["C", "A"]); + expect(published[0].fileKeys).toEqual(["C:a.ts:0", "A:a.ts:0"]); + expect(published[0].totalFiles).toBe(1); + expect(published[0].hideFileList).toBe(true); +}); + +test("contiguous selections still use the range diff and missing files do not open a tab", async () => { + await preview( + { kind: "range", baseRef: "A", targetRef: "C", oldest: commit("B"), newest: commit("C") }, + "a.ts", + ); + expect(loadRange).toHaveBeenCalledWith("C:/repo", "A", "C"); + expect(published).toHaveLength(1); + expect(published[0].totalAdditions).toBe(2); + loadCommit.mockResolvedValue([]); + await start(); + expect(published).toHaveLength(1); +}); diff --git a/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts b/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts new file mode 100644 index 000000000..8e144eaca --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-commit-file-preview.ts @@ -0,0 +1,79 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { getCommitDiff, getRefDiff } from "../api/git-diff-api"; +import type { MultiFileDiff } from "../types/git-diff.types"; +import type { GitDiff } from "../types/git.types"; +import { mapGitReadsInBatches } from "../utils/git-async-batch"; +import { + aggregateSelectedCommitDiffs, + type GitCommitSelectionDiff, +} from "../utils/git-commit-selection-diff"; +import { createCommitFileDiffPreview } from "../utils/multi-file-diff"; +import { createRequestGeneration } from "../utils/request-generation"; + +/** Owns automatic preview publication for one visible Git Log selection. */ +export function useCommitFilePreview( + repoPath: string | null, + scopeKey: string | null, + openPreview: (path: string, name: string, data: MultiFileDiff) => unknown, +) { + const requests = useRef(createRequestGeneration()); + // Invalidate before passive effects can start a preview for the new selection. + // Cleanup also prevents a closed/unmounted Log from activating a global editor buffer. + useLayoutEffect(() => { + requests.current.begin(); + return () => { + requests.current.begin(); + }; + }, [repoPath, scopeKey]); + + return useCallback( + async (selection: GitCommitSelectionDiff, filePath: string) => { + if (!repoPath || scopeKey === null) return; + const requestId = requests.current.begin(); + try { + let diffs: readonly GitDiff[] | null; + let commitHash: string; + let label: string; + let fileKeys: string[] | undefined; + let fileLabels: string[] | undefined; + if (selection.kind === "commit") { + diffs = await getCommitDiff(repoPath, selection.commit.hash); + commitHash = selection.commit.hash; + label = selection.commit.shortHash; + } else if (selection.kind === "range") { + diffs = await getRefDiff(repoPath, selection.baseRef, selection.targetRef); + commitHash = `${selection.baseRef ?? "root"}..${selection.targetRef}`; + label = `${selection.oldest.shortHash}..${selection.newest.shortHash}`; + } else { + const results = await mapGitReadsInBatches(selection.commits, async (commit) => ({ + commit, + diffs: await getCommitDiff(repoPath, commit.hash), + })); + if (results.some((result) => result.diffs === null)) return; + const aggregate = aggregateSelectedCommitDiffs( + results.map((result) => ({ commit: result.commit, diffs: result.diffs ?? [] })), + ); + diffs = aggregate.diffs; + fileKeys = aggregate.fileKeys; + fileLabels = aggregate.fileLabels; + commitHash = selection.commits[0]?.hash ?? "selection"; + label = String(selection.commits.length); + } + if (!requests.current.isCurrent(requestId) || !diffs) return; + const preview = createCommitFileDiffPreview({ + repoPath, + commitHash, + diffs, + filePath, + fileKeys, + fileLabels, + label, + }); + if (preview) openPreview(preview.virtualPath, preview.displayName, preview.diffData); + } catch (error) { + console.error("Error previewing commit file diff:", error); + } + }, + [repoPath, scopeKey, openPreview], + ); +} diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts index 8dd191ff9..835c88e9d 100644 --- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts @@ -30,6 +30,8 @@ import { createRequestGeneration, type RequestGeneration } from "../utils/reques import { createCommitDiffBuffer, createMultiFileDiff } from "../utils/multi-file-diff"; import { createSingleFileWorkingTreeDiff } from "../utils/working-tree-multi-diff"; +import { useCommitFilePreview } from "./use-commit-file-preview"; + const WORKING_TREE_TITLES: Record = { all: "git.diff.uncommitted", unstaged: "git.diff.unstagedChanges", @@ -73,6 +75,7 @@ export function useGitDiffActions({ currentBranch, currentReference, onBranchDiffOpened, + commitPreviewScope = null, }: { activeRepoPath: string | null; onFileSelect?: (path: string, isDir: boolean) => void; @@ -82,6 +85,7 @@ export function useGitDiffActions({ currentBranch?: string; currentReference?: GitReference; onBranchDiffOpened?: () => void; + commitPreviewScope?: string | null; }) { const { t } = useTranslation(); const [isLoadingCommitDiff, setIsLoadingCommitDiff] = useState(false); @@ -94,6 +98,11 @@ export function useGitDiffActions({ latestFileDiffRequestRef.current = latestFileDiffRequest; const activeRepoPathRef = useRef(activeRepoPath); activeRepoPathRef.current = activeRepoPath; + const previewCommitFileDiff = useCommitFilePreview( + activeRepoPath, + commitPreviewScope, + openDiffBuffer, + ); const openOriginalFile = useCallback( async (filePath: string) => { @@ -625,6 +634,7 @@ export function useGitDiffActions({ viewFileDiff, viewWorkingTreeDiff, viewCommitDiff, + previewCommitFileDiff, viewCommitRangeDiff, viewCommitSelectionDiff, viewStashDiff, diff --git a/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx new file mode 100644 index 000000000..16a2167d2 --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { act, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { GitLogColumnResizeHandle } from "@/features/git/components/log/git-log-column-resize-handle"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { useGitLogPreferencesStore } from "../stores/git-log-preferences.store"; +import { GIT_LOG_COLUMN_DEFAULT_WIDTHS } from "../utils/git-log-columns"; +import { useGitLogColumnResize } from "./use-git-log-column-resize"; + +let rowClicks = 0; +// Manual animation-frame queue so live widths are applied deterministically, without real timers. +let pendingFrames = new Map(); +let nextFrameHandle = 1; +const frameScheduler = { + scheduleFrame: (callback: FrameRequestCallback) => { + const handle = nextFrameHandle++; + pendingFrames.set(handle, callback); + return handle; + }, + cancelFrame: (handle: number) => { + pendingFrames.delete(handle); + }, +}; + +// Mirrors the table: one scroll container whose CSS variables drive every row's column widths. +function Harness() { + const scrollRef = useRef(null); + const { columnStyle, activeColumn, startResize } = useGitLogColumnResize( + scrollRef, + frameScheduler, + ); + + return ( + +
+
(rowClicks += 1)}> +
+ +
+
+ +
+
+
+ {activeColumn ?? "none"} +
+ ); +} + +function pointerEvent(type: string, init: { clientX: number; button?: number }) { + const event = new Event(type, { bubbles: true, cancelable: true }); + return Object.assign(event, { button: 0, ...init }); +} + +const flushFrames = () => { + const frames = [...pendingFrames.values()]; + pendingFrames = new Map(); + for (const frame of frames) frame(0); +}; + +let restoreDom: () => void; +let container: HTMLElement; +let root: Root; +const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousActEnvironment: boolean | undefined; + +beforeEach(async () => { + restoreDom = installHappyDom(); + previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + rowClicks = 0; + pendingFrames = new Map(); + useGitLogPreferencesStore.setState({ + authorColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + dateColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.date, + }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render(); + }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + restoreDom(); +}); + +const query = (testId: string) => + container.querySelector(`[data-testid="${testId}"]`) as HTMLElement; +const handle = (cellTestId: string) => + query(cellTestId).querySelector('[role="separator"]') as HTMLElement; + +test("dragging a column's left edge leftwards widens it live and persists once on release", async () => { + const scroll = query("scroll"); + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("112px"); + + await act(async () => { + handle("author-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 300 })); + }); + expect(query("active").textContent).toBe("author"); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointermove", { clientX: 260 })); + flushFrames(); + }); + // The live width lands on the DOM, but nothing is written to the store mid-drag. + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("152px"); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(112); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointerup", { clientX: 260 })); + }); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(152); + expect(useGitLogPreferencesStore.getState().dateColumnWidth).toBe(144); + expect(scroll.style.getPropertyValue("--git-log-author-width")).toBe("152px"); + expect(query("active").textContent).toBe("none"); +}); + +test("the date column resizes independently and respects its bounds", async () => { + const scroll = query("scroll"); + + await act(async () => { + handle("date-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 500 })); + }); + await act(async () => { + document.dispatchEvent(pointerEvent("pointermove", { clientX: -5000 })); + flushFrames(); + }); + expect(scroll.style.getPropertyValue("--git-log-date-width")).toBe("280px"); + + await act(async () => { + document.dispatchEvent(pointerEvent("pointerup", { clientX: -5000 })); + }); + expect(useGitLogPreferencesStore.getState().dateColumnWidth).toBe(280); + expect(useGitLogPreferencesStore.getState().authorColumnWidth).toBe(112); +}); + +test("pressing the handle neither selects the row nor starts a drag with the right button", async () => { + await act(async () => { + handle("author-cell").dispatchEvent(pointerEvent("pointerdown", { clientX: 10, button: 2 })); + }); + expect(query("active").textContent).toBe("none"); + + await act(async () => { + handle("author-cell").dispatchEvent(new Event("click", { bubbles: true })); + handle("author-cell").dispatchEvent(new Event("dblclick", { bubbles: true })); + }); + expect(rowClicks).toBe(0); +}); diff --git a/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts new file mode 100644 index 000000000..9c8f4cf65 --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-log-column-resize.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { + startDocumentResizeSession, + type DocumentResizeSession, + type DocumentResizeSessionOptions, +} from "@/utils/document-resize-session"; +import { useGitLogPreferencesStore } from "../stores/git-log-preferences.store"; +import { + GIT_LOG_COLUMN_CSS_VARIABLES, + GIT_LOG_MESSAGE_MIN_WIDTH, + clampGitLogColumnWidth, + type GitLogColumn, +} from "../utils/git-log-columns"; + +/** + * Owns the commit table's resizable author and date columns. + * + * Widths reach every row through CSS variables on the scroll container. While dragging, the + * variable is written straight to the DOM (once per frame) so the virtualized rows are not + * re-rendered; the final width is persisted once when the drag ends. + * + * `frameScheduler` replaces `requestAnimationFrame` so tests can flush frames deterministically. + */ +export function useGitLogColumnResize( + scrollRef: React.RefObject, + frameScheduler?: Pick, +) { + const authorWidth = useGitLogPreferencesStore.use.authorColumnWidth(); + const dateWidth = useGitLogPreferencesStore.use.dateColumnWidth(); + const { setColumnWidth } = useGitLogPreferencesStore.use.actions(); + const sessionRef = useRef(null); + const [activeColumn, setActiveColumn] = useState(null); + + const startResize = useCallback( + (column: GitLogColumn, event: React.PointerEvent) => { + // Only the primary button resizes; the secondary button must still open the row menu. + if (event.button !== 0) return; + event.preventDefault(); + // Keep the row from treating the press as a selection or the start of a double-click open. + event.stopPropagation(); + sessionRef.current?.dispose({ commit: true }); + + const variable = GIT_LOG_COLUMN_CSS_VARIABLES[column]; + sessionRef.current = startDocumentResizeSession({ + startX: event.clientX, + startWidth: column === "author" ? authorWidth : dateWidth, + // Both columns hug the right edge, so dragging their left edge leftwards widens them. + direction: -1, + cursor: "ew-resize", + clampWidth: (value) => clampGitLogColumnWidth(column, value), + applyWidth: (width) => scrollRef.current?.style.setProperty(variable, `${width}px`), + commitWidth: (width) => { + setColumnWidth(column, width); + sessionRef.current = null; + }, + onActiveChange: (active) => setActiveColumn(active ? column : null), + ...frameScheduler, + }); + }, + [authorWidth, dateWidth, frameScheduler, scrollRef, setColumnWidth], + ); + + // Persist an in-flight drag if the table unmounts mid-gesture, and drop listeners and styles. + useEffect( + () => () => { + sessionRef.current?.dispose({ commit: true }); + sessionRef.current = null; + }, + [], + ); + + const columnStyle = useMemo( + () => + ({ + [GIT_LOG_COLUMN_CSS_VARIABLES.author]: `${authorWidth}px`, + [GIT_LOG_COLUMN_CSS_VARIABLES.date]: `${dateWidth}px`, + // Rows never get narrower than their columns plus the message area; overflow scrolls. + "--git-log-row-min-width": `calc(var(${GIT_LOG_COLUMN_CSS_VARIABLES.author}) + var(${GIT_LOG_COLUMN_CSS_VARIABLES.date}) + ${GIT_LOG_MESSAGE_MIN_WIDTH}px)`, + }) as CSSProperties, + [authorWidth, dateWidth], + ); + + return { columnStyle, activeColumn, startResize }; +} diff --git a/windows/tauri/src/features/git/stores/git-log-preferences.store.ts b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts index cc909a107..68729879b 100644 --- a/windows/tauri/src/features/git/stores/git-log-preferences.store.ts +++ b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts @@ -4,6 +4,12 @@ import { createSelectors } from "@/utils/zustand-selectors"; import { createSafeJSONStorage } from "@/utils/zustand-storage"; import { normalizeRepositoryPath } from "../api/git-repo-api"; import type { GitReferenceKind } from "../types/git.types"; +import { + GIT_LOG_COLUMN_DEFAULT_WIDTHS, + clampGitLogColumnWidth, + normalizeGitLogColumnWidth, + type GitLogColumn, +} from "../utils/git-log-columns"; export type GitLogFilterScope = "text" | "author" | "branch"; @@ -19,6 +25,8 @@ interface GitLogPreferencesStore { showWorktreeRepositories: boolean; mainPanelLayout: GitLogPanelLayout; inspectorPanelLayout: GitLogPanelLayout; + authorColumnWidth: number; + dateColumnWidth: number; collapsedReferenceSections: GitReferenceKind[]; collapsedReferenceGroups: string[]; markedReferenceFullNamesByRepository: Record; @@ -30,6 +38,8 @@ interface GitLogPreferencesStore { setShowWorktreeRepositories: (show: boolean) => void; setMainPanelLayout: (layout: GitLogPanelLayout) => void; setInspectorPanelLayout: (layout: GitLogPanelLayout) => void; + /** Persists a commit-table column width; call once when a drag ends, not per pointer move. */ + setColumnWidth: (column: GitLogColumn, width: number) => void; toggleReferenceSection: (kind: GitReferenceKind) => void; toggleReferenceGroup: (id: string) => void; setReferenceExpansion: (sections: GitReferenceKind[], groups: string[]) => void; @@ -67,6 +77,8 @@ const useGitLogPreferencesStoreBase = create()( showWorktreeRepositories: true, mainPanelLayout: DEFAULT_MAIN_LAYOUT, inspectorPanelLayout: DEFAULT_INSPECTOR_LAYOUT, + authorColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + dateColumnWidth: GIT_LOG_COLUMN_DEFAULT_WIDTHS.date, collapsedReferenceSections: [], collapsedReferenceGroups: [], markedReferenceFullNamesByRepository: {}, @@ -79,6 +91,12 @@ const useGitLogPreferencesStoreBase = create()( set({ showWorktreeRepositories }), setMainPanelLayout: (mainPanelLayout) => set({ mainPanelLayout }), setInspectorPanelLayout: (inspectorPanelLayout) => set({ inspectorPanelLayout }), + setColumnWidth: (column, width) => + set( + column === "author" + ? { authorColumnWidth: clampGitLogColumnWidth(column, width) } + : { dateColumnWidth: clampGitLogColumnWidth(column, width) }, + ), toggleReferenceSection: (kind) => set((state) => ({ collapsedReferenceSections: toggleListItem(state.collapsedReferenceSections, kind), @@ -148,6 +166,11 @@ const useGitLogPreferencesStoreBase = create()( markedReferenceFullNamesByRepository: persistedPreferences.markedReferenceFullNamesByRepository ?? {}, showWorktreeRepositories: persistedPreferences.showWorktreeRepositories ?? true, + authorColumnWidth: normalizeGitLogColumnWidth( + "author", + persistedPreferences.authorColumnWidth, + ), + dateColumnWidth: normalizeGitLogColumnWidth("date", persistedPreferences.dateColumnWidth), actions: currentState.actions, }; }, diff --git a/windows/tauri/src/features/git/types/git-diff.types.ts b/windows/tauri/src/features/git/types/git-diff.types.ts index 0912597ae..d17aa1486 100644 --- a/windows/tauri/src/features/git/types/git-diff.types.ts +++ b/windows/tauri/src/features/git/types/git-diff.types.ts @@ -129,6 +129,8 @@ export interface MultiFileDiff { fileLabels?: string[]; initiallyExpandedFileKey?: string; initiallySelectedFileKey?: string; + /** Hides the changed-files navigator, e.g. for a single-file commit preview. */ + hideFileList?: boolean; /** Working-tree refresh identities keyed by file key. */ workingTreeTargets?: Record; isLoading?: boolean; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 7237fadb1..2ef12a13a 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -33,6 +33,8 @@ export interface GitCommit { author: string; email?: string; date: string; + /** Author's UTC offset in minutes (east positive) for `date`, when Core reports one. */ + dateUtcOffsetMinutes?: number; decorations: string; } diff --git a/windows/tauri/src/features/git/utils/git-log-columns.test.ts b/windows/tauri/src/features/git/utils/git-log-columns.test.ts new file mode 100644 index 000000000..4f60e3008 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-columns.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { + GIT_LOG_COLUMN_DEFAULT_WIDTHS, + clampGitLogColumnWidth, + normalizeGitLogColumnWidth, +} from "./git-log-columns"; + +describe("git log column widths", () => { + test("clamps drag results to a readable range per column", () => { + expect(clampGitLogColumnWidth("author", 0)).toBe(56); + expect(clampGitLogColumnWidth("author", 9999)).toBe(360); + expect(clampGitLogColumnWidth("date", 0)).toBe(72); + expect(clampGitLogColumnWidth("date", 9999)).toBe(280); + expect(clampGitLogColumnWidth("author", 140.4)).toBe(140); + }); + + test("restores defaults for missing or corrupt persisted values", () => { + expect(normalizeGitLogColumnWidth("author", undefined)).toBe( + GIT_LOG_COLUMN_DEFAULT_WIDTHS.author, + ); + expect(normalizeGitLogColumnWidth("date", "wide")).toBe(GIT_LOG_COLUMN_DEFAULT_WIDTHS.date); + expect(normalizeGitLogColumnWidth("date", Number.NaN)).toBe(GIT_LOG_COLUMN_DEFAULT_WIDTHS.date); + expect(normalizeGitLogColumnWidth("author", 5000)).toBe(360); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-columns.ts b/windows/tauri/src/features/git/utils/git-log-columns.ts new file mode 100644 index 000000000..950f426f7 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-columns.ts @@ -0,0 +1,41 @@ +export type GitLogColumn = "author" | "date"; + +export interface GitLogColumnWidths { + author: number; + date: number; +} + +export const GIT_LOG_COLUMN_DEFAULT_WIDTHS: GitLogColumnWidths = { author: 112, date: 144 }; + +// Bounds keep a column readable and stop a runaway drag from pushing the commit message out. +const GIT_LOG_COLUMN_MIN_WIDTHS: GitLogColumnWidths = { author: 56, date: 72 }; +const GIT_LOG_COLUMN_MAX_WIDTHS: GitLogColumnWidths = { author: 360, date: 280 }; + +/** + * Width reserved for the graph and commit message, on top of both columns and the row padding. + * It equals the previous fixed 520px row minimum minus the default author and date widths. + */ +export const GIT_LOG_MESSAGE_MIN_WIDTH = 280; + +/** CSS variables carrying the live column widths; set on the table's scroll container. */ +export const GIT_LOG_COLUMN_CSS_VARIABLES: Record = { + author: "--git-log-author-width", + date: "--git-log-date-width", +}; + +export function clampGitLogColumnWidth(column: GitLogColumn, width: number): number { + if (!Number.isFinite(width)) return GIT_LOG_COLUMN_DEFAULT_WIDTHS[column]; + return Math.round( + Math.min( + Math.max(width, GIT_LOG_COLUMN_MIN_WIDTHS[column]), + GIT_LOG_COLUMN_MAX_WIDTHS[column], + ), + ); +} + +/** Restores a persisted width, falling back to the default for missing or corrupt values. */ +export function normalizeGitLogColumnWidth(column: GitLogColumn, width: unknown): number { + return typeof width === "number" + ? clampGitLogColumnWidth(column, width) + : GIT_LOG_COLUMN_DEFAULT_WIDTHS[column]; +} diff --git a/windows/tauri/src/features/git/utils/git-log-date.test.ts b/windows/tauri/src/features/git/utils/git-log-date.test.ts new file mode 100644 index 000000000..b021e4727 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-date.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { formatGitLogDate } from "./git-log-date"; + +// Echoes the key and values so assertions stay independent of locale copy. +const t = (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key; + +const am = (time: string) => `git.log.timeAm:${JSON.stringify({ time })}`; +const pm = (time: string) => `git.log.timePm:${JSON.stringify({ time })}`; + +// Local time on purpose: Git's displayed date is interpreted in the machine's zone, and the +// offset passed alongside it is the local one so relative labels apply. +const now = new Date(2026, 8, 10, 15, 30); +const local = -now.getTimezoneOffset(); + +describe("formatGitLogDate", () => { + test("shows minutes ago for commits within the last hour", () => { + expect(formatGitLogDate("2026/09/10 15:30", t, local, now)).toBe("git.log.dateJustNow"); + expect(formatGitLogDate("2026/09/10 15:29", t, local, now)).toBe( + 'git.log.dateMinuteAgoOne:{"count":1}', + ); + expect(formatGitLogDate("2026/09/10 15:05", t, local, now)).toBe( + 'git.log.dateMinutesAgo:{"count":25}', + ); + expect(formatGitLogDate("2026/09/10 14:31", t, local, now)).toBe( + 'git.log.dateMinutesAgo:{"count":59}', + ); + }); + + test("shows Today and Yesterday with a localized 12-hour time once past an hour", () => { + expect(formatGitLogDate("2026/09/10 14:30", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: pm("2:30") })}`, + ); + expect(formatGitLogDate("2026/09/10 00:05", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: am("12:05") })}`, + ); + expect(formatGitLogDate("2026/09/09 23:59", t, local, now)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("11:59") })}`, + ); + expect(formatGitLogDate("2026/09/09 12:00", t, local, now)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("12:00") })}`, + ); + }); + + test("shows the calendar date with a 12-hour time for older commits", () => { + expect(formatGitLogDate("2026/09/08 09:07", t, local, now)).toBe( + `git.log.dateAbsolute:${JSON.stringify({ year: "2026", month: "09", day: "08", time: am("9:07") })}`, + ); + expect(formatGitLogDate("2025/12/31 21:45", t, local, now)).toBe( + `git.log.dateAbsolute:${JSON.stringify({ year: "2025", month: "12", day: "31", time: pm("9:45") })}`, + ); + }); + + test("measures Yesterday by calendar day, not by 24 elapsed hours", () => { + const justAfterMidnight = new Date(2026, 8, 10, 0, 10); + expect(formatGitLogDate("2026/09/09 23:50", t, local, justAfterMidnight)).toBe( + 'git.log.dateMinutesAgo:{"count":20}', + ); + expect(formatGitLogDate("2026/09/09 22:00", t, local, justAfterMidnight)).toBe( + `git.log.dateYesterday:${JSON.stringify({ time: pm("10:00") })}`, + ); + }); + + // Regression: a commit authored in another zone carries that zone's wall-clock time, so + // comparing it with local "now" would produce wrong "minutes ago" and "Today" labels. + test("shows only the calendar date when the author's zone differs or is unknown", () => { + const absoluteToday = `git.log.dateAbsolute:${JSON.stringify({ year: "2026", month: "09", day: "10", time: pm("3:25") })}`; + expect(formatGitLogDate("2026/09/10 15:25", t, local + 60, now)).toBe(absoluteToday); + expect(formatGitLogDate("2026/09/10 15:25", t, null, now)).toBe(absoluteToday); + expect(formatGitLogDate("2026/09/10 15:25", t, undefined, now)).toBe(absoluteToday); + }); + + test("falls back to absolute formats for future dates and passes bad input through", () => { + expect(formatGitLogDate("2026/09/10 16:00", t, local, now)).toBe( + `git.log.dateToday:${JSON.stringify({ time: pm("4:00") })}`, + ); + expect(formatGitLogDate("not a date", t, local, now)).toBe("not a date"); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-date.ts b/windows/tauri/src/features/git/utils/git-log-date.ts new file mode 100644 index 000000000..dfd011dea --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-date.ts @@ -0,0 +1,65 @@ +type Translate = (key: string, values?: Record) => string; + +// Matches the displayed part of Core's `--date=format:%Y/%m/%d %H:%M %z` commit dates; Core +// moves the `%z` offset into `dateUtcOffsetMinutes`. +const GIT_LOG_DATE_PATTERN = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2})$/; +const MS_PER_MINUTE = 60_000; +const MINUTES_PER_HOUR = 60; + +function formatTwelveHourTime(hours: number, minutes: number, t: Translate): string { + const displayHour = hours % 12 === 0 ? 12 : hours % 12; + const time = `${displayHour}:${String(minutes).padStart(2, "0")}`; + // The period word and its position are locale copy: "2:30 PM" in English, "下午 2:30" in Chinese. + return t(hours < 12 ? "git.log.timeAm" : "git.log.timePm", { time }); +} + +function startOfDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +/** + * Formats a Git log commit date the way IDEA's commit history does: "N minutes ago" within the + * last hour, "Today"/"Yesterday" with a 12-hour time, and the calendar date with a 12-hour time + * otherwise. Unparseable input is returned unchanged so unusual backend values stay visible. + * + * Git reports the date in the author's time zone. Relative labels are only meaningful when that + * zone matches the local one, so a missing or different `utcOffsetMinutes` always produces the + * calendar date. + */ +export function formatGitLogDate( + dateString: string, + t: Translate, + utcOffsetMinutes: number | null | undefined, + now: Date = new Date(), +): string { + const match = GIT_LOG_DATE_PATTERN.exec(dateString); + if (!match) return dateString; + + const [year, month, day, hours, minutes] = match.slice(1).map(Number); + const date = new Date(year, month - 1, day, hours, minutes); + if (Number.isNaN(date.getTime())) return dateString; + + const time = formatTwelveHourTime(hours, minutes, t); + const absolute = () => + // Field order is locale copy: English reads day/month/year, Chinese year/month/day. + t("git.log.dateAbsolute", { year: match[1], month: match[2], day: match[3], time }); + + // `getTimezoneOffset` is minutes west of UTC; Core reports minutes east. + const isLocalTimeZone = utcOffsetMinutes != null && utcOffsetMinutes === -date.getTimezoneOffset(); + if (!isLocalTimeZone) return absolute(); + + const elapsedMinutes = Math.floor((now.getTime() - date.getTime()) / MS_PER_MINUTE); + // Negative values come from clock skew; fall through to the absolute formats instead of + // showing a nonsensical "-3 minutes ago". + if (elapsedMinutes >= 0 && elapsedMinutes < MINUTES_PER_HOUR) { + if (elapsedMinutes < 1) return t("git.log.dateJustNow"); + return t(elapsedMinutes === 1 ? "git.log.dateMinuteAgoOne" : "git.log.dateMinutesAgo", { + count: elapsedMinutes, + }); + } + + const dayDifference = Math.round((startOfDay(now) - startOfDay(date)) / (24 * 60 * MS_PER_MINUTE)); + if (dayDifference === 0) return t("git.log.dateToday", { time }); + if (dayDifference === 1) return t("git.log.dateYesterday", { time }); + return absolute(); +} diff --git a/windows/tauri/src/features/git/utils/multi-file-diff.test.ts b/windows/tauri/src/features/git/utils/multi-file-diff.test.ts index 04a62a9d0..88fe9433f 100644 --- a/windows/tauri/src/features/git/utils/multi-file-diff.test.ts +++ b/windows/tauri/src/features/git/utils/multi-file-diff.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import type { GitDiff } from "../types/git.types"; -import { createCommitDiffBuffer, createMultiFileDiff } from "./multi-file-diff"; +import { + COMMIT_FILE_PREVIEW_PATH, + createCommitDiffBuffer, + createCommitFileDiffPreview, + createMultiFileDiff, +} from "./multi-file-diff"; const diff = (filePath: string, additions: number, deletions: number): GitDiff => ({ file_path: filePath, @@ -49,12 +54,53 @@ describe("commit diff buffers", () => { }); expect(multiDiff.files).toBe(diffs); - expect(multiDiff.fileKeys).toEqual([ - "newest:src/shared.ts", - "oldest:src/shared.ts", - ]); + expect(multiDiff.fileKeys).toEqual(["newest:src/shared.ts", "oldest:src/shared.ts"]); expect(multiDiff.fileLabels).toEqual(["newest", "oldest"]); expect(multiDiff.initiallyExpandedFileKey).toBe("newest:src/shared.ts"); expect(multiDiff.initiallySelectedFileKey).toBe("newest:src/shared.ts"); }); }); + +describe("commit file preview", () => { + test("shows only the selected file without commit metadata in a reusable tab", () => { + const selected = diff("src/feature.ts", 4, 3); + const preview = createCommitFileDiffPreview({ + repoPath: "C:/repo", + commitHash: "1234567890abcdef", + diffs: [selected], + filePath: selected.file_path, + label: "1234567", + }); + + if (!preview) throw new Error("Expected file preview"); + expect(preview.virtualPath).toBe(COMMIT_FILE_PREVIEW_PATH); + expect(preview.displayName).toBe("feature.ts (1234567)"); + expect(preview.diffData.files).toEqual([selected]); + expect(preview.diffData.totalFiles).toBe(1); + expect(preview.diffData.commitMessage).toBeUndefined(); + expect(preview.diffData.hideFileList).toBe(true); + expect(preview.diffData.initiallySelectedFileKey).toBe("src/feature.ts:0"); + }); + + test("finds a diff by its new, current or original path", () => { + const renamed: GitDiff = { + ...diff("src/new.ts", 1, 1), + old_path: "src/old.ts", + new_path: "src/new.ts", + is_renamed: true, + }; + const diffs = [diff("src/main.ts", 2, 1), renamed]; + + const preview = (filePath: string) => + createCommitFileDiffPreview({ + repoPath: "C:/repo", + commitHash: "abc", + diffs, + filePath, + label: "abc", + }); + expect(preview("src/new.ts")?.diffData.files).toEqual([renamed]); + expect(preview("src/old.ts")?.diffData.files).toEqual([renamed]); + expect(preview("src/missing.ts")).toBeNull(); + }); +}); diff --git a/windows/tauri/src/features/git/utils/multi-file-diff.ts b/windows/tauri/src/features/git/utils/multi-file-diff.ts index b35fd1b1a..7a86902cf 100644 --- a/windows/tauri/src/features/git/utils/multi-file-diff.ts +++ b/windows/tauri/src/features/git/utils/multi-file-diff.ts @@ -87,3 +87,54 @@ export function createCommitDiffBuffer({ diffData, }; } + +/** Stable path so every commit-file preview reuses one editor tab instead of opening a new one. */ +export const COMMIT_FILE_PREVIEW_PATH = "diff://commit-file-preview"; + +/** + * Builds a single-file diff for the commit-files preview. Unlike the commit diff buffer it carries + * no commit message metadata: the preview shows only the selected file's changes. + */ +export function createCommitFileDiffPreview({ + repoPath, + commitHash, + diffs, + filePath, + fileKeys, + fileLabels, + label, +}: { + repoPath: string; + commitHash: string; + diffs: readonly GitDiff[]; + filePath: string; + fileKeys?: string[]; + fileLabels?: string[]; + /** Short revision label shown next to the file name in the tab title. */ + label: string; +}) { + // Disconnected selections can contain several revisions of the same path. + // Keep every matching section and its commit identity, rather than taking the first match. + const indexes = diffs.flatMap((diff, index) => + [diff.new_path, diff.file_path, diff.old_path].includes(filePath) ? [index] : [], + ); + if (indexes.length === 0) return null; + const fileName = filePath.split("/").pop() ?? filePath; + const diffData = createMultiFileDiff({ + title: fileName, + repoPath, + commitHash, + diffs: indexes.map((index) => diffs[index]), + initialFilePath: filePath, + fileKeys: fileKeys ? indexes.map((index) => fileKeys[index]) : undefined, + fileLabels: fileLabels ? indexes.map((index) => fileLabels[index]) : undefined, + }); + diffData.hideFileList = true; + diffData.totalFiles = 1; + + return { + virtualPath: COMMIT_FILE_PREVIEW_PATH, + displayName: `${fileName} (${label})`, + diffData, + }; +} diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts index fd770c2db..d2eb20e51 100644 --- a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts @@ -31,12 +31,9 @@ describe("Git Log workbench entry points", () => { }); }); - test("places Git Log on the bottom activity rail above Settings", () => { + test("places Git Log on the bottom activity rail after Run", () => { expect(SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("run")).toBeLessThan( SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("gitLog"), ); - expect(SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("gitLog")).toBeLessThan( - SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS.indexOf("settings"), - ); }); }); diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 496e0d8f7..1ca59af7c 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -223,29 +223,18 @@ const BottomPane = () => { const resizeGutter = !isFullScreen ? (
-
-
+ /> ) : null; const paneContent = (
+ {/* Both activity rails stay outside the middle column so they span the full + workbench height; the full-width bottom pane only stretches between them. */} -
-
- - - +
+ + +
+
+ + + +
+ {terminalWidthMode === "editor" && deferredSurfacesReady && ( + + + + )} +
+ +
- {terminalWidthMode === "editor" && deferredSurfacesReady && ( + + {terminalWidthMode === "full" && deferredSurfacesReady && ( )}
-
- - {terminalWidthMode === "full" && deferredSurfacesReady && ( -
- - - -
- )}
{showStatusBar ?