File tabs now save Markdown, code and text edits through one document lifecycle, with dirty indicators, close protection, external-change checks, and BOM/newline preservation. Markdown gains heading navigation and file information alongside preview and source editing. Add named tab/window layout recipes using the existing session schema, remember quick-terminal dimensions, and allow the global hotkey to recall an existing window. Dock previews and insertion use the hovered pane, with an outer-edge target for a full row or column. Add Breeze and Mint light/dark themes, filter settings navigation as the query changes, expose a font dropdown arrow, and redraw the command icon. Validated on the isolated commit snapshot with Windows GPUI compilation, editor and settings interaction tests, workspace/theme tests, shared settings/split and i18n contracts, architecture checks, and formatting.
850 lines
31 KiB
Rust
850 lines
31 KiB
Rust
//! 代码文件 Tab:普通文件共用可保存的文本编辑器;Git 冲突使用三栏合并器。
|
|
//!
|
|
//! 普通代码继续走组件库的虚拟化 code editor。冲突页遵循 IntelliJ 一类合并器
|
|
//! 的空间语义:左侧当前版本、中间可编辑结果、右侧传入版本;外侧两栏只读,
|
|
//! 应用结果后写回工作树并 `git add`,不会另开浮窗或挤占 VCS 抽屉。
|
|
|
|
use std::io::Write as _;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use gpui::prelude::FluentBuilder as _;
|
|
use gpui::{
|
|
AppContext as _, ClipboardItem, Context, Entity, EventEmitter, IntoElement, ParentElement as _,
|
|
Render, SharedString, Styled as _, Window, div, px, relative,
|
|
};
|
|
|
|
use crate::display::side_panel::GitLocation;
|
|
use crate::gpui_shell::prelude::*;
|
|
|
|
/// 一次性读入的上限。行级虚拟化解决的是渲染成本,解析/塑形仍随内容量
|
|
/// 增长;8MB 已覆盖常见源码文件,超限的普通文件截断,冲突文件则拒绝写回。
|
|
const MAX_CODE_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
/// 扩展名 -> tree-sitter 语言 id(组件库 highlighter 的注册名)。
|
|
fn language_for_extension(extension: &str) -> Option<&'static str> {
|
|
Some(match extension.to_ascii_lowercase().as_str() {
|
|
"rs" => "rust",
|
|
"md" | "markdown" => "markdown",
|
|
"py" | "pyi" => "python",
|
|
"js" | "mjs" | "cjs" => "javascript",
|
|
"ts" | "mts" | "cts" => "typescript",
|
|
"tsx" => "tsx",
|
|
"jsx" => "jsx",
|
|
"c" | "h" => "c",
|
|
"cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp",
|
|
"cs" => "csharp",
|
|
"go" => "go",
|
|
"java" => "java",
|
|
"rb" => "ruby",
|
|
"php" => "php",
|
|
"swift" => "swift",
|
|
"kt" | "kts" => "kotlin",
|
|
"lua" => "lua",
|
|
"sh" | "bash" | "zsh" => "bash",
|
|
"ps1" | "psm1" | "psd1" => "powershell",
|
|
"toml" => "toml",
|
|
"yaml" | "yml" => "yaml",
|
|
"html" | "htm" => "html",
|
|
"css" => "css",
|
|
"scss" => "scss",
|
|
"sql" => "sql",
|
|
"zig" => "zig",
|
|
"cmake" => "cmake",
|
|
"dockerfile" => "dockerfile",
|
|
"ex" | "exs" => "elixir",
|
|
"erl" => "erlang",
|
|
"hs" => "haskell",
|
|
"ini" | "cfg" | "conf" => "ini",
|
|
"vim" => "vim",
|
|
"xml" => "xml",
|
|
"json" | "jsonl" | "ndjson" => "json",
|
|
"txt" | "log" | "text" => "text",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
pub(super) fn language_for_path(path: &str) -> &'static str {
|
|
Path::new(path)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.and_then(language_for_extension)
|
|
.unwrap_or("text")
|
|
}
|
|
|
|
/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列)。
|
|
pub fn viewable_file(path: &Path) -> bool {
|
|
path.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.is_some_and(|extension| language_for_extension(extension).is_some())
|
|
}
|
|
|
|
pub enum CodeTabViewEvent {
|
|
GitConflictResolved,
|
|
Changed,
|
|
}
|
|
|
|
impl EventEmitter<CodeTabViewEvent> for CodeTabView {}
|
|
|
|
#[derive(Clone)]
|
|
struct MergeKey {
|
|
location: GitLocation,
|
|
relative_path: String,
|
|
}
|
|
|
|
impl MergeKey {
|
|
fn matches(&self, location: &GitLocation, relative_path: &str) -> bool {
|
|
&self.location == location && self.relative_path == relative_path
|
|
}
|
|
|
|
fn display_path(&self) -> String {
|
|
match &self.location {
|
|
GitLocation::Local { root } => root.join(&self.relative_path).display().to_string(),
|
|
GitLocation::Wsl { distro, root } => {
|
|
format!("{distro}:{}", join_guest_path(root, &self.relative_path))
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum MergeState {
|
|
Loading,
|
|
Ready,
|
|
Saving,
|
|
Resolved,
|
|
Error(String),
|
|
}
|
|
|
|
struct MergeEditor {
|
|
key: MergeKey,
|
|
ours: Entity<InputState>,
|
|
theirs: Entity<InputState>,
|
|
ours_text: String,
|
|
theirs_text: String,
|
|
ours_missing: bool,
|
|
theirs_missing: bool,
|
|
state: MergeState,
|
|
}
|
|
|
|
struct ConflictDocument {
|
|
ours: Option<String>,
|
|
theirs: Option<String>,
|
|
result: String,
|
|
}
|
|
|
|
pub struct CodeTabView {
|
|
pub path: PathBuf,
|
|
pub title: String,
|
|
/// 冲突页的中栏合并结果;普通文件的缓冲区由 TextFileView 持有。
|
|
input: Entity<InputState>,
|
|
notice: Option<String>,
|
|
lines: usize,
|
|
merge: Option<MergeEditor>,
|
|
file: Option<Entity<super::file_editor::TextFileView>>,
|
|
_file_subscription: Option<gpui::Subscription>,
|
|
}
|
|
|
|
impl CodeTabView {
|
|
pub fn new(path: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let title = path
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.display().to_string());
|
|
let language = language_for_path(&path.to_string_lossy());
|
|
let input = code_input(language, window, cx);
|
|
let file = cx.new(|cx| super::file_editor::TextFileView::new(path.clone(), window, cx));
|
|
let subscription = cx.subscribe(&file, |_, _, _, cx| {
|
|
cx.emit(CodeTabViewEvent::Changed);
|
|
cx.notify();
|
|
});
|
|
Self {
|
|
path,
|
|
title,
|
|
input,
|
|
notice: None,
|
|
lines: 0,
|
|
merge: None,
|
|
file: Some(file),
|
|
_file_subscription: Some(subscription),
|
|
}
|
|
}
|
|
|
|
pub(super) fn file_editor(&self) -> Option<Entity<super::file_editor::TextFileView>> {
|
|
self.file.clone()
|
|
}
|
|
|
|
pub(super) fn tab_title(&self, cx: &gpui::App) -> String {
|
|
self.file.as_ref().map_or_else(|| self.title.clone(), |file| file.read(cx).tab_title())
|
|
}
|
|
|
|
pub(super) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
|
|
use gpui::Focusable as _;
|
|
self.file.as_ref().map_or_else(
|
|
|| self.input.read(cx).focus_handle(cx),
|
|
|file| file.read(cx).focus_handle(cx),
|
|
)
|
|
}
|
|
|
|
pub fn new_git_merge(
|
|
location: GitLocation,
|
|
relative_path: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let title = Path::new(&relative_path)
|
|
.file_name()
|
|
.map(|name| format!("合并 · {}", name.to_string_lossy()))
|
|
.unwrap_or_else(|| format!("合并 · {relative_path}"));
|
|
let language = language_for_path(&relative_path);
|
|
let input = code_input(language, window, cx);
|
|
let ours = code_input(language, window, cx);
|
|
let theirs = code_input(language, window, cx);
|
|
// `path` 只用于现有 tab 图标/标题合同;冲突 tab 的真实唯一键是 MergeKey。
|
|
let path = PathBuf::from(&relative_path);
|
|
let mut this = Self {
|
|
path,
|
|
title,
|
|
input,
|
|
notice: None,
|
|
lines: 0,
|
|
file: None,
|
|
_file_subscription: None,
|
|
merge: Some(MergeEditor {
|
|
key: MergeKey { location, relative_path },
|
|
ours,
|
|
theirs,
|
|
ours_text: String::new(),
|
|
theirs_text: String::new(),
|
|
ours_missing: false,
|
|
theirs_missing: false,
|
|
state: MergeState::Loading,
|
|
}),
|
|
};
|
|
this.reload_git_merge(window, cx);
|
|
this
|
|
}
|
|
|
|
pub fn is_regular_path(&self, path: &Path) -> bool {
|
|
self.merge.is_none() && self.path == path
|
|
}
|
|
|
|
pub fn matches_git_merge(&self, location: &GitLocation, relative_path: &str) -> bool {
|
|
self.merge.as_ref().is_some_and(|merge| merge.key.matches(location, relative_path))
|
|
}
|
|
|
|
/// 重新读盘(文件树再次双击同一路径时宿主调用)。
|
|
pub fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if self.merge.is_some() {
|
|
self.reload_git_merge(window, cx);
|
|
return;
|
|
}
|
|
if let Some(file) = &self.file {
|
|
file.update(cx, |file, cx| file.reload(window, cx));
|
|
}
|
|
}
|
|
|
|
pub fn reload_git_merge(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(merge) = self.merge.as_mut() else { return };
|
|
merge.state = MergeState::Loading;
|
|
self.notice = None;
|
|
let key = merge.key.clone();
|
|
let task = cx.background_executor().spawn(async move { load_conflict(&key) });
|
|
let window_handle = window.window_handle();
|
|
cx.spawn(async move |this, cx| {
|
|
let loaded = task.await;
|
|
let _ = window_handle.update(cx, |_, window, cx| {
|
|
let _ = this.update(cx, |view, cx| {
|
|
view.apply_loaded_conflict(loaded, window, cx);
|
|
});
|
|
});
|
|
})
|
|
.detach();
|
|
cx.notify();
|
|
}
|
|
|
|
fn apply_loaded_conflict(
|
|
&mut self,
|
|
loaded: Result<ConflictDocument, String>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(merge) = self.merge.as_mut() else { return };
|
|
match loaded {
|
|
Ok(document) => {
|
|
merge.ours_missing = document.ours.is_none();
|
|
merge.theirs_missing = document.theirs.is_none();
|
|
merge.ours_text = document.ours.unwrap_or_default();
|
|
merge.theirs_text = document.theirs.unwrap_or_default();
|
|
merge
|
|
.ours
|
|
.update(cx, |input, cx| input.set_value(merge.ours_text.clone(), window, cx));
|
|
merge
|
|
.theirs
|
|
.update(cx, |input, cx| input.set_value(merge.theirs_text.clone(), window, cx));
|
|
self.lines = document.result.lines().count();
|
|
self.input.update(cx, |input, cx| input.set_value(document.result, window, cx));
|
|
merge.state = MergeState::Ready;
|
|
},
|
|
Err(error) => merge.state = MergeState::Error(error),
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
fn adopt_ours(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(merge) = self.merge.as_ref() else { return };
|
|
if !matches!(merge.state, MergeState::Ready) {
|
|
return;
|
|
}
|
|
let value = merge.ours_text.clone();
|
|
self.lines = value.lines().count();
|
|
self.input.update(cx, |input, cx| input.set_value(value, window, cx));
|
|
cx.notify();
|
|
}
|
|
|
|
fn adopt_theirs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(merge) = self.merge.as_ref() else { return };
|
|
if !matches!(merge.state, MergeState::Ready) {
|
|
return;
|
|
}
|
|
let value = merge.theirs_text.clone();
|
|
self.lines = value.lines().count();
|
|
self.input.update(cx, |input, cx| input.set_value(value, window, cx));
|
|
cx.notify();
|
|
}
|
|
|
|
fn save_git_merge(&mut self, cx: &mut Context<Self>) {
|
|
let Some(merge) = self.merge.as_mut() else { return };
|
|
if !matches!(merge.state, MergeState::Ready) {
|
|
return;
|
|
}
|
|
let result = self.input.read(cx).value().to_string();
|
|
if contains_conflict_markers(&result) {
|
|
self.notice = Some("合并结果中仍有冲突标记,请处理后再应用".to_owned());
|
|
cx.notify();
|
|
return;
|
|
}
|
|
merge.state = MergeState::Saving;
|
|
self.notice = None;
|
|
let key = merge.key.clone();
|
|
let task =
|
|
cx.background_executor().spawn(async move { write_conflict_result(&key, result) });
|
|
cx.spawn(async move |this, cx| {
|
|
let saved = task.await;
|
|
let _ = this.update(cx, |view, cx| {
|
|
let Some(merge) = view.merge.as_mut() else { return };
|
|
match saved {
|
|
Ok(()) => {
|
|
merge.state = MergeState::Resolved;
|
|
cx.emit(CodeTabViewEvent::GitConflictResolved);
|
|
},
|
|
Err(error) => merge.state = MergeState::Error(error),
|
|
}
|
|
cx.notify();
|
|
});
|
|
})
|
|
.detach();
|
|
cx.notify();
|
|
}
|
|
|
|
fn render_file(&self, _: &mut Context<Self>) -> gpui::AnyElement {
|
|
div().size_full().children(self.file.clone()).into_any_element()
|
|
}
|
|
|
|
fn render_merge(&self, cx: &mut Context<Self>) -> gpui::AnyElement {
|
|
let merge = self.merge.as_ref().expect("merge renderer requires merge state");
|
|
let theme = cx.theme();
|
|
let muted = theme.muted_foreground;
|
|
let border = theme.border;
|
|
let mono_family = theme.mono_font_family.clone();
|
|
let mono_size = theme.mono_font_size;
|
|
let display_path = merge.key.display_path();
|
|
let ready = matches!(merge.state, MergeState::Ready);
|
|
let unresolved = contains_conflict_markers(self.input.read(cx).value().as_ref());
|
|
let (status, status_color) = match &merge.state {
|
|
MergeState::Loading => ("正在读取三个 Git 阶段…".to_owned(), muted),
|
|
MergeState::Ready if unresolved => ("仍有冲突标记".to_owned(), theme.warning),
|
|
MergeState::Ready => ("可应用".to_owned(), theme.success),
|
|
MergeState::Saving => ("正在写回并暂存…".to_owned(), muted),
|
|
MergeState::Resolved => ("已写回并暂存".to_owned(), theme.success),
|
|
MergeState::Error(error) => (error.clone(), theme.danger),
|
|
};
|
|
let ours_label =
|
|
if merge.ours_missing { "当前版本(文件不存在)" } else { "当前版本" };
|
|
let theirs_label =
|
|
if merge.theirs_missing { "传入版本(文件不存在)" } else { "传入版本" };
|
|
|
|
v_flex()
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.child(
|
|
h_flex()
|
|
.h(px(38.0))
|
|
.flex_shrink_0()
|
|
.px(px(12.0))
|
|
.items_center()
|
|
.gap_2()
|
|
.border_b_1()
|
|
.border_color(border)
|
|
.child(
|
|
Icon::new(Icon::empty())
|
|
.path(crate::gpui_shell::assets::nav::VCS_CONFLICT)
|
|
.small()
|
|
.text_color(theme.warning),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.truncate()
|
|
.font_family(mono_family.clone())
|
|
.text_size(px(12.0))
|
|
.child(display_path),
|
|
)
|
|
.child(
|
|
div().min_w_0().truncate().text_xs().text_color(status_color).child(status),
|
|
)
|
|
.child(
|
|
Button::new("merge-reload")
|
|
.icon(IconName::Redo2)
|
|
.ghost()
|
|
.xsmall()
|
|
.tooltip("重新读取冲突阶段")
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.reload_git_merge(window, cx);
|
|
})),
|
|
)
|
|
.child(
|
|
Button::new("merge-apply")
|
|
.icon(IconName::Check)
|
|
.label("应用并暂存")
|
|
.small()
|
|
.disabled(!ready || unresolved)
|
|
.tooltip(if unresolved {
|
|
"请先清除合并结果中的冲突标记"
|
|
} else {
|
|
"写回工作树并执行 git add"
|
|
})
|
|
.on_click(cx.listener(|this, _, _, cx| this.save_git_merge(cx))),
|
|
),
|
|
)
|
|
.when_some(self.notice.clone(), |root, notice| {
|
|
root.child(
|
|
h_flex()
|
|
.min_h(px(28.0))
|
|
.flex_shrink_0()
|
|
.px(px(12.0))
|
|
.border_b_1()
|
|
.border_color(border)
|
|
.text_xs()
|
|
.text_color(theme.warning)
|
|
.child(notice),
|
|
)
|
|
})
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.min_h_0()
|
|
.w_full()
|
|
.child(merge_pane(
|
|
ours_label,
|
|
&merge.ours,
|
|
true,
|
|
Some(
|
|
Button::new("merge-use-ours")
|
|
.icon(IconName::ArrowRight)
|
|
.ghost()
|
|
.xsmall()
|
|
.disabled(!ready)
|
|
.tooltip("用当前版本替换合并结果")
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.adopt_ours(window, cx);
|
|
})),
|
|
),
|
|
mono_family.clone(),
|
|
mono_size,
|
|
border,
|
|
muted,
|
|
))
|
|
.child(merge_pane(
|
|
"合并结果(可编辑)",
|
|
&self.input,
|
|
false,
|
|
None,
|
|
mono_family.clone(),
|
|
mono_size,
|
|
border,
|
|
theme.foreground,
|
|
))
|
|
.child(merge_pane(
|
|
theirs_label,
|
|
&merge.theirs,
|
|
true,
|
|
Some(
|
|
Button::new("merge-use-theirs")
|
|
.icon(IconName::ArrowLeft)
|
|
.ghost()
|
|
.xsmall()
|
|
.disabled(!ready)
|
|
.tooltip("用传入版本替换合并结果")
|
|
.on_click(cx.listener(|this, _, window, cx| {
|
|
this.adopt_theirs(window, cx);
|
|
})),
|
|
),
|
|
mono_family,
|
|
mono_size,
|
|
border,
|
|
muted,
|
|
)),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
}
|
|
|
|
impl Render for CodeTabView {
|
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
if self.merge.is_some() { self.render_merge(cx) } else { self.render_file(cx) }
|
|
}
|
|
}
|
|
|
|
fn code_input(
|
|
language: &'static str,
|
|
window: &mut Window,
|
|
cx: &mut Context<CodeTabView>,
|
|
) -> Entity<InputState> {
|
|
cx.new(|cx| {
|
|
InputState::new(window, cx)
|
|
.code_editor(language)
|
|
.line_number(true)
|
|
.indent_guides(true)
|
|
.soft_wrap(false)
|
|
})
|
|
}
|
|
|
|
fn editor(
|
|
state: &Entity<InputState>,
|
|
read_only: bool,
|
|
family: SharedString,
|
|
size: gpui::Pixels,
|
|
) -> Input {
|
|
Input::new(state)
|
|
.h_full()
|
|
.disabled(read_only)
|
|
.bordered(false)
|
|
.focus_bordered(false)
|
|
.rounded(px(0.0))
|
|
.font_family(family)
|
|
.text_size(size)
|
|
.line_height(relative(1.55))
|
|
.px(px(10.0))
|
|
.py(px(8.0))
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn merge_pane(
|
|
label: impl Into<SharedString>,
|
|
state: &Entity<InputState>,
|
|
read_only: bool,
|
|
action: Option<Button>,
|
|
family: SharedString,
|
|
size: gpui::Pixels,
|
|
border: gpui::Hsla,
|
|
label_color: gpui::Hsla,
|
|
) -> gpui::AnyElement {
|
|
let label: SharedString = label.into();
|
|
v_flex()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.h_full()
|
|
.border_r_1()
|
|
.border_color(border)
|
|
.child(
|
|
h_flex()
|
|
.h(px(32.0))
|
|
.flex_shrink_0()
|
|
.px(px(10.0))
|
|
.items_center()
|
|
.border_b_1()
|
|
.border_color(border)
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.truncate()
|
|
.text_xs()
|
|
.text_color(label_color)
|
|
.child(label),
|
|
)
|
|
.when_some(action, |header, button| header.child(button)),
|
|
)
|
|
.child(div().flex_1().min_h_0().child(editor(state, read_only, family, size)))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn contains_conflict_markers(text: &str) -> bool {
|
|
let mut ours = false;
|
|
let mut separator = false;
|
|
for line in text.lines() {
|
|
if line.starts_with("<<<<<<<") {
|
|
ours = true;
|
|
} else if ours && line.starts_with("=======") {
|
|
separator = true;
|
|
} else if ours && separator && line.starts_with(">>>>>>>") {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn validate_relative_path(path: &str) -> Result<(), String> {
|
|
if path.is_empty() || path.chars().any(char::is_control) {
|
|
return Err("冲突文件路径无效".to_owned());
|
|
}
|
|
let local = Path::new(path);
|
|
if local.components().any(|component| {
|
|
matches!(component, Component::Prefix(_) | Component::RootDir | Component::ParentDir)
|
|
}) || path.split('/').any(|part| part == "..")
|
|
{
|
|
return Err("冲突文件不在当前仓库内".to_owned());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn load_conflict(key: &MergeKey) -> Result<ConflictDocument, String> {
|
|
validate_relative_path(&key.relative_path)?;
|
|
let ours = read_stage(key, 2)?;
|
|
let theirs = read_stage(key, 3)?;
|
|
if ours.is_none() && theirs.is_none() {
|
|
return Err("Git 索引里没有可合并的 :2/:3 阶段".to_owned());
|
|
}
|
|
let result = read_worktree_file(key)?;
|
|
Ok(ConflictDocument {
|
|
ours: ours.map(|bytes| decode_conflict_text(bytes, "当前版本")).transpose()?,
|
|
theirs: theirs.map(|bytes| decode_conflict_text(bytes, "传入版本")).transpose()?,
|
|
result: decode_conflict_text(result, "合并结果")?,
|
|
})
|
|
}
|
|
|
|
fn decode_conflict_text(bytes: Vec<u8>, label: &str) -> Result<String, String> {
|
|
if bytes.len() > MAX_CODE_BYTES {
|
|
return Err(format!(
|
|
"{label}超过 {} MB,不能在三栏编辑器中安全处理",
|
|
MAX_CODE_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
if bytes.contains(&0) {
|
|
return Err(format!("{label}是二进制内容,三栏文本合并器无法处理"));
|
|
}
|
|
String::from_utf8(bytes)
|
|
.map_err(|_| format!("{label}不是 UTF-8 文本,已阻止可能破坏编码的写回"))
|
|
}
|
|
|
|
fn read_stage(key: &MergeKey, stage: u8) -> Result<Option<Vec<u8>>, String> {
|
|
let spec = format!(":{stage}:{}", key.relative_path);
|
|
let output = git_command(&key.location, &["show", &spec])?;
|
|
if output.status.success() { Ok(Some(output.stdout)) } else { Ok(None) }
|
|
}
|
|
|
|
fn read_worktree_file(key: &MergeKey) -> Result<Vec<u8>, String> {
|
|
match &key.location {
|
|
GitLocation::Local { root } => match std::fs::read(root.join(&key.relative_path)) {
|
|
Ok(bytes) => Ok(bytes),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
|
Err(error) => Err(format!("读取合并结果失败: {error}")),
|
|
},
|
|
GitLocation::Wsl { distro, root } => {
|
|
let path = join_guest_path(root, &key.relative_path);
|
|
let mut command = Command::new("wsl.exe");
|
|
let output = hidden_command(&mut command)
|
|
.args(["-d", distro, "--", "cat", "--", path.as_str()])
|
|
.output()
|
|
.map_err(|error| format!("无法从 WSL 读取冲突文件: {error}"))?;
|
|
if output.status.success() { Ok(output.stdout) } else { Ok(Vec::new()) }
|
|
},
|
|
}
|
|
}
|
|
|
|
fn write_conflict_result(key: &MergeKey, result: String) -> Result<(), String> {
|
|
validate_relative_path(&key.relative_path)?;
|
|
if result.len() > MAX_CODE_BYTES {
|
|
return Err(format!("合并结果超过 {} MB,已取消写回", MAX_CODE_BYTES / 1024 / 1024));
|
|
}
|
|
match &key.location {
|
|
GitLocation::Local { root } => {
|
|
std::fs::write(root.join(&key.relative_path), result.as_bytes())
|
|
.map_err(|error| format!("写回合并结果失败: {error}"))?
|
|
},
|
|
GitLocation::Wsl { distro, root } => {
|
|
let path = join_guest_path(root, &key.relative_path);
|
|
let mut command = Command::new("wsl.exe");
|
|
let mut child = hidden_command(&mut command)
|
|
.args(["-d", distro, "--", "sh", "-c", "cat > \"$1\"", "nebula", path.as_str()])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|error| format!("无法向 WSL 写回冲突文件: {error}"))?;
|
|
let write_result = child
|
|
.stdin
|
|
.take()
|
|
.ok_or("无法打开 WSL 写入管道".to_owned())?
|
|
.write_all(result.as_bytes());
|
|
if let Err(error) = write_result {
|
|
let _ = child.kill();
|
|
return Err(format!("写入 WSL 冲突文件失败: {error}"));
|
|
}
|
|
let output =
|
|
child.wait_with_output().map_err(|error| format!("等待 WSL 写回失败: {error}"))?;
|
|
if !output.status.success() {
|
|
return Err(first_command_error(&output.stderr, "WSL 写回失败"));
|
|
}
|
|
},
|
|
}
|
|
let staged = git_command(&key.location, &["add", "--", &key.relative_path])?;
|
|
if !staged.status.success() {
|
|
return Err(first_command_error(&staged.stderr, "git add 失败;文件已写回但尚未暂存"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn git_command(location: &GitLocation, args: &[&str]) -> Result<std::process::Output, String> {
|
|
let mut command = match location {
|
|
GitLocation::Local { root } => {
|
|
let mut command = Command::new("git");
|
|
command
|
|
.arg("-c")
|
|
.arg(format!("safe.directory={}", root.display()))
|
|
.arg("--no-optional-locks")
|
|
.arg("-C")
|
|
.arg(root);
|
|
command
|
|
},
|
|
GitLocation::Wsl { distro, root } => {
|
|
let mut command = Command::new("wsl.exe");
|
|
command.args(["-d", distro, "--", "git", "-C", root, "--no-optional-locks"]);
|
|
command
|
|
},
|
|
};
|
|
hidden_command(&mut command)
|
|
.args(args)
|
|
.output()
|
|
.map_err(|error| format!("无法运行 git: {error}"))
|
|
}
|
|
|
|
fn hidden_command(command: &mut Command) -> &mut Command {
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt as _;
|
|
command.creation_flags(0x0800_0000);
|
|
}
|
|
command
|
|
}
|
|
|
|
fn first_command_error(stderr: &[u8], fallback: &str) -> String {
|
|
String::from_utf8_lossy(stderr)
|
|
.lines()
|
|
.find(|line| !line.trim().is_empty())
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| fallback.to_owned())
|
|
}
|
|
|
|
fn join_guest_path(root: &str, relative: &str) -> String {
|
|
if root == "/" {
|
|
format!("/{relative}")
|
|
} else {
|
|
format!("{}/{relative}", root.trim_end_matches('/'))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn git(root: &Path, args: &[&str]) -> std::process::Output {
|
|
Command::new("git")
|
|
.arg("-C")
|
|
.arg(root)
|
|
.args(["-c", "user.name=Nebula Test", "-c", "user.email=nebula@example.invalid"])
|
|
.args(args)
|
|
.output()
|
|
.expect("run git")
|
|
}
|
|
|
|
fn commit(root: &Path, message: &str) {
|
|
let output = git(root, &["commit", "-m", message]);
|
|
assert!(
|
|
output.status.success(),
|
|
"git commit failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn complete_conflict_marker_triplet_is_detected() {
|
|
assert!(contains_conflict_markers(
|
|
"a\n<<<<<<< HEAD\nleft\n=======\nright\n>>>>>>> topic\nz"
|
|
));
|
|
assert!(!contains_conflict_markers("<<<<<<< shown as documentation only"));
|
|
assert!(!contains_conflict_markers("left\n=======\nright"));
|
|
}
|
|
|
|
#[test]
|
|
fn conflict_path_cannot_escape_repository() {
|
|
assert!(validate_relative_path("src/main.rs").is_ok());
|
|
assert!(validate_relative_path("../outside.rs").is_err());
|
|
assert!(validate_relative_path("/absolute.rs").is_err());
|
|
assert!(validate_relative_path("bad\nname.rs").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn local_conflict_loads_three_versions_and_stages_result() {
|
|
let directory = tempfile::tempdir().expect("create repository directory");
|
|
let root = directory.path();
|
|
let path = root.join("conflict.txt");
|
|
|
|
assert!(git(root, &["init", "--initial-branch=main"]).status.success());
|
|
std::fs::write(&path, "base\n").expect("write base");
|
|
assert!(git(root, &["add", "conflict.txt"]).status.success());
|
|
commit(root, "base");
|
|
|
|
assert!(git(root, &["checkout", "-b", "incoming"]).status.success());
|
|
std::fs::write(&path, "incoming\n").expect("write incoming");
|
|
assert!(git(root, &["add", "conflict.txt"]).status.success());
|
|
commit(root, "incoming");
|
|
|
|
assert!(git(root, &["checkout", "main"]).status.success());
|
|
std::fs::write(&path, "current\n").expect("write current");
|
|
assert!(git(root, &["add", "conflict.txt"]).status.success());
|
|
commit(root, "current");
|
|
|
|
let merge = git(root, &["merge", "incoming"]);
|
|
assert!(!merge.status.success(), "merge unexpectedly succeeded");
|
|
let unmerged = git(root, &["ls-files", "-u", "--", "conflict.txt"]);
|
|
assert!(
|
|
unmerged.status.success() && !unmerged.stdout.is_empty(),
|
|
"merge did not create conflict stages: {} {}",
|
|
String::from_utf8_lossy(&merge.stdout),
|
|
String::from_utf8_lossy(&merge.stderr)
|
|
);
|
|
|
|
let key = MergeKey {
|
|
location: GitLocation::Local { root: root.to_path_buf() },
|
|
relative_path: "conflict.txt".to_owned(),
|
|
};
|
|
let document = load_conflict(&key).expect("load conflict stages");
|
|
assert_eq!(document.ours.as_deref(), Some("current\n"));
|
|
assert_eq!(document.theirs.as_deref(), Some("incoming\n"));
|
|
assert!(contains_conflict_markers(&document.result));
|
|
|
|
write_conflict_result(&key, "resolved\n".to_owned()).expect("write and stage result");
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "resolved\n");
|
|
|
|
let unmerged = git(root, &["ls-files", "-u", "--", "conflict.txt"]);
|
|
assert!(unmerged.status.success());
|
|
assert!(unmerged.stdout.is_empty(), "unmerged index entries remain");
|
|
let staged = git(root, &["diff", "--cached", "--name-only", "--", "conflict.txt"]);
|
|
assert!(staged.status.success());
|
|
assert_eq!(String::from_utf8_lossy(&staged.stdout).trim(), "conflict.txt");
|
|
}
|
|
}
|