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

@@ -31,6 +31,7 @@ use crate::views::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth,
},
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState},
metadata_diff::MetadataDiffState,
modal,
@@ -47,6 +48,7 @@ use crate::views::{
mod editor_handlers;
mod engine_handlers;
mod git_handlers;
mod preview_handlers;
mod search;
mod tasks;
@@ -213,6 +215,52 @@ pub enum Message {
},
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>),
// Git
GitRefresh,
GitLoaded {
repository_dir: PathBuf,
result: Result<GitSnapshot, String>,
},
GitRemoteInputChanged(String),
GitCommitMessageChanged(String),
GitInitialize,
GitSetRemote,
GitCommit,
GitFetch,
GitPull,
GitPush,
GitPruneLfs,
GitLocalFinished {
repository_dir: PathBuf,
operation: engine::git::GitOperation,
result: Result<GitSnapshot, String>,
},
GitNetworkFinished {
repository_dir: PathBuf,
task_id: TaskId,
operation: engine::git::GitOperation,
result: Result<GitNetworkCompletion, String>,
},
OpenGitFileDiff(String),
OpenGitCommitDiff {
hash: String,
subject: String,
},
SelectGitCommitFile {
hash: String,
change: engine::git::ChangedFile,
},
GitDiffLoaded {
repository_dir: PathBuf,
tab_id: String,
result: Result<GitDiffLoad, String>,
},
GitFileHistoryLoaded {
repository_dir: PathBuf,
path: String,
result: Result<Vec<engine::git::GitCommit>, String>,
},
// Editor views
PostEditor(PostEditorMsg),
MediaEditor(MediaEditorMsg),
@@ -823,6 +871,10 @@ pub struct BdsApp {
site_validation_state: SiteValidationState,
metadata_diff_state: MetadataDiffState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState,
git_state: GitUiState,
git_diffs: HashMap<String, GitDiffState>,
git_file_history: Vec<engine::git::GitCommit>,
git_file_history_target: Option<String>,
}
// ───────────────────────────────────────────────────────────
@@ -962,6 +1014,10 @@ impl BdsApp {
site_validation_state: SiteValidationState::default(),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
git_diffs: HashMap::new(),
git_file_history: Vec::new(),
git_file_history_target: None,
},
init_task,
)
@@ -1028,6 +1084,10 @@ impl BdsApp {
site_validation_state: SiteValidationState::default(),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
git_diffs: HashMap::new(),
git_file_history: Vec::new(),
git_file_history_target: None,
}
}
@@ -1053,6 +1113,8 @@ impl BdsApp {
&& matches!(new_view, SidebarView::Posts | SidebarView::Pages);
if needs_post_refresh {
self.refresh_sidebar_posts()
} else if new_view == SidebarView::Git && new_visible {
self.refresh_git()
} else {
Task::none()
}
@@ -1125,6 +1187,7 @@ impl BdsApp {
} else {
self.active_tab = None;
}
self.git_diffs.remove(&id);
self.enforce_panel_tab_fallback();
self.sync_menu_state();
self.sync_embedded_preview_for_active_post()
@@ -1147,6 +1210,7 @@ impl BdsApp {
self.flush_active_post_editor();
self.tabs.clear();
self.active_tab = None;
self.git_diffs.clear();
self.hide_embedded_preview();
Task::none()
}
@@ -1190,6 +1254,7 @@ impl BdsApp {
if let Some(ref db) = self.db {
match engine::project::set_active_project(db.conn(), &project_id) {
Ok(()) => {
self.reset_git_for_project_change();
self.active_project =
self.projects.iter().find(|p| p.id == project_id).cloned();
self.preview_session = None;
@@ -1235,7 +1300,7 @@ impl BdsApp {
}
}
self.sync_menu_state();
Task::none()
self.refresh_git_if_visible()
}
Message::ProjectSwitched(result) => {
match result {
@@ -1266,6 +1331,7 @@ impl BdsApp {
let _ = engine::project::set_active_project(db.conn(), &project.id);
self.projects =
engine::project::list_projects(db.conn()).unwrap_or_default();
self.reset_git_for_project_change();
self.active_project = Some(project.clone());
self.preview_session = None;
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
@@ -1284,7 +1350,7 @@ impl BdsApp {
}
}
}
Task::none()
self.refresh_git_if_visible()
}
Message::ProjectCreated(result) => {
match result {
@@ -1354,6 +1420,7 @@ impl BdsApp {
let _ = engine::project::set_active_project(db.conn(), &project.id);
self.projects =
engine::project::list_projects(db.conn()).unwrap_or_default();
self.reset_git_for_project_change();
self.active_project = Some(project.clone());
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
self.preview_session = None;
@@ -1367,7 +1434,10 @@ impl BdsApp {
),
);
self.sync_menu_state();
return self.refresh_counts();
return Task::batch([
self.refresh_counts(),
self.refresh_git_if_visible(),
]);
}
Err(error) => self.notify(
ToastLevel::Error,
@@ -1817,7 +1887,11 @@ impl BdsApp {
// ── Panel ──
Message::SetPanelTab(tab) => {
self.panel_tab = tab;
Task::none()
if tab == PanelTab::GitLog {
self.refresh_git_file_history()
} else {
Task::none()
}
}
// ── Settings ──
@@ -1868,6 +1942,26 @@ impl BdsApp {
| Message::SiteGenerationIndexDone { .. }
| Message::SiteValidationLoaded(_)) => self.handle_engine_message(message),
// ── Git ──
message @ (Message::GitRefresh
| Message::GitLoaded { .. }
| Message::GitRemoteInputChanged(_)
| Message::GitCommitMessageChanged(_)
| Message::GitInitialize
| Message::GitSetRemote
| Message::GitCommit
| Message::GitFetch
| Message::GitPull
| Message::GitPush
| Message::GitPruneLfs
| Message::GitLocalFinished { .. }
| Message::GitNetworkFinished { .. }
| Message::OpenGitFileDiff(_)
| Message::OpenGitCommitDiff { .. }
| Message::SelectGitCommitFile { .. }
| Message::GitDiffLoaded { .. }
| Message::GitFileHistoryLoaded { .. }) => self.handle_git_message(message),
// ── Toasts ──
Message::ShowToast(level, msg) => {
self.toasts.push(Toast::new(level, msg));
@@ -2172,6 +2266,9 @@ impl BdsApp {
&self.site_validation_state,
&self.metadata_diff_state,
&self.translation_validation_state,
&self.git_state,
&self.git_diffs,
&self.git_file_history,
)
}
@@ -6201,6 +6298,7 @@ mod tests {
save_editor_settings_state_impl, save_script_editor_state_impl,
save_template_editor_state_impl,
};
use crate::i18n::t;
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::modal;
@@ -6214,6 +6312,7 @@ mod tests {
use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{ai, media, post, script, template};
use bds_core::i18n::UiLocale;
use bds_core::model::{Project, ScriptKind, TemplateKind};
use chrono::{Datelike, TimeZone};
use std::io::{Read, Write};
@@ -6346,6 +6445,22 @@ mod tests {
);
}
#[test]
fn git_network_actions_are_gated_in_airplane_mode() {
let (db, project, tempdir) = setup();
let mut app = BdsApp::new_for_tests(db, project, tempdir.path().to_path_buf());
app.offline_mode = true;
let _ = app.update(Message::GitFetch);
assert!(app.git_state.network_run.is_none());
assert!(
app.toasts
.iter()
.any(|toast| toast.message == t(UiLocale::En, "git.airplaneBlocked"))
);
}
#[test]
fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() {
let (db, project, tmp) = setup();

View File

@@ -0,0 +1,633 @@
use super::*;
use bds_core::engine::git::{
GitEngine, GitError, GitOperation, ReconcileAction, ReconcileEntityType,
reconcile_changed_files,
};
impl BdsApp {
pub(super) fn handle_git_message(&mut self, message: Message) -> Task<Message> {
match message {
Message::GitRefresh => self.refresh_git(),
Message::GitLoaded {
repository_dir,
result,
} => {
if self.data_dir.as_ref() != Some(&repository_dir) {
return Task::none();
}
match result {
Ok(snapshot) => self.git_state.apply_snapshot(snapshot),
Err(error) => {
self.git_state.loading = false;
self.git_state.error = Some(error);
}
}
Task::none()
}
Message::GitRemoteInputChanged(value) => {
self.git_state.remote_input = value;
Task::none()
}
Message::GitCommitMessageChanged(value) => {
self.git_state.commit_message = value;
Task::none()
}
Message::GitInitialize => {
let remote = self.git_state.remote_input.trim().to_string();
self.run_local_git_action(GitOperation::Initialize, move |engine| {
engine.initialize()?;
if !remote.is_empty() {
engine.set_remote(&remote)?;
}
Ok(())
})
}
Message::GitSetRemote => {
let remote = self.git_state.remote_input.trim().to_string();
self.run_local_git_action(GitOperation::Remote, move |engine| {
engine.set_remote(&remote)
})
}
Message::GitCommit => {
let message = self.git_state.commit_message.clone();
self.run_local_git_action(GitOperation::Commit, move |engine| {
engine.commit_all(&message).map(|_| ())
})
}
Message::GitPruneLfs => self.run_local_git_action(GitOperation::Lfs, |engine| {
engine.prune_lfs_cache(10).map(|_| ())
}),
Message::GitLocalFinished {
repository_dir,
operation,
result,
} => {
if self.data_dir.as_ref() != Some(&repository_dir) {
return Task::none();
}
match result {
Ok(snapshot) => {
self.git_state.apply_snapshot(snapshot);
if operation == GitOperation::Commit {
self.git_state.commit_message.clear();
self.close_git_diff_tabs();
}
self.notify(
ToastLevel::Success,
&tw(
self.ui_locale,
"git.operationDone",
&[("operation", &git_operation_label(self.ui_locale, operation))],
),
);
}
Err(error) => {
self.git_state.loading = false;
self.git_state.error = Some(error.clone());
self.notify(ToastLevel::Error, &error);
}
}
Task::none()
}
Message::GitFetch => self.start_git_network(GitOperation::Fetch),
Message::GitPull => self.start_git_network(GitOperation::Pull),
Message::GitPush => self.start_git_network(GitOperation::Push),
Message::GitNetworkFinished {
repository_dir,
task_id,
operation,
result,
} => {
if self.data_dir.as_ref() != Some(&repository_dir) {
self.refresh_task_snapshots();
return Task::none();
}
self.git_state.network_run = None;
if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) {
self.refresh_task_snapshots();
return Task::none();
}
match result {
Ok(completion) => {
self.task_manager.complete(task_id);
if !completion.output.trim().is_empty() {
self.add_output(completion.output.trim());
}
self.git_state.apply_snapshot(completion.snapshot);
self.apply_git_reconcile_events(&completion.events);
self.notify(
ToastLevel::Success,
&tw(
self.ui_locale,
"git.operationDone",
&[("operation", &git_operation_label(self.ui_locale, operation))],
),
);
self.refresh_task_snapshots();
return self.refresh_counts();
}
Err(error) => {
if self.task_manager.status(task_id) != Some(TaskStatus::Cancelled) {
self.task_manager.fail(task_id, error.clone());
self.notify(ToastLevel::Error, &error);
}
self.git_state.error = Some(error);
}
}
self.refresh_task_snapshots();
Task::none()
}
Message::OpenGitFileDiff(path) => self.open_git_file_diff(path),
Message::OpenGitCommitDiff { hash, subject } => {
self.open_git_commit_diff(hash, subject)
}
Message::SelectGitCommitFile { hash, change } => {
self.load_git_commit_file(hash, change)
}
Message::GitDiffLoaded {
repository_dir,
tab_id,
result,
} => {
if self.data_dir.as_ref() != Some(&repository_dir) {
return Task::none();
}
if let Some(state) = self.git_diffs.get_mut(&tab_id) {
state.loading = false;
match result {
Ok(loaded) => {
state.changes = loaded.changes;
state.selected_path = loaded.selected_path;
state.diff = loaded.diff;
state.error = None;
}
Err(error) => state.error = Some(error),
}
}
Task::none()
}
Message::GitFileHistoryLoaded {
repository_dir,
path,
result,
} => {
if self.data_dir.as_ref() != Some(&repository_dir) {
return Task::none();
}
if self.git_file_history_target.as_deref() == Some(&path) {
match result {
Ok(commits) => self.git_file_history = commits,
Err(error) => {
self.git_file_history.clear();
self.add_output(&error);
}
}
}
Task::none()
}
_ => Task::none(),
}
}
pub(super) fn refresh_git(&mut self) -> Task<Message> {
let Some(data_dir) = self.data_dir.clone() else {
self.git_state = GitUiState::default();
return Task::none();
};
self.git_state.loading = true;
let repository_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || git_snapshot(&GitEngine::new(data_dir)))
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitLoaded {
repository_dir: repository_dir.clone(),
result,
},
)
}
fn run_local_git_action<F>(&mut self, operation: GitOperation, work: F) -> Task<Message>
where
F: FnOnce(&GitEngine) -> Result<(), GitError> + Send + 'static,
{
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
self.git_state.loading = true;
self.git_state.error = None;
let repository_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let engine = GitEngine::new(data_dir);
work(&engine).map_err(|error| error.to_string())?;
git_snapshot(&engine)
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitLocalFinished {
repository_dir: repository_dir.clone(),
operation,
result,
},
)
}
fn start_git_network(&mut self, operation: GitOperation) -> Task<Message> {
if self.offline_mode {
self.notify(
ToastLevel::Warning,
&t(self.ui_locale, "git.airplaneBlocked"),
);
return Task::none();
}
if self.git_state.network_run.is_some() {
return Task::none();
}
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
return Task::none();
};
let project_id = project.id.clone();
let data_dir = data_dir.clone();
let repository_dir = data_dir.clone();
let db_path = self.db_path.clone();
let operation_label = git_operation_label(self.ui_locale, operation);
let label = tw(
self.ui_locale,
"git.runningOperation",
&[("operation", &operation_label)],
);
let task_id = self.task_manager.submit(&label);
let output = Arc::new(Mutex::new(Vec::new()));
self.git_state.network_run = Some(crate::views::git::GitNetworkRunState {
task_id,
output: Arc::clone(&output),
});
self.git_state.error = None;
self.refresh_task_snapshots();
let task_manager = Arc::clone(&self.task_manager);
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
let engine = GitEngine::new(&data_dir);
let old_head = (operation == GitOperation::Pull)
.then(|| engine.head())
.transpose()
.map_err(|error| error.to_string())?
.flatten();
let output_target = Arc::clone(&output);
let result = match operation {
GitOperation::Fetch => engine.fetch(
|| task_manager.is_cancelled(task_id),
move |chunk| output_target.lock().unwrap().push(chunk),
),
GitOperation::Pull => engine.pull(
|| task_manager.is_cancelled(task_id),
move |chunk| output_target.lock().unwrap().push(chunk),
),
GitOperation::Push => engine.push(
|| task_manager.is_cancelled(task_id),
move |chunk| output_target.lock().unwrap().push(chunk),
),
_ => unreachable!("only network operations are queued"),
}
.map_err(|error| error.to_string())?;
let mut events = Vec::new();
if operation == GitOperation::Pull
&& let (Some(old_head), Some(new_head)) =
(old_head, engine.head().map_err(|error| error.to_string())?)
&& old_head != new_head
{
let changes = engine
.changed_files(&old_head, &new_head)
.map_err(|error| error.to_string())?;
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let report = reconcile_changed_files(
db.conn(),
&data_dir,
&project_id,
&changes,
|event| events.push(event.clone()),
)
.map_err(|error| error.to_string())?;
debug_assert_eq!(events, report.events);
}
Ok(GitNetworkCompletion {
snapshot: git_snapshot(&engine)?,
events,
output: result.output,
})
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitNetworkFinished {
repository_dir: repository_dir.clone(),
task_id,
operation,
result,
},
)
}
fn open_git_file_diff(&mut self, path: String) -> Task<Message> {
let tab = crate::views::git::file_tab(&path);
let tab_id = tab.id.clone();
self.activate_git_tab(tab);
self.git_diffs
.insert(tab_id.clone(), GitDiffState::loading_file(path.clone()));
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let repository_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let diff = GitEngine::new(data_dir)
.file_diff(&path)
.map_err(|error| error.to_string())?;
Ok(GitDiffLoad {
changes: Vec::new(),
selected_path: Some(path),
diff: Some(diff),
})
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitDiffLoaded {
repository_dir: repository_dir.clone(),
tab_id: tab_id.clone(),
result,
},
)
}
fn open_git_commit_diff(&mut self, hash: String, subject: String) -> Task<Message> {
let tab = crate::views::git::commit_tab(&hash, &subject);
let tab_id = tab.id.clone();
self.activate_git_tab(tab);
self.git_diffs
.insert(tab_id.clone(), GitDiffState::loading_commit(hash.clone()));
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let repository_dir = data_dir.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let engine = GitEngine::new(data_dir);
let changes = engine
.commit_files(&hash)
.map_err(|error| error.to_string())?;
let selected = changes.first().cloned();
let diff = selected
.as_ref()
.map(|change| engine.commit_file_diff(&hash, change))
.transpose()
.map_err(|error| error.to_string())?;
Ok(GitDiffLoad {
selected_path: selected.map(|change| change.path),
changes,
diff,
})
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitDiffLoaded {
repository_dir: repository_dir.clone(),
tab_id: tab_id.clone(),
result,
},
)
}
fn load_git_commit_file(
&mut self,
hash: String,
change: engine::git::ChangedFile,
) -> Task<Message> {
let tab_id = format!("git-diff:commit:{hash}");
if let Some(state) = self.git_diffs.get_mut(&tab_id) {
state.loading = true;
state.selected_path = Some(change.path.clone());
state.error = None;
}
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let repository_dir = data_dir.clone();
let changes = self
.git_diffs
.get(&tab_id)
.map(|state| state.changes.clone())
.unwrap_or_default();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let diff = GitEngine::new(data_dir)
.commit_file_diff(&hash, &change)
.map_err(|error| error.to_string())?;
Ok(GitDiffLoad {
changes,
selected_path: Some(change.path),
diff: Some(diff),
})
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::GitDiffLoaded {
repository_dir: repository_dir.clone(),
tab_id: tab_id.clone(),
result,
},
)
}
fn activate_git_tab(&mut self, tab: Tab) {
self.flush_active_post_editor();
let index = tabs::open_tab(&mut self.tabs, tab);
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
self.enforce_panel_tab_fallback();
self.sync_menu_state();
}
fn close_git_diff_tabs(&mut self) {
let ids = self
.tabs
.iter()
.filter(|tab| tab.tab_type == TabType::GitDiff)
.map(|tab| tab.id.clone())
.collect::<Vec<_>>();
for id in ids {
self.git_diffs.remove(&id);
if let Some(index) = tabs::close_tab(&mut self.tabs, &id) {
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
} else {
self.active_tab = None;
}
}
}
pub(super) fn refresh_git_file_history(&mut self) -> Task<Message> {
let Some(path) = self.active_git_history_path() else {
self.git_file_history.clear();
self.git_file_history_target = None;
return Task::none();
};
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let repository_dir = data_dir.clone();
self.git_file_history_target = Some(path.clone());
self.git_file_history.clear();
Task::perform(
async move {
let task_path = path.clone();
let result = tokio::task::spawn_blocking(move || {
GitEngine::new(data_dir)
.file_history(&task_path)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")));
(path, result)
},
move |(path, result)| Message::GitFileHistoryLoaded {
repository_dir: repository_dir.clone(),
path,
result,
},
)
}
fn active_git_history_path(&self) -> Option<String> {
let tab_id = self.active_tab.as_deref()?;
let tab = self.tabs.iter().find(|tab| tab.id == tab_id)?;
match tab.tab_type {
TabType::Post => self
.db
.as_ref()
.and_then(|db| bds_core::db::queries::post::get_post_by_id(db.conn(), tab_id).ok())
.map(|post| post.file_path)
.filter(|path| !path.is_empty()),
TabType::Media => self
.media_editors
.get(tab_id)
.map(|media| media.file_path.clone())
.filter(|path| !path.is_empty()),
_ => None,
}
}
fn apply_git_reconcile_events(&mut self, events: &[engine::git::ReconcileEvent]) {
for event in events {
match event.entity_type {
ReconcileEntityType::Post => {
self.post_editors.remove(&event.entity_id);
}
ReconcileEntityType::PostTranslation => self.post_editors.clear(),
ReconcileEntityType::Script => {
self.script_editors.remove(&event.entity_id);
}
ReconcileEntityType::Template => {
self.template_editors.remove(&event.entity_id);
}
}
if event.action == ReconcileAction::Deleted {
let was_active = self.active_tab.as_deref() == Some(&event.entity_id);
match tabs::close_tab(&mut self.tabs, &event.entity_id) {
Some(index) if was_active => {
self.active_tab = self.tabs.get(index).map(|tab| tab.id.clone());
}
None if was_active => self.active_tab = None,
_ => {}
}
}
}
self.enforce_panel_tab_fallback();
self.sync_menu_state();
}
pub(super) fn reset_git_for_project_change(&mut self) {
if let Some(run) = self.git_state.network_run.take() {
self.task_manager.cancel(run.task_id);
}
self.close_git_diff_tabs();
self.git_state = GitUiState::default();
self.git_file_history.clear();
self.git_file_history_target = None;
self.refresh_task_snapshots();
}
pub(super) fn refresh_git_if_visible(&mut self) -> Task<Message> {
if self.sidebar_visible && self.sidebar_view == SidebarView::Git {
self.refresh_git()
} else {
Task::none()
}
}
}
fn git_operation_label(locale: bds_core::i18n::UiLocale, operation: GitOperation) -> String {
t(
locale,
match operation {
GitOperation::Initialize => "git.initialize",
GitOperation::Status => "git.noChanges",
GitOperation::Diff => "git.diff",
GitOperation::History => "git.history",
GitOperation::Remote => "git.remoteUrl",
GitOperation::Fetch => "git.fetch",
GitOperation::Pull | GitOperation::Reconcile => "git.pull",
GitOperation::Push => "git.push",
GitOperation::Commit => "git.commit",
GitOperation::Lfs => "git.pruneLfs",
},
)
}
fn git_snapshot(engine: &GitEngine) -> Result<GitSnapshot, String> {
let repository = engine.repository().map_err(|error| error.to_string())?;
if !repository.is_initialized {
return Ok(GitSnapshot {
repository,
files: Vec::new(),
history: Vec::new(),
remote: bds_core::engine::git::GitRemoteState {
local_branch: None,
upstream_branch: None,
has_upstream: false,
ahead: 0,
behind: 0,
},
});
}
let files = engine.status().map_err(|error| error.to_string())?;
let remote = engine.remote_state().map_err(|error| error.to_string())?;
let history = repository
.current_branch
.as_deref()
.map(|branch| engine.history(branch))
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or_default();
Ok(GitSnapshot {
repository,
files,
history,
remote,
})
}

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();