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

@@ -15,6 +15,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Local preview in the app or system browser. - Local preview in the app or system browser.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
- SSH-agent-based SCP or rsync publishing. - SSH-agent-based SCP or rsync publishing.
- Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.
- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol. - Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol.
RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview.

View File

@@ -8,17 +8,19 @@ Status describes the current source code as of 2026-07-19. Core one-shot AI, pub
## Current Extension Status ## Current Extension Status
### Git and richer validation — Partly done ### Git and richer validation — Complete
Done: Done:
- Site, media, translation, and metadata-diff engines and UI views. - Site, media, translation, and metadata-diff engines and UI views.
- `GitEngine` repository initialization and inspection, Git LFS image tracking, status, staged/unstaged and per-file/commit diffs, branch and rename-following file history, remote state, commit, fetch, fast-forward-only pull, push, cancellation, timeouts, and platform-specific authentication guidance.
- Localized Git sidebar, log panel, read-only inline/side-by-side diff tabs, live task output, airplane-mode gating, and post-pull reconciliation through the normal filesystem rebuild paths and typed events.
### Synchronization scripting bridge — Open
Open: Open:
- `GitEngine`, repository status, history, diff view, commit, fetch, pull, push, and LFS command integration. - Expose `bds.sync` through the scripting API using the shared Git workflow.
- Expose `bds.sync` after the shared Git/synchronization workflow exists.
- Replace the Git sidebar and Git log placeholders with working flows.
### WordPress import — Open ### WordPress import — Open
@@ -121,7 +123,7 @@ Open:
## Suggested Order ## Suggested Order
1. Git workflow and WordPress import. 1. WordPress import.
2. CLI, MCP, and domain events. 2. CLI, MCP, and domain events.
3. Conversational AI and agent tools. 3. Conversational AI and agent tools.
4. Embeddings and duplicate detection. 4. Embeddings and duplicate detection.

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ pub mod calendar;
pub mod error; pub mod error;
pub mod gallery_import; pub mod gallery_import;
pub mod generation; pub mod generation;
pub mod git;
pub mod media; pub mod media;
pub mod menu; pub mod menu;
pub mod meta; pub mod meta;

View File

@@ -929,8 +929,6 @@ pub(crate) fn rebuild_canonical_post(
.to_string(); .to_string();
let hash = content_hash(content.as_bytes()); let hash = content_hash(content.as_bytes());
let now = now_unix_ms();
let status = match fm.status.as_str() { let status = match fm.status.as_str() {
"published" => PostStatus::Published, "published" => PostStatus::Published,
"archived" => PostStatus::Archived, "archived" => PostStatus::Archived,
@@ -960,7 +958,7 @@ pub(crate) fn rebuild_canonical_post(
post.tags = fm.tags; post.tags = fm.tags;
post.categories = fm.categories; post.categories = fm.categories;
post.created_at = fm.created_at; post.created_at = fm.created_at;
post.updated_at = now; post.updated_at = fm.updated_at;
post.published_at = fm.published_at; post.published_at = fm.published_at;
qp::update_post(conn, &post)?; qp::update_post(conn, &post)?;
Ok(false) Ok(false)
@@ -993,7 +991,7 @@ pub(crate) fn rebuild_canonical_post(
published_categories: None, published_categories: None,
published_excerpt: None, published_excerpt: None,
created_at: fm.created_at, created_at: fm.created_at,
updated_at: now, updated_at: fm.updated_at,
published_at: fm.published_at, published_at: fm.published_at,
}; };
qp::insert_post(conn, &post)?; qp::insert_post(conn, &post)?;
@@ -1747,6 +1745,7 @@ mod tests {
assert_eq!(post.title, "Rebuilt Post"); assert_eq!(post.title, "Rebuilt Post");
assert_eq!(post.slug, "rebuilt-post"); assert_eq!(post.slug, "rebuilt-post");
assert_eq!(post.tags, vec!["test"]); assert_eq!(post.tags, vec!["test"]);
assert_eq!(post.updated_at, 1_705_320_000_000);
// Verify translation in DB // Verify translation in DB
let trans = let trans =

View File

@@ -8,7 +8,6 @@ use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Script, ScriptStatus}; use crate::model::{Script, ScriptStatus};
use crate::util::frontmatter::read_script_file; use crate::util::frontmatter::read_script_file;
use crate::util::now_unix_ms;
/// Report returned by `rebuild_scripts_from_filesystem`. /// Report returned by `rebuild_scripts_from_filesystem`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -83,8 +82,6 @@ pub(crate) fn rebuild_single_script(
.to_string(); .to_string();
let kind = fm.kind.parse().map_err(EngineError::Parse)?; let kind = fm.kind.parse().map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB // File exists on disk -> Published; content is None in DB
let status = ScriptStatus::Published; let status = ScriptStatus::Published;
@@ -101,7 +98,7 @@ pub(crate) fn rebuild_single_script(
script.status = status; script.status = status;
script.content = None; script.content = None;
script.created_at = fm.created_at; script.created_at = fm.created_at;
script.updated_at = now; script.updated_at = fm.updated_at;
qs::update_script(conn, &script)?; qs::update_script(conn, &script)?;
Ok(false) Ok(false)
} }
@@ -119,7 +116,7 @@ pub(crate) fn rebuild_single_script(
status, status,
content: None, content: None,
created_at: fm.created_at, created_at: fm.created_at,
updated_at: now, updated_at: fm.updated_at,
}; };
qs::insert_script(conn, &script)?; qs::insert_script(conn, &script)?;
Ok(true) Ok(true)
@@ -246,6 +243,7 @@ end
assert!(script.enabled); assert!(script.enabled);
assert_eq!(script.version, 5); assert_eq!(script.version, 5);
assert_eq!(script.status, ScriptStatus::Published); assert_eq!(script.status, ScriptStatus::Published);
assert_eq!(script.updated_at, 1_704_067_200_000);
assert!(script.content.is_none()); assert!(script.content.is_none());
} }

