Implement the Git workflow
This commit is contained in:
525
crates/bds-ui/src/views/git.rs
Normal file
525
crates/bds-ui/src/views/git.rs
Normal file
@@ -0,0 +1,525 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user