diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f9566d5..d2d8d3f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,10 @@ jobs: (process.env.R2_PUBLIC_BASE_URL ? `${process.env.R2_PUBLIC_BASE_URL.replace(/\/$/, "")}/latest.json` : ""); + if (!pubkey || !endpoint) { + console.error("TAURI_UPDATER_PUBKEY and an updater endpoint are required"); + process.exit(1); + } writeFileSync( process.env.RUNNER_TEMP + "/tauri.release.conf.json", JSON.stringify( @@ -107,6 +111,18 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npx tauri build --bundles app,dmg --config "$RUNNER_TEMP/tauri.release.conf.json" + - name: Download Linux packages + uses: actions/download-artifact@v4 + with: + name: linux-packages + path: target/release/bundle/linux + + - name: Download Windows packages + uses: actions/download-artifact@v4 + with: + name: windows-packages + path: target/release/bundle/nsis + - name: Upload to R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} @@ -122,14 +138,30 @@ jobs: TAR=$(find "$BUNDLE/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit) SIG="${TAR}.sig" DMG=$(find "$BUNDLE/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit) + shopt -s nullglob + WINDOWS_INSTALLERS=("$BUNDLE"/nsis/*.exe) + WINDOWS_SIGNATURES=("$BUNDLE"/nsis/*.exe.sig) + if (( ${#WINDOWS_INSTALLERS[@]} != 1 || ${#WINDOWS_SIGNATURES[@]} != 1 )); then + echo "Expected exactly one Windows installer and updater signature" >&2 + exit 1 + fi + WINDOWS_INSTALLER="${WINDOWS_INSTALLERS[0]}" + WINDOWS_SIGNATURE="${WINDOWS_SIGNATURES[0]}" BASE="${R2_PUBLIC_BASE_URL%/}" ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" RELEASE_PREFIX="releases/${VERSION}" TAR_KEY="${RELEASE_PREFIX}/MonoCode.app.tar.gz" SIG_KEY="${TAR_KEY}.sig" DMG_KEY="${RELEASE_PREFIX}/MonoCode_${VERSION}_aarch64.dmg" + WINDOWS_INSTALLER_KEY="${RELEASE_PREFIX}/MonoCode_${VERSION}_x64-setup.exe" + WINDOWS_SIGNATURE_KEY="${WINDOWS_INSTALLER_KEY}.sig" - VERSION="$VERSION" SIG="$SIG" TAR_URL="$BASE/$TAR_KEY" node <<'NODE' + VERSION="$VERSION" \ + SIG="$SIG" \ + TAR_URL="$BASE/$TAR_KEY" \ + WINDOWS_SIGNATURE="$WINDOWS_SIGNATURE" \ + WINDOWS_INSTALLER_URL="$BASE/$WINDOWS_INSTALLER_KEY" \ + node <<'NODE' const fs = require("fs"); fs.writeFileSync( "latest.json", @@ -143,6 +175,12 @@ jobs: signature: fs.readFileSync(process.env.SIG, "utf8").trim(), url: process.env.TAR_URL, }, + "windows-x86_64": { + signature: fs + .readFileSync(process.env.WINDOWS_SIGNATURE, "utf8") + .trim(), + url: process.env.WINDOWS_INSTALLER_URL, + }, }, }, null, @@ -157,23 +195,13 @@ jobs: aws s3 cp "$DMG" "s3://${R2_BUCKET_NAME}/${DMG_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp "$TAR" "s3://${R2_BUCKET_NAME}/${TAR_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp "$SIG" "s3://${R2_BUCKET_NAME}/${SIG_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" + aws s3 cp "$WINDOWS_INSTALLER" "s3://${R2_BUCKET_NAME}/${WINDOWS_INSTALLER_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" + aws s3 cp "$WINDOWS_SIGNATURE" "s3://${R2_BUCKET_NAME}/${WINDOWS_SIGNATURE_KEY}" --cache-control "$IMMUTABLE_CACHE" --endpoint-url "$ENDPOINT" # Keep the website's stable DMG link, but never let the CDN retain an # older release under this mutable name. aws s3 cp "$DMG" "s3://${R2_BUCKET_NAME}/MonoCode.dmg" --cache-control "$NO_CACHE" --endpoint-url "$ENDPOINT" aws s3 cp latest.json "s3://${R2_BUCKET_NAME}/latest.json" --content-type "application/json" --cache-control "$NO_CACHE" --endpoint-url "$ENDPOINT" - - name: Download Linux packages - uses: actions/download-artifact@v4 - with: - name: linux-packages - path: target/release/bundle/linux - - - name: Download Windows packages - uses: actions/download-artifact@v4 - with: - name: windows-packages - path: target/release/bundle/nsis - - name: GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -181,21 +209,23 @@ jobs: set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" BUNDLE="target/release/bundle" - TAR=$(find "$BUNDLE/macos" -maxdepth 1 -type f -name '*.app.tar.gz' -print -quit) + TAR="$BUNDLE/macos/MonoCode.app.tar.gz" SIG="${TAR}.sig" - DMG=$(find "$BUNDLE/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit) - shopt -s nullglob - DEB=("$BUNDLE"/linux/*.deb) - APPIMAGE=("$BUNDLE"/linux/*.AppImage) - NSIS=("$BUNDLE"/nsis/*.exe) - if (( ${#DEB[@]} != 1 || ${#APPIMAGE[@]} != 1 || ${#NSIS[@]} != 1 )); then - echo "Expected exactly one .deb, one AppImage, and one NSIS installer" >&2 - exit 1 - fi + DMG="$BUNDLE/dmg/MonoCode_${VERSION}_aarch64.dmg" + DEB="$BUNDLE/linux/MonoCode_${VERSION}_amd64.deb" + APPIMAGE="$BUNDLE/linux/MonoCode_${VERSION}_amd64.AppImage" + NSIS="$BUNDLE/nsis/MonoCode_${VERSION}_x64-setup.exe" + NSIS_SIG="${NSIS}.sig" + for artifact in "$DMG" "$TAR" "$SIG" "$DEB" "$APPIMAGE" "$NSIS" "$NSIS_SIG"; do + if [[ ! -f "$artifact" ]]; then + echo "Missing release artifact: $artifact" >&2 + exit 1 + fi + done gh release create "$GITHUB_REF_NAME" \ --title "MonoCode $VERSION" \ --notes "See CHANGELOG.md for details." \ - "$DMG" "$TAR" "$SIG" "${DEB[0]}" "${APPIMAGE[0]}" "${NSIS[0]}" + "$DMG" "$TAR" "$SIG" "$DEB" "$APPIMAGE" "$NSIS" "$NSIS_SIG" linux: name: Linux packages @@ -253,12 +283,57 @@ jobs: - run: npm ci + - name: Write updater release config + env: + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + TAURI_UPDATER_ENDPOINT: ${{ secrets.TAURI_UPDATER_ENDPOINT }} + R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }} + run: | + $endpoint = $env:TAURI_UPDATER_ENDPOINT + if ([string]::IsNullOrWhiteSpace($endpoint)) { + if ([string]::IsNullOrWhiteSpace($env:R2_PUBLIC_BASE_URL)) { + throw "TAURI_UPDATER_ENDPOINT or R2_PUBLIC_BASE_URL is required" + } + $endpoint = "$($env:R2_PUBLIC_BASE_URL.TrimEnd('/'))/latest.json" + } + if ([string]::IsNullOrWhiteSpace($env:TAURI_UPDATER_PUBKEY)) { + throw "TAURI_UPDATER_PUBKEY is required" + } + $config = @{ + bundle = @{ + createUpdaterArtifacts = $true + } + plugins = @{ + updater = @{ + pubkey = $env:TAURI_UPDATER_PUBKEY + endpoints = @($endpoint) + } + } + } + $config | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 "$env:RUNNER_TEMP\tauri.release.conf.json" + - name: Build Windows packages - run: npm run build:windows + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + $env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-msvc" + npx tauri build --bundles nsis --config "$env:RUNNER_TEMP\tauri.release.conf.json" + + - name: Stage Windows packages + run: | + $installers = @(Get-ChildItem "target/release/bundle/nsis" -Filter "*.exe") + $signatures = @(Get-ChildItem "target/release/bundle/nsis" -Filter "*.exe.sig") + if ($installers.Count -ne 1 -or $signatures.Count -ne 1) { + throw "Expected exactly one NSIS installer and one updater signature" + } + New-Item -ItemType Directory -Force "release-artifacts" | Out-Null + $artifacts = @($installers[0].FullName, $signatures[0].FullName) + Copy-Item -Path $artifacts -Destination "release-artifacts/" - name: Upload Windows packages uses: actions/upload-artifact@v4 with: name: windows-packages - path: target/release/bundle/nsis/*.exe + path: release-artifacts/ if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 965572ea..17681f1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.40] - 2026-09-08 + +### Added + +- Working-tree reviews now separate staged changes against `HEAD` from unstaged changes against the index, including partially staged files. +- The Changes panel can organize files into a collapsible directory tree, remembers the selected list or tree view, and can open every change in one review. + +### Fixed + +- Code block syntax highlighting follows MonoCode's appearance preference instead of the system color scheme. In #117 by @kartava. +- Split conversation panes share one continuous chat background instead of repeating the image in every pane. +- Project search safely treats include filters beginning with `-` as path patterns instead of Git options. In #125 by @Karajelly. +- Release publishing validates and uploads the expected versioned artifacts for every supported platform. + +## [0.1.39] - 2026-09-08 + +### Added + +- Windows releases now check for, download, and install signed updates through the same in-app update flow as macOS. + +### Changed + +- The prompt outline remains visible on slightly narrower windows. +- Removed the scrolled transcript's top-edge fade and blur effect. + +## [0.1.38] - 2026-09-08 + +### Added + +- The sidebar project picker is now searchable and keyboard navigable, shows each project's parent path, and includes actions for opening a new project or starting a new tab. +- Right-click a title-bar tab to close that tab, the other tabs, or every tab to its left or right. Bulk closing still protects unsaved files and running terminals. +- Shift-click conversations in the sidebar to select several at once, then pin, unpin, archive, unarchive, move into or out of folders, or delete them together. +- Settings → Appearance → Chat background adds an on-device image behind empty sessions or every conversation, with adjustable visibility. Each project can override the global image from its project-rail menu. +- Long transcripts have a vertical prompt outline for jumping between turns. Hover or keyboard-focus a marker to preview its prompt and reply. In #90 by @kartava. +- Drag image files into a note to copy them into MonoCode's local note storage and insert them into the note at the cursor. +- Archive the focused conversation with Shift+Command/Ctrl+A. The shortcut stays out of editors, terminals, diffs, and open overlays. In #89 by @kualta. + +### Changed + +- Scrolled transcripts fade and blur smoothly beneath the title bar, and popover backdrops now use theme-aware tints. +- Light mode uses an opaque native window for legibility, preserves the dark-mode glass settings, and gives the composer theme-specific shadows and send-button states. + +### Fixed + +- Enabling Sounds now plays the switch cue immediately. In #111 by @kartava. +- Sidebar multi-selection clears reliably when its menu closes or the pointer moves outside the selected conversation cards. +- Chat background changes appear across open session panes immediately, and the empty-session arcade stays hidden when a background is visible. +- Composer keyboard handlers ignore active IME composition, preventing Enter, Escape, and picker actions from firing while composing text. + ## [0.1.37] - 2026-09-07 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d9442e63..4aa62c51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2214,7 +2214,7 @@ dependencies = [ [[package]] name = "monocode" -version = "0.1.37" +version = "0.1.40" dependencies = [ "base64 0.22.1", "block2", diff --git a/Cargo.toml b/Cargo.toml index 1dfd758a..0e7983c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["src-tauri"] exclude = ["vendor/portable-pty"] [workspace.package] -version = "0.1.37" +version = "0.1.40" edition = "2021" license = "MIT" diff --git a/package-lock.json b/package-lock.json index f27bbb4a..0493f230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "monocode-desktop", - "version": "0.1.37", + "version": "0.1.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "monocode-desktop", - "version": "0.1.37", + "version": "0.1.40", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.12", diff --git a/package.json b/package.json index 5a7becb2..b7ab09ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "monocode-desktop", "private": true, - "version": "0.1.37", + "version": "0.1.40", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/src/chat_background.rs b/src-tauri/src/chat_background.rs new file mode 100644 index 00000000..dbf3597e --- /dev/null +++ b/src-tauri/src/chat_background.rs @@ -0,0 +1,210 @@ +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; + +use crate::fs::expand_home; + +const MAX_BACKGROUND_BYTES: u64 = 25 * 1024 * 1024; +const ALLOWED_EXT: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"]; + +fn backgrounds_dir(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("backgrounds"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn remove_existing_backgrounds(dir: &Path) -> Result<(), String> { + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == "chat-background" || name.starts_with("chat-background.") { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn project_background_stem(project: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in project.trim().as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100_0000_01b3); + } + format!("project-{hash:016x}") +} + +fn remove_project_background(dir: &Path, project: &str) -> Result<(), String> { + let stem = project_background_stem(project); + let prefix = format!("{stem}."); + let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if name == stem || name.starts_with(&prefix) { + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + } + Ok(()) +} + +fn background_extension(source: &Path) -> Result { + let ext = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ALLOWED_EXT.contains(&ext.as_str()) { + Ok(ext) + } else { + Err("Background must be a PNG, JPG, GIF, or WebP image.".into()) + } +} + +fn save_chat_background_sync(app: &AppHandle, source_path: &str) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let dest = dir.join(format!("chat-background.{ext}")); + let temp = dir.join(".chat-background-upload"); + + // Copy first so choosing the currently saved image remains safe. + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_existing_backgrounds(&dir)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +fn save_project_chat_background_sync( + app: &AppHandle, + project: &str, + source_path: &str, +) -> Result { + if project.trim().is_empty() { + return Err("Project is required".into()); + } + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > MAX_BACKGROUND_BYTES { + return Err(format!( + "Background is too large (maximum {} MB).", + MAX_BACKGROUND_BYTES / 1024 / 1024 + )); + } + let ext = background_extension(&source)?; + let dir = backgrounds_dir(app)?; + let stem = project_background_stem(project); + let dest = dir.join(format!("{stem}.{ext}")); + let temp = dir.join(format!(".{stem}-upload")); + + std::fs::copy(&source, &temp).map_err(|e| format!("{}: {e}", temp.display()))?; + remove_project_background(&dir, project)?; + std::fs::rename(&temp, &dest).map_err(|e| format!("{}: {e}", dest.display()))?; + Ok(dest.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub async fn save_chat_background(app: AppHandle, source_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || save_chat_background_sync(&app, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_chat_background(app: AppHandle) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_existing_backgrounds(&dir) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn save_project_chat_background( + app: AppHandle, + project: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + save_project_chat_background_sync(&app, &project, &source_path) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn remove_project_chat_background(app: AppHandle, project: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let dir = backgrounds_dir(&app)?; + remove_project_background(&dir, &project) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_supported_extensions_case_insensitively() { + assert_eq!( + background_extension(Path::new("wallpaper.JPEG")).unwrap(), + "jpeg" + ); + assert_eq!( + background_extension(Path::new("wallpaper.webp")).unwrap(), + "webp" + ); + } + + #[test] + fn rejects_files_the_webview_cannot_render_as_backgrounds() { + assert!(background_extension(Path::new("wallpaper.txt")).is_err()); + assert!(background_extension(Path::new("wallpaper")).is_err()); + } + + #[test] + fn project_background_stems_are_stable_and_distinct() { + assert_eq!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/me/agent") + ); + assert_ne!( + project_background_stem("/Users/me/agent"), + project_background_stem("/Users/other/agent") + ); + } +} diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 3453f429..532ef1e2 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -254,12 +254,19 @@ pub struct GitFileDiff { pub too_large: bool, } -/// Working-tree vs index (or empty) contents for one changed file. +/// Contents for one changed file. Staged diffs compare HEAD to the index; +/// unstaged diffs compare the index to the working tree. #[tauri::command] -pub async fn git_file_diff(cwd: String, relative: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_file_diff_for(&expand_home(&cwd), &relative)) - .await - .map_err(|e| e.to_string())? +pub async fn git_file_diff( + cwd: String, + relative: String, + staged: bool, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_file_diff_for(&expand_home(&cwd), &relative, staged) + }) + .await + .map_err(|e| e.to_string())? } const GIT_HISTORY_DEFAULT: u32 = 200; @@ -1019,7 +1026,7 @@ fn mark_cached_and_unstaged(root: &Path, files: &mut HashMap) { } } -fn git_file_diff_for(root: &Path, relative: &str) -> Result { +fn git_file_diff_for(root: &Path, relative: &str, staged: bool) -> Result { let relative = normalize_diff_path(relative); if relative.is_empty() || relative.starts_with('/') @@ -1039,24 +1046,31 @@ fn git_file_diff_for(root: &Path, relative: &str) -> Result let prefix = git_stdout(root, &["rev-parse", "--show-prefix"]).unwrap_or_default(); let index_spec = format!(":{prefix}{relative}"); - let original = git_blob(root, &index_spec); - let in_index = original.is_some(); - let orig = original.unwrap_or_default(); - let current = if abs.is_file() { - std::fs::read(&abs).unwrap_or_default() + let (original, current) = if staged { + let head_spec = format!("HEAD:{prefix}{relative}"); + (git_blob(root, &head_spec), git_blob(root, &index_spec)) } else { - Vec::new() + let current = if abs.is_file() { + Some(std::fs::read(&abs).unwrap_or_default()) + } else { + None + }; + (git_blob(root, &index_spec), current) }; + let had_original = original.is_some(); + let had_current = current.is_some(); + let orig = original.unwrap_or_default(); + let current = current.unwrap_or_default(); let binary = orig.contains(&0) || current.contains(&0); let too_large = orig.len() as u64 > MAX_TEXT_FILE_BYTES || current.len() as u64 > MAX_TEXT_FILE_BYTES; - let status = if !in_index { - if abs.is_file() { - "untracked" + let status = if !had_original && had_current { + if staged { + "added" } else { - "deleted" + "untracked" } - } else if !abs.exists() { + } else if had_original && !had_current { "deleted" } else { "modified" @@ -4309,7 +4323,7 @@ mod tests { } std::fs::write(dir.0.join("a.txt"), "alpha\ngamma\ndelta\n").unwrap(); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.status, "modified"); assert_eq!(diff.original, "alpha\nbeta\ngamma\n"); assert_eq!(diff.current, "alpha\ngamma\ndelta\n"); @@ -4317,6 +4331,60 @@ mod tests { assert!(!diff.too_large); } + #[test] + fn git_file_diff_staged_reads_head_and_index() { + let dir = tmp("git-file-diff-staged"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + std::fs::write(dir.0.join("a.txt"), "beta\n").unwrap(); + git_stage_file_for(&dir.0, "a.txt").unwrap(); + + let diff = git_file_diff_for(&dir.0, "a.txt", true).unwrap(); + assert_eq!(diff.status, "modified"); + assert_eq!(diff.original, "alpha\n"); + assert_eq!(diff.current, "beta\n"); + } + + #[test] + fn git_file_diff_staged_handles_additions_and_deletions() { + let dir = tmp("git-file-diff-staged-status"); + if !init_git_commit(&dir.0, &[("gone.txt", "old\n")]) { + return; + } + std::fs::write(dir.0.join("new.txt"), "new\n").unwrap(); + std::fs::remove_file(dir.0.join("gone.txt")).unwrap(); + assert!(git(&dir.0, &["add", "-A"])); + + let added = git_file_diff_for(&dir.0, "new.txt", true).unwrap(); + assert_eq!(added.status, "added"); + assert_eq!(added.original, ""); + assert_eq!(added.current, "new\n"); + + let deleted = git_file_diff_for(&dir.0, "gone.txt", true).unwrap(); + assert_eq!(deleted.status, "deleted"); + assert_eq!(deleted.original, "old\n"); + assert_eq!(deleted.current, ""); + } + + #[test] + fn git_file_diff_separates_staged_and_unstaged_changes() { + let dir = tmp("git-file-diff-partial"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\nbeta\ngamma\ndelta\n")]) { + return; + } + std::fs::write(dir.0.join("a.txt"), "alpha\nBETA\ngamma\nDELTA\n").unwrap(); + git_stage_contents_for(&dir.0, "a.txt", b"alpha\nBETA\ngamma\ndelta\n").unwrap(); + + let staged = git_file_diff_for(&dir.0, "a.txt", true).unwrap(); + assert_eq!(staged.original, "alpha\nbeta\ngamma\ndelta\n"); + assert_eq!(staged.current, "alpha\nBETA\ngamma\ndelta\n"); + + let unstaged = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); + assert_eq!(unstaged.original, "alpha\nBETA\ngamma\ndelta\n"); + assert_eq!(unstaged.current, "alpha\nBETA\ngamma\nDELTA\n"); + } + #[test] fn git_file_diff_untracked_has_empty_original() { let dir = tmp("git-file-diff-new"); @@ -4325,7 +4393,7 @@ mod tests { } std::fs::write(dir.0.join("new.txt"), "hello\n").unwrap(); - let diff = git_file_diff_for(&dir.0, "new.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "new.txt", false).unwrap(); assert_eq!(diff.status, "untracked"); assert_eq!(diff.original, ""); assert_eq!(diff.current, "hello\n"); @@ -4339,7 +4407,7 @@ mod tests { } std::fs::remove_file(dir.0.join("a.txt")).unwrap(); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.status, "deleted"); assert_eq!(diff.original, "alpha\n"); assert_eq!(diff.current, ""); @@ -4348,14 +4416,14 @@ mod tests { #[test] fn git_file_diff_rejects_path_escape() { let dir = tmp("git-file-diff-escape"); - assert!(git_file_diff_for(&dir.0, "../secret.txt").is_err()); + assert!(git_file_diff_for(&dir.0, "../secret.txt", false).is_err()); } #[test] fn git_file_diff_rejects_outside_a_repo() { let dir = tmp("git-file-diff-none"); std::fs::write(dir.0.join("notes.txt"), "hello\n").unwrap(); - assert!(git_file_diff_for(&dir.0, "notes.txt").is_err()); + assert!(git_file_diff_for(&dir.0, "notes.txt", false).is_err()); } #[test] @@ -4586,7 +4654,7 @@ mod tests { assert!(file.staged); assert!(file.unstaged); - let diff = git_file_diff_for(&dir.0, "a.txt").unwrap(); + let diff = git_file_diff_for(&dir.0, "a.txt", false).unwrap(); assert_eq!(diff.original, "alpha\nBETA\ngamma\ndelta\n"); assert_eq!(diff.current, "alpha\nBETA\ngamma\nDELTA\n"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33eb1886..f78b0cea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use tauri::Manager; +mod chat_background; mod checkpoint; mod cursor_store; mod fs; @@ -300,6 +301,8 @@ pub fn run() { notes::notes_get, notes::notes_upsert, notes::notes_delete, + notes::notes_save_image, + notes::notes_image_path, checkpoint::session_checkpoint_ensure, checkpoint::session_checkpoint_prepare, checkpoint::session_checkpoint_capture, @@ -314,9 +317,13 @@ pub fn run() { window::hide_window, window::destroy_window, window::confirm_quit, - window::enable_window_glass, + window::set_window_glass_enabled, window_transfer::stage_window_transfer, window_transfer::take_window_transfer, + chat_background::save_chat_background, + chat_background::remove_chat_background, + chat_background::save_project_chat_background, + chat_background::remove_project_chat_background, project_logo::save_project_logo, project_logo::remove_project_logo, project_logo::forget_logo_file, diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 73e2312b..ee185cee 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -19,7 +19,7 @@ //! shadow without that outline. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::{c_char, c_int, c_void}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -54,6 +54,7 @@ const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void; static PINNED: AtomicBool = AtomicBool::new(false); static BLUR_RADIUS: AtomicU8 = AtomicU8::new(BLUR_DEFAULT); static WINDOW_BADGES: OnceLock>> = OnceLock::new(); +static GLASS_WINDOWS: OnceLock>> = OnceLock::new(); type CgsConnection = usize; type SetBlurFn = unsafe extern "C" fn(CgsConnection, c_int, c_int) -> c_int; @@ -77,7 +78,10 @@ pub fn install(window: &WebviewWindow) { WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { stretch_titlebar(&event_window); } - WindowEvent::Destroyed => set_window_badge(&event_window, 0), + WindowEvent::Destroyed => { + set_window_badge(&event_window, 0); + set_glass_enabled(&event_window, false); + } _ => {} }); } @@ -148,7 +152,31 @@ pub fn set_visible(window: &WebviewWindow, visible: bool) { pub fn set_background_blur_radius(window: &WebviewWindow, radius: u8) { let radius = radius.clamp(BLUR_MIN, BLUR_MAX); BLUR_RADIUS.store(radius, Ordering::Relaxed); - apply_blur(window, radius); + if glass_enabled(window) { + apply_blur(window, radius); + } +} + +fn glass_windows() -> &'static Mutex> { + GLASS_WINDOWS.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn glass_enabled(window: &WebviewWindow) -> bool { + glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()) + .contains(window.label()) +} + +fn set_glass_enabled(window: &WebviewWindow, enabled: bool) { + let mut windows = glass_windows() + .lock() + .unwrap_or_else(|err| err.into_inner()); + if enabled { + windows.insert(window.label().to_string()); + } else { + windows.remove(window.label()); + } } /// Solid field behind the dock bounce. Same colour as the HTML sheet. @@ -177,10 +205,18 @@ fn set_launch_background(window: &WebviewWindow, r: u8, g: u8, b: u8) { /// Turn on desktop blur after the first UI paint. pub fn enable_glass(window: &WebviewWindow) { + set_glass_enabled(window, true); prepare_glass(window); apply_blur(window, BLUR_RADIUS.load(Ordering::Relaxed)); } +/// Light mode stays opaque because pale desktop content makes translucent UI illegible. +pub fn disable_glass(window: &WebviewWindow) { + set_glass_enabled(window, false); + apply_blur(window, 0); + set_launch_background(window, 247, 247, 247); +} + fn prepare_glass(window: &WebviewWindow) { let Some(ns_window) = ns_window(window) else { return; @@ -208,11 +244,7 @@ fn apply_blur(window: &WebviewWindow, radius: u8) { return; } unsafe { - set_blur( - connection, - window_number as c_int, - radius.max(BLUR_MIN) as c_int, - ); + set_blur(connection, window_number as c_int, radius as c_int); } } diff --git a/src-tauri/src/notes.rs b/src-tauri/src/notes.rs index 0e028818..9b823547 100644 --- a/src-tauri/src/notes.rs +++ b/src-tauri/src/notes.rs @@ -1,11 +1,18 @@ +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; +use crate::fs::expand_home; use crate::session_store::{now_millis, validate_id, SessionStore}; const TITLE_MAX: usize = 200; const BODY_MAX: usize = 1_000_000; +const IMAGE_MAX_BYTES: u64 = 20 * 1024 * 1024; +const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "svg"]; +const NOTE_ASSET_DIR: &str = "note-assets"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -34,6 +41,13 @@ pub struct NoteUpsert { pub source_cwd: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteImageAsset { + pub name: String, + pub markdown_path: String, +} + pub fn ensure_notes_table(conn: &Connection) -> rusqlite::Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS notes ( @@ -80,10 +94,160 @@ pub fn notes_upsert(store: State<'_, SessionStore>, note: NoteUpsert) -> Result< } #[tauri::command(async)] -pub fn notes_delete(store: State<'_, SessionStore>, id: String) -> Result<(), String> { +pub fn notes_delete( + app: AppHandle, + store: State<'_, SessionStore>, + id: String, +) -> Result<(), String> { validate_id(&id, "note")?; let conn = store.lock_conn()?; - delete_note(&conn, &id).map_err(|e| e.to_string()) + delete_note(&conn, &id).map_err(|e| e.to_string())?; + drop(conn); + // The note deletion is authoritative. A cleanup failure should not leave a + // successfully deleted note visible in the UI. + let _ = remove_note_assets(&app, &id); + Ok(()) +} + +#[tauri::command] +pub async fn notes_save_image( + app: AppHandle, + note_id: String, + source_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || save_note_image_sync(&app, ¬e_id, &source_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command(async)] +pub fn notes_image_path(app: AppHandle, asset: String) -> Result { + let relative = validate_note_asset_path(&asset)?; + let path = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(relative); + if !path.is_file() { + return Err("Note image was not found".into()); + } + Ok(path.to_string_lossy().into_owned()) +} + +fn note_assets_dir(app: &AppHandle, note_id: &str) -> Result { + validate_id(note_id, "note")?; + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join(NOTE_ASSET_DIR) + .join(note_id)) +} + +fn save_note_image_sync( + app: &AppHandle, + note_id: &str, + source_path: &str, +) -> Result { + let source = expand_home(source_path); + let meta = std::fs::metadata(&source).map_err(|e| format!("{}: {e}", source.display()))?; + if !meta.is_file() { + return Err("Not a file".into()); + } + if meta.len() > IMAGE_MAX_BYTES { + return Err(format!( + "Image is too large (maximum {} MB).", + IMAGE_MAX_BYTES / 1024 / 1024 + )); + } + + let (display_name, safe_name) = note_image_names(&source)?; + let dir = note_assets_dir(app, note_id)?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let stored_name = format!("{stamp}-{safe_name}"); + let destination = dir.join(&stored_name); + std::fs::copy(&source, &destination).map_err(|e| format!("{}: {e}", destination.display()))?; + + Ok(NoteImageAsset { + name: display_name, + markdown_path: format!("/{NOTE_ASSET_DIR}/{note_id}/{stored_name}"), + }) +} + +fn note_image_names(source: &Path) -> Result<(String, String), String> { + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !IMAGE_EXTENSIONS.contains(&extension.as_str()) { + return Err("Image must be a PNG, JPG, GIF, WebP, or SVG file.".into()); + } + let display_name = source + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("image") + .to_string(); + let stem = source + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image"); + let mut safe_stem: String = stem + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .take(80) + .collect(); + safe_stem = safe_stem.trim_matches('-').to_string(); + if safe_stem.is_empty() { + safe_stem = "image".into(); + } + Ok((display_name, format!("{safe_stem}.{extension}"))) +} + +fn validate_note_asset_path(asset: &str) -> Result { + let relative = asset + .strip_prefix('/') + .ok_or_else(|| "Invalid note image path".to_string())?; + let path = Path::new(relative); + let parts = path + .components() + .map(|part| match part { + Component::Normal(value) => value.to_str().map(str::to_string), + _ => None, + }) + .collect::>>() + .ok_or_else(|| "Invalid note image path".to_string())?; + if parts.len() != 3 || parts[0] != NOTE_ASSET_DIR { + return Err("Invalid note image path".into()); + } + validate_id(&parts[1], "note")?; + if parts[2].is_empty() + || !parts[2] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("Invalid note image path".into()); + } + Ok(path.to_path_buf()) +} + +fn remove_note_assets(app: &AppHandle, note_id: &str) -> Result<(), String> { + let dir = note_assets_dir(app, note_id)?; + match std::fs::remove_dir_all(dir) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } } fn list_notes(conn: &Connection) -> rusqlite::Result> { @@ -354,6 +518,28 @@ mod tests { assert_eq!(note.slug, "untitled"); } + #[test] + fn note_image_names_are_safe_and_keep_supported_extensions() { + assert_eq!( + note_image_names(Path::new("/tmp/Architecture draft [2].PNG")).unwrap(), + ( + "Architecture draft [2].PNG".into(), + "Architecture-draft--2.png".into() + ) + ); + assert!(note_image_names(Path::new("/tmp/archive.zip")).is_err()); + } + + #[test] + fn note_asset_paths_cannot_escape_app_data() { + assert_eq!( + validate_note_asset_path("/note-assets/note-1/123-image.png").unwrap(), + PathBuf::from("note-assets/note-1/123-image.png") + ); + assert!(validate_note_asset_path("/note-assets/note-1/../secret.png").is_err()); + assert!(validate_note_asset_path("/other/note-1/image.png").is_err()); + } + #[test] fn delete_removes_the_row() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 089bb7ff..60d263ae 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -85,6 +85,9 @@ fn git_grep(root: &Path, options: &SearchOptions, query: &str) -> Option, exclude: &Option) -> Vec } specs } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::ErrorKind; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + + struct Tmp(PathBuf); + + impl Drop for Tmp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn tmp(label: &str) -> Tmp { + loop { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "monocode-search-{label}-{}-{stamp}-{seq}", + std::process::id() + )); + match std::fs::create_dir(&dir) { + Ok(()) => return Tmp(dir), + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => panic!("{error}"), + } + } + } + + fn git(dir: &Path, args: &[&str]) -> bool { + Command::new("git") + .args(args) + .current_dir(dir) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + + #[test] + fn git_grep_treats_hyphen_prefixed_include_as_pathspec() { + let dir = tmp("hyphen-pathspec"); + if !git(&dir.0, &["init", "--quiet"]) { + return; + } + // `-l` is both a valid file name and git grep's files-with-matches flag. + std::fs::write(dir.0.join("-l"), "find me\n").unwrap(); + std::fs::write(dir.0.join("other.txt"), "find me too\n").unwrap(); + assert!(git(&dir.0, &["add", "--", "-l", "other.txt"])); + + let result = git_grep( + &dir.0, + &SearchOptions { + cwd: dir.0.to_string_lossy().into_owned(), + query: "find me".to_string(), + case_sensitive: true, + whole_word: false, + regex: false, + include: Some("-l".to_string()), + exclude: None, + }, + "find me", + ) + .unwrap(); + + assert_eq!(result.matches.len(), 1); + assert_eq!(result.matches[0].relative, "-l"); + assert_eq!(result.matches[0].preview, "find me"); + } +} diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 0555e302..666dd90f 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -42,22 +42,31 @@ pub fn open_new_window(app: &AppHandle) -> Result<(), String> { Ok(()) } -/// Desktop blur goes on after the first UI paint, not during the dock bounce. +/// Desktop blur goes on after the first UI paint and only in dark mode. #[tauri::command] -pub fn enable_window_glass(window: WebviewWindow) { +pub fn set_window_glass_enabled(window: WebviewWindow, enabled: bool) { #[cfg(target_os = "macos")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); - crate::macos::enable_glass(&window); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 3))); + crate::macos::enable_glass(&window); + } else { + crate::macos::disable_glass(&window); + } } #[cfg(target_os = "windows")] { - let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); - let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + if enabled { + let _ = window.set_background_color(Some(Color(0, 0, 0, 0))); + let _ = window.set_effects(EffectsBuilder::new().effect(Effect::Acrylic).build()); + } else { + let _ = window.set_effects(None); + let _ = window.set_background_color(Some(Color(247, 247, 247, 255))); + } } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { - let _ = window; + let _ = (window, enabled); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4a04aefb..e0e54808 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "MonoCode", - "version": "0.1.37", + "version": "0.1.40", "identifier": "com.monocode.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 9547b975..64f31df5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { notifyGitChanged, pickFolder, restoreSessionCheckout, + type GitFileDiffKind, type GitHistoryCommit, } from "./lib/fs"; import { @@ -275,6 +276,7 @@ import { setWindowFocused, } from "./lib/notifications"; import { playCue } from "./lib/sounds"; +import { archiveFocusedSession } from "./lib/archiveShortcut"; import { adjacentItemId, deferUnhandledEscape, @@ -2313,7 +2315,11 @@ export default function App({ ); const onOpenDiff = useCallback( - (path?: string, session?: { sessionId: string; cwd: string }) => { + ( + path?: string, + session?: { sessionId: string; cwd: string }, + changeKind?: GitFileDiffKind, + ) => { void (async () => { const diffCwd = session?.cwd ?? gitCwdRef.current; const resolved = path @@ -2332,12 +2338,17 @@ export default function App({ ); } if (loadDiffViewer() === "unified") { - return openChangesTab(tab, sidebarCwdRef.current, resolved); + return openChangesTab( + tab, + sidebarCwdRef.current, + resolved, + changeKind, + ); } if (!resolved) return tab; return openEditorTab( tab, - newFileTab(resolved, sidebarCwdRef.current, true), + newFileTab(resolved, sidebarCwdRef.current, true, changeKind), ); }), ); @@ -2348,6 +2359,23 @@ export default function App({ [activeTabId], ); + const onOpenWorkingTreeDiff = useCallback( + (path: string, kind?: GitFileDiffKind) => onOpenDiff(path, undefined, kind), + [onOpenDiff], + ); + + /** Stack every working-tree change in one review, whatever the diff-view setting. */ + const onOpenAllChanges = useCallback(() => { + setTabs((prev) => + prev.map((tab) => + tab.id === activeTabId + ? openChangesTab(tab, sidebarCwdRef.current) + : tab, + ), + ); + setComposerFocused(false); + }, [activeTabId]); + const onOpenCommit = useCallback( (commit: GitHistoryCommit) => { setTabs((prev) => @@ -2868,6 +2896,32 @@ export default function App({ [onRemoveHistorySession], ); + const onArchiveFocusedSession = useCallback( + (event: KeyboardEvent) => { + archiveFocusedSession( + event, + { + activeTabId: activeTabIdRef.current, + tabs: tabsRef.current, + sessions: sessionsRef.current, + projectTerminalFocused: projectTerminalFocusedRef.current, + surfaceOpen: Boolean( + searchViewOpenRef.current || + inboxViewOpenRef.current || + notesViewOpenRef.current || + settingsOpenRef.current || + filePickerOpenRef.current || + whatsNewVersionRef.current, + ), + }, + (sessionId) => { + void onArchiveHistorySession(sessionId, true); + }, + ); + }, + [onArchiveHistorySession], + ); + const onPinHistorySession = useCallback( async (sessionId: string, pinned: boolean) => { const open = sessionsRef.current.find( @@ -4703,6 +4757,7 @@ export default function App({ const actions = useRef({ onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4729,6 +4784,7 @@ export default function App({ }); actions.current = { onNew, + onArchiveFocusedSession, onCloseOtherTabs, onClosePane, onNext, @@ -4787,6 +4843,10 @@ export default function App({ } const cmd = tabCommand(e); if (cmd) { + if (cmd === "archive-session") { + actions.current.onArchiveFocusedSession(e); + return; + } const target = e.target instanceof Element ? e.target : null; const listNavigation = cmd === "prev-session" || @@ -5122,12 +5182,14 @@ export default function App({ canGoForward={tabVisitNav.canForward} onGoBack={onRailBack} onGoForward={onRailForward} - onOpenDiff={onOpenDiff} + onOpenDiff={onOpenWorkingTreeDiff} + onOpenAllChanges={onOpenAllChanges} onOpenCommit={onOpenCommit} onShowSourceControl={onToggleChanges} selectedDiffPath={ activeTab ? selectedChangePath(activeTab, gitCwd) : undefined } + selectedDiffKind={activeTab ? selectedChangeKind(activeTab) : undefined} selectedCommitSha={activeTab ? selectedCommitSha(activeTab) : undefined} textHarness={pickTextHarness(active?.harness)} recents={recents} @@ -5477,6 +5539,11 @@ function selectedChangePath( return displayPath(file.path, gitCwd || file.cwd); } +function selectedChangeKind(tab: WorkspaceTab): GitFileDiffKind | undefined { + const file = focusedFileTab(tab); + return file?.review ? file.changeKind : undefined; +} + function selectedCommitSha(tab: WorkspaceTab): string | undefined { const focused = focusedFileTab(tab); if (focused && isCommitTab(focused)) return focused.commit.sha; diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 1e29f3c4..5361f327 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -72,6 +72,7 @@ import type { UserQuestionPrompt, UserQuestionReply, } from "../lib/userQuestion"; +import { isImeComposition } from "../lib/keyboard"; import { createBlankSkill, rankSkills, @@ -302,6 +303,7 @@ function MessageQueue({ rows={1} onChange={(event) => setEditDraft(event.target.value)} onKeyDown={(event) => { + if (isImeComposition(event.nativeEvent)) return; if (event.key === "Escape") { event.preventDefault(); cancelEdit(); @@ -941,6 +943,7 @@ export function Composer({ }; const onKeyDown = (e: KeyboardEvent) => { + if (isImeComposition(e.nativeEvent)) return; if (creatingSkill) return; if (mentionOpen) { @@ -1147,7 +1150,7 @@ export function Composer({
@@ -1528,7 +1531,7 @@ function ComposerAction({ aria-label="Send" disabled={!hasValue} onClick={onSend} - className="grid size-6.5 place-items-center rounded-md bg-white text-black hover:bg-white/90 disabled:cursor-default disabled:bg-white/30 disabled:text-black/40 disabled:hover:bg-white/30" + className="composer-send grid size-6.5 place-items-center rounded-md bg-white text-black hover:bg-white/90 disabled:cursor-default disabled:bg-white/30 disabled:text-black/40 disabled:hover:bg-white/30" > diff --git a/src/chrome/GitChangesPanel.tsx b/src/chrome/GitChangesPanel.tsx index b31c8434..b8c0098f 100644 --- a/src/chrome/GitChangesPanel.tsx +++ b/src/chrome/GitChangesPanel.tsx @@ -6,8 +6,11 @@ import { ChevronRight, CloudUpload, ExternalLink, + FileDiff, + FolderTree, GitBranch, GitPullRequest, + ListBullet, Loader, Minus, Plus, @@ -19,6 +22,7 @@ import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, type ReactNode, @@ -50,10 +54,16 @@ import { subscribeGitChanged, type GitChangedFile, type GitDiffIndex, + type GitFileDiffKind, type GitHistoryCommit, type GitPr, } from "../lib/fs"; import type { HarnessId } from "../lib/session"; +import { + loadChangesView, + saveChangesView, + type ChangesView, +} from "../lib/appearance"; import { generateCommitMessage, generatePrContent } from "../lib/harness"; import { invalidateWatchedFiles } from "../lib/fileWatch"; import { MOD } from "../lib/platform"; @@ -73,6 +83,9 @@ function confirmNative(message: string, okLabel?: string): Promise { let stagedOpen = true; let changesOpen = true; let graphOpen = true; +let changesView: ChangesView = loadChangesView(); +/** Folders the user collapsed in tree view, keyed `:`. */ +const collapsedDirs = new Set(); const indexByCwd = new Map(); const prByCwd = new Map(); @@ -81,8 +94,10 @@ type Props = { enabled: boolean; textHarness?: HarnessId; selectedPath?: string; + selectedKind?: GitFileDiffKind; selectedSha?: string; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onOpenAllChanges: () => void; onOpenCommit: (commit: GitHistoryCommit) => void; }; @@ -91,8 +106,10 @@ export function GitChangesPanel({ enabled, textHarness, selectedPath, + selectedKind, selectedSha, onOpenFile, + onOpenAllChanges, onOpenCommit, }: Props) { const { index, reload } = useDiffIndex(cwd, enabled); @@ -156,9 +173,11 @@ export function GitChangesPanel({ index={index} files={files} selected={selectedPath} + selectedKind={selectedKind} enabled={enabled} fill onOpenFile={onOpenFile} + onOpenAllChanges={onOpenAllChanges} onMutated={(paths) => { reload(); notifyGitChanged(); @@ -212,9 +231,11 @@ function ChangedFiles({ index, files, selected, + selectedKind, enabled, fill, onOpenFile, + onOpenAllChanges, onMutated, }: { cwd: string; @@ -222,9 +243,11 @@ function ChangedFiles({ index: GitDiffIndex | null; files: GitChangedFile[]; selected?: string; + selectedKind?: GitFileDiffKind; enabled: boolean; fill: boolean; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onOpenAllChanges: () => void; onMutated: (paths?: string[]) => void; }) { const lockOverscroll = useLockOverscroll(); @@ -235,9 +258,13 @@ function ChangedFiles({ const [menuOpen, setMenuOpen] = useState(false); const [stagedExpanded, setStagedExpanded] = useState(stagedOpen); const [changesExpanded, setChangesExpanded] = useState(changesOpen); + const [view, setView] = useState(changesView); const { pr, reload: reloadPr } = usePrStatus(cwd, index?.branch); - const staged = files.filter((file) => file.staged); - const unstaged = files.filter((file) => file.unstaged); + const staged = useMemo(() => files.filter((file) => file.staged), [files]); + const unstaged = useMemo( + () => files.filter((file) => file.unstaged), + [files], + ); const hasRemote = Boolean(index?.remote); const hasOpenPr = pr?.state === "open"; const diverged = (index?.ahead ?? 0) > 0 && (index?.behind ?? 0) > 0; @@ -282,6 +309,12 @@ function ChangedFiles({ return () => window.removeEventListener("pointerdown", onPointer); }, [menuOpen]); + const toggleView = () => { + changesView = view === "tree" ? "list" : "tree"; + saveChangesView(changesView); + setView(changesView); + }; + const fail = (error: unknown) => { window.alert(error instanceof Error ? error.message : String(error)); }; @@ -566,7 +599,14 @@ function ChangedFiles({ stagedOpen = !stagedExpanded; setStagedExpanded(stagedOpen); }} + view={view} + onToggleView={toggleView} headerActions={[ + { + title: "Open All Changes", + icon: , + onClick: onOpenAllChanges, + }, { title: "Unstage All Changes", icon: , @@ -574,17 +614,16 @@ function ChangedFiles({ }, ]} > - {staged.map((file) => ( - - ))} + ) : null} {unstaged.length > 0 ? ( @@ -596,7 +635,14 @@ function ChangedFiles({ changesOpen = !changesExpanded; setChangesExpanded(changesOpen); }} + view={view} + onToggleView={toggleView} headerActions={[ + { + title: "Open All Changes", + icon: , + onClick: onOpenAllChanges, + }, { title: "Discard All Changes", icon: , @@ -609,17 +655,16 @@ function ChangedFiles({ }, ]} > - {unstaged.map((file) => ( - - ))} + ) : null} @@ -837,6 +882,8 @@ function FileSection({ count, open, onToggle, + view, + onToggleView, headerActions, children, }: { @@ -844,12 +891,14 @@ function FileSection({ count: number; open: boolean; onToggle: () => void; + view: ChangesView; + onToggleView: () => void; headerActions: { title: string; icon: ReactNode; onClick: () => void }[]; children: ReactNode; }) { return (
-
+
-
- {headerActions.map((action) => ( - - {action.icon} - - ))} -
+ + {view === "tree" ? ( + + ) : ( + + )} + + {headerActions.map((action) => ( + + {action.icon} + + ))}
{open ?
    {children}
: null}
); } +type ChangeDir = { + name: string; + /** Path relative to the repo root; "" for the implicit root. */ + path: string; + dirs: ChangeDir[]; + files: GitChangedFile[]; + /** Status shared by every descendant, or null when they differ. */ + status: string | null; +}; + +type ChangeRowProps = { + files: GitChangedFile[]; + view: ChangesView; + kind: GitFileDiffKind; + selected?: string; + selectedKind?: GitFileDiffKind; + busy: string | null; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; + onAction: ( + file: GitChangedFile, + action: "stage" | "unstage" | "discard", + ) => void; +}; + +function ChangeList({ files, view, ...rest }: ChangeRowProps) { + const tree = useMemo(() => buildChangeTree(files), [files]); + if (view === "tree") { + return ; + } + return ( + <> + {files.map((file) => ( + + ))} + + ); +} + +function ChangeDirChildren({ + dir, + depth, + kind, + selected, + selectedKind, + busy, + onOpenFile, + onAction, +}: Omit & { + dir: ChangeDir; + depth: number; +}) { + return ( + <> + {dir.dirs.map((child) => ( + + ))} + {dir.files.map((file) => ( + + ))} + + ); +} + +function ChangeDirRow({ + dir, + depth, + kind, + ...rest +}: Omit & { + dir: ChangeDir; + depth: number; +}) { + const key = `${kind}:${dir.path}`; + const [open, setOpen] = useState(() => !collapsedDirs.has(key)); + const toggle = () => { + if (open) collapsedDirs.add(key); + else collapsedDirs.delete(key); + setOpen(!open); + }; + return ( +
  • + + {open ? ( +
      + +
    + ) : null} +
  • + ); +} + +function isActive( + file: GitChangedFile, + selected: string | undefined, + selectedKind: GitFileDiffKind | undefined, + kind: GitFileDiffKind, +): boolean { + return selected === file.relative && (!selectedKind || selectedKind === kind); +} + +/** Nests changed files under their directories, VS Code's tree view. */ +function buildChangeTree(files: GitChangedFile[]): ChangeDir { + const root: ChangeDir = { + name: "", + path: "", + dirs: [], + files: [], + status: null, + }; + for (const file of files) { + const segments = file.relative.split("/"); + let node = root; + for (const segment of segments.slice(0, -1)) { + const path = node.path ? `${node.path}/${segment}` : segment; + let next = node.dirs.find((dir) => dir.path === path); + if (!next) { + next = { name: segment, path, dirs: [], files: [], status: null }; + node.dirs.push(next); + } + node = next; + } + node.files.push(file); + } + sortChangeDir(root); + return root; +} + +/** Sorts each level (folders first) and rolls descendant status upward. */ +function sortChangeDir(dir: ChangeDir): string | null { + dir.dirs.sort((a, b) => a.name.localeCompare(b.name)); + dir.files.sort((a, b) => + basename(a.relative).localeCompare(basename(b.relative)), + ); + let status: string | null = null; + let mixed = false; + const merge = (next: string | null) => { + if (next === null) mixed = true; + else if (status === null) status = next; + else if (status !== next) mixed = true; + }; + for (const child of dir.dirs) merge(sortChangeDir(child)); + for (const file of dir.files) merge(file.status); + dir.status = mixed ? null : status; + return dir.status; +} + function ChangeRow({ file, active, busy, kind, + depth, onOpenFile, onAction, }: { file: GitChangedFile; active: boolean; busy: boolean; - kind: "staged" | "unstaged"; - onOpenFile: (path: string) => void; + kind: GitFileDiffKind; + /** Set in tree view: nesting level, and the folder path moves to the tree. */ + depth?: number; + onOpenFile: (path: string, kind: GitFileDiffKind) => void; onAction: ( file: GitChangedFile, action: "stage" | "unstage" | "discard", ) => void; }) { const name = basename(file.relative); - const dir = dirname(file.relative); + const tree = depth !== undefined; + const dir = tree ? "" : dirname(file.relative); const canOpen = file.status !== "deleted"; return (
  • { - if (canOpen) onOpenFile(file.path); + if (canOpen) onOpenFile(file.path, kind); }} className="flex min-w-0 flex-1 items-center gap-1.5 text-left" > + {tree ? : null} {name} diff --git a/src/chrome/Popover.tsx b/src/chrome/Popover.tsx index 3cfda737..988a481d 100644 --- a/src/chrome/Popover.tsx +++ b/src/chrome/Popover.tsx @@ -57,7 +57,7 @@ type Props = Omit, "style"> & { const FRAME = "isolate overflow-hidden rounded-xl border border-content/10 shadow-xl"; const BACKDROP = - "pointer-events-none absolute inset-0 z-0 bg-content/10 backdrop-blur-xl [backface-visibility:hidden] [transform:translateZ(0)]"; + "popover-backdrop pointer-events-none absolute inset-0 z-0 backdrop-blur-xl [backface-visibility:hidden] [transform:translateZ(0)]"; /** Which corner the open animation grows from, so it reads as anchored. */ function origin(side: PopoverSide, align: PopoverAlign): string { diff --git a/src/chrome/ProjectBackgroundDialog.tsx b/src/chrome/ProjectBackgroundDialog.tsx new file mode 100644 index 00000000..78d4b1ea --- /dev/null +++ b/src/chrome/ProjectBackgroundDialog.tsx @@ -0,0 +1,224 @@ +import { useState, type ReactNode } from "react"; +import { Loader } from "./icons"; +import { Modal } from "./Modal"; +import { + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + chatBackgroundSrc, + loadChatBackgroundOpacity, + loadChatBackgroundPath, + loadChatBackgroundScope, + type ChatBackgroundScope, +} from "../lib/appearance"; +import { + clearProjectChatBackground, + pickAndSaveProjectChatBackground, + projectChatBackgroundSrc, +} from "../lib/chatBackground"; +import { + clearProjectChatBackgroundSetting, + loadProjectChatBackground, + projectChatBackgroundRevision, + saveProjectChatBackground, +} from "../lib/projectChatBackground"; + +type Props = { + project: string; + name: string; + onClose: () => void; +}; + +export function ProjectBackgroundDialog({ project, name, onClose }: Props) { + const initial = loadProjectChatBackground(project); + const [path, setPath] = useState(initial?.path ?? null); + const [opacity, setOpacity] = useState( + initial?.opacity ?? loadChatBackgroundOpacity(), + ); + const [scope, setScope] = useState( + initial?.scope ?? loadChatBackgroundScope(), + ); + const [revision, setRevision] = useState(projectChatBackgroundRevision); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const globalPath = loadChatBackgroundPath(); + const previewSrc = path + ? projectChatBackgroundSrc(path, revision) + : chatBackgroundSrc(globalPath); + + const save = ( + nextPath: string, + nextOpacity: number, + nextScope: ChatBackgroundScope, + ) => { + saveProjectChatBackground(project, { + path: nextPath, + opacity: nextOpacity, + scope: nextScope, + }); + setRevision(projectChatBackgroundRevision()); + }; + + const choose = async () => { + setBusy(true); + setError(null); + try { + const nextPath = await pickAndSaveProjectChatBackground(project); + if (!nextPath) return; + save(nextPath, opacity, scope); + setPath(nextPath); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const removeImage = async () => { + setBusy(true); + setError(null); + try { + await clearProjectChatBackground(project); + clearProjectChatBackgroundSetting(project); + setPath(null); + setOpacity(loadChatBackgroundOpacity()); + setScope(loadChatBackgroundScope()); + setRevision(projectChatBackgroundRevision()); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const updateOpacity = (percent: number) => { + const next = Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, percent / 100), + ); + setOpacity(next); + if (path) save(path, next, scope); + }; + + const updateScope = (next: ChatBackgroundScope) => { + setScope(next); + if (path) save(path, opacity, next); + }; + + return ( + +
    +
    +
    + {previewSrc ? ( + + ) : ( +
    + No background selected +
    + )} +
    + +

    + {path + ? "This image overrides the global background for this project." + : "This project currently follows the global Appearance setting."} +

    + {error ? ( +

    {error}

    + ) : null} +
    + + +
    + {[ + { value: "empty" as const, label: "Empty only" }, + { value: "all" as const, label: "All sessions" }, + ].map((option) => ( + + ))} +
    +
    + + +
    + updateOpacity(Number(event.target.value))} + /> + + {Math.round(opacity * 100)}% + +
    +
    + + {path ? ( + + ) : null} +
    +
    + ); +} + +function ProjectBackgroundRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
    + {label} + {children} +
    + ); +} diff --git a/src/chrome/ProjectRail.tsx b/src/chrome/ProjectRail.tsx index 06d72ebe..32f51a58 100644 --- a/src/chrome/ProjectRail.tsx +++ b/src/chrome/ProjectRail.tsx @@ -5,6 +5,7 @@ import { ChevronUp, CircleAlert, FolderOpen, + ImagePlus, Inbox, MoreHorizontal, Pin, @@ -61,6 +62,7 @@ import { import { formatLiveElapsed, type LiveAgent } from "../lib/liveAgents"; import { HarnessIcon } from "./HarnessIcon"; import { ProjectLogoIcon } from "./ProjectLogoIcon"; +import { ProjectBackgroundDialog } from "./ProjectBackgroundDialog"; import { ProjectMascot } from "./ProjectMascot"; import { RailAction, RailSearch } from "./RailAction"; import { RemoveProjectDialog } from "./RemoveProjectDialog"; @@ -84,6 +86,11 @@ function projectMenuExtraItems( canRemove: boolean, ): TabGroupMenuExtraItem[] { const items: TabGroupMenuExtraItem[] = [ + { + id: "background", + label: "Background image", + icon: ImagePlus, + }, pinned ? { id: "unpin", label: "Unpin project", icon: PinOff } : { id: "pin", label: "Pin project", icon: Pin }, @@ -189,6 +196,10 @@ export function ProjectRail({ path: string; name: string; } | null>(null); + const [backgroundProject, setBackgroundProject] = useState<{ + project: string; + name: string; + } | null>(null); const lockOverscroll = useLockOverscroll(); const scrollRef = useRef(null); const groupLogos = useTabGroupLogos(); @@ -323,7 +334,12 @@ export function ProjectRail({ if (!projectMenu) return; const { path, projectKey } = projectMenu; if (action === "pin" || action === "unpin") onTogglePin(path); - else if (action === "reveal") void revealPath(path); + else if (action === "background") { + setBackgroundProject({ + project: projectKey, + name: resolveTabGroupLabel(projectKey, groupLabels, basename(path)), + }); + } else if (action === "reveal") void revealPath(path); else if (action === "archive") { onRemoveProject?.(path, { purgeData: false }); } else if (action === "delete") { @@ -472,7 +488,7 @@ export function ProjectRail({ onOpenWhatsNew={onOpenWhatsNew} onDismissUpdate={onDismissUpdate} /> -
    +
    setRemoving(null)} /> ) : null} + {backgroundProject ? ( + setBackgroundProject(null)} + /> + ) : null}
    ; + visible?: boolean; + /** Renders the turn that holds the block. Returns false when the block is unknown. */ + revealBlock?: (blockId: string) => boolean; +}; + +export function PromptOutline({ + blocks, + scope, + visible = true, + revealBlock, +}: Props) { + const prompts = useMemo(() => promptBlocks(blocks), [blocks]); + const [activeId, setActiveId] = useState(null); + const [stackBudget, setStackBudget] = useState(BAR_STACK_MAX_PX); + const [hover, setHover] = useState(null); + const [open, setOpen] = useState(false); + const [focusId, setFocusId] = useState(null); + const rail = useRef(null); + const frame = useRef(null); + const openTimer = useRef(null); + const pointerInside = useRef(false); + const lastPromptId = useRef(null); + lastPromptId.current = prompts[prompts.length - 1]?.id ?? null; + + const measure = useCallback(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) { + setActiveId(null); + return; + } + const viewport = scroller.getBoundingClientRect(); + // A hidden tab has zero-size boxes. The rule would then select the last prompt. + if (viewport.height === 0) return; + setStackBudget( + Math.min( + BAR_STACK_MAX_PX, + Math.floor(viewport.height * BAR_STACK_PANE_SHARE), + ), + ); + // Streaming re-measures on every frame, and it almost always lands here: + // pinned to the end, where the last prompt wins whatever the anchors say. + // Answer from the block list and skip the walk. + const distanceToEnd = + scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; + if (distanceToEnd <= NEAR_END_PX) { + setActiveId(lastPromptId.current); + return; + } + const anchors: OutlineAnchor[] = []; + for (const el of scroller.querySelectorAll(ANCHOR)) { + const id = el.dataset.promptAnchor; + if (id) anchors.push({ id, ...promptBand(el, viewport) }); + } + setActiveId( + activePromptId( + { top: viewport.top, bottom: viewport.bottom }, + anchors, + distanceToEnd, + ), + ); + }, [scope]); + + const schedule = useCallback(() => { + if (frame.current != null) return; + frame.current = window.requestAnimationFrame(() => { + frame.current = null; + measure(); + }); + }, [measure]); + + useEffect(() => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + scroller.addEventListener("scroll", schedule, { passive: true }); + const observer = new ResizeObserver(schedule); + observer.observe(scroller); + // Content growth moves the anchors without a scroll event. + if (scroller.firstElementChild) + observer.observe(scroller.firstElementChild); + schedule(); + return () => { + scroller.removeEventListener("scroll", schedule); + observer.disconnect(); + if (frame.current != null) { + window.cancelAnimationFrame(frame.current); + frame.current = null; + } + }; + }, [schedule, scope]); + + useEffect(() => { + schedule(); + }, [schedule, blocks, visible]); + + const cancelOpen = () => { + if (openTimer.current == null) return; + window.clearTimeout(openTimer.current); + openTimer.current = null; + }; + useEffect(() => cancelOpen, []); + + /** The ripple follows the pointer at once. The card waits out a pass-through. */ + const hoverBar = (id: string, el: HTMLElement) => { + setHover({ id, el }); + if (open || openTimer.current != null) return; + openTimer.current = window.setTimeout(() => { + openTimer.current = null; + setOpen(true); + }, OPEN_DELAY_MS); + }; + const showBar = (id: string, el: HTMLElement) => { + cancelOpen(); + setHover({ id, el }); + setOpen(true); + }; + const close = () => { + cancelOpen(); + setHover(null); + setOpen(false); + }; + + const leaveRail = () => { + pointerInside.current = false; + // Keyboard focus holds the card open after the pointer moves away. + if (keyboardFocused(rail.current)) return; + close(); + }; + const blurRail = (event: ReactFocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget)) return; + if (pointerInside.current) return; + close(); + }; + + const hoverId = hover?.id ?? null; + const preview = useMemo( + () => (hoverId ? promptPreview(blocks, hoverId) : null), + [blocks, hoverId], + ); + + const jumpTo = (id: string) => { + const scroller = scope.current?.querySelector(SCROLLER); + if (!scroller) return; + // The transcript scrolls to the bottom on each streaming update until a + // wheel-up event occurs. Send one, so the jump stays. + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); + const selector = `[data-prompt-anchor="${CSS.escape(id)}"]`; + let anchor = scroller.querySelector(selector); + if (!anchor && revealBlock?.(id)) { + anchor = scroller.querySelector(selector); + } + if (!anchor) { + scroller.scrollTop = 0; + return; + } + const target = anchor.closest(TURN) ?? anchor; + const align = () => { + const delta = + target.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + SCROLL_INSET_PX; + if (Math.abs(delta) > 2) scroller.scrollTop += delta; + }; + align(); + // Turns that enter the screen get their real height. That can move the + // target. + window.requestAnimationFrame(align); + }; + + if (prompts.length < MIN_PROMPTS) return null; + + const activeIndex = prompts.findIndex((prompt) => prompt.id === activeId); + const stack = barStack( + prompts.length, + activeIndex >= 0 ? activeIndex : null, + stackBudget, + ); + const bars = prompts.slice(stack.start, stack.end); + const hoverIndex = hover ? bars.findIndex((bar) => bar.id === hover.id) : -1; + // One tab stop for the whole rail. Arrow keys walk it from there. + const tabId = + [focusId, activeId].find((id) => bars.some((bar) => bar.id === id)) ?? + bars[0].id; + + const onKeyDown = (event: ReactKeyboardEvent) => { + const step = + event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + if (step === 0) return; + event.preventDefault(); + const from = bars.findIndex((bar) => bar.id === tabId); + const next = bars[from + step]; + if (!next) return; + setFocusId(next.id); + rail.current + ?.querySelector(`[data-prompt-bar="${CSS.escape(next.id)}"]`) + ?.focus(); + }; + + return ( +
    { + pointerInside.current = true; + }} + onMouseLeave={leaveRail} + onBlur={blurRail} + onKeyDown={onKeyDown} + className="absolute top-1/2 right-4 z-30 flex -translate-y-1/2 flex-col items-end @max-[58rem]:hidden" + > + {bars.map((prompt, index) => { + const lift = barLift(index, hoverIndex); + const distance = hoverIndex < 0 ? 0 : Math.abs(index - hoverIndex); + // The pointer owns the fill while it is on the rail. Off the rail, the + // fill goes back to marking the scroll position. + const lit = + hoverIndex >= 0 ? index === hoverIndex : prompt.id === activeId; + return ( + + ); + })} + {open && preview && hoverIndex >= 0 ? ( + +

    + {preview.title} +

    + {preview.reply ? ( +

    + {preview.reply} +

    + ) : null} + {preview.detail ? ( +

    + {preview.detail} +

    + ) : null} +
    + ) : null} +
    + ); +} + +/** Clicking a bar focuses it as well. Only a keyboard focus holds the card open. */ +function keyboardFocused(rail: HTMLElement | null): boolean { + const el = document.activeElement; + return ( + el instanceof HTMLElement && + !!rail?.contains(el) && + el.matches(":focus-visible") + ); +} + +/** + * content-visibility skips off-screen turns. A read inside a skipped turn + * forces its layout. Use the turn box for an off-screen turn. Use the exact + * prompt box for an on-screen turn. + */ +function promptBand(anchor: HTMLElement, viewport: DOMRect): OutlineBand { + const turn = anchor.closest(TURN) ?? anchor; + const turnBox = turn.getBoundingClientRect(); + const onScreen = + turnBox.bottom > viewport.top && turnBox.top < viewport.bottom; + const box = + turn !== anchor && onScreen ? anchor.getBoundingClientRect() : turnBox; + return { top: box.top, bottom: box.bottom }; +} + +/** One bar per prompt while the bars fit the budget. The gap shrinks first. Past that, a window slides. */ +function barStack(count: number, activeIndex: number | null, budget: number) { + const fit = Math.max( + 1, + Math.floor((budget + BAR_GAP_MIN_PX) / (BAR_HEIGHT_PX + BAR_GAP_MIN_PX)), + ); + const window_ = barWindow(count, activeIndex, fit); + const shown = window_.end - window_.start; + const gap = + shown > 1 + ? Math.min( + BAR_GAP_PX, + Math.max( + BAR_GAP_MIN_PX, + Math.floor((budget - shown * BAR_HEIGHT_PX) / (shown - 1)), + ), + ) + : 0; + return { ...window_, gap }; +} diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index a0eb7751..c3ff42cc 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -29,7 +29,11 @@ import { saveSidebarTabOrder, type SidebarTabId, } from "../lib/appearance"; -import { basename, type GitHistoryCommit } from "../lib/fs"; +import { + basename, + type GitFileDiffKind, + type GitHistoryCommit, +} from "../lib/fs"; import { IS_MAC, MOD } from "../lib/platform"; import { resolveModel } from "../lib/models"; import { prettyParent, projectKey, projectName } from "../lib/paths"; @@ -202,9 +206,11 @@ type Props = { canGoForward?: boolean; onGoBack?: () => void; onGoForward?: () => void; - onOpenDiff?: (path: string) => void; + onOpenDiff?: (path: string, kind?: GitFileDiffKind) => void; + onOpenAllChanges?: () => void; onOpenCommit?: (commit: GitHistoryCommit) => void; selectedDiffPath?: string; + selectedDiffKind?: GitFileDiffKind; selectedCommitSha?: string; textHarness?: HarnessId; onShowSourceControl?: () => void; @@ -275,8 +281,10 @@ function SidebarComponent({ onGoBack, onGoForward, onOpenDiff, + onOpenAllChanges, onOpenCommit, selectedDiffPath, + selectedDiffKind, selectedCommitSha, textHarness, onShowSourceControl, @@ -331,6 +339,7 @@ function SidebarComponent({ const [selectedSessionIds, setSelectedSessionIds] = useState>( () => new Set(), ); + const contextSelectionRef = useRef(false); const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; @@ -561,7 +570,7 @@ function SidebarComponent({ useEffect(() => { if (!sessionMenu && !folderMenu && !filterMenu) return; const onScroll = () => { - setSessionMenu(null); + closeSessionMenu(); setFolderMenu(null); setFilterMenu(null); }; @@ -572,13 +581,29 @@ function SidebarComponent({ useEffect(() => { if (selectedSessionIds.size === 0) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; + const clear = () => { + contextSelectionRef.current = false; setSelectedSessionIds(new Set()); setSessionMenu(null); }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + clear(); + }; + // A pointer landing off the cards drops the selection; a menu acting on + // it stays open, and the cards handle their own clicks. + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + const el = target instanceof Element ? target : null; + if (el?.closest("[data-session-card],[data-popover-side]")) return; + clear(); + }; window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); + window.addEventListener("pointerdown", onPointerDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("pointerdown", onPointerDown); + }; }, [selectedSessionIds.size]); const commitSessionFolders = (next: SessionFolder[]) => { @@ -717,7 +742,8 @@ function SidebarComponent({ ) => { e.preventDefault(); e.stopPropagation(); - if (!selectedSessionIds.has(sessionId)) { + contextSelectionRef.current = !selectedSessionIds.has(sessionId); + if (contextSelectionRef.current) { setSelectedSessionIds(new Set([sessionId])); } setFilterMenu(null); @@ -725,6 +751,13 @@ function SidebarComponent({ setSessionMenu({ x: e.clientX, y: e.clientY, sessionId }); }; + const closeSessionMenu = () => { + setSessionMenu(null); + if (!contextSelectionRef.current) return; + contextSelectionRef.current = false; + setSelectedSessionIds(new Set()); + }; + const onFolderContextMenu = ( folderId: string, e: ReactMouseEvent, @@ -742,7 +775,7 @@ function SidebarComponent({ const sessionIds = menuSessionIds; const archived = allMenuSessionsArchived; const pinned = allMenuSessionsPinned; - setSessionMenu(null); + closeSessionMenu(); if (id === "pin") { if (sessionIds.length > 1 && onPinSessions) { onPinSessions(sessionIds, !pinned); @@ -849,6 +882,7 @@ function SidebarComponent({ event: ReactMouseEvent, ) => { if (event.shiftKey) { + contextSelectionRef.current = false; setSessionMenu(null); setSelectedSessionIds((current) => toggleSessionSelection(current, sessionId), @@ -1373,8 +1407,10 @@ function SidebarComponent({ enabled={open} textHarness={textHarness} selectedPath={selectedDiffPath} + selectedKind={selectedDiffKind} selectedSha={selectedCommitSha} onOpenFile={onOpenDiff ?? onOpenFile} + onOpenAllChanges={onOpenAllChanges ?? (() => {})} onOpenCommit={onOpenCommit ?? (() => {})} />
    @@ -1386,7 +1422,7 @@ function SidebarComponent({ onOpenWhatsNew={onOpenWhatsNew} onDismissUpdate={onDismissUpdate} /> -
    +
    setSessionMenu(null)} + onClose={closeSessionMenu} /> ) : null} {folderMenu ? ( diff --git a/src/chrome/SidebarUpdate.test.ts b/src/chrome/SidebarUpdate.test.ts new file mode 100644 index 00000000..a8d9ac76 --- /dev/null +++ b/src/chrome/SidebarUpdate.test.ts @@ -0,0 +1,158 @@ +import { createElement, type ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { UpdaterPhase, UpdaterSnapshot } from "../lib/updater"; +import { + SidebarUpdate, + SidebarUpdateFooter, + isSidebarUpdateActionable, +} from "./SidebarUpdate"; + +const updaterMocks = vi.hoisted(() => ({ + installPendingUpdate: vi.fn(), +})); + +// The updater module reaches for Tauri plugins at import time; stub them so the +// component under test can be imported in the plain node environment. +vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: vi.fn(), + message: vi.fn(), +})); +vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: vi.fn() })); +vi.mock("@tauri-apps/plugin-updater", () => ({ check: vi.fn() })); +vi.mock("../lib/updater", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + installPendingUpdate: ( + ...args: Parameters + ) => updaterMocks.installPendingUpdate(...args), + }; +}); + +function installButtonClick() { + let onClick: (() => void) | undefined; + function Capture() { + const tree = SidebarUpdate({ + snapshot: { + phase: "available", + currentVersion: "0.1.37", + availableVersion: "0.1.38", + }, + onSnapshot: vi.fn(), + }) as ReactElement<{ onClick: () => void }>; + onClick = tree.props.onClick; + return tree; + } + renderToStaticMarkup(createElement(Capture)); + if (!onClick) throw new Error("expected the install button handler"); + return onClick; +} + +describe("isSidebarUpdateActionable", () => { + it("only claims sidebar space for an update the user can act on", () => { + const phases: UpdaterPhase[] = [ + "idle", + "checking", + "current", + "available", + "downloading", + "error", + ]; + const actionable = phases.filter((phase) => + isSidebarUpdateActionable({ phase, currentVersion: "0.1.37" }), + ); + expect(actionable).toEqual(["available", "downloading"]); + }); +}); + +describe("SidebarUpdate", () => { + it("offers the install action for an available version", () => { + const markup = renderToStaticMarkup( + createElement(SidebarUpdate, { + snapshot: { + phase: "available", + currentVersion: "0.1.37", + availableVersion: "0.1.38", + }, + onSnapshot: vi.fn(), + }), + ); + + expect(markup).toContain("Update to 0.1.38"); + expect(markup).toContain("v0.1.37"); + expect(markup).not.toContain('disabled=""'); + }); + + it("reports download progress and blocks a second click", () => { + const markup = renderToStaticMarkup( + createElement(SidebarUpdate, { + snapshot: { + phase: "downloading", + currentVersion: "0.1.37", + availableVersion: "0.1.38", + progress: 42, + }, + onSnapshot: vi.fn(), + }), + ); + + expect(markup).toContain("Downloading 42%"); + expect(markup).toContain('disabled=""'); + }); + + it("ignores a second click while readAppVersion is still pending", async () => { + // installPendingUpdate awaits readAppVersion before it reports "downloading", + // so a second click can still land while `busy` is false. The hanging mock + // is that window. + let releaseVersionRead!: (snapshot: UpdaterSnapshot) => void; + updaterMocks.installPendingUpdate.mockReset(); + updaterMocks.installPendingUpdate.mockImplementation( + () => + new Promise((resolve) => { + releaseVersionRead = resolve; + }), + ); + + const onClick = installButtonClick(); + const first = Promise.resolve(onClick()); + const second = Promise.resolve(onClick()); + + expect(updaterMocks.installPendingUpdate).toHaveBeenCalledOnce(); + + releaseVersionRead({ + phase: "downloading", + currentVersion: "0.1.37", + availableVersion: "0.1.38", + }); + await Promise.all([first, second]); + expect(updaterMocks.installPendingUpdate).toHaveBeenCalledOnce(); + }); +}); + +describe("SidebarUpdateFooter", () => { + // renderToStaticMarkup never runs effects, so the automatic probe stays in its + // initial `idle` phase here — exactly the state that used to render a + // permanent "Check for updates" row. + it("stays silent while the automatic probe has nothing to offer", () => { + expect(renderToStaticMarkup(createElement(SidebarUpdateFooter, {}))).toBe( + "", + ); + }); + + it("still shows the post-install card without an update row", () => { + const markup = renderToStaticMarkup( + createElement(SidebarUpdateFooter, { + update: { version: "0.1.37" }, + onOpenWhatsNew: vi.fn(), + onDismissUpdate: vi.fn(), + }), + ); + + expect(markup).toContain("Updated to 0.1.37"); + expect(markup).toContain("What's new"); + expect(markup).not.toContain("Check for updates"); + expect(markup).not.toContain("Update to"); + }); +}); diff --git a/src/chrome/SidebarUpdate.tsx b/src/chrome/SidebarUpdate.tsx index 02fe933f..2c190f95 100644 --- a/src/chrome/SidebarUpdate.tsx +++ b/src/chrome/SidebarUpdate.tsx @@ -1,15 +1,22 @@ -import { ArrowDownCircle, Loader, RefreshCw } from "./icons"; -import { useCallback, useEffect, useState } from "react"; +import { ArrowDownCircle, Loader } from "./icons"; +import { useCallback, useEffect, useRef, useState } from "react"; import { installPendingUpdate, probeForUpdate, readAppVersion, - runUpdateFlow, type UpdaterSnapshot, } from "../lib/updater"; import type { InstalledUpdate } from "../lib/updateNotice"; import { UpdateRailCard } from "./UpdateRailCard"; +// The sidebar row only earns its space when there is something to act on: an +// update waiting to be installed, or one already downloading. Every other phase +// — including a probe that failed — stays silent, because manual "Check for +// updates" already lives in Settings and the app menu. +export function isSidebarUpdateActionable(snapshot: UpdaterSnapshot): boolean { + return snapshot.phase === "available" || snapshot.phase === "downloading"; +} + export function SidebarUpdateFooter({ update, onOpenWhatsNew, @@ -19,26 +26,15 @@ export function SidebarUpdateFooter({ onOpenWhatsNew?: (version: string) => void; onDismissUpdate?: () => void; }) { - return ( -
    - {update && onOpenWhatsNew && onDismissUpdate ? ( - - ) : null} - -
    - ); -} - -export function SidebarUpdate() { const [snapshot, setSnapshot] = useState({ phase: "idle", currentVersion: "…", }); + // The automatic probe runs on mount whether or not it ends up rendering + // anything, so a newly published version still surfaces on its own. The + // snapshot lives here rather than in SidebarUpdate so the footer can drop its + // padding entirely when neither child has anything to show. useEffect(() => { let cancelled = false; @@ -70,29 +66,56 @@ export function SidebarUpdate() { }; }, []); - const onClick = useCallback(async () => { - if (snapshot.phase === "downloading" || snapshot.phase === "checking") { - return; - } + const card = + update && onOpenWhatsNew && onDismissUpdate ? ( + + ) : null; + const actionable = isSidebarUpdateActionable(snapshot); - if (snapshot.phase === "available") { - await installPendingUpdate(setSnapshot); - return; - } + if (!card && !actionable) return null; + + // The gap down to the Settings block belongs to that block's own padding, so + // the footer can disappear without leaving the sidebar's bottom row flush + // against the scrolling list above it. + return ( +
    + {card} + {actionable ? ( + + ) : null} +
    + ); +} + +export function SidebarUpdate({ + snapshot, + onSnapshot, +}: { + snapshot: UpdaterSnapshot; + onSnapshot: (next: UpdaterSnapshot) => void; +}) { + const busy = snapshot.phase === "downloading"; + // `busy` only flips after installPendingUpdate awaits readAppVersion, so a + // second click can still land. The ref closes that window immediately. + const installing = useRef(false); - await runUpdateFlow(true, setSnapshot); - }, [snapshot.phase]); + const onClick = useCallback(async () => { + if (busy || installing.current) return; + installing.current = true; + try { + await installPendingUpdate(onSnapshot); + } finally { + installing.current = false; + } + }, [busy, onSnapshot]); - const busy = - snapshot.phase === "checking" || snapshot.phase === "downloading"; - const hasUpdate = snapshot.phase === "available"; - const label = hasUpdate - ? `Update to ${snapshot.availableVersion}` - : busy - ? snapshot.phase === "downloading" - ? `Downloading${snapshot.progress != null ? ` ${snapshot.progress}%` : "…"}` - : "Checking…" - : "Check for updates"; + const label = busy + ? `Downloading${snapshot.progress != null ? ` ${snapshot.progress}%` : "…"}` + : `Update to ${snapshot.availableVersion}`; return (
    diff --git a/src/chrome/icons.tsx b/src/chrome/icons.tsx index cc7d2f93..0a0b6511 100644 --- a/src/chrome/icons.tsx +++ b/src/chrome/icons.tsx @@ -33,12 +33,14 @@ import Delete02Icon from "@hugeicons/core-free-icons/Delete02Icon"; import DragDropVerticalIcon from "@hugeicons/core-free-icons/DragDropVerticalIcon"; import File01Icon from "@hugeicons/core-free-icons/File01Icon"; import FileAddIcon from "@hugeicons/core-free-icons/FileAddIcon"; +import FileDiffIcon from "@hugeicons/core-free-icons/FileDiffIcon"; import FilePlusCornerIcon from "@hugeicons/core-free-icons/FilePlusCornerIcon"; import FilterIcon from "@hugeicons/core-free-icons/FilterIcon"; import FlashIcon from "@hugeicons/core-free-icons/FlashIcon"; import Folder01Icon from "@hugeicons/core-free-icons/Folder01Icon"; import FolderAddIcon from "@hugeicons/core-free-icons/FolderAddIcon"; import FolderOpenIcon from "@hugeicons/core-free-icons/FolderOpenIcon"; +import FolderTreeIcon from "@hugeicons/core-free-icons/FolderTreeIcon"; import GaugeIcon from "@hugeicons/core-free-icons/GaugeIcon"; import GitBranchIcon from "@hugeicons/core-free-icons/GitBranchIcon"; import GitCompareIcon from "@hugeicons/core-free-icons/GitCompareIcon"; @@ -52,6 +54,7 @@ import KeyboardIcon from "@hugeicons/core-free-icons/KeyboardIcon"; import LayoutAlignRightIcon from "@hugeicons/core-free-icons/LayoutAlignRightIcon"; import LayoutBottomIcon from "@hugeicons/core-free-icons/LayoutBottomIcon"; import LayoutTopIcon from "@hugeicons/core-free-icons/LayoutTopIcon"; +import LeftToRightListBulletIcon from "@hugeicons/core-free-icons/LeftToRightListBulletIcon"; import ListEndIcon from "@hugeicons/core-free-icons/ListEndIcon"; import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon"; import Loading03Icon from "@hugeicons/core-free-icons/Loading03Icon"; @@ -162,12 +165,14 @@ export const CloudUpload = wrap(CloudUploadIcon, "CloudUpload"); export const Copy = wrap(Copy01Icon, "Copy"); export const ExternalLink = wrap(LinkSquare02Icon, "ExternalLink"); export const File = wrap(File01Icon, "File"); +export const FileDiff = wrap(FileDiffIcon, "FileDiff"); export const FilePlus = wrap(FileAddIcon, "FilePlus"); export const FilePlusCorner = wrap(FilePlusCornerIcon, "FilePlusCorner"); export const FoldVertical = wrap(FoldVerticalIcon, "FoldVertical"); export const Folder = wrap(Folder01Icon, "Folder"); export const FolderOpen = wrap(FolderOpenIcon, "FolderOpen"); export const FolderPlus = wrap(FolderAddIcon, "FolderPlus"); +export const FolderTree = wrap(FolderTreeIcon, "FolderTree"); export const Gauge = wrap(GaugeIcon, "Gauge"); export const GitBranch = wrap(GitBranchIcon, "GitBranch"); export const GitCompare = wrap(GitCompareIcon, "GitCompare"); @@ -185,6 +190,7 @@ export const GripVertical = wrap(DragDropVerticalIcon, "GripVertical"); export const ImagePlus = wrap(ImageAdd01Icon, "ImagePlus"); export const Inbox = wrap(InboxIcon, "Inbox"); export const Keyboard = wrap(KeyboardIcon, "Keyboard"); +export const ListBullet = wrap(LeftToRightListBulletIcon, "ListBullet"); export const ListEnd = wrap(ListEndIcon, "ListEnd"); export const ListFilter = wrap(FilterIcon, "ListFilter"); export const Loader = wrap(Loading03Icon, "Loader"); diff --git a/src/index.css b/src/index.css index e257f7d0..0d97a270 100644 --- a/src/index.css +++ b/src/index.css @@ -3,6 +3,8 @@ @source "../node_modules/@streamdown/code/dist/*.js"; @source "../node_modules/@streamdown/mermaid/dist/*.js"; +@custom-variant dark (&:where(html:not(.theme-light), html:not(.theme-light) *)); + @theme { --color-background-base: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) @@ -25,6 +27,7 @@ --theme-hue: 240; --theme-saturation: 0%; --sidebar-opacity: 0.85; + --chat-background-opacity: 0.24; --background-lightness: 9%; --content-lightness: 92%; --link-color: #7dd3fc; @@ -95,9 +98,9 @@ html:not(.is-mac) ::-webkit-scrollbar-corner { background: transparent; } -html.has-native-glass, -html.has-native-glass body, -html.has-native-glass #root { +html.has-native-glass:not(.theme-light), +html.has-native-glass:not(.theme-light) body, +html.has-native-glass:not(.theme-light) #root { background: transparent; } @@ -106,10 +109,10 @@ html.has-native-glass #root { } html.theme-light .sidebar-glass { - background: color-mix(in srgb, var(--color-background-base) 93%, black 7%); + background: var(--color-background-base); } -html.has-native-glass .sidebar-glass { +html.has-native-glass:not(.theme-light) .sidebar-glass { background: hsl( var(--theme-hue) var(--theme-saturation) var(--background-lightness) / var(--sidebar-opacity) @@ -120,6 +123,33 @@ html.has-native-glass .sidebar-glass { background: var(--color-background-base); } +.chat-pane-background::before { + content: ""; + position: absolute; + left: var(--chat-background-left, 0); + top: var(--chat-background-top, 0); + width: var(--chat-background-width, 100%); + height: var(--chat-background-height, 100%); + z-index: -1; + background-image: var(--chat-background-image, none); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + opacity: 0; + pointer-events: none; +} + +html.has-chat-background .chat-pane-background::before, +.chat-pane-background[data-project-chat-background="true"]::before { + opacity: var(--chat-background-opacity); +} + +html.chat-background-empty-only + .chat-pane-background[data-project-chat-background="false"][data-session-empty="false"]::before, +.chat-pane-background[data-project-chat-background="true"][data-project-background-scope="empty"][data-session-empty="false"]::before { + opacity: 0; +} + .transcript-turn { content-visibility: auto; contain-intrinsic-block-size: auto 240px; @@ -143,7 +173,7 @@ html.has-native-glass .sidebar-glass { min-height: var(--transcript-viewport, 0px); } -html.has-native-glass.glass-body .body-glass { +html.has-native-glass.glass-body:not(.theme-light) .body-glass { background: color-mix( in srgb, var(--color-background-base) calc(var(--sidebar-opacity) * 100%), @@ -233,6 +263,31 @@ html.is-resizing * { scrollbar-width: none; } +html.theme-light [data-composer-box] { + background: var(--color-background-base); + box-shadow: + 0 6px 24px color-mix(in srgb, var(--color-content) 9%, transparent), + 0 2px 6px color-mix(in srgb, var(--color-content) 6%, transparent); +} + +html.theme-light .composer-send:not(:disabled) { + background: var(--color-content); + color: var(--color-background-base); +} + +html.theme-light .composer-send:not(:disabled):hover { + background: color-mix(in srgb, var(--color-content) 90%, transparent); +} + +html.theme-light .composer-send:disabled { + background: color-mix(in srgb, var(--color-content) 25%, transparent); + color: color-mix( + in srgb, + var(--color-background-base) 75%, + transparent + ); +} + .composer-field { color: transparent; caret-color: var(--color-content); @@ -1095,6 +1150,16 @@ header[data-tauri-drag-region] button { } } +/* Keep the original content tint in dark mode. In light mode, use the base + color at the same opacity so the surface stays translucent without graying. */ +.popover-backdrop { + background: color-mix(in srgb, var(--color-content) 2%, transparent); +} + +html.theme-light .popover-backdrop { + background: color-mix(in srgb, var(--color-background-base) 10%, transparent); +} + /* Popover contents grow in from the edge they hang off, sliding the last few pixels out of their trigger. The glass frame stays still: WebKit composites `backdrop-filter` on its own layer and can flash a stale backdrop if that diff --git a/src/lib/appearance.test.ts b/src/lib/appearance.test.ts index 92b56a0b..dfca0c23 100644 --- a/src/lib/appearance.test.ts +++ b/src/lib/appearance.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_SCOPE_DEFAULT, + loadChatBackgroundOpacity, + loadChatBackgroundPath, + loadChatBackgroundScope, loadTranscriptLayout, + saveChatBackgroundOpacity, + saveChatBackgroundPath, + saveChatBackgroundScope, saveTranscriptLayout, TRANSCRIPT_LAYOUT_DEFAULT, loadTranscriptAnchor, @@ -15,6 +23,9 @@ import { const KEY = "monocode.transcriptLayout"; const SCHEME_KEY = "monocode.colorScheme"; const ANCHOR_KEY = "monocode.transcriptAnchor"; +const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; +const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; +const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; function mockLocalStorage() { const data = new Map(); @@ -84,6 +95,43 @@ describe("transcript prompt-to-top setting", () => { }); }); +describe("chat background setting", () => { + beforeEach(mockLocalStorage); + afterEach(() => { + localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + localStorage.removeItem(CHAT_BACKGROUND_OPACITY_KEY); + localStorage.removeItem(CHAT_BACKGROUND_SCOPE_KEY); + }); + + it("stores and clears the app-owned background path", () => { + expect(loadChatBackgroundPath()).toBeNull(); + saveChatBackgroundPath("/app-data/backgrounds/chat-background.webp"); + expect(loadChatBackgroundPath()).toBe( + "/app-data/backgrounds/chat-background.webp", + ); + saveChatBackgroundPath(null); + expect(loadChatBackgroundPath()).toBeNull(); + }); + + it("defaults and clamps background visibility", () => { + expect(loadChatBackgroundOpacity()).toBe(CHAT_BACKGROUND_OPACITY_DEFAULT); + saveChatBackgroundOpacity(1); + expect(loadChatBackgroundOpacity()).toBe(0.65); + saveChatBackgroundOpacity(0); + expect(loadChatBackgroundOpacity()).toBe(0.05); + }); + + it("persists where the background is shown", () => { + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + saveChatBackgroundScope("empty"); + expect(loadChatBackgroundScope()).toBe("empty"); + saveChatBackgroundScope("all"); + expect(loadChatBackgroundScope()).toBe("all"); + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, "transcript"); + expect(loadChatBackgroundScope()).toBe(CHAT_BACKGROUND_SCOPE_DEFAULT); + }); +}); + function mockSystemScheme(scheme: "dark" | "light") { Object.defineProperty(globalThis, "window", { value: { diff --git a/src/lib/appearance.ts b/src/lib/appearance.ts index 38ae84d6..eb6dfaf9 100644 --- a/src/lib/appearance.ts +++ b/src/lib/appearance.ts @@ -1,4 +1,4 @@ -import { invoke } from "@tauri-apps/api/core"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { HAS_NATIVE_GLASS, IS_MAC } from "./platform"; import { applyUiScale, loadUiScale } from "./uiScale"; @@ -13,10 +13,21 @@ const SIDEBAR_TAB_ORDER_KEY = "monocode.sidebarTabOrder"; const PROJECT_RAIL_WIDTH_KEY = "monocode.projectRailWidth"; const TRANSCRIPT_LAYOUT_KEY = "monocode.transcriptLayout"; const TRANSCRIPT_ANCHOR_KEY = "monocode.transcriptAnchor"; +const CHAT_BACKGROUND_PATH_KEY = "monocode.chatBackgroundPath"; +const CHAT_BACKGROUND_OPACITY_KEY = "monocode.chatBackgroundOpacity"; +const CHAT_BACKGROUND_SCOPE_KEY = "monocode.chatBackgroundScope"; +const CHANGES_VIEW_KEY = "monocode.changesView"; +let chatBackgroundRevision = Date.now(); +let nativeGlassReady = false; + +export const CHAT_BACKGROUND_PATH_CHANGE_EVENT = + "monocode:chat-background-path-change"; export type ColorScheme = "dark" | "light"; export type ThemePreference = ColorScheme | "system"; export type TranscriptLayout = "full" | "chat"; +export type ChatBackgroundScope = "empty" | "all"; +export type ChangesView = "list" | "tree"; export const THEME_PREFERENCE_DEFAULT: ThemePreference = "dark"; @@ -25,6 +36,8 @@ export const SCHEME_CHANGE_EVENT = "monocode:schemechange"; export const TRANSCRIPT_LAYOUT_DEFAULT: TranscriptLayout = "full"; +export const CHANGES_VIEW_DEFAULT: ChangesView = "list"; + export const TRANSCRIPT_ANCHOR_DEFAULT = true; /** Fired on `window` whenever prompt-to-top anchoring flips (detail: boolean). */ @@ -64,6 +77,11 @@ export const PROJECT_RAIL_WIDTH_DEFAULT = 200; export const BODY_GLASS_DEFAULT = true; +export const CHAT_BACKGROUND_OPACITY_MIN = 0.05; +export const CHAT_BACKGROUND_OPACITY_MAX = 0.65; +export const CHAT_BACKGROUND_OPACITY_DEFAULT = 0.24; +export const CHAT_BACKGROUND_SCOPE_DEFAULT: ChatBackgroundScope = "all"; + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -163,6 +181,9 @@ export function initAppearance() { applySidebarOpacity(loadSidebarOpacity()); applySidebarBlur(loadSidebarBlur()); applyBodyGlass(loadBodyGlass()); + applyChatBackground(loadChatBackgroundPath()); + applyChatBackgroundOpacity(loadChatBackgroundOpacity()); + applyChatBackgroundScope(loadChatBackgroundScope()); void applyUiScale(loadUiScale()); } @@ -207,12 +228,23 @@ export function isLightScheme(): boolean { export function applyThemePreference(value: ThemePreference): ColorScheme { const next = resolveColorScheme(value); document.documentElement.classList.toggle("theme-light", next === "light"); + if (nativeGlassReady) syncNativeGlass(next); window.dispatchEvent( new CustomEvent(SCHEME_CHANGE_EVENT, { detail: next }), ); return next; } +function syncNativeGlass(scheme: ColorScheme) { + void invoke("set_window_glass_enabled", { enabled: scheme === "dark" }); +} + +/** Applies native transparency once the opaque launch cover can be removed. */ +export function activateWindowAppearance() { + nativeGlassReady = true; + syncNativeGlass(isLightScheme() ? "light" : "dark"); +} + /** Keeps the "system" preference in sync when the OS flips appearance. */ export function watchSystemColorScheme() { const query = systemQuery(); @@ -282,6 +314,112 @@ export function applyBodyGlass(value: boolean) { return value; } +export function loadChatBackgroundPath(): string | null { + try { + return localStorage.getItem(CHAT_BACKGROUND_PATH_KEY)?.trim() || null; + } catch { + return null; + } +} + +export function saveChatBackgroundPath(value: string | null) { + try { + if (value) localStorage.setItem(CHAT_BACKGROUND_PATH_KEY, value); + else localStorage.removeItem(CHAT_BACKGROUND_PATH_KEY); + } catch { + // private mode / quota + } + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(CHAT_BACKGROUND_PATH_CHANGE_EVENT)); +} + +export function subscribeChatBackgroundPath(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHAT_BACKGROUND_PATH_CHANGE_EVENT, onStoreChange); + return () => + window.removeEventListener( + CHAT_BACKGROUND_PATH_CHANGE_EVENT, + onStoreChange, + ); +} + +export function applyChatBackground(path: string | null) { + const root = document.documentElement; + root.classList.toggle("has-chat-background", !!path); + if (!path) { + root.style.removeProperty("--chat-background-image"); + return null; + } + chatBackgroundRevision += 1; + const src = chatBackgroundSrc(path); + root.style.setProperty( + "--chat-background-image", + `url(${JSON.stringify(src)})`, + ); + return path; +} + +export function chatBackgroundSrc(path: string | null): string | null { + return path ? `${convertFileSrc(path)}?v=${chatBackgroundRevision}` : null; +} + +export function loadChatBackgroundOpacity(): number { + return clamp( + readNumber(CHAT_BACKGROUND_OPACITY_KEY) ?? CHAT_BACKGROUND_OPACITY_DEFAULT, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); +} + +export function saveChatBackgroundOpacity(value: number) { + writeNumber( + CHAT_BACKGROUND_OPACITY_KEY, + clamp(value, CHAT_BACKGROUND_OPACITY_MIN, CHAT_BACKGROUND_OPACITY_MAX), + ); +} + +export function applyChatBackgroundOpacity(value: number) { + const next = clamp( + value, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_OPACITY_MAX, + ); + document.documentElement.style.setProperty( + "--chat-background-opacity", + String(next), + ); + return next; +} + +function isChatBackgroundScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadChatBackgroundScope(): ChatBackgroundScope { + try { + const raw = localStorage.getItem(CHAT_BACKGROUND_SCOPE_KEY); + return isChatBackgroundScope(raw) ? raw : CHAT_BACKGROUND_SCOPE_DEFAULT; + } catch { + return CHAT_BACKGROUND_SCOPE_DEFAULT; + } +} + +export function saveChatBackgroundScope(value: ChatBackgroundScope) { + try { + localStorage.setItem(CHAT_BACKGROUND_SCOPE_KEY, value); + } catch { + // private mode / quota + } +} + +export function applyChatBackgroundScope(value: ChatBackgroundScope) { + document.documentElement.classList.toggle( + "chat-background-empty-only", + value === "empty", + ); + return value; +} + function isSidebarTabId(value: unknown): value is SidebarTabId { return ( value === "files" || @@ -372,6 +510,27 @@ export function saveTranscriptLayout(value: TranscriptLayout) { ); } +function isChangesView(value: unknown): value is ChangesView { + return value === "list" || value === "tree"; +} + +export function loadChangesView(): ChangesView { + try { + const raw = localStorage.getItem(CHANGES_VIEW_KEY); + return isChangesView(raw) ? raw : CHANGES_VIEW_DEFAULT; + } catch { + return CHANGES_VIEW_DEFAULT; + } +} + +export function saveChangesView(value: ChangesView) { + try { + localStorage.setItem(CHANGES_VIEW_KEY, value); + } catch { + // private mode / quota + } +} + export function loadTranscriptAnchor(): boolean { return readFlag(TRANSCRIPT_ANCHOR_KEY) ?? TRANSCRIPT_ANCHOR_DEFAULT; } diff --git a/src/lib/archiveShortcut.test.ts b/src/lib/archiveShortcut.test.ts new file mode 100644 index 00000000..128b81a7 --- /dev/null +++ b/src/lib/archiveShortcut.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { archiveFocusedSession } from "./archiveShortcut"; + +// Only the DOM operations used by the handler are needed in the Node suite. +class ElementStub { + constructor(readonly ancestors: string[] = []) {} + closest(selector: string) { + return selector.split(", ").some((part) => this.ancestors.includes(part)) + ? this + : null; + } + getClientRects = vi.fn(() => [{}]); +} + +afterEach(() => vi.unstubAllGlobals()); + +function fixture() { + const overlays: { selector: string; element: ElementStub }[] = []; + vi.stubGlobal("Element", ElementStub); + vi.stubGlobal("document", { + querySelectorAll: (selector: string) => + overlays + .filter((overlay) => selector.split(", ").includes(overlay.selector)) + .map((overlay) => overlay.element), + }); + const style = vi.fn(() => ({ visibility: "visible" })); + vi.stubGlobal("getComputedStyle", style); + const context = { + activeTabId: "tab", + tabs: [{ id: "tab", focusedId: "session", diffFocused: false }], + sessions: [{ id: "session" }, { id: "other" }], + projectTerminalFocused: false, + surfaceOpen: false, + }; + const event = { + defaultPrevented: false, + target: new ElementStub(["textarea", "[data-composer]"]), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + const archive = vi.fn(); + return { + context, + event, + archive, + overlays, + style, + run: () => + archiveFocusedSession( + event as unknown as KeyboardEvent, + context, + archive, + ), + }; +} + +function expectUntouched(f: ReturnType) { + f.run(); + expect(f.event.preventDefault).not.toHaveBeenCalled(); + expect(f.event.stopPropagation).not.toHaveBeenCalled(); + expect(f.archive).not.toHaveBeenCalled(); +} + +describe("archive shortcut routing", () => { + it("consumes the event before archiving exactly the focused session", () => { + const f = fixture(); + f.context.tabs[0].focusedId = "other"; + f.archive.mockImplementation(() => { + expect(f.event.preventDefault).toHaveBeenCalledOnce(); + expect(f.event.stopPropagation).toHaveBeenCalledOnce(); + }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("other"); + }); + + it.each(["editor", "terminal"])( + "preserves the key in a focused %s pane", + (pane) => { + const f = fixture(); + f.context.tabs[0].focusedId = pane; + expectUntouched(f); + }, + ); + + it.each(["diff", "dock", "surface", "missing tab", "already handled"])( + "preserves the key when blocked by %s", + (reason) => { + const f = fixture(); + if (reason === "diff") f.context.tabs[0].diffFocused = true; + if (reason === "dock") f.context.projectTerminalFocused = true; + if (reason === "surface") f.context.surfaceOpen = true; + if (reason === "missing tab") f.context.activeTabId = "missing"; + if (reason === "already handled") f.event.defaultPrevented = true; + expectUntouched(f); + }, + ); + + it.each([".cm-editor", ".monocode-terminal", "input"])( + "respects %s DOM focus even before workspace focus updates", + (ancestor) => { + const f = fixture(); + f.event.target = new ElementStub([ancestor]); + expectUntouched(f); + }, + ); + + it.each([ + '[role="menu"]', + '[role="dialog"]', + '[role="alertdialog"]', + "[data-popover-side]", + "[data-skill-picker]", + "[data-mention-picker]", + ])( + "blocks an open %s even when focus remains in the composer", + (selector) => { + const f = fixture(); + f.overlays.push({ selector, element: new ElementStub() }); + expectUntouched(f); + f.overlays.pop(); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }, + ); + + it("leaves a TabGroupMenu rename input event untouched", () => { + const f = fixture(); + f.event.target = new ElementStub(['[role="menu"]', "input"]); + f.overlays.push({ selector: '[role="menu"]', element: new ElementStub() }); + expectUntouched(f); + }); + + it.each([ + "no layout", + "hidden visibility", + "[hidden]", + "[inert]", + '[aria-hidden="true"]', + ])("ignores an inactive overlay (%s)", (hidden) => { + const f = fixture(); + const element = new ElementStub([hidden]); + if (hidden === "no layout") element.getClientRects.mockReturnValue([]); + if (hidden === "hidden visibility") + f.style.mockReturnValue({ visibility: "hidden" }); + f.overlays.push({ selector: "[data-skill-picker]", element }); + f.run(); + expect(f.archive).toHaveBeenCalledExactlyOnceWith("session"); + }); +}); diff --git a/src/lib/archiveShortcut.ts b/src/lib/archiveShortcut.ts new file mode 100644 index 00000000..6ce9b61b --- /dev/null +++ b/src/lib/archiveShortcut.ts @@ -0,0 +1,52 @@ +type ArchiveContext = { + activeTabId: string; + tabs: readonly { id: string; focusedId: string; diffFocused?: boolean }[]; + sessions: readonly { id: string }[]; + projectTerminalFocused: boolean; + surfaceOpen: boolean; +}; + +/** Archive only after confirming that the focused conversation owns the key. */ +export function archiveFocusedSession( + event: KeyboardEvent, + context: ArchiveContext, + archive: (sessionId: string) => void, +): void { + if ( + event.defaultPrevented || + context.projectTerminalFocused || + context.surfaceOpen + ) + return; + + const tab = context.tabs.find((entry) => entry.id === context.activeTabId); + if (!tab || tab.diffFocused) return; + const session = context.sessions.find((entry) => entry.id === tab.focusedId); + if (!session) return; + + const target = event.target instanceof Element ? event.target : null; + if (target?.closest(".cm-editor, .monocode-terminal")) return; + if ( + target?.closest('input, textarea, select, [contenteditable="true"]') && + !target.closest("[data-composer]") + ) + return; + + // Popovers can leave focus in the composer. Check the whole document, + // excluding overlays in hidden or inactive surfaces. + const overlayOpen = Array.from( + document.querySelectorAll( + '[data-popover-side], [role="dialog"], [role="alertdialog"], [role="menu"], [data-skill-picker], [data-mention-picker]', + ), + ).some( + (element) => + element.getClientRects().length > 0 && + getComputedStyle(element).visibility !== "hidden" && + !element.closest('[hidden], [inert], [aria-hidden="true"]'), + ); + if (overlayOpen) return; + + event.preventDefault(); + event.stopPropagation(); + archive(session.id); +} diff --git a/src/lib/chatBackground.test.ts b/src/lib/chatBackground.test.ts new file mode 100644 index 00000000..6819cf37 --- /dev/null +++ b/src/lib/chatBackground.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearProjectChatBackground, + pickAndSaveChatBackground, + pickAndSaveProjectChatBackground, + projectChatBackgroundSrc, + removeChatBackground, +} from "./chatBackground"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + open: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => `asset://${path}`, + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open })); + +describe("chat background image", () => { + beforeEach(() => { + mocks.invoke.mockReset(); + mocks.open.mockReset(); + }); + + it("copies a picked image into app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/aurora.webp"); + mocks.invoke.mockResolvedValue( + "/app-data/backgrounds/chat-background.webp", + ); + + await expect(pickAndSaveChatBackground()).resolves.toBe( + "/app-data/backgrounds/chat-background.webp", + ); + expect(mocks.invoke).toHaveBeenCalledWith("save_chat_background", { + sourcePath: "/Pictures/aurora.webp", + }); + }); + + it("leaves the current background alone when picking is cancelled", async () => { + mocks.open.mockResolvedValue(null); + await expect(pickAndSaveChatBackground()).resolves.toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("removes the saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + await removeChatBackground(); + expect(mocks.invoke).toHaveBeenCalledWith("remove_chat_background"); + }); + + it("copies a picked image into project-specific app storage", async () => { + mocks.open.mockResolvedValue("/Pictures/grid.png"); + mocks.invoke.mockResolvedValue("/app-data/backgrounds/project-abc.png"); + + await expect( + pickAndSaveProjectChatBackground("/work/agent-terminal"), + ).resolves.toBe("/app-data/backgrounds/project-abc.png"); + expect(mocks.invoke).toHaveBeenCalledWith("save_project_chat_background", { + project: "/work/agent-terminal", + sourcePath: "/Pictures/grid.png", + }); + }); + + it("removes only the selected project's saved background", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await clearProjectChatBackground("/work/agent-terminal"); + + expect(mocks.invoke).toHaveBeenCalledWith( + "remove_project_chat_background", + { project: "/work/agent-terminal" }, + ); + }); + + it("cache-busts project background URLs after replacement", () => { + expect(projectChatBackgroundSrc("/app-data/background.png", 42)).toBe( + "asset:///app-data/background.png?v=42", + ); + }); +}); diff --git a/src/lib/chatBackground.ts b/src/lib/chatBackground.ts new file mode 100644 index 00000000..c68c44f7 --- /dev/null +++ b/src/lib/chatBackground.ts @@ -0,0 +1,54 @@ +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; + +export async function pickAndSaveChatBackground(): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_chat_background", { sourcePath }); +} + +export function removeChatBackground(): Promise { + return invoke("remove_chat_background"); +} + +export async function pickAndSaveProjectChatBackground( + project: string, +): Promise { + const sourcePath = await open({ + multiple: false, + directory: false, + title: "Choose project chat background", + filters: [ + { + name: "Images", + extensions: ["png", "jpg", "jpeg", "gif", "webp"], + }, + ], + }); + if (typeof sourcePath !== "string" || !sourcePath) return null; + return invoke("save_project_chat_background", { + project, + sourcePath, + }); +} + +export function clearProjectChatBackground(project: string): Promise { + return invoke("remove_project_chat_background", { project }); +} + +export function projectChatBackgroundSrc( + path: string, + revision: number, +): string { + return `${convertFileSrc(path)}?v=${revision}`; +} diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 9bb1cd83..660656a7 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -98,8 +98,18 @@ export type GitFileDiff = { tooLarge: boolean; }; -export function gitFileDiff(cwd: string, relative: string): Promise { - return invoke("git_file_diff", { cwd, relative }); +export type GitFileDiffKind = "staged" | "unstaged"; + +export function gitFileDiff( + cwd: string, + relative: string, + kind: GitFileDiffKind = "unstaged", +): Promise { + return invoke("git_file_diff", { + cwd, + relative, + staged: kind === "staged", + }); } export type GitHistoryRef = { diff --git a/src/lib/keyboard.test.ts b/src/lib/keyboard.test.ts new file mode 100644 index 00000000..05ee12a7 --- /dev/null +++ b/src/lib/keyboard.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isImeComposition } from "./keyboard"; + +function keyEvent( + overrides: Partial> = {}, +): Pick { + return { + isComposing: false, + keyCode: 13, + ...overrides, + }; +} + +describe("isImeComposition", () => { + it("detects an active IME composition", () => { + expect(isImeComposition(keyEvent({ isComposing: true }))).toBe(true); + }); + + it("detects the legacy IME key code used by WebKit", () => { + expect(isImeComposition(keyEvent({ keyCode: 229 }))).toBe(true); + }); + + it("leaves ordinary keyboard events alone", () => { + expect(isImeComposition(keyEvent())).toBe(false); + }); +}); diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts new file mode 100644 index 00000000..46a34636 --- /dev/null +++ b/src/lib/keyboard.ts @@ -0,0 +1,5 @@ +type ImeKeyboardEvent = Pick; + +export function isImeComposition(event: ImeKeyboardEvent): boolean { + return event.isComposing || event.keyCode === 229; +} diff --git a/src/lib/layout.test.ts b/src/lib/layout.test.ts index ae1bbb01..d864c40d 100644 --- a/src/lib/layout.test.ts +++ b/src/lib/layout.test.ts @@ -154,12 +154,18 @@ describe("openSessionChangesTab", () => { describe("openChangesTab", () => { it("reuses one Changes tab and updates the focused file", () => { const cwd = "/repo"; - const first = openChangesTab(newTab("session-a"), cwd, "/repo/a.ts"); - const second = openChangesTab(first, cwd, "/repo/b.ts"); + const first = openChangesTab( + newTab("session-a"), + cwd, + "/repo/a.ts", + "staged", + ); + const second = openChangesTab(first, cwd, "/repo/b.ts", "unstaged"); const files = second.editorPanes[0]?.files ?? []; expect(files.filter(isChangesTab)).toHaveLength(1); expect(files.filter(isReviewTab)).toHaveLength(1); expect(files.find(isChangesTab)?.path).toBe("/repo/b.ts"); + expect(files.find(isChangesTab)?.changeKind).toBe("unstaged"); }); it("opens a Changes tab without a focused file", () => { diff --git a/src/lib/layout.ts b/src/lib/layout.ts index cd8f0abc..2608971d 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -1,4 +1,5 @@ import type { ReleaseNotesTabSource } from "./releaseNotes"; +import type { GitFileDiffKind } from "./fs"; import { applyTerminalMeta, defaultTerminalTitle, @@ -54,6 +55,8 @@ export type FilePaneTab = { review?: boolean; /** Single working-tree review of every changed file (unified diff). */ changes?: boolean; + /** Which side of a staged/unstaged path was selected in source control. */ + changeKind?: GitFileDiffKind; /** Read-only diff built from one session's captured before/after snapshots. */ sessionChanges?: SessionChangesSource; /** Historical commit review (unified diff, read-only). */ @@ -105,22 +108,29 @@ export function newFileTab( path: string, cwd: string, review = false, + changeKind?: GitFileDiffKind, ): FilePaneTab { return { id: crypto.randomUUID(), path, cwd, ...(review ? { review: true } : {}), + ...(changeKind ? { changeKind } : {}), }; } -export function newChangesTab(cwd: string, focusPath?: string): FilePaneTab { +export function newChangesTab( + cwd: string, + focusPath?: string, + focusKind?: GitFileDiffKind, +): FilePaneTab { return { id: crypto.randomUUID(), path: focusPath || cwd, cwd, review: true, changes: true, + ...(focusKind ? { changeKind: focusKind } : {}), }; } @@ -452,16 +462,23 @@ export function openChangesTab( tab: WorkspaceTab, cwd: string, focusPath?: string, + focusKind?: GitFileDiffKind, ): WorkspaceTab { tab = isolateTerminalPanes(tab); - const next = newChangesTab(cwd, focusPath); + const next = newChangesTab(cwd, focusPath, focusKind); const existingPane = tab.editorPanes.find((pane) => pane.files.some(isChangesTab), ); const existingFile = existingPane?.files.find(isChangesTab); if (existingPane && existingFile) { - const updated = focusPath ? { ...existingFile, path: focusPath } : existingFile; + const updated = focusPath + ? { + ...existingFile, + path: focusPath, + changeKind: focusKind, + } + : existingFile; return { ...tab, focusedId: existingPane.id, diff --git a/src/lib/noteImages.test.ts b/src/lib/noteImages.test.ts new file mode 100644 index 00000000..c7102f29 --- /dev/null +++ b/src/lib/noteImages.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + insertNoteImagesMarkdown, + isNoteImagePath, + noteImageMarkdown, + type NoteImageAsset, +} from "./noteImages"; + +const image: NoteImageAsset = { + name: "Architecture [draft].png", + markdownPath: "/note-assets/note-1/123-architecture-draft.png", +}; + +describe("note image markdown", () => { + it("escapes image names used as alt text", () => { + expect(noteImageMarkdown(image)).toBe( + "![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)", + ); + }); + + it("inserts images as blocks at the cursor", () => { + expect(insertNoteImagesMarkdown("BeforeAfter", 6, 6, [image])).toEqual({ + value: + "Before\n\n![Architecture \\[draft\\].png](/note-assets/note-1/123-architecture-draft.png)\n\nAfter", + cursor: 85, + }); + }); + + it("replaces the selection and separates multiple images", () => { + const second = { + name: "flow.png", + markdownPath: "/note-assets/note-1/456-flow.png", + }; + expect( + insertNoteImagesMarkdown("Top\nreplace\nBottom", 4, 12, [image, second]), + ).toEqual({ + value: [ + "Top", + "", + noteImageMarkdown(image), + "", + noteImageMarkdown(second), + "", + "Bottom", + ].join("\n"), + cursor: 129, + }); + }); + + it("recognizes only note asset references", () => { + expect(isNoteImagePath(image.markdownPath)).toBe(true); + expect(isNoteImagePath("https://example.com/image.png")).toBe(false); + }); +}); diff --git a/src/lib/noteImages.ts b/src/lib/noteImages.ts new file mode 100644 index 00000000..3c8e5498 --- /dev/null +++ b/src/lib/noteImages.ts @@ -0,0 +1,135 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + attachmentsFromFiles, + attachmentsFromPaths, + revokeAttachment, +} from "./attachments"; +import type { Attachment } from "./session"; + +export const NOTE_IMAGE_PREFIX = "/note-assets/"; + +export type NoteImageAsset = { + name: string; + markdownPath: string; +}; + +export type MarkdownInsertion = { + value: string; + cursor: number; +}; + +export async function saveNoteImagesFromFiles( + noteId: string, + files: File[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromFiles(files)); +} + +export async function saveNoteImagesFromPaths( + noteId: string, + paths: string[], +): Promise { + return saveNoteImageAttachments(noteId, await attachmentsFromPaths(paths)); +} + +async function saveNoteImageAttachments( + noteId: string, + attachments: Attachment[], +): Promise { + const images = attachments.filter((file) => file.kind === "image"); + if (images.length === 0) { + throw new Error("Drop a PNG, JPG, GIF, WebP, or SVG image."); + } + + const saved: NoteImageAsset[] = []; + let failure: unknown; + try { + for (const image of images) { + let sourcePath = image.path; + let temporary = false; + if (!sourcePath && image.data) { + sourcePath = await invoke("write_attachment", { + name: image.name, + data: image.data, + }); + temporary = true; + } + if (!sourcePath) continue; + try { + saved.push( + await invoke("notes_save_image", { + noteId, + sourcePath, + }), + ); + } catch (err: unknown) { + failure ??= err; + } finally { + if (temporary) { + await invoke("delete_path", { path: sourcePath }).catch( + () => undefined, + ); + } + } + } + } finally { + for (const image of images) revokeAttachment(image); + } + + if (saved.length === 0) { + if (failure instanceof Error) throw failure; + if (failure) throw new Error(String(failure)); + throw new Error("None of the dropped images could be added to the note."); + } + return saved; +} + +export function noteImageMarkdown(image: NoteImageAsset): string { + const alt = image.name + .replace(/[\r\n]+/g, " ") + .replace(/\\/g, "\\\\") + .replace(/([\[\]])/g, "\\$1"); + return `![${alt}](${image.markdownPath})`; +} + +export function insertNoteImagesMarkdown( + value: string, + start: number, + end: number, + images: NoteImageAsset[], +): MarkdownInsertion { + if (images.length === 0) { + const cursor = Math.max(0, Math.min(start, value.length)); + return { value, cursor }; + } + + const from = Math.max(0, Math.min(start, value.length)); + const to = Math.max(from, Math.min(end, value.length)); + const before = value.slice(0, from); + const after = value.slice(to); + const block = images.map(noteImageMarkdown).join("\n\n"); + const leading = before + ? before.endsWith("\n\n") + ? "" + : before.endsWith("\n") + ? "\n" + : "\n\n" + : ""; + const trailing = after + ? after.startsWith("\n\n") + ? "" + : after.startsWith("\n") + ? "\n" + : "\n\n" + : ""; + const inserted = `${leading}${block}`; + + return { + value: `${before}${inserted}${trailing}${after}`, + cursor: before.length + inserted.length, + }; +} + +export function isNoteImagePath(value: string): boolean { + return value.startsWith(NOTE_IMAGE_PREFIX); +} diff --git a/src/lib/projectChatBackground.test.ts b/src/lib/projectChatBackground.test.ts new file mode 100644 index 00000000..d7cab8e3 --- /dev/null +++ b/src/lib/projectChatBackground.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearProjectChatBackgroundSetting, + loadProjectChatBackground, + saveProjectChatBackground, +} from "./projectChatBackground"; + +const KEY = "monocode:project-chat-backgrounds"; + +function mockBrowserStorage() { + const data = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + removeItem: (key: string) => data.delete(key), + clear: () => data.clear(), + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "window", { + value: { dispatchEvent: () => true }, + configurable: true, + }); +} + +describe("project chat background settings", () => { + beforeEach(mockBrowserStorage); + + it("stores independent overrides for each project", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.22, + scope: "empty", + }); + expect(loadProjectChatBackground("/work/beta")).toEqual({ + path: "/backgrounds/beta.png", + opacity: 0.48, + scope: "all", + }); + }); + + it("clamps visibility to the supported range", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 1, + scope: "all", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0, + scope: "all", + }); + + expect(loadProjectChatBackground("/work/alpha")?.opacity).toBe(0.65); + expect(loadProjectChatBackground("/work/beta")?.opacity).toBe(0.05); + }); + + it("falls back safely when stored project data is malformed", () => { + localStorage.setItem( + KEY, + JSON.stringify({ + "/work/alpha": { + path: "/backgrounds/alpha.webp", + opacity: "bright", + scope: "transcript", + }, + }), + ); + + expect(loadProjectChatBackground("/work/alpha")).toEqual({ + path: "/backgrounds/alpha.webp", + opacity: 0.24, + scope: "all", + }); + }); + + it("clears one project without changing the others", () => { + saveProjectChatBackground("/work/alpha", { + path: "/backgrounds/alpha.webp", + opacity: 0.2, + scope: "empty", + }); + saveProjectChatBackground("/work/beta", { + path: "/backgrounds/beta.webp", + opacity: 0.3, + scope: "all", + }); + + clearProjectChatBackgroundSetting("/work/alpha"); + + expect(loadProjectChatBackground("/work/alpha")).toBeNull(); + expect(loadProjectChatBackground("/work/beta")?.path).toBe( + "/backgrounds/beta.webp", + ); + }); +}); diff --git a/src/lib/projectChatBackground.ts b/src/lib/projectChatBackground.ts new file mode 100644 index 00000000..a8a5b300 --- /dev/null +++ b/src/lib/projectChatBackground.ts @@ -0,0 +1,113 @@ +import { + CHAT_BACKGROUND_OPACITY_MAX, + CHAT_BACKGROUND_OPACITY_MIN, + CHAT_BACKGROUND_SCOPE_DEFAULT, + loadChatBackgroundOpacity, + loadChatBackgroundScope, + type ChatBackgroundScope, +} from "./appearance"; + +const KEY = "monocode:project-chat-backgrounds"; + +export const PROJECT_CHAT_BACKGROUND_CHANGED = + "monocode:project-chat-background-changed"; + +export type ProjectChatBackground = { + path: string; + opacity: number; + scope: ChatBackgroundScope; +}; + +type StoredProjectChatBackground = Partial; + +let revision = Date.now(); + +function clampOpacity(value: number): number { + return Math.min( + CHAT_BACKGROUND_OPACITY_MAX, + Math.max(CHAT_BACKGROUND_OPACITY_MIN, value), + ); +} + +function read(): Record { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function write(value: Record) { + try { + localStorage.setItem(KEY, JSON.stringify(value)); + } catch { + // private mode / quota + } +} + +function validScope(value: unknown): value is ChatBackgroundScope { + return value === "empty" || value === "all"; +} + +export function loadProjectChatBackground( + project: string, +): ProjectChatBackground | null { + const stored = read()[project]; + const path = typeof stored?.path === "string" ? stored.path.trim() : ""; + if (!path) return null; + const opacity = + typeof stored.opacity === "number" && Number.isFinite(stored.opacity) + ? clampOpacity(stored.opacity) + : loadChatBackgroundOpacity(); + return { + path, + opacity, + scope: validScope(stored.scope) ? stored.scope : loadChatBackgroundScope(), + }; +} + +export function saveProjectChatBackground( + project: string, + value: ProjectChatBackground, +) { + const path = value.path.trim(); + if (!project || !path) return; + const next = read(); + next[project] = { + path, + opacity: clampOpacity(value.opacity), + scope: validScope(value.scope) + ? value.scope + : CHAT_BACKGROUND_SCOPE_DEFAULT, + }; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function clearProjectChatBackgroundSetting(project: string) { + const next = read(); + if (!(project in next)) return; + delete next[project]; + write(next); + notifyProjectChatBackgroundChanged(); +} + +export function notifyProjectChatBackgroundChanged() { + revision += 1; + window.dispatchEvent(new CustomEvent(PROJECT_CHAT_BACKGROUND_CHANGED)); +} + +export function projectChatBackgroundRevision(): number { + return revision; +} + +export function subscribeProjectChatBackground(listener: () => void) { + window.addEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); + return () => + window.removeEventListener(PROJECT_CHAT_BACKGROUND_CHANGED, listener); +} diff --git a/src/lib/projectData.ts b/src/lib/projectData.ts index cccfef0f..9b42f668 100644 --- a/src/lib/projectData.ts +++ b/src/lib/projectData.ts @@ -1,5 +1,7 @@ import { projectKey } from "./paths"; import { clearProjectLogo } from "./projectLogos"; +import { clearProjectChatBackground } from "./chatBackground"; +import { clearProjectChatBackgroundSetting } from "./projectChatBackground"; import { normalizeProjectPath } from "./recents"; import { deleteSession, listSessionsByProject } from "./sessionStore"; import { clearTabGroupSettings } from "./tabGroups"; @@ -20,5 +22,7 @@ export async function removeProjectData(path: string): Promise { } // Drops the copied image from app data; the localStorage entry goes with it. await clearProjectLogo(key).catch(() => undefined); + await clearProjectChatBackground(key).catch(() => undefined); + clearProjectChatBackgroundSetting(key); clearTabGroupSettings(key); } diff --git a/src/lib/promptOutline.test.ts b/src/lib/promptOutline.test.ts new file mode 100644 index 00000000..c5ef0e4e --- /dev/null +++ b/src/lib/promptOutline.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + activePromptId, + barLift, + previewLines, + promptPreview, +} from "./promptOutline"; +import type { Block } from "./session"; + +const viewport = { top: 100, bottom: 500 }; + +function anchor(id: string, top: number, bottom: number) { + return { id, top, bottom }; +} + +describe("activePromptId", () => { + it("returns null with no anchors", () => { + expect(activePromptId(viewport, [])).toBeNull(); + }); + + it("picks the topmost prompt inside the viewport", () => { + const anchors = [ + anchor("a", 0, 40), + anchor("b", 150, 190), + anchor("c", 300, 340), + anchor("d", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("counts a prompt cut by the viewport top as inside", () => { + const anchors = [anchor("a", 80, 120), anchor("b", 200, 240)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("lets a prompt that peeks in at the bottom win over the reply above", () => { + const anchors = [anchor("a", 0, 40), anchor("b", 480, 520)]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("falls back to the last prompt above the viewport", () => { + const anchors = [ + anchor("a", 0, 20), + anchor("b", 40, 60), + anchor("c", 600, 640), + ]; + expect(activePromptId(viewport, anchors)).toBe("b"); + }); + + it("treats a prompt that ends at the viewport top as above", () => { + const anchors = [anchor("a", 60, 100), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("falls back to the first prompt when all sit below", () => { + const anchors = [anchor("a", 600, 640), anchor("b", 700, 740)]; + expect(activePromptId(viewport, anchors)).toBe("a"); + }); + + it("marks the last prompt at the end of the transcript", () => { + const anchors = [ + anchor("a", 120, 160), + anchor("b", 260, 300), + anchor("c", 400, 440), + ]; + expect(activePromptId(viewport, anchors, 0)).toBe("c"); + expect(activePromptId(viewport, anchors, 16)).toBe("c"); + }); + + it("keeps the topmost visible prompt while there is room to scroll", () => { + const anchors = [anchor("a", 120, 160), anchor("b", 400, 440)]; + expect(activePromptId(viewport, anchors, 17)).toBe("a"); + }); +}); + +describe("barLift", () => { + it("is flat with no hovered bar", () => { + expect(barLift(3, null)).toBe(0); + expect(barLift(3, -1)).toBe(0); + }); + + it("peaks on the hovered bar and tapers over the ripple span", () => { + expect(barLift(3, 3)).toBe(1); + expect(barLift(2, 3)).toBeCloseTo(2 / 3); + expect(barLift(5, 3)).toBeCloseTo(1 / 3); + expect(barLift(6, 3)).toBe(0); + }); +}); + +describe("previewLines", () => { + it("strips markers and collapses blank lines", () => { + const text = "# Heading\n\n- **first** point\n\n2) second point\n"; + expect(previewLines(text, 2)).toEqual(["Heading", "first point"]); + }); + + it("skips fenced code and rules", () => { + const text = "---\n```ts\nconst a = 1;\n```\nAfter the code."; + expect(previewLines(text, 2)).toEqual(["After the code."]); + }); + + it("stops at the line budget", () => { + expect(previewLines("a\nb\nc", 2)).toEqual(["a", "b"]); + }); +}); + +describe("promptPreview", () => { + const block = (id: string, role: Block["role"], text: string): Block => ({ + id, + role, + text, + }); + + it("pairs a prompt with the head of its reply", () => { + const blocks = [ + block("u1", "user", "First ask"), + block("t1", "tool", "ran something"), + block("a1", "assistant", "Yes.\nBuild the control plane."), + block("u2", "user", "Second ask"), + block("a2", "assistant", "Later reply"), + ]; + expect(promptPreview(blocks, "u1")).toEqual({ + title: "First ask", + reply: "Yes.", + detail: "Build the control plane.", + }); + }); + + it("leaves the reply out when the turn has none yet", () => { + const blocks = [block("u1", "user", "Only ask")]; + expect(promptPreview(blocks, "u1")).toEqual({ + title: "Only ask", + reply: undefined, + detail: undefined, + }); + }); + + it("returns null for an unknown prompt", () => { + expect(promptPreview([], "missing")).toBeNull(); + }); +}); diff --git a/src/lib/promptOutline.ts b/src/lib/promptOutline.ts new file mode 100644 index 00000000..00b50559 --- /dev/null +++ b/src/lib/promptOutline.ts @@ -0,0 +1,141 @@ +import type { Block } from "./session"; + +/** A vertical span in viewport coordinates. */ +export type OutlineBand = { top: number; bottom: number }; + +export type OutlineAnchor = OutlineBand & { id: string }; + +export const NEAR_END_PX = 16; + +export function promptBlocks(blocks: Block[]): Block[] { + return blocks.filter((block) => block.role === "user"); +} + +/** + * Selects the topmost prompt inside the viewport. With no prompt inside, + * selects the last prompt above the viewport, else the first prompt. Near the + * end of the transcript, selects the last prompt: the prompts on the final + * screen can not reach the top. + */ +export function activePromptId( + viewport: OutlineBand, + anchors: OutlineAnchor[], + distanceToEnd = Number.POSITIVE_INFINITY, +): string | null { + if (anchors.length === 0) return null; + if (distanceToEnd <= NEAR_END_PX) return anchors[anchors.length - 1].id; + const inside = anchors.find( + (anchor) => anchor.bottom > viewport.top && anchor.top < viewport.bottom, + ); + if (inside) return inside.id; + let above: OutlineAnchor | undefined; + for (const anchor of anchors) { + if (anchor.bottom <= viewport.top) above = anchor; + } + return (above ?? anchors[0]).id; +} + +/** Selects at most `max` prompts. The window slides to keep the active prompt inside. It prefers the newest prompts. */ +export function barWindow( + count: number, + activeIndex: number | null, + max: number, +): { start: number; end: number } { + if (count <= max) return { start: 0, end: count }; + const newest = count - max; + const start = + activeIndex == null ? newest : Math.max(0, Math.min(newest, activeIndex)); + return { start, end: start + max }; +} + +export function promptLabel(block: Block): string { + const card = block.secondOpinion; + const textShown = !card || card.kind === "handoff"; + const text = textShown ? firstLine(block.text) : ""; + if (text) return text; + if (card) { + if (card.kind === "handoff") return "Handoff"; + const request = firstLine(card.request ?? ""); + return request ? `Second opinion: ${request}` : "Second opinion"; + } + if (block.noteCard?.title) return block.noteCard.title; + const files = block.attachments ?? []; + if (files.length > 0) { + const [first] = files; + return files.length > 1 ? `${first.name} +${files.length - 1}` : first.name; + } + return "Empty message"; +} + +function firstLine(text: string): string { + const line = text + .split(/\r?\n/) + .map((part) => part.trim()) + .find(Boolean); + return (line ?? "").replace(/\s+/g, " "); +} + +/** How far the hover ripple reaches, in bars on each side. */ +export const RIPPLE_SPAN = 2; + +/** Dock-style magnification: 1 on the hovered bar, tapering to 0 past the ripple span. */ +export function barLift(index: number, hoverIndex: number | null): number { + if (hoverIndex == null || hoverIndex < 0) return 0; + const distance = Math.abs(index - hoverIndex); + if (distance > RIPPLE_SPAN) return 0; + return (RIPPLE_SPAN + 1 - distance) / (RIPPLE_SPAN + 1); +} + +/** The prompt and the head of its reply, shown while a bar is hovered. */ +export type PromptPreview = { + title: string; + reply?: string; + detail?: string; +}; + +const REPLY_SCAN_CHARS = 2000; + +export function promptPreview( + blocks: Block[], + promptId: string, +): PromptPreview | null { + const index = blocks.findIndex((block) => block.id === promptId); + if (index < 0) return null; + let reply: string[] = []; + for (let i = index + 1; i < blocks.length; i += 1) { + const block = blocks[i]; + if (block.role === "user") break; + if (block.role !== "assistant") continue; + reply = previewLines(block.text, 2); + if (reply.length > 0) break; + } + return { + title: promptLabel(blocks[index]), + reply: reply[0], + detail: reply[1], + }; +} + +/** The first `max` prose lines of a reply. Markers, fenced code and rules drop out. */ +export function previewLines(text: string, max: number): string[] { + const lines: string[] = []; + let fenced = false; + for (const raw of text.slice(0, REPLY_SCAN_CHARS).split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("```")) { + fenced = !fenced; + continue; + } + if (fenced) continue; + const plain = line + .replace(/^(?:[#>]+|[-*+]|\d+[.)])\s+/, "") + .replace(/\*\*|`/g, "") + .replace(/\s+/g, " ") + .trim(); + // Rules, table separators and lone punctuation read as noise in a preview. + if (!/[\p{L}\p{N}]/u.test(plain)) continue; + lines.push(plain); + if (lines.length === max) break; + } + return lines; +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 1a472f1b..3005d87f 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -157,9 +157,7 @@ describe("grid arcade enabled setting", () => { describe("workspace navigation keybindings", () => { it("documents session and project cycling in the shortcut list", () => { const rows = KEYBINDINGS.filter( - (row) => - row.command.startsWith("Session:") || - row.command.startsWith("Project:"), + (row) => /^(Session|Project): (Previous|Next)$/.test(row.command), ); expect(rows.map((row) => row.command)).toEqual([ "Session: Previous", diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d65f097c..14a74708 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -345,6 +345,11 @@ export const KEYBINDINGS: KeybindingRow[] = [ { command: "Tab: Forward", keys: `${MOD}]`, when: "Always" }, { command: "Tab: Activate 1–8", keys: `${MOD}1 … ${MOD}8`, when: "Always" }, { command: "Tab: Activate Last", keys: `${MOD}9`, when: "Always" }, + { + command: "Session: Archive", + keys: `${MOD}${SHIFT}A`, + when: "sessionFocus && !overlay", + }, { command: "Session: Previous", keys: `${MOD}${SHIFT}↑`, diff --git a/src/lib/tabKeys.test.ts b/src/lib/tabKeys.test.ts index 6000f949..55b575e9 100644 --- a/src/lib/tabKeys.test.ts +++ b/src/lib/tabKeys.test.ts @@ -39,6 +39,27 @@ function key( } describe("tabCommand", () => { + it("archives with Cmd+Shift+A or Ctrl+Shift+A", () => { + expect( + tabCommand(key({ key: "A", metaKey: true, shiftKey: true })), + ).toBe("archive-session"); + expect( + tabCommand(key({ key: "a", ctrlKey: true, shiftKey: true })), + ).toBe("archive-session"); + }); + + it.each([ + {}, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { metaKey: true, shiftKey: true, altKey: true }, + { metaKey: true, shiftKey: true, isComposing: true }, + { metaKey: true, shiftKey: true, repeat: true }, + ])("leaves other A key events alone (%j)", (modifiers) => { + expect(tabCommand(key({ key: "a", ...modifiers }))).toBeNull(); + }); + it("opens a terminal pane with cmd-backtick", () => { expect(tabCommand(key({ key: "`", code: "Backquote", metaKey: true }))).toBe( "new-terminal", diff --git a/src/lib/tabKeys.ts b/src/lib/tabKeys.ts index 91be9aff..b8bd6b53 100644 --- a/src/lib/tabKeys.ts +++ b/src/lib/tabKeys.ts @@ -22,6 +22,7 @@ * Reset zoom cmd-0 * Previous session shift-cmd-up * Next session shift-cmd-down + * Archive session shift-cmd-a * Previous project shift-cmd-left * Next project shift-cmd-right * Stop focused turn escape @@ -44,6 +45,7 @@ export type TabCommand = | "toggle-terminal" | "prev-session" | "next-session" + | "archive-session" | "prev-project" | "next-project" | { activate: number } @@ -76,6 +78,7 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { const key = e.key.toLowerCase(); if (e.shiftKey) { + if (key === "a" && !e.repeat) return "archive-session"; if (e.key === "]" || e.key === "}") return "next"; if (e.key === "[" || e.key === "{") return "prev"; if (e.key === "ArrowUp") return "prev-session"; diff --git a/src/lib/workingTreeDiff.test.ts b/src/lib/workingTreeDiff.test.ts new file mode 100644 index 00000000..6b1aab7c --- /dev/null +++ b/src/lib/workingTreeDiff.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { GitChangedFile } from "./fs"; +import { + prioritizeWorkingTreeDiffEntries, + workingTreeDiffEntries, + workingTreeDiffEntryLabel, + workingTreeDiffFocusId, +} from "./workingTreeDiff"; + +function file( + relative: string, + staged: boolean, + unstaged: boolean, +): GitChangedFile { + return { + path: `/repo/${relative}`, + relative, + status: "modified", + additions: 1, + deletions: 0, + staged, + unstaged, + }; +} + +describe("workingTreeDiffEntries", () => { + it("creates a staged entry for a clean staged file", () => { + const entries = workingTreeDiffEntries([file("a.ts", true, false)]); + expect(entries.map(({ id, kind }) => ({ id, kind }))).toEqual([ + { id: "staged:a.ts", kind: "staged" }, + ]); + }); + + it("creates both comparisons for a partially staged file", () => { + const entries = workingTreeDiffEntries([file("a.ts", true, true)]); + expect(entries.map((entry) => entry.id)).toEqual([ + "staged:a.ts", + "unstaged:a.ts", + ]); + expect(entries.map(workingTreeDiffEntryLabel)).toEqual([ + "a.ts (Staged)", + "a.ts (Unstaged)", + ]); + }); + + it("focuses and prioritizes the selected comparison", () => { + const entries = workingTreeDiffEntries([ + file("a.ts", true, true), + file("b.ts", false, true), + ]); + expect(workingTreeDiffFocusId(entries, "/repo/a.ts", "unstaged")).toBe( + "unstaged:a.ts", + ); + expect( + prioritizeWorkingTreeDiffEntries(entries, "/repo/a.ts", "unstaged")[0] + ?.id, + ).toBe("unstaged:a.ts"); + }); +}); diff --git a/src/lib/workingTreeDiff.ts b/src/lib/workingTreeDiff.ts new file mode 100644 index 00000000..fb8b8e12 --- /dev/null +++ b/src/lib/workingTreeDiff.ts @@ -0,0 +1,63 @@ +import type { GitChangedFile, GitFileDiffKind } from "./fs"; + +export type WorkingTreeDiffEntry = { + id: string; + kind: GitFileDiffKind; + file: GitChangedFile; +}; + +export function workingTreeDiffEntryId( + kind: GitFileDiffKind, + relative: string, +): string { + return `${kind}:${relative}`; +} + +/** Staged entries come first, matching the source-control sidebar. */ +export function workingTreeDiffEntries( + files: readonly GitChangedFile[], +): WorkingTreeDiffEntry[] { + const entries: WorkingTreeDiffEntry[] = []; + for (const kind of ["staged", "unstaged"] as const) { + for (const file of files) { + if (kind === "staged" ? file.staged : file.unstaged) { + entries.push({ + id: workingTreeDiffEntryId(kind, file.relative), + kind, + file, + }); + } + } + } + return entries; +} + +export function workingTreeDiffEntryLabel(entry: WorkingTreeDiffEntry): string { + if (!entry.file.staged || !entry.file.unstaged) return entry.file.relative; + return `${entry.file.relative} (${entry.kind === "staged" ? "Staged" : "Unstaged"})`; +} + +export function workingTreeDiffFocusId( + entries: readonly WorkingTreeDiffEntry[], + focusPath: string | undefined, + focusKind?: GitFileDiffKind, +): string | undefined { + if (!focusPath) return undefined; + return entries.find( + (entry) => + (!focusKind || entry.kind === focusKind) && + (entry.file.path === focusPath || entry.file.relative === focusPath), + )?.id; +} + +export function prioritizeWorkingTreeDiffEntries( + entries: readonly WorkingTreeDiffEntry[], + focusPath: string | undefined, + focusKind?: GitFileDiffKind, +): WorkingTreeDiffEntry[] { + const focusId = workingTreeDiffFocusId(entries, focusPath, focusKind); + if (!focusId) return [...entries]; + const focused = entries.find((entry) => entry.id === focusId); + if (!focused) return [...entries]; + return [focused, ...entries.filter((entry) => entry !== focused)]; +} diff --git a/src/lib/workspaceSnapshot.test.ts b/src/lib/workspaceSnapshot.test.ts index faa9d028..2db6eacb 100644 --- a/src/lib/workspaceSnapshot.test.ts +++ b/src/lib/workspaceSnapshot.test.ts @@ -53,7 +53,7 @@ describe("collectWorkspaceSnapshot", () => { }); it("round-trips a unified Changes tab", () => { - const file = newChangesTab("/tmp/a", "/tmp/a/src/lib.rs"); + const file = newChangesTab("/tmp/a", "/tmp/a/src/lib.rs", "staged"); const tab = { ...newTab("s1"), id: "t1", @@ -65,6 +65,7 @@ describe("collectWorkspaceSnapshot", () => { expect(restored?.changes).toBe(true); expect(restored?.review).toBe(true); expect(restored?.path).toBe("/tmp/a/src/lib.rs"); + expect(restored?.changeKind).toBe("staged"); }); it("round-trips a session-scoped Changes tab", () => { diff --git a/src/lib/workspaceSnapshot.ts b/src/lib/workspaceSnapshot.ts index 2bf38133..419f24b7 100644 --- a/src/lib/workspaceSnapshot.ts +++ b/src/lib/workspaceSnapshot.ts @@ -451,6 +451,9 @@ function sanitizeFile(raw: unknown): FilePaneTab | null { ...(sessionChanges ? { sessionChanges, review: true } : {}), ...(value.review === true ? { review: true } : {}), ...(value.changes === true ? { changes: true, review: true } : {}), + ...(value.changeKind === "staged" || value.changeKind === "unstaged" + ? { changeKind: value.changeKind } + : {}), ...(value.terminal === true ? { terminal: true } : {}), }; } diff --git a/src/main.tsx b/src/main.tsx index e2fd8e81..9330f2f8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,8 @@ import React, { useLayoutEffect } from "react"; import ReactDOM from "react-dom/client"; -import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; -import { initAppearance } from "./lib/appearance"; +import { activateWindowAppearance, initAppearance } from "./lib/appearance"; import { initSounds } from "./lib/sounds"; import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; import { consumeInstalledUpdate } from "./lib/updateNotice"; @@ -17,7 +16,7 @@ function dismissBootSplash() { if (!splash || splash.dataset.dismissed === "1") return; splash.dataset.dismissed = "1"; const fade = () => { - void invoke("enable_window_glass"); + activateWindowAppearance(); splash.classList.add("boot-splash-out"); window.setTimeout(() => splash.remove(), 180); }; diff --git a/src/surfaces/AgentMarkdown.test.ts b/src/surfaces/AgentMarkdown.test.ts index d56e639d..194ab6e8 100644 --- a/src/surfaces/AgentMarkdown.test.ts +++ b/src/surfaces/AgentMarkdown.test.ts @@ -66,3 +66,18 @@ describe("AgentMarkdown inline code", () => { expect(classes).not.toContain("h-6"); }); }); + +describe("AgentMarkdown note images", () => { + it("keeps app-owned note image references for the async image resolver", () => { + const markup = renderToStaticMarkup( + createElement(AgentMarkdown, { + text: "![Diagram](/note-assets/note-1/123-diagram.png)", + }), + ); + + expect(markup).toContain( + 'data-note-image="/note-assets/note-1/123-diagram.png"', + ); + expect(markup).toContain('alt="Diagram"'); + }); +}); diff --git a/src/surfaces/AgentMarkdown.tsx b/src/surfaces/AgentMarkdown.tsx index 529ccd7d..c6d09a30 100644 --- a/src/surfaces/AgentMarkdown.tsx +++ b/src/surfaces/AgentMarkdown.tsx @@ -1,4 +1,5 @@ import { code } from "@streamdown/code"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import { createContext, isValidElement, @@ -28,6 +29,7 @@ import { useColorScheme } from "../hooks/useColorScheme"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { copyText } from "../lib/clipboard"; import { INBOX_MEDIA_PREFIXES, isInboxMediaUrl } from "../lib/inboxMedia"; +import { isNoteImagePath } from "../lib/noteImages"; import { InboxMedia } from "./InboxMedia"; const MERMAID_BASE_CONFIG = { @@ -51,7 +53,9 @@ const MARKDOWN_REHYPE_PLUGINS: PluggableList = [ [ harden, { - allowedImagePrefixes: [] as string[], + // MarkdownImage remains the final allowlist. The wildcard lets app-owned + // relative note URLs reach that component without changing link parsing. + allowedImagePrefixes: ["*"], allowedLinkPrefixes: ["*"], allowDataImages: true, imageBlockPolicy: "remove" as const, @@ -302,6 +306,42 @@ function CodeCopyButton({ code }: { code: string }) { type MarkdownImageProps = ComponentProps<"img"> & { node?: unknown }; +const noteImageSrcCache = new Map(); + +function NoteAssetImage({ + asset, + alt, + ...props +}: Omit & { asset: string }) { + const [src, setSrc] = useState(() => noteImageSrcCache.get(asset)); + + useEffect(() => { + if (src) return; + let cancelled = false; + void invoke("notes_image_path", { asset }) + .then((path) => { + const next = convertFileSrc(path); + noteImageSrcCache.set(asset, next); + if (!cancelled) setSrc(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [asset, src]); + + return ( + {alt + ); +} + function MarkdownImage({ src, alt, @@ -313,6 +353,9 @@ function MarkdownImage({ if (url.startsWith("data:image/")) { return {alt; } + if (isNoteImagePath(url)) { + return ; + } if (!allowRemoteMedia || !url || !isInboxMediaUrl(url)) return null; return ; } diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index b10bc82f..83373b84 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -22,6 +22,7 @@ import { useState, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { AttachmentChip } from "../chrome/AttachmentChip"; import { FilePreview } from "../chrome/FilePreview"; import { FileTypeIcon } from "../chrome/FileTypeIcon"; @@ -116,6 +117,8 @@ type Props = { onHandoff?: (harness: HarnessId, turn: Block[], model: string) => void; onJumpToBottomChange?: (show: boolean) => void; onJumpToBottomReady?: (jump: () => void) => void; + /** Passes a function that renders the turn that holds a block. The render completes before the function returns. */ + onRevealReady?: (reveal: (blockId: string) => boolean) => void; /** False while another tab is in front; local transcript state is retained. */ visible?: boolean; }; @@ -138,6 +141,7 @@ function AgentTranscriptComponent({ onHandoff, onJumpToBottomChange, onJumpToBottomReady, + onRevealReady, visible = true, }: Props) { const lockOverscroll = useLockOverscroll(); @@ -293,6 +297,10 @@ function AgentTranscriptComponent({ const turns = groupTurns(blocks); const firstVisibleTurn = Math.max(0, turns.length - visibleTurnCount); const visibleTurns = turns.slice(firstVisibleTurn); + const turnsRef = useRef(turns); + turnsRef.current = turns; + const visibleTurnCountRef = useRef(visibleTurnCount); + visibleTurnCountRef.current = visibleTurnCount; useLayoutEffect(() => { const previousHeight = prependHeight.current; @@ -304,15 +312,40 @@ function AgentTranscriptComponent({ el.scrollHeight - el.scrollTop - el.clientHeight; }, [visibleTurnCount]); - const loadEarlier = () => { + const prepareToPrepend = useCallback(() => { const el = scroller.current; if (el) prependHeight.current = el.scrollHeight; stickToBottom.current = false; + }, []); + + const loadEarlier = () => { + prepareToPrepend(); setVisibleTurnCount((count) => Math.min(turns.length, count + TURN_PAGE_SIZE), ); }; + const revealBlock = useCallback( + (blockId: string): boolean => { + const all = turnsRef.current; + const index = all.findIndex((turn) => + turn.some((block) => block.id === blockId), + ); + if (index < 0) return false; + const needed = all.length - index; + if (needed <= visibleTurnCountRef.current) return true; + prepareToPrepend(); + // Synchronous. The caller finds the turn in the DOM after this call. + flushSync(() => setVisibleTurnCount(needed)); + return true; + }, + [prepareToPrepend], + ); + + useEffect(() => { + onRevealReady?.(revealBlock); + }, [revealBlock, onRevealReady]); + return (
    { + it("renders the arcade when no chat background is selected", () => { + const markup = renderToStaticMarkup( + createElement(EmptySession, { cwd: "/work/demo" }), + ); + + expect(markup).toContain(" { + const markup = renderToStaticMarkup( + createElement(EmptySession, { + cwd: "/work/demo", + hasChatBackground: true, + }), + ); + + expect(markup).not.toContain("(); const arcadeEnabled = useSyncExternalStore( subscribeGridArcadeEnabled, @@ -30,7 +31,7 @@ export function EmptySession({ cwd, composer }: Props) { ref={lockOverscroll} className="relative flex h-full min-h-0 overflow-y-auto overscroll-none" > - {arcadeEnabled ? : null} + {arcadeEnabled && !hasChatBackground ? : null} {composer ? (
    diff --git a/src/surfaces/FileEditor.tsx b/src/surfaces/FileEditor.tsx index 71a8f65c..081666b5 100644 --- a/src/surfaces/FileEditor.tsx +++ b/src/surfaces/FileEditor.tsx @@ -208,7 +208,16 @@ export function FileEditor({ setGitBase({ path, original: null }); const load = () => { - void gitFileDiff(cwd, relative) + void (async () => { + let diff = await gitFileDiff(cwd, relative, "unstaged"); + // A review tab opened from a clean staged file has no index-to-disk + // delta. Fall back to HEAD-to-index so the staged patch is still + // visible in the editor-style diff view. + if (!diff.binary && !diff.tooLarge && diff.original === diff.current) { + diff = await gitFileDiff(cwd, relative, "staged"); + } + return diff; + })() .then((diff) => { if (cancelled) return; if (diff.binary || diff.tooLarge) { diff --git a/src/surfaces/FilePane.tsx b/src/surfaces/FilePane.tsx index f0227a7f..152c4887 100644 --- a/src/surfaces/FilePane.tsx +++ b/src/surfaces/FilePane.tsx @@ -121,7 +121,11 @@ function FilePaneComponent({
    ) : unifiedReview && activeFile ? (
    - +
    ) : null} {pane.files.map((file) => { diff --git a/src/surfaces/NotesView.tsx b/src/surfaces/NotesView.tsx index 74a96e7e..fd0c6503 100644 --- a/src/surfaces/NotesView.tsx +++ b/src/surfaces/NotesView.tsx @@ -1,4 +1,5 @@ import { LoaderCircle, Plus, Search, File, Trash2 } from "../chrome/icons"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; import { Fragment, useCallback, @@ -6,6 +7,7 @@ import { useMemo, useRef, useState, + type DragEvent as ReactDragEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { useMarkdownMode } from "../chrome/MarkdownModeToggle"; @@ -28,6 +30,12 @@ import { requestAddNoteToChat, type Note, } from "../lib/notes"; +import { + insertNoteImagesMarkdown, + saveNoteImagesFromFiles, + saveNoteImagesFromPaths, + type NoteImageAsset, +} from "../lib/noteImages"; import { projectKey, projectName } from "../lib/paths"; import { IS_MAC } from "../lib/platform"; import { looksLikeProject } from "../lib/recents"; @@ -500,9 +508,14 @@ function NoteEditor({ const [title, setTitle] = useState(note.title); const [body, setBody] = useState(note.body); const [saveError, setSaveError] = useState(null); + const [imageDrag, setImageDrag] = useState(false); + const [imageBusy, setImageBusy] = useState(false); const titleRef = useRef(title); const bodyRef = useRef(body); const noteRef = useRef(note); + const dropZoneRef = useRef(null); + const sourceFieldRef = useRef(null); + const lastDropAt = useRef(0); const skipSave = useRef(false); const saveTimer = useRef(null); const saveQueue = useRef(Promise.resolve()); @@ -553,6 +566,111 @@ function NoteEditor({ }, 400); }, [persist]); + const insertionRange = useCallback(() => { + const field = sourceFieldRef.current; + if (!field) { + const end = bodyRef.current.length; + return { start: end, end }; + } + return { + start: field.selectionStart, + end: field.selectionEnd, + }; + }, []); + + const addDroppedImages = useCallback( + async ( + load: () => Promise, + range: { start: number; end: number }, + ) => { + setImageBusy(true); + setImageDrag(false); + try { + const images = await load(); + const inserted = insertNoteImagesMarkdown( + bodyRef.current, + range.start, + range.end, + images, + ); + bodyRef.current = inserted.value; + setBody(inserted.value); + setSaveError(null); + scheduleSave(); + window.requestAnimationFrame(() => { + const field = sourceFieldRef.current; + if (!field) return; + field.focus(); + field.setSelectionRange(inserted.cursor, inserted.cursor); + }); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setImageBusy(false); + } + }, + [scheduleSave], + ); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + + const toClientPoint = (x: number, y: number) => { + const scale = window.devicePixelRatio || 1; + if (scale !== 1 && (x > window.innerWidth || y > window.innerHeight)) { + return { x: x / scale, y: y / scale }; + } + return { x, y }; + }; + const overDropZone = (x: number, y: number) => { + const zone = dropZoneRef.current; + if (!zone) return false; + const point = toClientPoint(x, y); + const rect = zone.getBoundingClientRect(); + return ( + point.x >= rect.left && + point.x <= rect.right && + point.y >= rect.top && + point.y <= rect.bottom + ); + }; + + void getCurrentWebview() + .onDragDropEvent((event) => { + if (event.payload.type === "leave") { + setImageDrag(false); + return; + } + const { x, y } = event.payload.position; + const over = overDropZone(x, y); + if (event.payload.type === "enter" || event.payload.type === "over") { + setImageDrag(over); + return; + } + if (event.payload.type !== "drop") return; + setImageDrag(false); + if (!over || Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const range = insertionRange(); + const paths = event.payload.paths; + void addDroppedImages( + () => saveNoteImagesFromPaths(note.id, paths), + range, + ); + }) + .then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }) + .catch(() => undefined); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [addDroppedImages, insertionRange, note.id]); + useEffect(() => { return () => { if (saveTimer.current != null) window.clearTimeout(saveTimer.current); @@ -658,20 +776,59 @@ function NoteEditor({ onSelect={() => setMode("source")} />
    - {mode === "source" ? ( - { - setBody(next); - scheduleSave(); - }} - /> - ) : body.trim() ? ( - - ) : ( -

    No description

    - )} +
    ) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setImageDrag(true); + }} + onDragLeave={(event: ReactDragEvent) => { + const next = event.relatedTarget as Node | null; + if (next && event.currentTarget.contains(next)) return; + setImageDrag(false); + }} + onDrop={(event: ReactDragEvent) => { + if (!hasDroppedFiles(event.dataTransfer)) return; + event.preventDefault(); + setImageDrag(false); + if (Date.now() - lastDropAt.current < 250) return; + lastDropAt.current = Date.now(); + const files = [...event.dataTransfer.files]; + if (files.length === 0) return; + const range = insertionRange(); + void addDroppedImages( + () => saveNoteImagesFromFiles(note.id, files), + range, + ); + }} + > + {imageDrag || imageBusy ? ( +
    + {imageBusy ? "Adding images…" : "Drop images here"} +
    + ) : null} + {mode === "source" ? ( + { + setBody(next); + scheduleSave(); + }} + /> + ) : body.trim() ? ( + + ) : ( +

    No description

    + )} +
    ); @@ -680,10 +837,12 @@ function NoteEditor({ function NoteSource({ value, onChange, + textareaRef, autoFocus = false, }: { value: string; onChange: (value: string) => void; + textareaRef: { current: HTMLTextAreaElement | null }; autoFocus?: boolean; }) { const lines = value.split("\n"); @@ -716,6 +875,7 @@ function NoteSource({ style={{ left: gutterWidth }} />