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