Implement the Git workflow

This commit is contained in:
2026-07-19 11:53:02 +02:00
parent 90a9002124
commit 422f71c8ad
20 changed files with 3441 additions and 45 deletions

View 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;

View File

@@ -1,5 +1,6 @@
pub mod activity_bar;
pub mod dashboard;
pub mod git;
pub mod media_editor;
pub mod metadata_diff;
pub mod modal;

View File

@@ -2,6 +2,7 @@ use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale;
use crate::app::Message;
@@ -109,6 +110,7 @@ pub fn view(
locale: UiLocale,
active_tab_is_post: bool,
active_tab_is_post_or_media: bool,
git_file_history: &[GitCommit],
) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55);
@@ -349,15 +351,45 @@ pub fn view(
}
}
PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration)
container(
text(t(locale, "panel.gitLogPlaceholder"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
if git_file_history.is_empty() {
container(
text(t(locale, "git.noFileHistory"))
.size(12)
.shaping(Shaping::Advanced)
.color(muted),
)
.padding(8)
.into()
} else {
let items = git_file_history
.iter()
.map(|commit| {
let hash = commit.hash.clone();
let subject = commit.subject.clone().unwrap_or_else(|| hash.clone());
let short = hash.chars().take(7).collect::<String>();
button(
row![
text(short).size(11).font(iced::Font::MONOSPACE),
text(subject.clone()).size(11),
Space::with_width(Length::Fill),
text(commit.date.clone().unwrap_or_default()).size(10),
]
.spacing(8),
)
.on_press(Message::OpenGitCommitDiff { hash, subject })
.padding([4, 8])
.width(Length::Fill)
.style(inputs::disclosure_button)
.into()
})
.collect::<Vec<Element<'static, Message>>>();
scrollable(
iced::widget::Column::with_children(items)
.spacing(2)
.padding(8),
)
.into()
}
}
};

View File

@@ -13,6 +13,7 @@ use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
use crate::views::git::GitUiState;
/// Sidebar container style — dark background.
fn sidebar_style(_theme: &Theme) -> container::Style {
@@ -79,7 +80,7 @@ fn placeholder_key(view: SidebarView) -> &'static str {
SidebarView::Tags => "sidebar.tagsHeader",
SidebarView::Chat => "sidebar.chatPlaceholder",
SidebarView::Import => "sidebar.importPlaceholder",
SidebarView::Git => "sidebar.gitPlaceholder",
SidebarView::Git => "git.noChanges",
SidebarView::Settings => "sidebar.settingsHeader",
}
}
@@ -635,6 +636,8 @@ pub fn view(
active_tab: Option<&str>,
locale: UiLocale,
_data_dir: Option<&Path>,
git_state: &GitUiState,
offline_mode: bool,
) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key());
let muted = Color::from_rgb(0.50, 0.50, 0.55);
@@ -1140,6 +1143,7 @@ pub fn view(
.collect();
iced::widget::Column::with_children(items).spacing(1).into()
}
SidebarView::Git => crate::views::git::sidebar_view(git_state, offline_mode, locale),
_ => text(t(locale, placeholder_key(sidebar_view)))
.size(12)
.shaping(Shaping::Advanced)

View File

@@ -5,6 +5,7 @@ use iced::widget::text::Shaping;
use iced::widget::{Space, button, column, container, mouse_area, row, stack, text};
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Project, Script, Template};
@@ -16,6 +17,7 @@ use crate::state::toast::Toast;
use crate::views::{
activity_bar,
dashboard::DashboardState,
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState},
modal, panel,
@@ -123,6 +125,9 @@ pub fn view<'a>(
site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
git_diffs: &'a HashMap<String, GitDiffState>,
git_file_history: &'a [GitCommit],
) -> Element<'a, Message> {
// Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -148,6 +153,7 @@ pub fn view<'a>(
site_validation_state,
metadata_diff_state,
translation_validation_state,
git_diffs,
);
// Right column: tab bar + content + panel
@@ -176,6 +182,7 @@ pub fn view<'a>(
locale,
active_tab_is_post,
active_tab_is_post_or_media,
git_file_history,
));
}
let right = container(right_col.width(Length::Fill).height(Length::Fill))
@@ -202,6 +209,8 @@ pub fn view<'a>(
active_tab,
locale,
data_dir,
git_state,
offline_mode,
));
// Resize drag handle: 4px wide strip between sidebar and content
@@ -379,6 +388,7 @@ fn route_content_area<'a>(
site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
) -> Element<'a, Message> {
match route_kind(
tabs,
@@ -454,6 +464,19 @@ fn route_content_area<'a>(
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
}
ContentRoute::GitDiff(tab_id) => {
if let Some(state) = git_diffs.get(tab_id) {
let settings = settings_state.cloned().unwrap_or_default();
git::diff_view(
state,
&settings.diff_view_style,
settings.wrap_long_lines,
locale,
)
} else {
loading_view(locale)
}
}
ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
}
}
@@ -472,6 +495,7 @@ enum ContentRoute<'a> {
SiteValidation,
MetadataDiff,
TranslationValidation,
GitDiff(&'a str),
Placeholder(&'a str),
}
@@ -547,11 +571,11 @@ fn route_kind<'a>(
}
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::Style
| TabType::Chat
| TabType::Import
| TabType::MenuEditor
| TabType::GitDiff
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
@@ -615,7 +639,6 @@ mod tests {
TabType::Chat,
TabType::Import,
TabType::MenuEditor,
TabType::GitDiff,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::FindDuplicates,
@@ -642,6 +665,29 @@ mod tests {
}
}
#[test]
fn git_diff_tab_routes_to_real_view() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let site_validation_state = SiteValidationState::default();
let tabs = vec![tab("git-diff:file.txt", TabType::GitDiff, "file.txt")];
let route = route_kind(
&tabs,
Some("git-diff:file.txt"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
None,
None,
None,
&site_validation_state,
);
assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt")));
}
#[test]
fn validation_tabs_route_to_real_views() {
let empty_posts = HashMap::new();