fix(gpui): virtualize Git drawer rows and reuse snapshots

This commit is contained in:
kuddev committed 2026-09-20 18:51:14 +08:00
1 parent 03ea192871
commit ca07f9999e
9 files changed
+814 -498

No files matched your search

@@ -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.
+7 -3
View File
@@ -407,7 +407,7 @@ pub struct SidePanel {
/// Directories the user expanded (persists across refreshes).
expanded: HashSet<PathBuf>,
/// Git snapshot, `None` when the root isn't inside a work tree.
git: Option<GitInfo>,
git: Option<std::sync::Arc<GitInfo>>,
/// 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<std::sync::Arc<GitInfo>> {
self.git.clone()
}
pub fn root(&self) -> Option<&Path> {
+6 -3
View File
@@ -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));
+2 -12
View File
@@ -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<String>,
/// 命令面板的行覆盖:`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<Self>) {
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
+10 -480
View File
@@ -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::<Vec<_>>();
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;
@@ -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<Self>,
) {
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();
}
}
@@ -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<Message>,
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<Arc<GitInfo>>,
#[cfg(test)]
pub(super) rendered: Vec<usize>,
view: Option<GitPanelView>,
root: Option<PathBuf>,
rows: Vec<VcsRow>,
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<Arc<GitInfo>>,
view: GitPanelView,
root: Option<PathBuf>,
) {
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
}
}
@@ -0,0 +1,146 @@
use super::*;
use crate::display::side_panel::{GitInfo, GitPanelView};
use gpui::{ListOffset, Modifiers, TestAppContext};
struct ListProbe(Entity<NebulaWorkspace>);
impl Render for ListProbe {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<GitInfo> {
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);
}
@@ -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<Self>) -> 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<Self>,
) -> 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::<Vec<_>>();
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()
}
}