From ca07f9999e7c5dad8cf44116bfd57a007a4e6e89 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sun, 20 Sep 2026 18:36:49 +0800 Subject: [PATCH] fix(gpui): virtualize Git drawer rows and reuse snapshots --- .../2026-09-20-visible-row-rendering.md | 61 +++ nebula_app/src/display/side_panel/mod.rs | 10 +- nebula_app/src/display/side_panel/tests.rs | 9 +- nebula_app/src/gpui_shell/workspace.rs | 14 +- .../src/gpui_shell/workspace/vcs_panel.rs | 490 +----------------- .../workspace/vcs_panel/commit_input.rs | 18 + .../workspace/vcs_panel/list_model.rs | 172 ++++++ .../workspace/vcs_panel/list_tests.rs | 146 ++++++ .../gpui_shell/workspace/vcs_panel/rows.rs | 392 ++++++++++++++ 9 files changed, 814 insertions(+), 498 deletions(-) create mode 100644 architecture/notes/nebula_app/gpui_shell/workspace/vcs_panel/2026-09-20-visible-row-rendering.md create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/list_model.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/list_tests.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/rows.rs diff --git a/architecture/notes/nebula_app/gpui_shell/workspace/vcs_panel/2026-09-20-visible-row-rendering.md b/architecture/notes/nebula_app/gpui_shell/workspace/vcs_panel/2026-09-20-visible-row-rendering.md new file mode 100644 index 0000000..0848579 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/workspace/vcs_panel/2026-09-20-visible-row-rendering.md @@ -0,0 +1,61 @@ +# Git drawer rendering follows the viewport + +## Status + +Proposed for review with implementation. + +## Context + +Opening a Git drawer containing hundreds of changes made the GPUI workspace +expensive to repaint. An active Agent spinner schedules workspace redraws, so +otherwise unchanged repository data repeatedly incurred the same UI cost. + +## Evidence + +The legacy `display/side_panel/render.rs` bounds row construction with skip/take. +The shared `SidePanel` still coalesces background scans and refreshes on explicit +triggers. The GPUI drawer cloned all GitInfo vectors, constructed every change +row and rebuilt history topology inside each render. Its scroll container clipped +pixels after row construction; clipping did not bound construction cost. + +## Decision + +Publish an immutable Arc snapshot from the existing shared worker. Retain the +borrowed `git()` query for legacy callers. The GPUI VCS list caches descriptors +and graph topology by snapshot identity, view and repository root, and uses a +persistent variable-height GPUI ListState to construct only requested rows. + +Keep operation callbacks tied to descriptor paths and section identities. Preserve +scroll position for refreshed data in the same repository/view, and reset when +switching repository or view. Use one row renderer for actual UI and layout tests. + +## Rejected alternatives + +- Add a faster scan interval: does not address repeated UI construction. +- Freeze background spinners: removes visible progress without fixing the list. +- Cache an entire element tree: risks stale selection, theme and operation state. +- Add a second VCS backend: duplicates existing scan ownership and coalescing. + +## Consequences + +Snapshot replacement does O(number of records) descriptor work once. A normal +repaint builds visible rows plus bounded overdraw. Visible rows still read current +selection, theme and operation availability. The Arc retains one current snapshot +until consumers release it. No additional watcher, polling loop or worker exists. + +## Validation + +The GPUI layout regression loads 237 and 5,000 changes in a 300px viewport, counts +actual row construction, scrolls to a late row and clicks its real hit area. +Grouping tests preserve conflict deduplication and staged/unstaged operation +identities. These bounded-work checks do not establish a whole-application CPU +percentage; native desktop performance and visual acceptance remain separate. + +## Supersedes + +None. + +## Revisit when + +Repository snapshots become incremental or VCS rows require different navigation +semantics. Preserve viewport-bounded construction across future shell migrations. diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index d0a1994..6c6918c 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -407,7 +407,7 @@ pub struct SidePanel { /// Directories the user expanded (persists across refreshes). expanded: HashSet, /// Git snapshot, `None` when the root isn't inside a work tree. - git: Option, + git: Option>, /// Scroll offset in rows. pub scroll: usize, /// Files-view filter query; non-empty switches the tree to a flat list of @@ -713,7 +713,7 @@ impl SidePanel { } self.enumeration_failed = !snapshot.enumeration_ok; if let Some(git) = snapshot.git { - self.git = git; + self.git = git.map(std::sync::Arc::new); } true } @@ -1468,7 +1468,11 @@ impl SidePanel { } pub fn git(&self) -> Option<&GitInfo> { - self.git.as_ref() + self.git.as_deref() + } + + pub fn git_snapshot(&self) -> Option> { + self.git.clone() } pub fn root(&self) -> Option<&Path> { diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index be53af9..8700def 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -284,7 +284,10 @@ mod tests { panel.open = true; panel.root = Some(root.clone()); panel.followed_wsl = Some(located.clone()); - panel.git = Some(GitInfo { branch: "keep-until-ready".to_owned(), ..Default::default() }); + panel.git = Some(std::sync::Arc::new(GitInfo { + branch: "keep-until-ready".to_owned(), + ..Default::default() + })); *panel.snapshot_slot.lock().unwrap() = Some(PanelSnapshot { root: root.clone(), @@ -548,7 +551,7 @@ mod tests { fn git_hover_only_accepts_real_file_rows() { let mut panel = SidePanel::new(); panel.view = PanelView::Git; - panel.git = Some(GitInfo { + panel.git = Some(std::sync::Arc::new(GitInfo { vcs: VcsKind::Git, branch: "main".into(), plus: 0, @@ -560,7 +563,7 @@ mod tests { history: Vec::new(), repository_root: None, repository: None, - }); + })); assert!(!panel.git_row_is_file(0), "未暂存标题"); assert!(panel.git_row_is_file(1)); diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index a0e1170..de1e208 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -767,6 +767,7 @@ pub struct NebulaWorkspace { /// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型 /// `vcs_commit_message`,不经旧壳的内部输入状态机。 git_commit_input: vcs_panel::CommitInput, + vcs_list: vcs_panel::VcsList, /// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。 vcs_discard_confirm: Option, /// 命令面板的行覆盖:`None` = 常规命令目录,`Some` = 某个专用列表 @@ -1053,6 +1054,7 @@ impl NebulaWorkspace { saved_commands: crate::saved_commands::SavedCommands::load().unwrap_or_default(), _command_manager_subscription: command_manager_subscription, git_commit_input, + vcs_list: vcs_panel::VcsList::default(), vcs_discard_confirm: None, palette_override: None, shell_picker_open: false, @@ -2079,18 +2081,6 @@ impl NebulaWorkspace { self.toggle_side_panel(crate::display::side_panel::PanelView::Git, cx); } - /// 提交按钮/Enter:读 GPUI 输入框的消息直达共享模型(git 提交暂存区、 - /// svn 提交工作副本),成功入队后清空输入。 - fn submit_vcs_commit(&mut self, window: &mut Window, cx: &mut Context) { - let message = self.git_commit_input.input.read(cx).value().trim().to_string(); - if message.is_empty() { - return; - } - self.side_panel.vcs_commit_message(&message); - self.git_commit_input.input.update(cx, |input, cx| input.set_value("", window, cx)); - cx.notify(); - } - /// The catalog itself is owned by the old/shared command model. This /// presentation advertises only actions whose execution path is already /// wired in GPUI; unsupported rows remain in the shared catalog and appear diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs index 443f5e1..9efcd1e 100644 --- a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs @@ -8,8 +8,12 @@ use super::*; use crate::i18n::{Message, UiLanguage}; use gpui_component::menu::PopupMenuItem; +use std::path::{Path, PathBuf}; mod commit_input; +mod list_model; +mod rows; +pub(super) use list_model::VcsList; mod relative_time; pub(super) use commit_input::CommitInput; use relative_time::git_relative_time_at; @@ -24,487 +28,16 @@ impl NebulaWorkspace { self.git_commit_input.sync_language(window, cx); let theme = cx.theme(); let muted = theme.muted_foreground; - let hover = theme.list_hover; - let selected_bg = theme.list_active; - // 第三条轨道固定使用从主题主色旋出的紫色。普通 lane 不能借 danger - // 红色(那会误报错误),也不能直接借 link(有些主题里与 primary 同色)。 - let lane_purple = gpui::Hsla { - h: (theme.primary.h + 0.20) % 1.0, - s: theme.primary.s.max(0.42), - l: theme.primary.l, - a: theme.primary.a, - }; let symbol: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); - // VCS 状态跟着 `SidePanel::vcs_root`:显式浏览定位优先,其次终端 cwd。 let root = self.side_panel.vcs_root().map(Path::to_path_buf); - let selected = self.side_panel.selected.clone(); - let git = self.side_panel.git().cloned(); + let git = self.side_panel.git_snapshot(); let vcs = git.as_ref().map(|info| info.vcs); let git_view = self.side_panel.git_view; let op_running = self.side_panel.op_running(); let op_error = self.side_panel.localized_op_error(language); - // 浏览定位是否生效——决定要不要画“回到终端当前目录”。链式构建里不能 - // 再借 `self`,所以这些和下面的弱引用都在这里一次取好。 let browsing_elsewhere = self.side_panel.custom_root_active(); - // 下拉菜单的动作要在自己的闭包里回到本实体;`cx.listener` 只能给 - // 直接挂在元素上的回调用,菜单项拿不到它。 let menu_target = cx.entity().downgrade(); - let mut rows = Vec::new(); - - if let Some(info) = git.as_ref() { - use crate::display::side_panel::{GitPanelView, VcsKind}; - /// 分组决定可用的行内暂存、取消暂存或冲突处理操作。 - #[derive(Clone, Copy, PartialEq)] - enum RowOps { - /// 变更组:暂存 + 丢弃(untracked 不给丢弃——restore 不删新文件)。 - Unstaged, - /// 已暂存组:取消暂存。 - Staged, - /// Git 冲突组:打开工作区里的三栏合并 Tab。 - Conflict, - /// SVN 无暂存区,具体操作由状态字母决定。 - Svn, - } - let is_git = info.vcs == VcsKind::Git; - if is_git && git_view == GitPanelView::History { - let graph_rows = git_graph_rows(&info.history); - let (lane_width, lane_spacing) = git_lane_layout(&graph_rows); - if info.history.is_empty() { - rows.push( - div() - .py_3() - .px_2() - .text_sm() - .text_color(muted) - .child(language.text(Message::VcsNoHistory)) - .into_any_element(), - ); - } - for (index, (commit, graph)) in - info.history.iter().zip(graph_rows.into_iter()).enumerate() - { - let graph_cell = git_lane_canvas( - graph, - lane_width, - lane_spacing, - [theme.primary, theme.success, lane_purple, theme.warning], - theme.popover, - ); - let refs = git_ref_labels(&commit.decorations) - .into_iter() - .take(2) - .map(|git_ref| { - let color = match git_ref.kind { - GitRefKind::Head | GitRefKind::Local => theme.primary, - GitRefKind::Remote => lane_purple, - GitRefKind::Tag => theme.warning, - }; - div() - .h(px(15.0)) - .max_w(px(72.0)) - .flex_shrink_0() - .px(px(4.0)) - .rounded(px(4.0)) - .border_1() - .border_color(color.opacity(0.38)) - .bg(color.opacity(0.12)) - .truncate() - .text_size(px(9.5)) - .text_color(color) - .child(git_ref.label) - .into_any_element() - }) - .collect::>(); - let meta = format!( - "{} · {} · {}", - commit.author, - git_relative_time(commit.timestamp, language), - commit.short_hash - ); - rows.push( - h_flex() - .id(SharedString::from(format!("git-history-{index}"))) - .w_full() - .h(px(46.0)) - .px_2() - .items_start() - .hover(|row| row.bg(hover)) - .child(graph_cell) - .child( - v_flex() - .flex_1() - .min_w_0() - .h_full() - .justify_center() - .gap_1() - .child( - h_flex().min_w_0().gap(px(4.0)).children(refs).child( - div() - .min_w_0() - .truncate() - .text_sm() - .child(commit.subject.clone()), - ), - ) - .child( - div().truncate().text_xs().text_color(muted).child(meta), - ), - ) - .into_any_element(), - ); - } - } else { - let conflict_paths: std::collections::HashSet<&str> = - info.conflicts.iter().map(|(_, path)| path.as_str()).collect(); - // 变更页保留三组;冲突页只列冲突。路径从后两组过滤,因为数据层 - // 为旧壳兼容仍把冲突同时留在 staged/unstaged。 - let mut sections: Vec<(&str, &str, Vec<&(char, String)>, RowOps)> = Vec::new(); - if !info.conflicts.is_empty() { - sections.push(( - "conflicts", - language.text(Message::VcsMergeConflicts), - info.conflicts.iter().collect(), - if is_git { RowOps::Conflict } else { RowOps::Svn }, - )); - } - let not_conflicted = - |(_, path): &&(char, String)| !conflict_paths.contains(path.as_str()); - match info.vcs { - VcsKind::Git if git_view == GitPanelView::Changes => { - sections.push(( - "staged", - language.text(Message::VcsStaged), - info.staged.iter().filter(not_conflicted).collect(), - RowOps::Staged, - )); - sections.push(( - "changes", - language.text(Message::VcsChanges), - info.unstaged.iter().filter(not_conflicted).collect(), - RowOps::Unstaged, - )); - }, - VcsKind::Git => {}, - VcsKind::Svn => sections.push(( - "svn", - "修改", - info.unstaged.iter().filter(not_conflicted).collect(), - RowOps::Svn, - )), - VcsKind::SvnRepository => {}, - } - let clean = sections.iter().all(|(_, _, entries, _)| entries.is_empty()); - if clean && info.vcs != VcsKind::SvnRepository { - rows.push( - div() - .py_2() - .px_2() - .text_sm() - .text_color(muted) - .child(if is_git && git_view == GitPanelView::Conflicts { - language.text(Message::VcsNoConflicts) - } else { - language.text(Message::VcsNoChanges) - }) - .into_any_element(), - ); - } - let discard_confirm = self.vcs_discard_confirm.clone(); - for (section_id, section, entries, ops) in sections { - if entries.is_empty() { - continue; - } - rows.push( - h_flex() - .h(px(26.0)) - .px_2() - .items_center() - .text_xs() - .text_color(muted) - .child(section) - .child(div().ml_2().child(entries.len().to_string())) - .into_any_element(), - ); - for (index, (status, relative_path)) in entries.into_iter().enumerate() { - let path = root - .as_ref() - .map(|root| root.join(relative_path)) - .unwrap_or_else(|| std::path::PathBuf::from(relative_path)); - let selected_row = selected.as_ref() == Some(&path); - let status_color = match status { - 'A' | '?' => theme.success, - 'D' | '!' => theme.danger, - 'C' | 'U' => theme.danger, - _ => theme.warning, - }; - // 路径拆分显示:文件名主体 + 灰色父目录。 - let (file_name, parent) = match relative_path.rfind('/') { - Some(pos) => ( - relative_path[pos + 1..].to_owned(), - relative_path[..pos].to_owned(), - ), - None => (relative_path.clone(), String::new()), - }; - let row_group = - SharedString::from(format!("vcs-row-actions-{section_id}-{index}")); - let open_path = path.clone(); - let stage_path = relative_path.clone(); - let svn_add_path = relative_path.clone(); - let unstage_path = relative_path.clone(); - let discard_path = relative_path.clone(); - let resolve_path = relative_path.clone(); - let diff_path = relative_path.clone(); - let menu_path = relative_path.clone(); - let merge_button_path = relative_path.clone(); - let merge_open_path = relative_path.clone(); - let discard_armed = - discard_confirm.as_deref() == Some(relative_path.as_str()); - let git_discard = ops == RowOps::Unstaged && *status != '?'; - let svn_revert = ops == RowOps::Svn && !matches!(*status, '?' | 'C'); - let can_discard = git_discard || svn_revert; - let svn_add = ops == RowOps::Svn && *status == '?'; - let svn_resolve = ops == RowOps::Svn && *status == 'C'; - let svn_diff = ops == RowOps::Svn && !matches!(*status, '?' | '!'); - rows.push( - h_flex() - .id(SharedString::from(format!( - "git-tree-row-{section_id}-{index}-{relative_path}" - ))) - .group(row_group.clone()) - .h(px(30.0)) - .w_full() - .px_2() - .gap_2() - .items_center() - .rounded_md() - .when(selected_row, |row| row.bg(selected_bg)) - .hover(|row| row.bg(hover)) - .child( - div() - .w(px(14.0)) - .flex_shrink_0() - .font_family(symbol.clone()) - .text_sm() - .text_color(status_color) - .child(status.to_string()), - ) - .child( - h_flex() - .flex_1() - .min_w_0() - .gap_1() - .items_center() - .child( - div() - .flex_shrink_0() - .text_sm() - .child(file_name.clone()), - ) - .when(!parent.is_empty(), |line| { - line.child( - div() - .min_w_0() - .truncate() - .text_xs() - .text_color(muted) - .child(parent.clone()), - ) - }), - ) - .when(can_discard, |row| { - row.child( - Button::new(SharedString::from(format!( - "vcs-discard-{section_id}-{index}" - ))) - .map(|button| { - if discard_armed { - button - .label(if svn_revert { - "确认还原" - } else { - language.text(Message::VcsConfirmDiscard) - }) - .danger() - .xsmall() - } else { - button - .icon(IconName::Undo2) - .ghost() - .xsmall() - .tooltip(if svn_revert { - "还原 SVN 改动" - } else { - language.text(Message::VcsDiscard) - }) - } - }) - .when(!discard_armed, |button| { - button - .invisible() - .group_hover(row_group.clone(), |button| { - button.visible() - }) - }) - .on_click( - cx.listener(move |this, _, _, cx| { - if this.vcs_discard_confirm.as_deref() - == Some(discard_path.as_str()) - { - this.vcs_discard_confirm = None; - if svn_revert { - this.side_panel - .svn_revert_path(&discard_path); - } else { - this.side_panel - .git_discard_path(&discard_path); - } - } else { - this.vcs_discard_confirm = - Some(discard_path.clone()); - } - cx.notify(); - }), - ), - ) - }) - .when(ops == RowOps::Unstaged && is_git, |row| { - row.child( - Button::new(SharedString::from(format!( - "vcs-stage-{section_id}-{index}" - ))) - .icon(IconName::Plus) - .ghost() - .xsmall() - .tooltip(language.text(Message::VcsStage)) - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click( - cx.listener(move |this, _, _, cx| { - this.vcs_discard_confirm = None; - this.side_panel.git_stage_path(&stage_path); - cx.notify(); - }), - ), - ) - }) - .when(svn_add, |row| { - row.child( - Button::new(SharedString::from(format!( - "svn-add-{section_id}-{index}" - ))) - .icon(IconName::Plus) - .ghost() - .xsmall() - .tooltip("添加到 SVN") - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click( - cx.listener(move |this, _, _, cx| { - this.vcs_discard_confirm = None; - this.side_panel.svn_add_path(&svn_add_path); - cx.notify(); - }), - ), - ) - }) - .when(svn_resolve, |row| { - row.child( - Button::new(SharedString::from(format!( - "svn-resolve-{section_id}-{index}" - ))) - .label("解决") - .ghost() - .xsmall() - .tooltip("保留当前内容并标记冲突已解决") - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click( - cx.listener(move |this, _, _, cx| { - this.vcs_discard_confirm = None; - this.side_panel.svn_resolve_path(&resolve_path); - cx.notify(); - }), - ), - ) - }) - .when(ops == RowOps::Svn, |row| { - // 这一行的完整 SVN 操作集(日志、blame、锁、 - // 忽略、改名、删除、冲突、属性)都在这个菜单里, - // 行内只多一个 ⋯ 位。 - row.child(Self::svn_row_menu( - &menu_target, - &menu_path, - index, - section_id, - )) - }) - .when(ops == RowOps::Staged, |row| { - row.child( - Button::new(SharedString::from(format!( - "vcs-unstage-{section_id}-{index}" - ))) - .icon(IconName::Minus) - .ghost() - .xsmall() - .tooltip(language.text(Message::VcsUnstage)) - .invisible() - .group_hover(row_group.clone(), |button| button.visible()) - .on_click( - cx.listener(move |this, _, _, cx| { - this.vcs_discard_confirm = None; - this.side_panel.git_unstage_path(&unstage_path); - cx.notify(); - }), - ), - ) - }) - .when(ops == RowOps::Conflict && is_git, |row| { - row.child( - Button::new(SharedString::from(format!( - "git-resolve-{section_id}-{index}" - ))) - .icon( - Icon::new(Icon::empty()) - .path(crate::gpui_shell::assets::nav::VCS_CONFLICT), - ) - .ghost() - .xsmall() - .tooltip(language.text(Message::VcsResolveInMergeEditor)) - .on_click( - cx.listener(move |this, _, window, cx| { - this.open_git_merge_tab( - merge_button_path.clone(), - window, - cx, - ); - }), - ), - ) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.side_panel.selected = Some(path.clone()); - cx.notify(); - })) - .on_double_click(cx.listener(move |this, _, window, cx| { - if ops == RowOps::Conflict && is_git { - this.open_git_merge_tab( - merge_open_path.clone(), - window, - cx, - ); - } else if !svn_diff - || !this.side_panel.svn_diff_path(&diff_path) - { - // Git 与未版本化 SVN 文件仍走现有文档路由。 - this.open_document_path(open_path.clone(), window, cx); - } - })) - .into_any_element(), - ); - } - } - } - } + self.vcs_list.sync(git.clone(), git_view, root.clone()); use crate::display::side_panel::{GitPanelView, VcsKind}; let summary = git.as_ref().map(|info| { @@ -914,13 +447,7 @@ impl NebulaWorkspace { .when_some(self.side_panel.localized_root_notice(language), |panel, notice| { panel.child(div().text_xs().text_color(theme.warning).child(notice)) }) - .child( - v_flex() - .flex_1() - .min_h_0() - .overflow_y_scrollbar() - .child(v_flex().w_full().gap_1().children(rows)), - ) + .child(self.render_vcs_list(cx)) .child( h_flex() .justify_end() @@ -1467,3 +994,6 @@ mod tests { assert_eq!(refs[3].kind, GitRefKind::Local); } } + +#[cfg(all(test, feature = "gpui-test-support"))] +mod list_tests; diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs index c774b5d..9c780fa 100644 --- a/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs @@ -111,3 +111,21 @@ impl CommitInput { self.language = language; } } + +impl super::NebulaWorkspace { + /// 提交按钮/Enter:读 GPUI 输入框的消息直达共享模型(git 提交暂存区、 + /// svn 提交工作副本),成功入队后清空输入。 + pub(in crate::gpui_shell::workspace) fn submit_vcs_commit( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) { + let message = self.git_commit_input.input.read(cx).value().trim().to_string(); + if message.is_empty() { + return; + } + self.side_panel.vcs_commit_message(&message); + self.git_commit_input.input.update(cx, |input, cx| input.set_value("", window, cx)); + cx.notify(); + } +} diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/list_model.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/list_model.rs new file mode 100644 index 0000000..fef7499 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/list_model.rs @@ -0,0 +1,172 @@ +//! Stable presentation data derived once per repository snapshot or selected view. +use super::*; +use crate::display::side_panel::{GitCommit, GitInfo, GitPanelView, VcsKind}; + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum RowOps { + Unstaged, + Staged, + Conflict, + Svn, +} + +#[derive(Clone)] +pub(super) enum VcsRow { + Heading { + message: Option, + count: usize, + }, + Empty(Message), + History { + index: usize, + commit: GitCommit, + graph: GitGraphRow, + }, + Change { + section_id: &'static str, + index: usize, + status: char, + relative_path: String, + ops: RowOps, + }, +} + +pub(in crate::gpui_shell::workspace) struct VcsList { + snapshot: Option>, + #[cfg(test)] + pub(super) rendered: Vec, + view: Option, + root: Option, + rows: Vec, + lane_layout: (f32, f32), + pub(super) scroll: gpui::ListState, +} + +impl Default for VcsList { + fn default() -> Self { + Self { + snapshot: None, + #[cfg(test)] + rendered: Vec::new(), + view: None, + root: None, + rows: Vec::new(), + lane_layout: (0.0, 0.0), + scroll: gpui::ListState::new(0, gpui::ListAlignment::Top, px(34.0)), + } + } +} + +impl VcsList { + pub(super) fn sync( + &mut self, + snapshot: Option>, + view: GitPanelView, + root: Option, + ) { + let same_snapshot = match (&self.snapshot, &snapshot) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + let same_location = self.view == Some(view) && self.root == root; + if same_snapshot && same_location { + return; + } + self.snapshot = snapshot; + self.view = Some(view); + self.root = root; + self.rows.clear(); + if let Some(info) = self.snapshot.as_ref() { + if info.vcs == VcsKind::Git && view == GitPanelView::History { + let graphs = git_graph_rows(&info.history); + self.lane_layout = git_lane_layout(&graphs); + self.rows.extend( + info.history + .iter() + .cloned() + .zip(graphs) + .enumerate() + .map(|(index, (commit, graph))| VcsRow::History { index, commit, graph }), + ); + if self.rows.is_empty() { + self.rows.push(VcsRow::Empty(Message::VcsNoHistory)); + } + } else { + let conflicts: std::collections::HashSet<_> = + info.conflicts.iter().map(|(_, p)| p.as_str()).collect(); + let mut section = + |id, message, entries: &[(char, String)], ops, exclude_conflicts: bool| { + let changes: Vec<_> = entries + .iter() + .filter(|(_, p)| !exclude_conflicts || !conflicts.contains(p.as_str())) + .enumerate() + .map(|(index, (status, path))| VcsRow::Change { + section_id: id, + index, + status: *status, + relative_path: path.clone(), + ops, + }) + .collect(); + if !changes.is_empty() { + self.rows.push(VcsRow::Heading { message, count: changes.len() }); + self.rows.extend(changes); + } + }; + let is_git = info.vcs == VcsKind::Git; + section( + "conflicts", + Some(Message::VcsMergeConflicts), + &info.conflicts, + if is_git { RowOps::Conflict } else { RowOps::Svn }, + false, + ); + match info.vcs { + VcsKind::Git if view == GitPanelView::Changes => { + section( + "staged", + Some(Message::VcsStaged), + &info.staged, + RowOps::Staged, + true, + ); + section( + "changes", + Some(Message::VcsChanges), + &info.unstaged, + RowOps::Unstaged, + true, + ); + }, + VcsKind::Svn => section("svn", None, &info.unstaged, RowOps::Svn, true), + _ => {}, + } + if self.rows.is_empty() && info.vcs != VcsKind::SvnRepository { + self.rows.push(VcsRow::Empty(if is_git && view == GitPanelView::Conflicts { + Message::VcsNoConflicts + } else { + Message::VcsNoChanges + })); + } + } + } + if same_location { + let offset = self.scroll.logical_scroll_top(); + self.scroll.splice(0..self.scroll.item_count(), self.rows.len()); + self.scroll.scroll_to(offset); + } else { + self.scroll.reset(self.rows.len()); + } + } + + pub(super) fn row(&self, index: usize) -> Option<&VcsRow> { + self.rows.get(index) + } + pub(super) fn root(&self) -> Option<&PathBuf> { + self.root.as_ref() + } + pub(super) fn lane_layout(&self) -> (f32, f32) { + self.lane_layout + } +} diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/list_tests.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/list_tests.rs new file mode 100644 index 0000000..2c822f7 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/list_tests.rs @@ -0,0 +1,146 @@ +use super::*; +use crate::display::side_panel::{GitInfo, GitPanelView}; +use gpui::{ListOffset, Modifiers, TestAppContext}; + +struct ListProbe(Entity); +impl Render for ListProbe { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .w(px(320.0)) + .h(px(300.0)) + .flex() + .child(self.0.update(cx, |workspace, cx| workspace.render_vcs_list(cx))) + } +} + +fn snapshot(count: usize) -> Arc { + Arc::new(GitInfo { + unstaged: (0..count).map(|ix| ('M', format!("src/file-{ix}.rs"))).collect(), + ..GitInfo::default() + }) +} + +#[gpui::test] +fn git_list_repaints_visible_rows_and_clicks_the_scrolled_path(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::gpui_shell::math_view::register(cx); + crate::gpui_shell::file_editor::init(cx); + crate::gpui_shell::init(cx); + cx.set_reduce_motion(true); + windowing::initialize(cx, crate::runtime_api::RuntimeHub::new()); + }); + let data = snapshot(237); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().to_path_buf(); + let mut owner = None; + let mut probe = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + NebulaWorkspace::new( + window, + None, + None, + 1, + crate::runtime_api::RuntimeHub::new(), + windowing::WorkspaceStartup::Empty, + windowing::WindowRole::Regular, + cx, + ) + }); + workspace.update(cx, |workspace, _| { + workspace.vcs_list.sync(Some(data.clone()), GitPanelView::Changes, Some(root.clone())); + }); + let view = cx.new(|_| ListProbe(workspace.clone())); + owner = Some(workspace); + probe = Some(view.clone()); + Root::new(view, window, cx) + }); + let owner = owner.unwrap(); + let probe = probe.unwrap(); + cx.run_until_parked(); + for _ in 0..3 { + cx.update(|window, cx| { + owner.update(cx, |workspace, _| { + workspace.vcs_list.rendered.clear(); + workspace.vcs_list.sync( + Some(data.clone()), + GitPanelView::Changes, + Some(root.clone()), + ); + }); + probe.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + let rendered = owner.read_with(cx, |workspace, _| workspace.vcs_list.rendered.clone()); + assert!(!rendered.is_empty()); + assert!(rendered.len() < 32, "300px viewport must not build 237 rows: {rendered:?}"); + assert!(!rendered.contains(&230)); + } + cx.update(|window, cx| { + owner.update(cx, |workspace, _| { + workspace + .vcs_list + .scroll + .scroll_to(ListOffset { item_ix: 220, offset_in_item: px(0.0) }); + }); + probe.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + // Descriptor 0 is a heading, so row 220 is file 219, not file 220. + let bounds = cx.debug_bounds("git-tree-row-changes-219-src/file-219.rs").unwrap(); + assert!(bounds.size.height >= px(30.0)); + cx.simulate_mouse_down(bounds.center(), MouseButton::Left, Modifiers::default()); + cx.simulate_mouse_up(bounds.center(), MouseButton::Left, Modifiers::default()); + assert_eq!( + owner.read_with(cx, |workspace, _| workspace.side_panel.selected.clone()), + Some(root.join("src/file-219.rs")) + ); + + cx.update(|window, cx| { + owner.update(cx, |workspace, _| { + let before = workspace.vcs_list.scroll.logical_scroll_top(); + workspace.vcs_list.sync( + Some(snapshot(5000)), + GitPanelView::Changes, + Some(root.clone()), + ); + assert_eq!(workspace.vcs_list.scroll.logical_scroll_top().item_ix, before.item_ix); + workspace.vcs_list.rendered.clear(); + }); + probe.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + let rendered = owner.read_with(cx, |workspace, _| workspace.vcs_list.rendered.clone()); + assert!(!rendered.is_empty() && rendered.len() < 32, "5000 rows: {rendered:?}"); +} + +#[test] +fn grouping_keeps_conflict_and_stage_actions_attached_to_the_correct_paths() { + use list_model::{RowOps, VcsRow}; + let data = Arc::new(GitInfo { + conflicts: vec![('U', "conflict.rs".into())], + staged: vec![('U', "conflict.rs".into()), ('A', "staged.rs".into())], + unstaged: vec![('U', "conflict.rs".into()), ('M', "modified.rs".into())], + ..GitInfo::default() + }); + let mut list = VcsList::default(); + list.sync(Some(data.clone()), GitPanelView::Changes, Some(PathBuf::from("/repo"))); + let paths: Vec<_> = (0..list.scroll.item_count()) + .filter_map(|ix| match list.row(ix).unwrap() { + VcsRow::Change { relative_path, ops, .. } => Some((relative_path.as_str(), *ops)), + _ => None, + }) + .collect(); + assert!( + paths + == [ + ("conflict.rs", RowOps::Conflict), + ("staged.rs", RowOps::Staged), + ("modified.rs", RowOps::Unstaged) + ] + ); + list.sync(Some(data), GitPanelView::Conflicts, Some(PathBuf::from("/repo"))); + assert_eq!(list.scroll.item_count(), 2); + assert_eq!(list.scroll.logical_scroll_top().item_ix, 0); +} diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/rows.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/rows.rs new file mode 100644 index 0000000..6d77bd4 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/rows.rs @@ -0,0 +1,392 @@ +//! Construct only the Git/SVN rows requested by the virtual list. +use super::list_model::{RowOps, VcsRow}; +use super::*; + +impl NebulaWorkspace { + pub(super) fn render_vcs_list(&self, cx: &mut Context) -> gpui::AnyElement { + let scroll = self.vcs_list.scroll.clone(); + let owner = cx.entity().downgrade(); + v_flex() + .flex_1() + .min_h_0() + .relative() + .overflow_hidden() + .child( + gpui::list(scroll.clone(), move |index, window, cx| { + owner + .update(cx, |workspace, cx| workspace.render_vcs_row(index, window, cx)) + .unwrap_or_else(|_| div().into_any_element()) + }) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(16.0)) + .child(gpui_component::scroll::Scrollbar::vertical(&scroll)), + ) + .into_any_element() + } + + pub(super) fn render_vcs_row( + &mut self, + visible_index: usize, + _window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + #[cfg(test)] + self.vcs_list.rendered.push(visible_index); + let Some(row) = self.vcs_list.row(visible_index).cloned() else { + return div().into_any_element(); + }; + let language = crate::gpui_shell::config::ui_language(cx); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let hover = theme.list_hover; + let selected_bg = theme.list_active; + let symbol: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into(); + let lane_purple = gpui::Hsla { + h: (theme.primary.h + 0.20) % 1.0, + s: theme.primary.s.max(0.42), + l: theme.primary.l, + a: theme.primary.a, + }; + let root = self.vcs_list.root().cloned(); + let selected = self.side_panel.selected.clone(); + let menu_target = cx.entity().downgrade(); + let element = match row { + VcsRow::Heading { message, count } => h_flex() + .h(px(26.0)) + .px_2() + .items_center() + .text_xs() + .text_color(muted) + .child(message.map(|m| language.text(m)).unwrap_or("修改")) + .child(div().ml_2().child(count.to_string())) + .into_any_element(), + VcsRow::Empty(message) => div() + .py_2() + .px_2() + .text_sm() + .text_color(muted) + .child(language.text(message)) + .into_any_element(), + VcsRow::History { index, commit, graph } => { + let (lane_width, lane_spacing) = self.vcs_list.lane_layout(); + let graph_cell = git_lane_canvas( + graph, + lane_width, + lane_spacing, + [theme.primary, theme.success, lane_purple, theme.warning], + theme.popover, + ); + let refs = git_ref_labels(&commit.decorations) + .into_iter() + .take(2) + .map(|git_ref| { + let color = match git_ref.kind { + GitRefKind::Head | GitRefKind::Local => theme.primary, + GitRefKind::Remote => lane_purple, + GitRefKind::Tag => theme.warning, + }; + div() + .h(px(15.0)) + .max_w(px(72.0)) + .flex_shrink_0() + .px(px(4.0)) + .rounded(px(4.0)) + .border_1() + .border_color(color.opacity(0.38)) + .bg(color.opacity(0.12)) + .truncate() + .text_size(px(9.5)) + .text_color(color) + .child(git_ref.label) + .into_any_element() + }) + .collect::>(); + let meta = format!( + "{} · {} · {}", + commit.author, + git_relative_time(commit.timestamp, language), + commit.short_hash + ); + h_flex() + .id(SharedString::from(format!("git-history-{index}"))) + .w_full() + .h(px(46.0)) + .px_2() + .items_start() + .hover(|row| row.bg(hover)) + .child(graph_cell) + .child( + v_flex() + .flex_1() + .min_w_0() + .h_full() + .justify_center() + .gap_1() + .child(h_flex().min_w_0().gap(px(4.0)).children(refs).child( + div().min_w_0().truncate().text_sm().child(commit.subject.clone()), + )) + .child(div().truncate().text_xs().text_color(muted).child(meta)), + ) + .into_any_element() + }, + VcsRow::Change { section_id, index, status, relative_path, ops } => { + let is_git = ops != RowOps::Svn; + let status = &status; + let relative_path = &relative_path; + let discard_confirm = self.vcs_discard_confirm.clone(); + let path = root + .as_ref() + .map(|root| root.join(relative_path)) + .unwrap_or_else(|| std::path::PathBuf::from(relative_path)); + let selected_row = selected.as_ref() == Some(&path); + let status_color = match status { + 'A' | '?' => theme.success, + 'D' | '!' => theme.danger, + 'C' | 'U' => theme.danger, + _ => theme.warning, + }; + // 路径拆分显示:文件名主体 + 灰色父目录。 + let (file_name, parent) = match relative_path.rfind('/') { + Some(pos) => { + (relative_path[pos + 1..].to_owned(), relative_path[..pos].to_owned()) + }, + None => (relative_path.clone(), String::new()), + }; + let row_group = SharedString::from(format!("vcs-row-actions-{section_id}-{index}")); + let open_path = path.clone(); + let stage_path = relative_path.clone(); + let svn_add_path = relative_path.clone(); + let unstage_path = relative_path.clone(); + let discard_path = relative_path.clone(); + let resolve_path = relative_path.clone(); + let diff_path = relative_path.clone(); + let menu_path = relative_path.clone(); + let merge_button_path = relative_path.clone(); + let merge_open_path = relative_path.clone(); + let discard_armed = discard_confirm.as_deref() == Some(relative_path.as_str()); + let git_discard = ops == RowOps::Unstaged && *status != '?'; + let svn_revert = ops == RowOps::Svn && !matches!(*status, '?' | 'C'); + let can_discard = git_discard || svn_revert; + let svn_add = ops == RowOps::Svn && *status == '?'; + let svn_resolve = ops == RowOps::Svn && *status == 'C'; + let svn_diff = ops == RowOps::Svn && !matches!(*status, '?' | '!'); + h_flex() + .id(SharedString::from(format!( + "git-tree-row-{section_id}-{index}-{relative_path}" + ))) + .debug_selector(|| format!("git-tree-row-{section_id}-{index}-{relative_path}")) + .group(row_group.clone()) + .h(px(30.0)) + .w_full() + .px_2() + .gap_2() + .items_center() + .rounded_md() + .when(selected_row, |row| row.bg(selected_bg)) + .hover(|row| row.bg(hover)) + .child( + div() + .w(px(14.0)) + .flex_shrink_0() + .font_family(symbol.clone()) + .text_sm() + .text_color(status_color) + .child(status.to_string()), + ) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .items_center() + .child(div().flex_shrink_0().text_sm().child(file_name.clone())) + .when(!parent.is_empty(), |line| { + line.child( + div() + .min_w_0() + .truncate() + .text_xs() + .text_color(muted) + .child(parent.clone()), + ) + }), + ) + .when(can_discard, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-discard-{section_id}-{index}" + ))) + .map(|button| { + if discard_armed { + button + .label(if svn_revert { + "确认还原" + } else { + language.text(Message::VcsConfirmDiscard) + }) + .danger() + .xsmall() + } else { + button.icon(IconName::Undo2).ghost().xsmall().tooltip( + if svn_revert { + "还原 SVN 改动" + } else { + language.text(Message::VcsDiscard) + }, + ) + } + }) + .when(!discard_armed, |button| { + button + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + }) + .on_click(cx.listener( + move |this, _, _, cx| { + if this.vcs_discard_confirm.as_deref() + == Some(discard_path.as_str()) + { + this.vcs_discard_confirm = None; + if svn_revert { + this.side_panel.svn_revert_path(&discard_path); + } else { + this.side_panel.git_discard_path(&discard_path); + } + } else { + this.vcs_discard_confirm = Some(discard_path.clone()); + } + cx.notify(); + }, + )), + ) + }) + .when(ops == RowOps::Unstaged && is_git, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-stage-{section_id}-{index}" + ))) + .icon(IconName::Plus) + .ghost() + .xsmall() + .tooltip(language.text(Message::VcsStage)) + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener( + move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.git_stage_path(&stage_path); + cx.notify(); + }, + )), + ) + }) + .when(svn_add, |row| { + row.child( + Button::new(SharedString::from(format!( + "svn-add-{section_id}-{index}" + ))) + .icon(IconName::Plus) + .ghost() + .xsmall() + .tooltip("添加到 SVN") + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener( + move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.svn_add_path(&svn_add_path); + cx.notify(); + }, + )), + ) + }) + .when(svn_resolve, |row| { + row.child( + Button::new(SharedString::from(format!( + "svn-resolve-{section_id}-{index}" + ))) + .label("解决") + .ghost() + .xsmall() + .tooltip("保留当前内容并标记冲突已解决") + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener( + move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.svn_resolve_path(&resolve_path); + cx.notify(); + }, + )), + ) + }) + .when(ops == RowOps::Svn, |row| { + // 这一行的完整 SVN 操作集(日志、blame、锁、 + // 忽略、改名、删除、冲突、属性)都在这个菜单里, + // 行内只多一个 ⋯ 位。 + row.child(Self::svn_row_menu(&menu_target, &menu_path, index, section_id)) + }) + .when(ops == RowOps::Staged, |row| { + row.child( + Button::new(SharedString::from(format!( + "vcs-unstage-{section_id}-{index}" + ))) + .icon(IconName::Minus) + .ghost() + .xsmall() + .tooltip(language.text(Message::VcsUnstage)) + .invisible() + .group_hover(row_group.clone(), |button| button.visible()) + .on_click(cx.listener( + move |this, _, _, cx| { + this.vcs_discard_confirm = None; + this.side_panel.git_unstage_path(&unstage_path); + cx.notify(); + }, + )), + ) + }) + .when(ops == RowOps::Conflict && is_git, |row| { + row.child( + Button::new(SharedString::from(format!( + "git-resolve-{section_id}-{index}" + ))) + .icon( + Icon::new(Icon::empty()) + .path(crate::gpui_shell::assets::nav::VCS_CONFLICT), + ) + .ghost() + .xsmall() + .tooltip(language.text(Message::VcsResolveInMergeEditor)) + .on_click(cx.listener( + move |this, _, window, cx| { + this.open_git_merge_tab(merge_button_path.clone(), window, cx); + }, + )), + ) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.side_panel.selected = Some(path.clone()); + cx.notify(); + })) + .on_double_click(cx.listener(move |this, _, window, cx| { + if ops == RowOps::Conflict && is_git { + this.open_git_merge_tab(merge_open_path.clone(), window, cx); + } else if !svn_diff || !this.side_panel.svn_diff_path(&diff_path) { + // Git 与未版本化 SVN 文件仍走现有文档路由。 + this.open_document_path(open_path.clone(), window, cx); + } + })) + .into_any_element() + }, + }; + div().w_full().pb_1().child(element).into_any_element() + } +}