View File

@@ -8,7 +8,6 @@ use crate::db::queries::template as qt;
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::model::{Template, TemplateStatus}; use crate::model::{Template, TemplateStatus};
use crate::util::frontmatter::read_template_file; use crate::util::frontmatter::read_template_file;
use crate::util::now_unix_ms;
/// Report returned by `rebuild_templates_from_filesystem`. /// Report returned by `rebuild_templates_from_filesystem`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -83,8 +82,6 @@ pub(crate) fn rebuild_single_template(
.to_string(); .to_string();
let kind = fm.kind.parse().map_err(EngineError::Parse)?; let kind = fm.kind.parse().map_err(EngineError::Parse)?;
let now = now_unix_ms();
// File exists on disk -> Published; content is None in DB // File exists on disk -> Published; content is None in DB
let status = TemplateStatus::Published; let status = TemplateStatus::Published;
@@ -100,7 +97,7 @@ pub(crate) fn rebuild_single_template(
tpl.status = status; tpl.status = status;
tpl.content = None; tpl.content = None;
tpl.created_at = fm.created_at; tpl.created_at = fm.created_at;
tpl.updated_at = now; tpl.updated_at = fm.updated_at;
qt::update_template(conn, &tpl)?; qt::update_template(conn, &tpl)?;
Ok(false) Ok(false)
} }
@@ -117,7 +114,7 @@ pub(crate) fn rebuild_single_template(
status, status,
content: None, content: None,
created_at: fm.created_at, created_at: fm.created_at,
updated_at: now, updated_at: fm.updated_at,
}; };
qt::insert_template(conn, &tpl)?; qt::insert_template(conn, &tpl)?;
Ok(true) Ok(true)
@@ -236,6 +233,7 @@ updatedAt: \"2024-01-01T00:00:00.000Z\"
assert_eq!(tpl.version, 3); assert_eq!(tpl.version, 3);
assert_eq!(tpl.status, TemplateStatus::Published); assert_eq!(tpl.status, TemplateStatus::Published);
assert!(tpl.content.is_none()); assert!(tpl.content.is_none());
assert_eq!(tpl.updated_at, 1_704_067_200_000);
} }
#[test] #[test]

View File

