diff --git a/CHANGELOG.md b/CHANGELOG.md index 7765226..eb3920b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Every release entry is provided in English and Simplified Chinese. - **Multi-line paste stops interrupting codex** — the multi-line paste confirmation now only fires when newlines are actually headed to a shell that would execute them line by line. Applications in bracketed-paste mode (codex, vim, modern PSReadLine) receive the paste as one atomic chunk, so the warning no longer blocks them. The dark-theme "Enter" keycap on the confirm dialog's accent button also derives from the button's own ink instead of the near-black panel color. (#35) - **Math overlays survive markdown-unescaped delimiters (GPUI shell)** — some AI CLIs render their markdown before printing and eat the backslashes of `\[ \]` / `\( \)` (markdown punctuation escapes), leaving bare `[ … ]` blocks and `(\sqrt{…})` spans that never overlaid. The shared scanner now recognizes those bare forms when the content carries a known TeX command (`\int`, `\frac`, …) and the brackets sit alone on their line edges; JSON arrays, `[INFO]` logs and regex `(\d+)` stay literal. Explicit TeX lengths (`\\[6pt]`, `\kern`) also regained their point-to-pixel scale in the GPUI shell, matching the legacy renderer. - **Markdown reader fills the reading column (GPUI shell)** — soft line breaks inside a paragraph (hand-wrapped README prose) rendered as hard line breaks, leaving the right side of the column empty and ragged. They now merge into spaces per CommonMark, with CJK neighbours joining directly. -- **Markdown images actually load (GPUI shell)** — the document tab now resolves relative image paths against the document's own directory, loads absolute local paths from disk, and fetches `http(s)` images through a ureq HTTP client on the background executor (gpui ships a null client by default), so README logos, shields badges (SVG included) and screenshots finally display. +- **Markdown images actually load (GPUI shell)** — the document tab now resolves relative image paths against the document's own directory, loads absolute local paths from disk, and fetches `http(s)` images through a ureq HTTP client on the background executor (gpui ships a null client by default), so README logos, shields badges (SVG included) and screenshots finally display. Animated GIF/WebP (local or remote) are flattened to a still PNG first, because gpui panics when a multi-frame image's frame index is reused on a static image. #### Improved @@ -73,7 +73,7 @@ Every release entry is provided in English and Simplified Chinese. - **多行粘贴不再打断 codex** — 多行粘贴确认现在只在换行真的会被 shell 逐行执行时弹出。处于 bracketed paste 模式的应用(codex、vim、新版 PSReadLine)会把粘贴当作一个整体接收,警告不再拦路。确认框里深色主题下 accent 主按钮上的「Enter」键帽也改从按钮自身墨色派生,不再是一块近黑色。(#35) - **公式覆盖层兼容被 markdown 反转义的定界符(GPUI 壳)** — 部分 AI CLI 先渲染 markdown 再上屏,`\[ \]` / `\( \)` 的反斜杠被当作 markdown 标点转义吃掉,屏幕上只剩裸 `[ … ]` 块与 `(\sqrt{…})`,公式从不渲染。共享扫描器现在识别这些裸形态:内容须携带已知 TeX 命令(`\int`、`\frac`……)且方括号独占行首/行尾;JSON 数组、`[INFO]` 日志、正则 `(\d+)` 保持原样。GPUI 壳里 TeX 显式长度(`\\[6pt]`、`\kern`)的 pt→px 换算也已对齐旧壳。 - **Markdown 阅读器铺满阅读列(GPUI 壳)** — 段落内的软换行(README 手工折行的正文)此前渲染成硬换行,右侧留出一大片参差空白。现按 CommonMark 合并为空格,中日韩相邻行直接相连不插空格。 -- **Markdown 图片真正能加载(GPUI 壳)** — 文档 tab 现在把相对图片路径按文档所在目录解析、绝对路径直接读盘、`http(s)` 图源经后台 ureq 客户端拉取(gpui 默认装的是空客户端),README 的 logo、shields 徽章(含 SVG)与截图终于都能显示。 +- **Markdown 图片真正能加载(GPUI 壳)** — 文档 tab 现在把相对图片路径按文档所在目录解析、绝对路径直接读盘、`http(s)` 图源经后台 ureq 客户端拉取(gpui 默认装的是空客户端),README 的 logo、shields 徽章(含 SVG)与截图终于都能显示。本地和网络的 GIF / 动画 WebP 会先压成单帧 PNG:gpui 在多帧图的帧下标被静态图复用时会直接 panic。 #### 改进 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 31c9323..d8d36f0 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -137,7 +137,7 @@ xdg = "3.0.0" rfd = "0.17.2" [target.'cfg(not(target_os = "macos"))'.dependencies] -image = { version = "0.25.8", default-features = false, features = ["bmp", "jpeg", "png", "webp"] } +image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp"] } png = { version = "0.17.5", default-features = false, optional = true } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/nebula_app/src/gpui_shell/doc_tabs.rs b/nebula_app/src/gpui_shell/doc_tabs.rs index a440155..37e0dcd 100644 --- a/nebula_app/src/gpui_shell/doc_tabs.rs +++ b/nebula_app/src/gpui_shell/doc_tabs.rs @@ -247,7 +247,7 @@ impl DocTabView { Ok(bytes) => { let truncated = bytes.len() > MAX_DOC_BYTES; let slice = if truncated { &bytes[..MAX_DOC_BYTES] } else { &bytes[..] }; - let text = String::from_utf8_lossy(slice).into_owned(); + let text = rewrite_doc_images(&String::from_utf8_lossy(slice), self.path.parent()); self.notice = truncated .then(|| format!("文件超过 {} KB,仅显示开头部分", MAX_DOC_BYTES / 1024)); self.content = text.into(); @@ -318,3 +318,64 @@ impl Render for DocTabView { ) } } + +/// 把文档里的本地 GIF / 动画 WebP 换成缓存里的单帧 PNG。 +/// 网络图走 HTTP 客户端同一套压帧;本地图不经过 HTTP,不换的话 gpui +/// 仍会播 GIF,markdown 多图共用 element id 时越界 panic。 +fn rewrite_doc_images(text: &str, base: Option<&Path>) -> String { + use std::sync::LazyLock; + static MARKDOWN_IMG: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"(!\[[^\]]*\]\()([^)\s]+)"#).unwrap() + }); + // `regex` crate 不支持反向引用 `\2`,双引号/单引号拆开写。 + static HTML_IMG_DQ: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*")([^"]+)""#).unwrap() + }); + static HTML_IMG_SQ: LazyLock = LazyLock::new(|| { + regex::Regex::new(r#"(?i)(]*?\bsrc\s*=\s*')([^']+)'"#).unwrap() + }); + let rewritten = MARKDOWN_IMG.replace_all(text, |caps: ®ex::Captures<'_>| { + format!("{}{}", &caps[1], flatten_local_image_url(&caps[2], base)) + }); + let rewritten = HTML_IMG_DQ.replace_all(&rewritten, |caps: ®ex::Captures<'_>| { + format!(r#"{}{}""#, &caps[1], flatten_local_image_url(&caps[2], base)) + }); + HTML_IMG_SQ + .replace_all(&rewritten, |caps: ®ex::Captures<'_>| { + format!("{}{}'", &caps[1], flatten_local_image_url(&caps[2], base)) + }) + .into_owned() +} + +fn flatten_local_image_url(url: &str, base: Option<&Path>) -> String { + let lower = url.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("data:") { + return url.to_owned(); + } + let path = Path::new(url); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + match base { + Some(base) => base.join(path), + None => return url.to_owned(), + } + }; + let Ok(bytes) = std::fs::read(&resolved) else { + return url.to_owned(); + }; + let Some(png) = crate::gpui_shell::http::flatten_animated_to_png(&bytes) else { + return url.to_owned(); + }; + let cache = std::env::temp_dir().join("nebula-md-img"); + if std::fs::create_dir_all(&cache).is_err() { + return url.to_owned(); + } + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&bytes, &mut hasher); + let dest = cache.join(format!("{:016x}.png", std::hash::Hasher::finish(&hasher))); + if !dest.exists() && std::fs::write(&dest, png).is_err() { + return url.to_owned(); + } + dest.to_string_lossy().replace('\\', "/") +} diff --git a/nebula_app/src/gpui_shell/http.rs b/nebula_app/src/gpui_shell/http.rs index c134903..9496632 100644 --- a/nebula_app/src/gpui_shell/http.rs +++ b/nebula_app/src/gpui_shell/http.rs @@ -63,15 +63,77 @@ fn fetch_blocking( for (name, value) in response.headers().iter() { header_pairs.push((name.as_str().to_owned(), value.as_bytes().to_vec())); } - let bytes = response + let mut bytes = response .body_mut() .with_config() .limit(MAX_RESPONSE_BYTES) .read_to_vec() .map_err(|error| anyhow!("读取响应失败: {error}"))?; + // 不是 anyhow:gpui img 把 GIF 帧下标存在 element state,markdown + // 多图共用 `.id(序号)` 时会把第 N 帧套到只有 1 帧的 PNG/SVG 上 panic。 + // 网络动图进 gpui 前压成单帧 PNG。 + if let Some(png) = flatten_animated_to_png(&bytes) { + header_pairs.retain(|(name, _)| { + let lower = name.to_ascii_lowercase(); + lower != "content-type" && lower != "content-length" + }); + header_pairs.push(("content-type".to_owned(), b"image/png".to_vec())); + header_pairs.push(("content-length".to_owned(), png.len().to_string().into_bytes())); + bytes = png; + } Ok((status, header_pairs, bytes)) } +/// GIF 一律压成单帧 PNG;动画 WebP 同样处理。 +/// +/// 不能只判断「是否多帧」:解码失败时旧逻辑会把原 GIF 交给 gpui, +/// gpui 的 GifDecoder 仍会播动画,markdown 多图共用 `.id(序号)` 越界 panic。 +pub(crate) fn flatten_animated_to_png(bytes: &[u8]) -> Option> { + if is_gif(bytes) || is_animated_webp(bytes) { + return Some(first_frame_png(bytes)); + } + None +} + +fn is_gif(bytes: &[u8]) -> bool { + bytes.starts_with(b"GIF87a") + || bytes.starts_with(b"GIF89a") + || matches!(image::guess_format(bytes), Ok(image::ImageFormat::Gif)) +} + +fn is_animated_webp(bytes: &[u8]) -> bool { + image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(bytes)) + .map(|decoder| decoder.has_animation()) + .unwrap_or(false) +} + +fn first_frame_png(bytes: &[u8]) -> Vec { + encode_png(bytes).unwrap_or_else(placeholder_png) +} + +fn encode_png(bytes: &[u8]) -> Option> { + use image::ImageEncoder as _; + let rgba = image::load_from_memory(bytes).ok()?.into_rgba8(); + let mut out = Vec::new(); + image::codecs::png::PngEncoder::new(&mut out) + .write_image(rgba.as_raw(), rgba.width(), rgba.height(), image::ExtendedColorType::Rgba8) + .ok()?; + Some(out) +} + +fn placeholder_png() -> Vec { + use image::ImageEncoder as _; + let rgba = image::RgbaImage::from_pixel(1, 1, image::Rgba([0, 0, 0, 0])); + let mut out = Vec::new(); + let _ = image::codecs::png::PngEncoder::new(&mut out).write_image( + rgba.as_raw(), + 1, + 1, + image::ExtendedColorType::Rgba8, + ); + out +} + impl HttpClient for UreqClient { fn type_name(&self) -> &'static str { std::any::type_name::() diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index 3ba9bc8..3350280 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -10,6 +10,7 @@ param( [switch] $Force, [switch] $ValidateOnly, [string] $OutputDirectory, + [string] $TargetDirectory, [string] $InnoCompiler ) @@ -43,7 +44,13 @@ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $repo $OutputDirectory } $outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory) -$targetRoot = Join-Path $repo "target\$Configuration" +if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { + $TargetDirectory = Join-Path $repo 'target' +} elseif (-not [System.IO.Path]::IsPathRooted($TargetDirectory)) { + $TargetDirectory = Join-Path $repo $TargetDirectory +} +$cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory) +$targetRoot = Join-Path $cargoTargetRoot $Configuration $setupPath = Join-Path $outputRoot "NebulaTerminal-$Version-windows-x64-setup.exe" $requiredFiles = @( @@ -68,16 +75,25 @@ if (-not (Test-Path -LiteralPath $installerScript -PathType Leaf)) { if (-not $SkipBuild) { Push-Location $repo + $previousTargetDirectory = $env:CARGO_TARGET_DIR try { - $cargoArgs = @('build', '--workspace') + $env:CARGO_TARGET_DIR = $cargoTargetRoot + $workspaceArgs = @('build', '--workspace') + $gpuiArgs = @('build', '-p', 'nebula', '--bin', 'nebula', '--features', 'gpui-shell') if ($Configuration -eq 'release') { - $cargoArgs += '--release' + $workspaceArgs += '--release' + $gpuiArgs += '--release' } - & cargo @cargoArgs + & cargo @workspaceArgs if ($LASTEXITCODE -ne 0) { - throw "Cargo build failed with exit code $LASTEXITCODE" + throw "Cargo workspace build failed with exit code $LASTEXITCODE" + } + & cargo @gpuiArgs + if ($LASTEXITCODE -ne 0) { + throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE" } } finally { + $env:CARGO_TARGET_DIR = $previousTargetDirectory Pop-Location } } @@ -149,7 +165,7 @@ if (-not $translationValid) { Push-Location $PSScriptRoot try { - & $InnoCompiler "/DAppVersion=$Version" "/DNumericVersion=$numericVersion" "/DConfiguration=$Configuration" "/O$outputRoot" $installerScript + & $InnoCompiler "/DAppVersion=$Version" "/DNumericVersion=$numericVersion" "/DConfiguration=$Configuration" "/DBuildRoot=$targetRoot" "/O$outputRoot" $installerScript if ($LASTEXITCODE -ne 0) { throw "Inno Setup compilation failed with exit code $LASTEXITCODE" } diff --git a/scripts/installer.iss b/scripts/installer.iss index cb7d97a..e716c54 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -1,9 +1,9 @@ #ifndef AppVersion - #define AppVersion "1.0.0" + #define AppVersion "1.1.0" #endif #ifndef NumericVersion - #define NumericVersion "1.0.0.0" + #define NumericVersion "1.1.0.0" #endif #ifndef Configuration @@ -11,7 +11,9 @@ #endif #define RepoRoot ".." -#define BuildRoot RepoRoot + "\target\" + Configuration +#ifndef BuildRoot + #define BuildRoot RepoRoot + "\target\" + Configuration +#endif [Setup] AppId={{61022144-7D0A-4E54-94F2-C329A8F58656} diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 1e2c03b..eabe0a4 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -92,11 +92,21 @@ if (-not $SkipBuild) { $env:CARGO_TARGET_DIR = $cargoTargetRoot if ($Configuration -eq 'release') { & cargo build --workspace --release + if ($LASTEXITCODE -ne 0) { + throw "Cargo workspace build failed with exit code $LASTEXITCODE" + } + # 产品主窗是 GPUI(`nebula --gpui`),链接本地 gpui-component fork。 + # workspace 默认 feature 不含 gpui-shell,不补这一步装出来的是旧壳。 + & cargo build -p nebula --bin nebula --release --features gpui-shell } else { & cargo build --workspace + if ($LASTEXITCODE -ne 0) { + throw "Cargo workspace build failed with exit code $LASTEXITCODE" + } + & cargo build -p nebula --bin nebula --features gpui-shell } if ($LASTEXITCODE -ne 0) { - throw "Cargo build failed with exit code $LASTEXITCODE" + throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE" } } finally { $env:CARGO_TARGET_DIR = $previousTargetDirectory diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index e14392c..314276a 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -36,6 +36,11 @@ try { throw 'Release builds must strip debuginfo before packaging' } + $scriptBody = Get-Content -LiteralPath $packageScript -Raw -Encoding UTF8 + if ($scriptBody -notmatch '--features gpui-shell') { + throw 'Release packaging must rebuild nebula with --features gpui-shell so the zip ships the GPUI shell.' + } + & $packageScript -Version 'unreleased' -SkipBuild -OutputDirectory $resolvedOutput $zipPath = Join-Path $resolvedOutput 'NebulaTerminal-vunreleased-windows-x64.zip' if (-not (Test-Path -LiteralPath $zipPath -PathType Leaf)) {