diff --git a/.agents/plans/PLANS.md b/.agents/plans/PLANS.md
index 06612d4..4949812 100644
--- a/.agents/plans/PLANS.md
+++ b/.agents/plans/PLANS.md
@@ -10,7 +10,7 @@ Persistent plans for multi-step work on **blxcode**. Individual plans live as Ma
| planned | [coordinated-subagents.md](coordinated-subagents.md) | Coordinated Subagents: Rollen, i18n Subcards, Provider-Reuse, Environment/Shell/Git/Web-Tools, scoped Toolgruppen, Inline-Timeline |
| planned | [kanban-board-view.md](kanban-board-view.md) | Kanban-View im Plans-Panel: Status-Spalten, DnD fuer Karten/Spalten, Spalten ein-/ausblenden, Markdown-Writeback |
| planned | [terminal-grid-drag-drop.md](terminal-grid-drag-drop.md) | Terminal-Slots per Drag-Handle im Grid umsortieren; Cross-Workspace-Transfer per Sidebar-Drop mit PTY-Erhalt, Session- und Notification-Migration |
-| planned | [file-browser-rich-preview.md](file-browser-rich-preview.md) | File-Browser-Preview: Bilder (SVG/Raster), Video, gerendertes Markdown, Mermaid; neue Topbar mit Datei-Metadaten und sandboxed Backend-Commands |
+| active | [file-browser-rich-preview.md](file-browser-rich-preview.md) | File-Browser-Preview: Bilder (SVG/Raster), Video, gerendertes Markdown, Mermaid; neue Topbar mit Datei-Metadaten und sandboxed Backend-Commands |
| done | [agent-chat-maximize.md](agent-chat-maximize.md) | Agent-Tab: Chat-Maximize-Toggle vor Reset; Voice-Hero kompakt, mehr Platz fuer Chat-Verlauf |
| done | [agent-image-context.md](agent-image-context.md) | Bilder per Drag/Drop und Paste an Agent-Kontext; Drop-Zone, Preview-Dialog, einmaliges Senden, dann read |
| done | [agent-image-modus.md](agent-image-modus.md) | Agent Image-Modus: Chat-Toggle, Settings-Tab Image, Referenzbilder, Generierung via OpenAI/OpenRouter, Workspace-Speicherung und Download |
diff --git a/.agents/plans/file-browser-rich-preview.md b/.agents/plans/file-browser-rich-preview.md
index cc97704..dccd186 100644
--- a/.agents/plans/file-browser-rich-preview.md
+++ b/.agents/plans/file-browser-rich-preview.md
@@ -216,19 +216,27 @@ Erinnerung aus `CLAUDE.md`: Alle Sprach-Locale-Files müssen die neuen Keys habe
## Tasks
-- [ ] `backend-meta` - `FileMeta`, `FileKind`, `stat_workspace_file` Command + Unit-Tests
-- [ ] `backend-image` - `BinaryFilePreview` + `read_workspace_image_file` + Cap + Tests
-- [ ] `backend-video` - `read_workspace_video_file` + Cap + Tests
-- [ ] `backend-register` - Commands in `lib.rs` registrieren
-- [ ] `bridge-types` - Serde-Spiegel + async Wrapper in `tauri_bridge.rs`
-- [ ] `frontend-module` - Neues `file_preview/`-Modul mit `mod.rs` Dispatcher + `util.rs` (classify, format)
-- [ ] `frontend-header` - Topbar mit Name/Pfad/Größe/mtime + Refresh
-- [ ] `frontend-image` - SVG-Inline + Raster-`
` mit Data-URL + Centered-Stage
-- [ ] `frontend-video` - `
"#;
+ let out = sanitize_markdown_html(html);
+ assert!(!out.contains("iframe"));
+ assert!(!out.contains("onclick"));
+ assert!(out.contains("x"#;
+ let out = sanitize_markdown_html(html);
+ assert!(!out.contains("javascript:"));
+ }
+
+ #[test]
+ fn sanitize_markdown_preserves_utf8_codepoints() {
+ let html = "
Hallo, schöne Grüße für €-Land — 你好 ✓
";
+ let out = sanitize_markdown_html(html);
+ assert!(out.contains("schöne"));
+ assert!(out.contains("Grüße"));
+ assert!(out.contains("für"));
+ assert!(out.contains("€"));
+ assert!(out.contains("你好"));
+ assert!(out.contains("✓"));
+ assert!(!out.contains("ü"));
+ assert!(!out.contains("ö"));
+ }
+
+ #[test]
+ fn sanitize_svg_preserves_utf8_codepoints() {
+ let svg = r#""#;
+ let out = sanitize_svg(svg);
+ assert!(out.contains("für"));
+ assert!(out.contains("€"));
+ assert!(!out.contains("ü"));
+ }
+
+ #[test]
+ fn hljs_lang_for_ext_covers_common_languages() {
+ assert_eq!(hljs_lang_for_ext("rs"), Some("rust"));
+ assert_eq!(hljs_lang_for_ext("ts"), Some("typescript"));
+ assert_eq!(hljs_lang_for_ext("tsx"), Some("typescript"));
+ assert_eq!(hljs_lang_for_ext("js"), Some("javascript"));
+ assert_eq!(hljs_lang_for_ext("py"), Some("python"));
+ assert_eq!(hljs_lang_for_ext("html"), Some("xml"));
+ assert_eq!(hljs_lang_for_ext("css"), Some("css"));
+ assert_eq!(hljs_lang_for_ext("toml"), Some("ini"));
+ assert_eq!(hljs_lang_for_ext("zz_unknown"), None);
+ }
+
+ #[test]
+ fn html_escape_covers_special_chars() {
+ assert_eq!(
+ html_escape(r#"& "quoted" 'apo'
"#),
+ "<div class="x">& "quoted" 'apo'</div>"
+ );
+ }
+
+ #[test]
+ fn split_highlighted_into_lines_simple_text() {
+ let lines = split_highlighted_into_lines("a\nb\nc");
+ assert_eq!(lines, vec!["a", "b", "c"]);
+ }
+
+ #[test]
+ fn split_highlighted_into_lines_preserves_utf8() {
+ let lines = split_highlighted_into_lines("Grüße\nfür dich\n你好");
+ assert_eq!(lines, vec!["Grüße", "für dich", "你好"]);
+ }
+
+ #[test]
+ fn split_highlighted_into_lines_balances_open_spans() {
+ let html = r#""hello
+world""#;
+ let lines = split_highlighted_into_lines(html);
+ assert_eq!(lines.len(), 2);
+ assert!(lines[0].starts_with(r#""#));
+ assert!(lines[0].ends_with(""));
+ assert!(lines[1].starts_with(r#""#));
+ assert!(lines[1].ends_with(""));
+ }
+
+ #[test]
+ fn split_highlighted_into_lines_handles_nested_spans_across_newlines() {
+ let html = r#"xy
+zw"#;
+ let lines = split_highlighted_into_lines(html);
+ assert_eq!(lines.len(), 2);
+ // line 1 closes both open spans
+ assert!(lines[0].ends_with(""));
+ // line 2 reopens both in order
+ assert!(lines[1].starts_with(r#""#));
+ }
+
+ #[test]
+ fn split_highlighted_into_lines_keeps_empty_lines() {
+ let lines = split_highlighted_into_lines("a\n\nb");
+ assert_eq!(lines, vec!["a", "", "b"]);
+ }
+}
diff --git a/src/workbench/file_preview/video_view.rs b/src/workbench/file_preview/video_view.rs
new file mode 100644
index 0000000..fa5b114
--- /dev/null
+++ b/src/workbench/file_preview/video_view.rs
@@ -0,0 +1,85 @@
+//! Video preview renderer (base64 data URL into a native ``).
+
+use crate::i18n::I18nKey;
+use crate::service::I18nService;
+use crate::tauri_bridge::{is_tauri_shell, read_workspace_video_file, BinaryFilePreview};
+use crate::workbench::file_preview::util::{render_load_error, FilePreviewError};
+use crate::workbench::WorkbenchService;
+use leptos::prelude::*;
+use leptos::task::spawn_local;
+
+#[derive(Clone, PartialEq)]
+struct VideoSrc {
+ data_url: String,
+ mime: String,
+}
+
+#[component]
+pub fn VideoView(
+ workspace_id: u64,
+ rel_path: String,
+ reload_tick: ReadSignal,
+) -> impl IntoView {
+ let wb = expect_context::();
+ let i18n = expect_context::();
+ let video = RwSignal::new(None::>);
+
+ let rel_for_effect = rel_path.clone();
+ Effect::new(move |_| {
+ let _ = reload_tick.get();
+ video.set(None);
+ if !is_tauri_shell() {
+ video.set(Some(Err(FilePreviewError::NoTauri)));
+ return;
+ }
+ let Some(ws) = wb
+ .workspaces()
+ .get()
+ .into_iter()
+ .find(|w| w.id == workspace_id)
+ else {
+ video.set(Some(Err(FilePreviewError::WorkspaceNotFound)));
+ return;
+ };
+ let root = ws.cwd;
+ let rel = rel_for_effect.clone();
+ spawn_local(async move {
+ match read_workspace_video_file(root, rel).await {
+ Ok(BinaryFilePreview {
+ base64,
+ mime,
+ byte_len,
+ truncated,
+ }) => {
+ if truncated {
+ video.set(Some(Err(FilePreviewError::TooLarge(byte_len))));
+ } else {
+ let data_url = format!("data:{mime};base64,{base64}");
+ video.set(Some(Ok(VideoSrc { data_url, mime })));
+ }
+ }
+ Err(e) => video.set(Some(Err(FilePreviewError::Failed(e)))),
+ }
+ });
+ });
+
+ view! {
+
+ {move || match video.get() {
+ None => view! {
+
{i18n.tr(I18nKey::FilePreviewLoading)}
+ }.into_any(),
+ Some(Err(err)) => render_load_error(i18n, I18nKey::FilePreviewLoadFailedVideo, err),
+ Some(Ok(src)) => view! {
+
+
+
+ }.into_any(),
+ }}
+
+ }
+}
diff --git a/src/workbench/git_graph/mod.rs b/src/workbench/git_graph/mod.rs
index f7585cb..0b75627 100644
--- a/src/workbench/git_graph/mod.rs
+++ b/src/workbench/git_graph/mod.rs
@@ -1,26 +1,13 @@
-//! Sidebar git commit graph with swim-lane SVG.
+//! Sidebar git commit graph — native `git log --graph` gutter + commit rows.
use crate::i18n::I18nKey;
use crate::service::I18nService;
-use crate::tauri_bridge::{
- git_commit_graph, GitCommitNode, GitGraphLayout, GitGraphRow, GIT_MISSING_CODE,
-};
+use crate::tauri_bridge::{git_commit_graph, GitGraphEntry, GitGraphLayout, GIT_MISSING_CODE};
use crate::workbench::sidebar_view_section::{SidebarSectionIconBtn, SidebarViewSection};
use crate::workbench::WorkbenchService;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::Icon as LxIcon;
-use std::collections::HashMap;
-
-const LANE_PITCH: f64 = 14.0;
-/// Left inset for the first swim-lane (kept small so the column isn't mostly empty).
-const LANE_ORIGIN: f64 = 4.0;
-/// SVG viewBox height; y-coordinates are 0..VIEW_H so lines stretch with each row.
-const VIEW_H: f64 = 100.0;
-/// Commit node sits on the subject line (~top fifth of a typical multi-line row).
-const NODE_Y: f64 = 18.0;
-const NODE_R: f64 = 3.5;
-const LANE_COL_PAD: f64 = 4.0;
#[component]
pub fn GitGraphSection(git_repo_available: ReadSignal