@@ -0,0 +1,199 @@
use std::fs;
use bds_core::engine::git::{FileStatusKind, GitEngine};
fn git(dir: &std::path::Path, args: &[&str]) {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_text(dir: &std::path::Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
#[test]
fn status_diff_history_and_missing_diff_sides_follow_the_spec() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-b", "master"]);
git(dir.path(), &["config", "user.name", "RuDS Test"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
fs::write(dir.path().join("kept.txt"), "before\n").unwrap();
fs::write(dir.path().join("deleted.txt"), "gone\n").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-m", "initial"]);
fs::write(dir.path().join("kept.txt"), "after\n").unwrap();
fs::remove_file(dir.path().join("deleted.txt")).unwrap();
fs::write(dir.path().join("added.txt"), "new\n").unwrap();
let engine = GitEngine::new(dir.path());
let status = engine.status().unwrap();
assert!(
status
.iter()
.any(|file| { file.path == "kept.txt" && file.kind == FileStatusKind::Modified })
);
assert!(
status
.iter()
.any(|file| { file.path == "deleted.txt" && file.kind == FileStatusKind::Deleted })
);
assert!(
status
.iter()
.any(|file| { file.path == "added.txt" && file.kind == FileStatusKind::Untracked })
);
let diff = engine.diff().unwrap();
assert!(diff.unstaged.contains("-before"));
assert!(diff.unstaged.contains("+after"));
let added = engine.file_diff("added.txt").unwrap();
assert_eq!(added.original, "");
assert_eq!(added.modified, "new\n");
let deleted = engine.file_diff("deleted.txt").unwrap();
assert_eq!(deleted.original, "gone\n");
assert_eq!(deleted.modified, "");
assert_eq!(engine.file_history("kept.txt").unwrap().len(), 1);
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-m", "working tree changes"]);
let hash = git_text(dir.path(), &["rev-parse", "HEAD"]);
let changes = engine.commit_files(&hash).unwrap();
let added_change = changes
.iter()
.find(|change| change.path == "added.txt")
.unwrap();
let deleted_change = changes
.iter()
.find(|change| change.path == "deleted.txt")
.unwrap();
let added = engine.commit_file_diff(&hash, added_change).unwrap();
let deleted = engine.commit_file_diff(&hash, deleted_change).unwrap();
assert_eq!(
(added.original.as_str(), added.modified.as_str()),
("", "new\n")
);
assert_eq!(
(deleted.original.as_str(), deleted.modified.as_str()),
("gone\n", "")
);
}
#[test]
fn commit_rejects_blank_messages_before_staging() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-b", "master"]);
fs::write(dir.path().join("untracked.txt"), "content\n").unwrap();
let error = GitEngine::new(dir.path()).commit_all(" \n\t ").unwrap_err();
assert!(error.to_string().contains("commit message"));
let output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(output.stdout.is_empty());
}
#[test]
fn remote_state_history_fetch_and_fast_forward_pull_use_real_refs() {
let root = tempfile::tempdir().unwrap();
let remote = root.path().join("remote.git");
let local = root.path().join("local");
let peer = root.path().join("peer");
fs::create_dir(&local).unwrap();
git(
root.path(),
&["init", "--bare", "-b", "master", remote.to_str().unwrap()],
);
git(&local, &["init", "-b", "master"]);
git(&local, &["config", "user.name", "Local"]);
git(&local, &["config", "user.email", "local@example.com"]);
fs::write(local.join("post.md"), "one\n").unwrap();
git(&local, &["add", "-A"]);
git(&local, &["commit", "-m", "local one"]);
let engine = GitEngine::new(&local);
engine.set_remote(remote.to_str().unwrap()).unwrap();
git(&local, &["push", "-u", "origin", "master"]);
git(
root.path(),
&["clone", remote.to_str().unwrap(), peer.to_str().unwrap()],
);
git(&peer, &["config", "user.name", "Peer"]);
git(&peer, &["config", "user.email", "peer@example.com"]);
fs::write(peer.join("post.md"), "two\n").unwrap();
git(&peer, &["add", "-A"]);
git(&peer, &["commit", "-m", "remote two"]);
git(&peer, &["push"]);
let mut streamed = String::new();
engine
.fetch(|| false, |chunk| streamed.push_str(&chunk.text))
.unwrap();
let state = engine.remote_state().unwrap();
assert_eq!(state.behind, 1);
assert_eq!(state.ahead, 0);
assert!(
engine
.history("master")
.unwrap()
.iter()
.any(|commit| commit.subject.as_deref() == Some("remote two")
&& commit.sync_status == bds_core::engine::git::SyncStatus::RemoteOnly)
);
engine.pull(|| false, |_| {}).unwrap();
let state = engine.remote_state().unwrap();
assert_eq!((state.ahead, state.behind), (0, 0));
assert_eq!(fs::read_to_string(local.join("post.md")).unwrap(), "two\n");
}
#[test]
fn file_history_follows_renames_and_is_limited_to_fifty_commits() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "-b", "master"]);
git(dir.path(), &["config", "user.name", "RuDS Test"]);
git(dir.path(), &["config", "user.email", "test@example.com"]);
fs::write(dir.path().join("old.txt"), "0\n").unwrap();
git(dir.path(), &["add", "-A"]);
git(dir.path(), &["commit", "-m", "old name"]);
git(dir.path(), &["mv", "old.txt", "new.txt"]);
git(dir.path(), &["commit", "-m", "renamed"]);
for index in 1..=49 {
fs::write(dir.path().join("new.txt"), format!("{index}\n")).unwrap();
git(dir.path(), &["add", "new.txt"]);
git(dir.path(), &["commit", "-m", &format!("change {index}")]);
}
let history = GitEngine::new(dir.path()).file_history("new.txt").unwrap();
assert_eq!(history.len(), 50);
assert_eq!(history[0].subject.as_deref(), Some("change 49"));
assert!(
history
.iter()
.any(|commit| commit.subject.as_deref() == Some("renamed"))
);
}

View File

