526 lines
15 KiB
Rust
526 lines
15 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use bds_core::engine::git::{
|
|
ChangedFile, GitCommit, GitFileDiff, GitFileStatus, GitOutput, GitRemoteState, GitRepository,
|
|
ReconcileEvent, SyncStatus,
|
|
};
|
|
use bds_core::i18n::UiLocale;
|
|
use iced::widget::text::{Shaping, Wrapping};
|
|
use iced::widget::{Space, button, column, container, row, scrollable, text, text_input};
|
|
use iced::{Color, Element, Font, Length};
|
|
|
|
use crate::app::Message;
|
|
use crate::components::inputs;
|
|
use crate::i18n::{t, tw};
|
|
use crate::state::tabs::{Tab, TabType};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitSnapshot {
|
|
pub repository: GitRepository,
|
|
pub files: Vec<GitFileStatus>,
|
|
pub history: Vec<GitCommit>,
|
|
pub remote: GitRemoteState,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitNetworkCompletion {
|
|
pub snapshot: GitSnapshot,
|
|
pub events: Vec<ReconcileEvent>,
|
|
pub output: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitUiState {
|
|
pub repository: GitRepository,
|
|
pub files: Vec<GitFileStatus>,
|
|
pub history: Vec<GitCommit>,
|
|
pub remote: GitRemoteState,
|
|
pub remote_input: String,
|
|
pub commit_message: String,
|
|
pub loading: bool,
|
|
pub error: Option<String>,
|
|
pub network_run: Option<GitNetworkRunState>,
|
|
}
|
|
|
|
impl Default for GitUiState {
|
|
fn default() -> Self {
|
|
Self {
|
|
repository: GitRepository {
|
|
is_initialized: false,
|
|
remote_url: None,
|
|
provider: None,
|
|
current_branch: None,
|
|
has_lfs: false,
|
|
},
|
|
files: Vec::new(),
|
|
history: Vec::new(),
|
|
remote: GitRemoteState {
|
|
local_branch: None,
|
|
upstream_branch: None,
|
|
has_upstream: false,
|
|
ahead: 0,
|
|
behind: 0,
|
|
},
|
|
remote_input: String::new(),
|
|
commit_message: String::new(),
|
|
loading: false,
|
|
error: None,
|
|
network_run: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl GitUiState {
|
|
pub fn apply_snapshot(&mut self, snapshot: GitSnapshot) {
|
|
self.remote_input = snapshot.repository.remote_url.clone().unwrap_or_default();
|
|
self.repository = snapshot.repository;
|
|
self.files = snapshot.files;
|
|
self.history = snapshot.history;
|
|
self.remote = snapshot.remote;
|
|
self.loading = false;
|
|
self.error = None;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitNetworkRunState {
|
|
pub task_id: u64,
|
|
pub output: Arc<Mutex<Vec<GitOutput>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum GitDiffKind {
|
|
File,
|
|
Commit { hash: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitDiffState {
|
|
pub kind: GitDiffKind,
|
|
pub changes: Vec<ChangedFile>,
|
|
pub selected_path: Option<String>,
|
|
pub diff: Option<GitFileDiff>,
|
|
pub loading: bool,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct GitDiffLoad {
|
|
pub changes: Vec<ChangedFile>,
|
|
pub selected_path: Option<String>,
|
|
pub diff: Option<GitFileDiff>,
|
|
}
|
|
|
|
impl GitDiffState {
|
|
pub fn loading_file(path: String) -> Self {
|
|
Self {
|
|
kind: GitDiffKind::File,
|
|
changes: Vec::new(),
|
|
selected_path: Some(path),
|
|
diff: None,
|
|
loading: true,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
pub fn loading_commit(hash: String) -> Self {
|
|
Self {
|
|
kind: GitDiffKind::Commit { hash },
|
|
changes: Vec::new(),
|
|
selected_path: None,
|
|
diff: None,
|
|
loading: true,
|
|
error: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn sidebar_view(
|
|
state: &GitUiState,
|
|
offline_mode: bool,
|
|
locale: UiLocale,
|
|
) -> Element<'static, Message> {
|
|
if state.loading && !state.repository.is_initialized {
|
|
return text(t(locale, "git.loading"))
|
|
.size(12)
|
|
.color(Color::from_rgb(0.5, 0.5, 0.55))
|
|
.into();
|
|
}
|
|
if !state.repository.is_initialized {
|
|
return column![
|
|
text(t(locale, "git.notRepository"))
|
|
.size(12)
|
|
.color(Color::from_rgb(0.6, 0.6, 0.65)),
|
|
text_input(&t(locale, "git.remoteOptional"), &state.remote_input)
|
|
.on_input(Message::GitRemoteInputChanged)
|
|
.size(12)
|
|
.padding([6, 8])
|
|
.style(inputs::field_style),
|
|
button(text(t(locale, "git.initialize")).size(12))
|
|
.on_press(Message::GitInitialize)
|
|
.padding([5, 8])
|
|
.style(inputs::primary_button),
|
|
error_view(state.error.as_deref()),
|
|
]
|
|
.spacing(8)
|
|
.into();
|
|
}
|
|
|
|
let branch = state.repository.current_branch.as_deref().unwrap_or("—");
|
|
let upstream = state.remote.upstream_branch.as_deref().unwrap_or("—");
|
|
let header = column![
|
|
text(format!("⎇ {branch}"))
|
|
.size(13)
|
|
.shaping(Shaping::Advanced),
|
|
text(format!(
|
|
"{upstream} ↑{} ↓{}",
|
|
state.remote.ahead, state.remote.behind
|
|
))
|
|
.size(11)
|
|
.color(Color::from_rgb(0.55, 0.55, 0.6)),
|
|
]
|
|
.spacing(2);
|
|
|
|
let network_running = state.network_run.is_some();
|
|
let network_button = |key, message| {
|
|
let button = button(text(t(locale, key)).size(11))
|
|
.padding([4, 6])
|
|
.style(inputs::secondary_button);
|
|
if offline_mode || network_running {
|
|
button
|
|
} else {
|
|
button.on_press(message)
|
|
}
|
|
};
|
|
let actions = row![
|
|
network_button("git.fetch", Message::GitFetch),
|
|
network_button("git.pull", Message::GitPull),
|
|
network_button("git.push", Message::GitPush),
|
|
]
|
|
.spacing(4)
|
|
.wrap();
|
|
|
|
let mut content: Vec<Element<'static, Message>> = vec![header.into(), actions.into()];
|
|
if offline_mode {
|
|
content.push(
|
|
text(t(locale, "git.airplaneBlocked"))
|
|
.size(10)
|
|
.color(Color::from_rgb(0.72, 0.58, 0.35))
|
|
.into(),
|
|
);
|
|
}
|
|
content.push(
|
|
row![
|
|
text_input(&t(locale, "git.remoteUrl"), &state.remote_input)
|
|
.on_input(Message::GitRemoteInputChanged)
|
|
.size(11)
|
|
.padding([5, 7])
|
|
.style(inputs::field_style),
|
|
button(text(t(locale, "git.saveRemote")).size(11))
|
|
.on_press(Message::GitSetRemote)
|
|
.padding([5, 7])
|
|
.style(inputs::secondary_button),
|
|
]
|
|
.spacing(4)
|
|
.into(),
|
|
);
|
|
content.push(section_title(
|
|
tw(
|
|
locale,
|
|
"git.changesCount",
|
|
&[("count", &state.files.len().to_string())],
|
|
),
|
|
locale,
|
|
));
|
|
content.push(
|
|
row![
|
|
text_input(&t(locale, "git.commitMessage"), &state.commit_message)
|
|
.on_input(Message::GitCommitMessageChanged)
|
|
.size(11)
|
|
.padding([5, 7])
|
|
.style(inputs::field_style),
|
|
{
|
|
let commit = button(text(t(locale, "git.commit")).size(11))
|
|
.padding([5, 7])
|
|
.style(inputs::primary_button);
|
|
if state.files.is_empty() || state.commit_message.trim().is_empty() {
|
|
commit
|
|
} else {
|
|
commit.on_press(Message::GitCommit)
|
|
}
|
|
},
|
|
]
|
|
.spacing(4)
|
|
.into(),
|
|
);
|
|
if state.files.is_empty() {
|
|
content.push(muted_text(t(locale, "git.noChanges")));
|
|
} else {
|
|
content.extend(state.files.iter().map(status_button));
|
|
}
|
|
content.push(section_title(t(locale, "git.history"), locale));
|
|
if state.history.is_empty() {
|
|
content.push(muted_text(t(locale, "git.noCommits")));
|
|
} else {
|
|
content.extend(state.history.iter().take(20).map(history_button));
|
|
}
|
|
content.push(
|
|
button(text(t(locale, "git.pruneLfs")).size(11))
|
|
.on_press(Message::GitPruneLfs)
|
|
.padding([4, 7])
|
|
.style(inputs::secondary_button)
|
|
.into(),
|
|
);
|
|
if let Some(run) = &state.network_run {
|
|
content.push(network_output(run, locale));
|
|
}
|
|
content.push(error_view(state.error.as_deref()));
|
|
iced::widget::Column::with_children(content)
|
|
.spacing(6)
|
|
.into()
|
|
}
|
|
|
|
fn section_title(label: String, _locale: UiLocale) -> Element<'static, Message> {
|
|
text(label)
|
|
.size(11)
|
|
.color(Color::from_rgb(0.62, 0.62, 0.68))
|
|
.into()
|
|
}
|
|
|
|
fn muted_text(label: String) -> Element<'static, Message> {
|
|
text(label)
|
|
.size(11)
|
|
.color(Color::from_rgb(0.5, 0.5, 0.55))
|
|
.into()
|
|
}
|
|
|
|
fn error_view(error: Option<&str>) -> Element<'static, Message> {
|
|
text(error.unwrap_or_default().to_string())
|
|
.size(11)
|
|
.color(Color::from_rgb(0.85, 0.35, 0.35))
|
|
.into()
|
|
}
|
|
|
|
fn status_button(file: &GitFileStatus) -> Element<'static, Message> {
|
|
let path = file.path.clone();
|
|
button(
|
|
row![
|
|
text(path.clone()).size(11),
|
|
Space::with_width(Length::Fill),
|
|
text(file.kind.code()).size(11),
|
|
]
|
|
.spacing(6),
|
|
)
|
|
.on_press(Message::OpenGitFileDiff(path))
|
|
.width(Length::Fill)
|
|
.padding([5, 8])
|
|
.style(inputs::disclosure_button)
|
|
.into()
|
|
}
|
|
|
|
fn history_button(commit: &GitCommit) -> Element<'static, Message> {
|
|
let hash = commit.hash.clone();
|
|
let subject = commit.subject.clone().unwrap_or_else(|| hash.clone());
|
|
let short = hash.chars().take(7).collect::<String>();
|
|
let marker = match commit.sync_status {
|
|
SyncStatus::Both => "●",
|
|
SyncStatus::LocalOnly => "↑",
|
|
SyncStatus::RemoteOnly => "↓",
|
|
};
|
|
button(
|
|
column![
|
|
text(subject.clone()).size(11),
|
|
text(format!("{marker} {short}"))
|
|
.size(10)
|
|
.color(Color::from_rgb(0.5, 0.5, 0.56)),
|
|
]
|
|
.spacing(1),
|
|
)
|
|
.on_press(Message::OpenGitCommitDiff { hash, subject })
|
|
.width(Length::Fill)
|
|
.padding([5, 8])
|
|
.style(inputs::disclosure_button)
|
|
.into()
|
|
}
|
|
|
|
fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static, Message> {
|
|
let output = run
|
|
.output
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|chunk| chunk.text.as_str())
|
|
.collect::<String>();
|
|
column![
|
|
text(t(locale, "git.liveOutput"))
|
|
.size(11)
|
|
.color(Color::from_rgb(0.62, 0.62, 0.68)),
|
|
container(
|
|
text(output)
|
|
.size(10)
|
|
.font(Font::MONOSPACE)
|
|
.wrapping(Wrapping::Word)
|
|
)
|
|
.padding(6),
|
|
button(text(t(locale, "common.cancel")).size(11))
|
|
.on_press(Message::CancelTask(run.task_id))
|
|
.padding([4, 7])
|
|
.style(inputs::secondary_button),
|
|
]
|
|
.spacing(4)
|
|
.into()
|
|
}
|
|
|
|
pub fn diff_view(
|
|
state: &GitDiffState,
|
|
view_style: &str,
|
|
wrap_long_lines: bool,
|
|
locale: UiLocale,
|
|
) -> Element<'static, Message> {
|
|
let title = match &state.kind {
|
|
GitDiffKind::File => state
|
|
.selected_path
|
|
.clone()
|
|
.unwrap_or_else(|| t(locale, "git.diff")),
|
|
GitDiffKind::Commit { hash } => tw(
|
|
locale,
|
|
"git.commitDiffTitle",
|
|
&[("hash", &hash.chars().take(7).collect::<String>())],
|
|
),
|
|
};
|
|
let header = inputs::card(
|
|
column![
|
|
text(title).size(20).shaping(Shaping::Advanced),
|
|
text(t(locale, "git.readOnly"))
|
|
.size(11)
|
|
.color(Color::from_rgb(0.58, 0.58, 0.63)),
|
|
]
|
|
.spacing(4),
|
|
);
|
|
let mut sections: Vec<Element<'static, Message>> = vec![header.into()];
|
|
if matches!(state.kind, GitDiffKind::Commit { .. }) && !state.changes.is_empty() {
|
|
sections.push(inputs::toolbar(
|
|
state
|
|
.changes
|
|
.iter()
|
|
.map(|change| {
|
|
let selected = state.selected_path.as_deref() == Some(&change.path);
|
|
let button = button(text(change.path.clone()).size(11))
|
|
.padding([4, 7])
|
|
.style(if selected {
|
|
inputs::primary_button
|
|
} else {
|
|
inputs::secondary_button
|
|
});
|
|
if selected {
|
|
button.into()
|
|
} else if let GitDiffKind::Commit { hash } = &state.kind {
|
|
button
|
|
.on_press(Message::SelectGitCommitFile {
|
|
hash: hash.clone(),
|
|
change: change.clone(),
|
|
})
|
|
.into()
|
|
} else {
|
|
button.into()
|
|
}
|
|
})
|
|
.collect(),
|
|
Vec::new(),
|
|
));
|
|
}
|
|
if state.loading {
|
|
sections.push(muted_text(t(locale, "git.loadingDiff")));
|
|
} else if let Some(error) = &state.error {
|
|
sections.push(error_view(Some(error)));
|
|
} else if let Some(diff) = &state.diff {
|
|
let wrapping = if wrap_long_lines {
|
|
Wrapping::Word
|
|
} else {
|
|
Wrapping::None
|
|
};
|
|
if view_style == "side-by-side" {
|
|
let original = code_card(t(locale, "git.original"), diff.original.clone(), wrapping);
|
|
let modified = code_card(t(locale, "git.modified"), diff.modified.clone(), wrapping);
|
|
sections.push(
|
|
row![original, modified]
|
|
.spacing(12)
|
|
.height(Length::Fill)
|
|
.into(),
|
|
);
|
|
} else {
|
|
sections.push(code_card(
|
|
t(locale, "git.inlineDiff"),
|
|
diff.patch.clone(),
|
|
wrapping,
|
|
));
|
|
}
|
|
} else {
|
|
sections.push(muted_text(t(locale, "git.noDiff")));
|
|
}
|
|
container(scrollable(
|
|
iced::widget::Column::with_children(sections)
|
|
.spacing(12)
|
|
.padding(16),
|
|
))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn code_card(label: String, contents: String, wrapping: Wrapping) -> Element<'static, Message> {
|
|
inputs::card(
|
|
column![
|
|
text(label)
|
|
.size(12)
|
|
.color(Color::from_rgb(0.66, 0.66, 0.72)),
|
|
scrollable(
|
|
text(contents)
|
|
.size(12)
|
|
.font(Font::MONOSPACE)
|
|
.wrapping(wrapping)
|
|
)
|
|
.height(Length::Fill),
|
|
]
|
|
.spacing(8)
|
|
.height(Length::Fill),
|
|
)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
pub fn file_tab(path: &str) -> Tab {
|
|
Tab {
|
|
id: format!("git-diff:{path}"),
|
|
tab_type: TabType::GitDiff,
|
|
title: PathTitle(path).to_string(),
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
}
|
|
}
|
|
|
|
pub fn commit_tab(hash: &str, subject: &str) -> Tab {
|
|
let short = hash.chars().take(7).collect::<String>();
|
|
Tab {
|
|
id: format!("git-diff:commit:{hash}"),
|
|
tab_type: TabType::GitDiff,
|
|
title: if subject.is_empty() {
|
|
short
|
|
} else {
|
|
format!("{short} {subject}")
|
|
},
|
|
is_transient: true,
|
|
is_dirty: false,
|
|
}
|
|
}
|
|
|
|
struct PathTitle<'a>(&'a str);
|
|
|
|
impl fmt::Display for PathTitle<'_> {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(self.0.rsplit('/').next().unwrap_or(self.0))
|
|
}
|
|
}
|
|
|
|
use std::fmt;
|