Merge pull request #225 from Kuddev/fix/git-drawer-repaint-20260920
fix(gpui): bound Git drawer rendering to visible rows
This commit is contained in:
9 files changed
+814
-498
No files matched your search
+61
@@ -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.
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user