@@ -31,6 +31,7 @@ use crate::views::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth, DashboardTimelineMonth,
}, },
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState}, media_editor::{LinkedPostItem, MediaEditorMsg, MediaEditorState},
metadata_diff::MetadataDiffState, metadata_diff::MetadataDiffState,
modal, modal,
@@ -47,6 +48,7 @@ use crate::views::{
mod editor_handlers; mod editor_handlers;
mod engine_handlers; mod engine_handlers;
mod git_handlers;
mod preview_handlers; mod preview_handlers;
mod search; mod search;
mod tasks; mod tasks;
@@ -213,6 +215,52 @@ pub enum Message {
}, },
SiteValidationLoaded(Result<engine::validate_site::SiteValidationReport, String>), 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 // Editor views
PostEditor(PostEditorMsg), PostEditor(PostEditorMsg),
MediaEditor(MediaEditorMsg), MediaEditor(MediaEditorMsg),
@@ -823,6 +871,10 @@ pub struct BdsApp {
site_validation_state: SiteValidationState, site_validation_state: SiteValidationState,
metadata_diff_state: MetadataDiffState, metadata_diff_state: MetadataDiffState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState, 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(), site_validation_state: SiteValidationState::default(),
metadata_diff_state: MetadataDiffState::default(), metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::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, init_task,
) )
@@ -1028,6 +1084,10 @@ impl BdsApp {
site_validation_state: SiteValidationState::default(), site_validation_state: SiteValidationState::default(),
metadata_diff_state: MetadataDiffState::default(), metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::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); && matches!(new_view, SidebarView::Posts | SidebarView::Pages);
if needs_post_refresh { if needs_post_refresh {
self.refresh_sidebar_posts() self.refresh_sidebar_posts()
} else if new_view == SidebarView::Git && new_visible {
self.refresh_git()
} else { } else {
Task::none() Task::none()
} }
@@ -1125,6 +1187,7 @@ impl BdsApp {
} else { } else {
self.active_tab = None; self.active_tab = None;
} }
self.git_diffs.remove(&id);
self.enforce_panel_tab_fallback(); self.enforce_panel_tab_fallback();
self.sync_menu_state(); self.sync_menu_state();
self.sync_embedded_preview_for_active_post() self.sync_embedded_preview_for_active_post()
@@ -1147,6 +1210,7 @@ impl BdsApp {
self.flush_active_post_editor(); self.flush_active_post_editor();
self.tabs.clear(); self.tabs.clear();
self.active_tab = None; self.active_tab = None;
self.git_diffs.clear();
self.hide_embedded_preview(); self.hide_embedded_preview();
Task::none() Task::none()
} }
@@ -1190,6 +1254,7 @@ impl BdsApp {
if let Some(ref db) = self.db { if let Some(ref db) = self.db {
match engine::project::set_active_project(db.conn(), &project_id) { match engine::project::set_active_project(db.conn(), &project_id) {
Ok(()) => { Ok(()) => {
self.reset_git_for_project_change();
self.active_project = self.active_project =
self.projects.iter().find(|p| p.id == project_id).cloned(); self.projects.iter().find(|p| p.id == project_id).cloned();
self.preview_session = None; self.preview_session = None;
@@ -1235,7 +1300,7 @@ impl BdsApp {
} }
} }
self.sync_menu_state(); self.sync_menu_state();
Task::none() self.refresh_git_if_visible()
} }
Message::ProjectSwitched(result) => { Message::ProjectSwitched(result) => {
match result { match result {
@@ -1266,6 +1331,7 @@ impl BdsApp {
let _ = engine::project::set_active_project(db.conn(), &project.id); let _ = engine::project::set_active_project(db.conn(), &project.id);
self.projects = self.projects =
engine::project::list_projects(db.conn()).unwrap_or_default(); engine::project::list_projects(db.conn()).unwrap_or_default();
self.reset_git_for_project_change();
self.active_project = Some(project.clone()); self.active_project = Some(project.clone());
self.preview_session = None; self.preview_session = None;
self.data_dir = project.data_path.as_ref().map(PathBuf::from); 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) => { Message::ProjectCreated(result) => {
match result { match result {
@@ -1354,6 +1420,7 @@ impl BdsApp {
let _ = engine::project::set_active_project(db.conn(), &project.id); let _ = engine::project::set_active_project(db.conn(), &project.id);
self.projects = self.projects =
engine::project::list_projects(db.conn()).unwrap_or_default(); engine::project::list_projects(db.conn()).unwrap_or_default();
self.reset_git_for_project_change();
self.active_project = Some(project.clone()); self.active_project = Some(project.clone());
self.data_dir = project.data_path.as_ref().map(PathBuf::from); self.data_dir = project.data_path.as_ref().map(PathBuf::from);
self.preview_session = None; self.preview_session = None;
@@ -1367,7 +1434,10 @@ impl BdsApp {
), ),
); );
self.sync_menu_state(); self.sync_menu_state();
return self.refresh_counts(); return Task::batch([
self.refresh_counts(),
self.refresh_git_if_visible(),
]);
} }
Err(error) => self.notify( Err(error) => self.notify(
ToastLevel::Error, ToastLevel::Error,
@@ -1817,8 +1887,12 @@ impl BdsApp {
// ── Panel ── // ── Panel ──
Message::SetPanelTab(tab) => { Message::SetPanelTab(tab) => {
self.panel_tab = tab; self.panel_tab = tab;
if tab == PanelTab::GitLog {
self.refresh_git_file_history()
} else {
Task::none() Task::none()
} }
}
// ── Settings ── // ── Settings ──
Message::SetOfflineMode(mode) => { Message::SetOfflineMode(mode) => {
@@ -1868,6 +1942,26 @@ impl BdsApp {
| Message::SiteGenerationIndexDone { .. } | Message::SiteGenerationIndexDone { .. }
| Message::SiteValidationLoaded(_)) => self.handle_engine_message(message), | 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 ── // ── Toasts ──
Message::ShowToast(level, msg) => { Message::ShowToast(level, msg) => {
self.toasts.push(Toast::new(level, msg)); self.toasts.push(Toast::new(level, msg));
@@ -2172,6 +2266,9 @@ impl BdsApp {
&self.site_validation_state, &self.site_validation_state,
&self.metadata_diff_state, &self.metadata_diff_state,
&self.translation_validation_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_editor_settings_state_impl, save_script_editor_state_impl,
save_template_editor_state_impl, save_template_editor_state_impl,
}; };
use crate::i18n::t;
use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::modal; use crate::views::modal;
@@ -6214,6 +6312,7 @@ mod tests {
use bds_core::engine::generation::GenerationReport; use bds_core::engine::generation::GenerationReport;
use bds_core::engine::task::{TaskStatus, TaskStatus::*}; use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{ai, media, post, script, template}; use bds_core::engine::{ai, media, post, script, template};
use bds_core::i18n::UiLocale;
use bds_core::model::{Project, ScriptKind, TemplateKind}; use bds_core::model::{Project, ScriptKind, TemplateKind};
use chrono::{Datelike, TimeZone}; use chrono::{Datelike, TimeZone};
use std::io::{Read, Write}; 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] #[test]
fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() { fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() {
let (db, project, tmp) = setup(); 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 activity_bar;
pub mod dashboard; pub mod dashboard;
pub mod git;
pub mod media_editor; pub mod media_editor;
pub mod metadata_diff; pub mod metadata_diff;
pub mod modal; 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::widget::{Space, button, column, container, row, scrollable, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
@@ -109,6 +110,7 @@ pub fn view(
locale: UiLocale, locale: UiLocale,
active_tab_is_post: bool, active_tab_is_post: bool,
active_tab_is_post_or_media: bool, active_tab_is_post_or_media: bool,
git_file_history: &[GitCommit],
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let muted = Color::from_rgb(0.50, 0.50, 0.55); let muted = Color::from_rgb(0.50, 0.50, 0.55);
@@ -349,15 +351,45 @@ pub fn view(
} }
} }
PanelTab::GitLog => { PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration) if git_file_history.is_empty() {
container( container(
text(t(locale, "panel.gitLogPlaceholder")) text(t(locale, "git.noFileHistory"))
.size(12) .size(12)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(muted), .color(muted),
) )
.padding(8) .padding(8)
.into() .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::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter}; use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType}; use crate::state::tabs::{Tab, TabType};
use crate::views::git::GitUiState;
/// Sidebar container style — dark background. /// Sidebar container style — dark background.
fn sidebar_style(_theme: &Theme) -> container::Style { fn sidebar_style(_theme: &Theme) -> container::Style {
@@ -79,7 +80,7 @@ fn placeholder_key(view: SidebarView) -> &'static str {
SidebarView::Tags => "sidebar.tagsHeader", SidebarView::Tags => "sidebar.tagsHeader",
SidebarView::Chat => "sidebar.chatPlaceholder", SidebarView::Chat => "sidebar.chatPlaceholder",
SidebarView::Import => "sidebar.importPlaceholder", SidebarView::Import => "sidebar.importPlaceholder",
SidebarView::Git => "sidebar.gitPlaceholder", SidebarView::Git => "git.noChanges",
SidebarView::Settings => "sidebar.settingsHeader", SidebarView::Settings => "sidebar.settingsHeader",
} }
} }
@@ -635,6 +636,8 @@ pub fn view(
active_tab: Option<&str>, active_tab: Option<&str>,
locale: UiLocale, locale: UiLocale,
_data_dir: Option<&Path>, _data_dir: Option<&Path>,
git_state: &GitUiState,
offline_mode: bool,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key()); let header_text = t(locale, sidebar_view.i18n_key());
let muted = Color::from_rgb(0.50, 0.50, 0.55); let muted = Color::from_rgb(0.50, 0.50, 0.55);
@@ -1140,6 +1143,7 @@ pub fn view(
.collect(); .collect();
iced::widget::Column::with_children(items).spacing(1).into() 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))) _ => text(t(locale, placeholder_key(sidebar_view)))
.size(12) .size(12)
.shaping(Shaping::Advanced) .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::widget::{Space, button, column, container, mouse_area, row, stack, text};
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Project, Script, Template}; use bds_core::model::{Media, Post, Project, Script, Template};
@@ -16,6 +17,7 @@ use crate::state::toast::Toast;
use crate::views::{ use crate::views::{
activity_bar, activity_bar,
dashboard::DashboardState, dashboard::DashboardState,
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState}, media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState}, metadata_diff::{self, MetadataDiffState},
modal, panel, modal, panel,
@@ -123,6 +125,9 @@ pub fn view<'a>(
site_validation_state: &'a SiteValidationState, site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState, metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState, translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
git_diffs: &'a HashMap<String, GitDiffState>,
git_file_history: &'a [GitCommit],
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// Activity bar (leftmost column) // Activity bar (leftmost column)
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
@@ -148,6 +153,7 @@ pub fn view<'a>(
site_validation_state, site_validation_state,
metadata_diff_state, metadata_diff_state,
translation_validation_state, translation_validation_state,
git_diffs,
); );
// Right column: tab bar + content + panel // Right column: tab bar + content + panel
@@ -176,6 +182,7 @@ pub fn view<'a>(
locale, locale,
active_tab_is_post, active_tab_is_post,
active_tab_is_post_or_media, active_tab_is_post_or_media,
git_file_history,
)); ));
} }
let right = container(right_col.width(Length::Fill).height(Length::Fill)) let right = container(right_col.width(Length::Fill).height(Length::Fill))
@@ -202,6 +209,8 @@ pub fn view<'a>(
active_tab, active_tab,
locale, locale,
data_dir, data_dir,
git_state,
offline_mode,
)); ));
// Resize drag handle: 4px wide strip between sidebar and content // Resize drag handle: 4px wide strip between sidebar and content
@@ -379,6 +388,7 @@ fn route_content_area<'a>(
site_validation_state: &'a SiteValidationState, site_validation_state: &'a SiteValidationState,
metadata_diff_state: &'a MetadataDiffState, metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState, translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
match route_kind( match route_kind(
tabs, tabs,
@@ -454,6 +464,19 @@ fn route_content_area<'a>(
ContentRoute::TranslationValidation => { ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale) 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), ContentRoute::Placeholder(title) => welcome::tab_placeholder(locale, title, None),
} }
} }
@@ -472,6 +495,7 @@ enum ContentRoute<'a> {
SiteValidation, SiteValidation,
MetadataDiff, MetadataDiff,
TranslationValidation, TranslationValidation,
GitDiff(&'a str),
Placeholder(&'a str), Placeholder(&'a str),
} }
@@ -547,11 +571,11 @@ fn route_kind<'a>(
} }
TabType::SiteValidation => ContentRoute::SiteValidation, TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff, TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::Style TabType::Style
| TabType::Chat | TabType::Chat
| TabType::Import | TabType::Import
| TabType::MenuEditor | TabType::MenuEditor
| TabType::GitDiff
| TabType::Documentation | TabType::Documentation
| TabType::ApiDocumentation | TabType::ApiDocumentation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title), | TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
@@ -615,7 +639,6 @@ mod tests {
TabType::Chat, TabType::Chat,
TabType::Import, TabType::Import,
TabType::MenuEditor, TabType::MenuEditor,
TabType::GitDiff,
TabType::Documentation, TabType::Documentation,
TabType::ApiDocumentation, TabType::ApiDocumentation,
TabType::FindDuplicates, 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] #[test]
fn validation_tabs_route_to_real_views() { fn validation_tabs_route_to_real_views() {
let empty_posts = HashMap::new(); let empty_posts = HashMap::new();

View File

@@ -65,7 +65,6 @@ panel-output = Ausgabe
panel-postLinks = Beitragsverknüpfungen panel-postLinks = Beitragsverknüpfungen
panel-gitLog = Git-Verlauf panel-gitLog = Git-Verlauf
panel-postLinksPlaceholder = Keine Beitragsverknüpfungen vorhanden panel-postLinksPlaceholder = Keine Beitragsverknüpfungen vorhanden
panel-gitLogPlaceholder = Kein Git-Verlauf vorhanden
panel-closeTitle = Panel schließen panel-closeTitle = Panel schließen
panel-noRecentTasks = Keine aktuellen Aufgaben panel-noRecentTasks = Keine aktuellen Aufgaben
panel-noOutput = Keine Ausgabe panel-noOutput = Keine Ausgabe
@@ -123,7 +122,6 @@ sidebar-noScriptsYet = Noch keine Skripte
sidebar-noTemplatesYet = Noch keine Vorlagen sidebar-noTemplatesYet = Noch keine Vorlagen
sidebar-chatPlaceholder = KI-Assistent erscheint hier sidebar-chatPlaceholder = KI-Assistent erscheint hier
sidebar-importPlaceholder = Inhalte aus externen Quellen importieren sidebar-importPlaceholder = Inhalte aus externen Quellen importieren
sidebar-gitPlaceholder = Versionskontrolle-Integration
sidebar-loading = Lädt... sidebar-loading = Lädt...
sidebar-settingsHeader = Einstellungen sidebar-settingsHeader = Einstellungen
sidebar-tagsHeader = Schlagwörter sidebar-tagsHeader = Schlagwörter
@@ -489,3 +487,32 @@ find-replace = Ersetzen
find-replaceAll = Alle ersetzen find-replaceAll = Alle ersetzen
find-noMatches = Keine Treffer gefunden find-noMatches = Keine Treffer gefunden
find-replacedCount = { $count } Treffer ersetzt find-replacedCount = { $count } Treffer ersetzt
git-loading = Repository wird geladen…
git-notRepository = Dieses Projekt ist noch kein Git-Repository.
git-remoteOptional = Remote-URL (optional)
git-initialize = Git initialisieren
git-fetch = Abrufen
git-pull = Herunterladen
git-push = Hochladen
git-airplaneBlocked = Netzwerk-Git-Aktionen sind im Flugmodus nicht verfügbar.
git-remoteUrl = Origin-URL
git-saveRemote = Speichern
git-changesCount = Änderungen ({ $count })
git-commitMessage = Commit-Nachricht
git-commit = Commit
git-noChanges = Keine Änderungen
git-history = Verlauf
git-noCommits = Noch keine Commits
git-pruneLfs = LFS-Cache bereinigen
git-liveOutput = Live-Ausgabe
git-diff = Git-Vergleich
git-commitDiffTitle = Commit { $hash }
git-readOnly = Schreibgeschützter Git-Vergleich
git-loadingDiff = Vergleich wird geladen…
git-original = Original
git-modified = Geändert
git-inlineDiff = Inline-Vergleich
git-noDiff = Keine Unterschiede
git-noFileHistory = Kein Verlauf für diese Datei
git-operationDone = Git { $operation } abgeschlossen
git-runningOperation = Git { $operation }

View File

@@ -65,7 +65,6 @@ panel-output = Output
panel-postLinks = Post Links panel-postLinks = Post Links
panel-gitLog = Git Log panel-gitLog = Git Log
panel-postLinksPlaceholder = No post links to display panel-postLinksPlaceholder = No post links to display
panel-gitLogPlaceholder = No git history to display
panel-closeTitle = Close panel panel-closeTitle = Close panel
panel-noRecentTasks = No recent tasks panel-noRecentTasks = No recent tasks
panel-noOutput = No output panel-noOutput = No output
@@ -123,7 +122,6 @@ sidebar-noScriptsYet = No scripts yet
sidebar-noTemplatesYet = No templates yet sidebar-noTemplatesYet = No templates yet
sidebar-chatPlaceholder = AI assistant will appear here sidebar-chatPlaceholder = AI assistant will appear here
sidebar-importPlaceholder = Import content from external sources sidebar-importPlaceholder = Import content from external sources
sidebar-gitPlaceholder = Source control integration
sidebar-loading = Loading... sidebar-loading = Loading...
sidebar-settingsHeader = Settings sidebar-settingsHeader = Settings
sidebar-tagsHeader = Tags sidebar-tagsHeader = Tags
@@ -489,3 +487,32 @@ find-replace = Replace
find-replaceAll = Replace All find-replaceAll = Replace All
find-noMatches = No matches found find-noMatches = No matches found
find-replacedCount = Replaced { $count } matches find-replacedCount = Replaced { $count } matches
git-loading = Loading repository…
git-notRepository = This project is not a Git repository yet.
git-remoteOptional = Remote URL (optional)
git-initialize = Initialize Git
git-fetch = Fetch
git-pull = Pull
git-push = Push
git-airplaneBlocked = Network Git actions are unavailable in airplane mode.
git-remoteUrl = Origin URL
git-saveRemote = Save
git-changesCount = Changes ({ $count })
git-commitMessage = Commit message
git-commit = Commit
git-noChanges = No changes
git-history = History
git-noCommits = No commits yet
git-pruneLfs = Prune LFS cache
git-liveOutput = Live output
git-diff = Git Diff
git-commitDiffTitle = Commit { $hash }
git-readOnly = Read-only Git comparison
git-loadingDiff = Loading diff…
git-original = Original
git-modified = Modified
git-inlineDiff = Inline diff
git-noDiff = No differences
git-noFileHistory = No history for this file
git-operationDone = Git { $operation } completed
git-runningOperation = Git { $operation }

View File

@@ -65,7 +65,6 @@ panel-output = Salida
panel-postLinks = Enlaces de entradas panel-postLinks = Enlaces de entradas
panel-gitLog = Historial de Git panel-gitLog = Historial de Git
panel-postLinksPlaceholder = No hay enlaces de entradas para mostrar panel-postLinksPlaceholder = No hay enlaces de entradas para mostrar
panel-gitLogPlaceholder = No hay historial de Git para mostrar
panel-closeTitle = Cerrar panel panel-closeTitle = Cerrar panel
panel-noRecentTasks = No hay tareas recientes panel-noRecentTasks = No hay tareas recientes
panel-noOutput = Sin salida panel-noOutput = Sin salida
@@ -123,7 +122,6 @@ sidebar-noScriptsYet = Aún no hay scripts
sidebar-noTemplatesYet = Aún no hay plantillas sidebar-noTemplatesYet = Aún no hay plantillas
sidebar-chatPlaceholder = El asistente IA aparecerá aquí sidebar-chatPlaceholder = El asistente IA aparecerá aquí
sidebar-importPlaceholder = Importar contenido desde fuentes externas sidebar-importPlaceholder = Importar contenido desde fuentes externas
sidebar-gitPlaceholder = Integración del control de código fuente
sidebar-loading = Cargando... sidebar-loading = Cargando...
sidebar-settingsHeader = Configuración sidebar-settingsHeader = Configuración
sidebar-tagsHeader = Etiquetas sidebar-tagsHeader = Etiquetas
@@ -489,3 +487,32 @@ find-replace = Reemplazar
find-replaceAll = Reemplazar todo find-replaceAll = Reemplazar todo
find-noMatches = No se encontraron coincidencias find-noMatches = No se encontraron coincidencias
find-replacedCount = Se reemplazaron { $count } coincidencias find-replacedCount = Se reemplazaron { $count } coincidencias
git-loading = Cargando repositorio…
git-notRepository = Este proyecto aún no es un repositorio Git.
git-remoteOptional = URL remota (opcional)
git-initialize = Inicializar Git
git-fetch = Obtener
git-pull = Descargar
git-push = Subir
git-airplaneBlocked = Las acciones Git de red no están disponibles en modo avión.
git-remoteUrl = URL de origin
git-saveRemote = Guardar
git-changesCount = Cambios ({ $count })
git-commitMessage = Mensaje de commit
git-commit = Commit
git-noChanges = Sin cambios
git-history = Historial
git-noCommits = Aún no hay commits
git-pruneLfs = Limpiar caché LFS
git-liveOutput = Salida en directo
git-diff = Comparación Git
git-commitDiffTitle = Commit { $hash }
git-readOnly = Comparación Git de solo lectura
git-loadingDiff = Cargando comparación…
git-original = Original
git-modified = Modificado
git-inlineDiff = Comparación en línea
git-noDiff = Sin diferencias
git-noFileHistory = No hay historial para este archivo
git-operationDone = Git { $operation } completado
git-runningOperation = Git { $operation }

View File

@@ -65,7 +65,6 @@ panel-output = Sortie
panel-postLinks = Liens d'articles panel-postLinks = Liens d'articles
panel-gitLog = Historique Git panel-gitLog = Historique Git
panel-postLinksPlaceholder = Aucun lien d'article à afficher panel-postLinksPlaceholder = Aucun lien d'article à afficher
panel-gitLogPlaceholder = Aucun historique Git à afficher
panel-closeTitle = Fermer le panneau panel-closeTitle = Fermer le panneau
panel-noRecentTasks = Aucune tâche récente panel-noRecentTasks = Aucune tâche récente
panel-noOutput = Aucune sortie panel-noOutput = Aucune sortie
@@ -123,7 +122,6 @@ sidebar-noScriptsYet = Aucun script
sidebar-noTemplatesYet = Aucun modèle sidebar-noTemplatesYet = Aucun modèle
sidebar-chatPlaceholder = L'assistant IA apparaîtra ici sidebar-chatPlaceholder = L'assistant IA apparaîtra ici
sidebar-importPlaceholder = Importer du contenu depuis des sources externes sidebar-importPlaceholder = Importer du contenu depuis des sources externes
sidebar-gitPlaceholder = Intégration du contrôle de source
sidebar-loading = Chargement... sidebar-loading = Chargement...
sidebar-settingsHeader = Paramètres sidebar-settingsHeader = Paramètres
sidebar-tagsHeader = Étiquettes sidebar-tagsHeader = Étiquettes
@@ -489,3 +487,32 @@ find-replace = Remplacer
find-replaceAll = Tout remplacer find-replaceAll = Tout remplacer
find-noMatches = Aucun résultat find-noMatches = Aucun résultat
find-replacedCount = { $count } résultats remplacés find-replacedCount = { $count } résultats remplacés
git-loading = Chargement du dépôt…
git-notRepository = Ce projet nest pas encore un dépôt Git.
git-remoteOptional = URL distante (facultative)
git-initialize = Initialiser Git
git-fetch = Récupérer
git-pull = Tirer
git-push = Pousser
git-airplaneBlocked = Les actions Git réseau sont indisponibles en mode avion.
git-remoteUrl = URL dorigine
git-saveRemote = Enregistrer
git-changesCount = Modifications ({ $count })
git-commitMessage = Message de commit
git-commit = Commit
git-noChanges = Aucune modification
git-history = Historique
git-noCommits = Aucun commit
git-pruneLfs = Nettoyer le cache LFS
git-liveOutput = Sortie en direct
git-diff = Comparaison Git
git-commitDiffTitle = Commit { $hash }
git-readOnly = Comparaison Git en lecture seule
git-loadingDiff = Chargement de la comparaison…
git-original = Original
git-modified = Modifié
git-inlineDiff = Comparaison intégrée
git-noDiff = Aucune différence
git-noFileHistory = Aucun historique pour ce fichier
git-operationDone = Git { $operation } terminé
git-runningOperation = Git { $operation }

View File

@@ -65,7 +65,6 @@ panel-output = Uscita
panel-postLinks = Link ai post panel-postLinks = Link ai post
panel-gitLog = Cronologia Git panel-gitLog = Cronologia Git
panel-postLinksPlaceholder = Nessun link ai post da visualizzare panel-postLinksPlaceholder = Nessun link ai post da visualizzare
panel-gitLogPlaceholder = Nessuna cronologia Git da visualizzare
panel-closeTitle = Chiudi pannello panel-closeTitle = Chiudi pannello
panel-noRecentTasks = Nessuna attività recente panel-noRecentTasks = Nessuna attività recente
panel-noOutput = Nessun output panel-noOutput = Nessun output
@@ -123,7 +122,6 @@ sidebar-noScriptsYet = Nessuno script
sidebar-noTemplatesYet = Nessun modello sidebar-noTemplatesYet = Nessun modello
sidebar-chatPlaceholder = L'assistente IA apparirà qui sidebar-chatPlaceholder = L'assistente IA apparirà qui
sidebar-importPlaceholder = Importa contenuti da fonti esterne sidebar-importPlaceholder = Importa contenuti da fonti esterne
sidebar-gitPlaceholder = Integrazione del controllo sorgente
sidebar-loading = Caricamento... sidebar-loading = Caricamento...
sidebar-settingsHeader = Impostazioni sidebar-settingsHeader = Impostazioni
sidebar-tagsHeader = Tag sidebar-tagsHeader = Tag
@@ -489,3 +487,32 @@ find-replace = Sostituisci
find-replaceAll = Sostituisci tutto find-replaceAll = Sostituisci tutto
find-noMatches = Nessuna corrispondenza find-noMatches = Nessuna corrispondenza
find-replacedCount = Sostituite { $count } corrispondenze find-replacedCount = Sostituite { $count } corrispondenze
git-loading = Caricamento del repository…
git-notRepository = Questo progetto non è ancora un repository Git.
git-remoteOptional = URL remoto (facoltativo)
git-initialize = Inizializza Git
git-fetch = Recupera
git-pull = Scarica
git-push = Carica
git-airplaneBlocked = Le azioni Git di rete non sono disponibili in modalità aereo.
git-remoteUrl = URL origin
git-saveRemote = Salva
git-changesCount = Modifiche ({ $count })
git-commitMessage = Messaggio di commit
git-commit = Commit
git-noChanges = Nessuna modifica
git-history = Cronologia
git-noCommits = Nessun commit
git-pruneLfs = Pulisci cache LFS
git-liveOutput = Output in tempo reale
git-diff = Confronto Git
git-commitDiffTitle = Commit { $hash }
git-readOnly = Confronto Git in sola lettura
git-loadingDiff = Caricamento confronto…
git-original = Originale
git-modified = Modificato
git-inlineDiff = Confronto in linea
git-noDiff = Nessuna differenza
git-noFileHistory = Nessuna cronologia per questo file
git-operationDone = Git { $operation } completato
git-runningOperation = Git { $operation }