diff --git a/.gitignore b/.gitignore index ae74443f..5e106db8 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ src/extensions/bundled/*/grammars/*.wasm # local codex settings src-tauri/.claude/settings.local.json + +# local scratch dirs +.pi/ +.tmp/ diff --git a/Cargo.lock b/Cargo.lock index d6538d22..a6bccd17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -623,6 +623,7 @@ dependencies = [ "walkdir", "which", "window-vibrancy", + "wry", "zip 2.4.2", ] diff --git a/crates/lsp/src/client.rs b/crates/lsp/src/client.rs index d9cd0809..222dc22b 100644 --- a/crates/lsp/src/client.rs +++ b/crates/lsp/src/client.rs @@ -6,8 +6,9 @@ use serde_json::{Value, json}; use std::{ collections::HashMap, ffi::OsStr, + fs, io::{BufRead, BufReader, Read, Write}, - path::PathBuf, + path::{Path, PathBuf}, process::{Child, Command, Stdio}, sync::{ Arc, Mutex, @@ -20,6 +21,14 @@ use tokio::sync::oneshot; type PendingRequests = Arc>>>>; +fn is_js_server_path(server_path: &Path) -> bool { + fs::canonicalize(server_path) + .unwrap_or_else(|_| server_path.to_path_buf()) + .extension() + .map(|ext| ext == OsStr::new("js") || ext == OsStr::new("mjs") || ext == OsStr::new("cjs")) + .unwrap_or(false) +} + #[derive(Clone)] pub struct LspClient { request_counter: Arc, @@ -37,10 +46,7 @@ impl LspClient { app_handle: Option, ) -> Result<(Self, Child)> { // Check if this is a JavaScript-based language server - let is_js_server = server_path - .extension() - .map(|ext| ext == OsStr::new("js") || ext == OsStr::new("mjs") || ext == OsStr::new("cjs")) - .unwrap_or(false); + let is_js_server = is_js_server_path(&server_path); let (command_path, final_args) = if is_js_server { // JS-based server requires Node.js runtime @@ -687,3 +693,57 @@ impl LspClient { self.notify::(params) } } + +#[cfg(test)] +mod tests { + use super::is_js_server_path; + use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + + fn temp_test_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("athas-lsp-{name}-{unique}")); + fs::create_dir_all(&dir).expect("temp test dir should be created"); + dir + } + + #[test] + fn detects_direct_js_entrypoints() { + let dir = temp_test_dir("direct-js"); + let entrypoint = dir.join("server.mjs"); + fs::write(&entrypoint, "console.log('ok');").expect("entrypoint should be written"); + + assert!(is_js_server_path(&entrypoint)); + + let _ = fs::remove_dir_all(dir); + } + + #[cfg(unix)] + #[test] + fn detects_js_entrypoints_through_bin_symlinks() { + use std::os::unix::fs::symlink; + + let dir = temp_test_dir("symlink-js"); + let package_dir = dir.join("typescript-language-server"); + let bin_dir = dir.join(".bin"); + fs::create_dir_all(package_dir.join("lib")).expect("package lib dir should be created"); + fs::create_dir_all(&bin_dir).expect("bin dir should be created"); + + let entrypoint = package_dir.join("lib/cli.mjs"); + fs::write(&entrypoint, "console.log('ok');").expect("entrypoint should be written"); + + let bin_link = bin_dir.join("typescript-language-server"); + symlink("../typescript-language-server/lib/cli.mjs", &bin_link) + .expect("bin symlink should be created"); + + assert!(is_js_server_path(&bin_link)); + + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e465c69f..87054d80 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -101,3 +101,6 @@ tauri-plugin-window-state = "2" [target.'cfg(target_os = "macos")'.dependencies] rand = "0.8.5" objc = "0.2" + +[target.'cfg(target_os = "linux")'.dependencies] +wry = "0.53.5" diff --git a/src-tauri/src/commands/ui/window.rs b/src-tauri/src/commands/ui/window.rs index e6626e38..e451cc1e 100644 --- a/src-tauri/src/commands/ui/window.rs +++ b/src-tauri/src/commands/ui/window.rs @@ -1,16 +1,50 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU32, Ordering}; +#[cfg(target_os = "linux")] +use std::{cell::RefCell, collections::HashMap}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; use tauri::{ - AppHandle, Emitter, Manager, WebviewBuilder, WebviewUrl, WebviewWindow, command, + AppHandle, Emitter, Manager, WebviewBuilder, WebviewUrl, WebviewWindow, Window, command, webview::PageLoadEvent, }; +#[cfg(target_os = "linux")] +use wry::{ + Rect as WryRect, WebView as WryWebView, WebViewBuilder as WryWebViewBuilder, + dpi::{PhysicalPosition as WryPhysicalPosition, PhysicalSize as WryPhysicalSize}, +}; // Counter for generating unique web viewer labels static WEB_VIEWER_COUNTER: AtomicU32 = AtomicU32::new(0); static APP_WINDOW_COUNTER: AtomicU32 = AtomicU32::new(0); +#[cfg(target_os = "linux")] +thread_local! { + static LINUX_EMBEDDED_WEBVIEWS: RefCell> = RefCell::new(HashMap::new()); +} + +#[cfg(target_os = "linux")] +fn linux_embedded_bounds(x: f64, y: f64, width: f64, height: f64) -> WryRect { + WryRect { + position: WryPhysicalPosition::new(x.round() as i32, y.round() as i32).into(), + size: WryPhysicalSize::new(width.round() as u32, height.round() as u32).into(), + } +} + +#[cfg(target_os = "linux")] +fn with_linux_embedded_webview( + label: &str, + f: impl FnOnce(&WryWebView) -> Result, +) -> Result { + LINUX_EMBEDDED_WEBVIEWS.with(|webviews| { + let webviews = webviews.borrow(); + let webview = webviews + .get(label) + .ok_or_else(|| format!("Webview not found: {label}"))?; + f(webview) + }) +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct EmbeddedWebviewPageLoadEvent { @@ -309,7 +343,7 @@ fn build_webview_bridge_script(webview_label: &str) -> Result { #[command] pub async fn create_embedded_webview( - app: tauri::AppHandle, + window: Window, url: String, x: f64, y: f64, @@ -318,18 +352,51 @@ pub async fn create_embedded_webview( ) -> Result { let counter = WEB_VIEWER_COUNTER.fetch_add(1, Ordering::SeqCst); let webview_label = format!("web-viewer-{counter}"); - let parsed_url = normalize_webview_url(&url)?; - // Get the main window - let main_webview_window = app - .get_webview_window("main") - .ok_or("Main window not found")?; + #[cfg(target_os = "linux")] + { + let parent_window = window.clone(); + let embedded_label = webview_label.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + + window + .run_on_main_thread(move || { + let result = (|| { + let builder = WryWebViewBuilder::new() + .with_url(&parsed_url) + .with_bounds(linux_embedded_bounds(x, y, width, height)) + .with_initialization_script(SHORTCUT_INTERCEPTOR_SCRIPT) + .with_devtools(true) + .with_visible(true); + + let webview = builder + .build_as_child(&parent_window) + .map_err(|e| format!("Failed to create embedded webview: {e}"))?; + + LINUX_EMBEDDED_WEBVIEWS.with(|webviews| { + webviews + .borrow_mut() + .insert(embedded_label.clone(), webview); + }); + + Ok(embedded_label) + })(); + + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview creation: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview creation channel dropped".to_string())?; + } - // Get the underlying Window to use add_child - let main_window = main_webview_window.as_ref().window(); + let x = x.round() as i32; + let y = y.round() as i32; + let width = width.round() as u32; + let height = height.round() as u32; - // Build webview with conditional react-grab injection for localhost let mut webview_builder = WebviewBuilder::new( &webview_label, WebviewUrl::External( @@ -341,10 +408,10 @@ pub async fn create_embedded_webview( webview_builder = webview_builder.initialization_script(build_webview_bridge_script(&webview_label)?); - let app_handle = app.clone(); + let app_handle = window.app_handle().clone(); let event_webview_label = webview_label.clone(); let navigation_webview_label = webview_label.clone(); - let navigation_app_handle = app.clone(); + let navigation_app_handle = window.app_handle().clone(); webview_builder = webview_builder.on_navigation(move |url| { let event = EmbeddedWebviewLocationChangeEvent { webview_label: navigation_webview_label.clone(), @@ -368,16 +435,17 @@ pub async fn create_embedded_webview( let _ = app_handle.emit("embedded-webview-page-load", event); }); - // Create embedded webview within the main window - let webview = main_window + // Create embedded webview within the calling window so coordinates + // are relative to the correct content area (fixes multi-window and + // Linux positioning where the hardcoded "main" lookup could fail). + let webview = window .add_child( webview_builder, - tauri::LogicalPosition::new(x, y), - tauri::LogicalSize::new(width, height), + tauri::PhysicalPosition::new(x, y), + tauri::PhysicalSize::new(width, height), ) .map_err(|e| format!("Failed to create embedded webview: {e}"))?; - // Set auto resize to follow parent window webview .set_auto_resize(false) .map_err(|e| format!("Failed to set auto resize: {e}"))?; @@ -390,6 +458,22 @@ pub async fn close_embedded_webview( app: tauri::AppHandle, webview_label: String, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + LINUX_EMBEDDED_WEBVIEWS.with(|webviews| { + webviews.borrow_mut().remove(&webview_label); + }); + let _ = tx.send(Ok(())); + }) + .map_err(|e| format!("Failed to schedule embedded webview close: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview close channel dropped".to_string())?; + } + if let Some(webview) = app.get_webview(&webview_label) { webview .close() @@ -404,6 +488,25 @@ pub async fn navigate_embedded_webview( webview_label: String, url: String, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let parsed_url = normalize_webview_url(&url)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + webview + .load_url(&parsed_url) + .map_err(|e| format!("Failed to navigate: {e}")) + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview navigation: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview navigation channel dropped".to_string())?; + } + if let Some(webview) = app.get_webview(&webview_label) { let parsed_url = normalize_webview_url(&url)?; @@ -433,12 +536,36 @@ pub async fn resize_embedded_webview( return Ok(()); } + #[cfg(target_os = "linux")] + { + let bounds = linux_embedded_bounds(x, y, width, height); + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + webview + .set_bounds(bounds) + .map_err(|e| format!("Failed to set bounds: {e}")) + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview resize: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview resize channel dropped".to_string())?; + } + + let x = x.round() as i32; + let y = y.round() as i32; + let width = width.round() as u32; + let height = height.round() as u32; + if let Some(webview) = app.get_webview(&webview_label) { webview - .set_position(tauri::LogicalPosition::new(x, y)) + .set_position(tauri::PhysicalPosition::new(x, y)) .map_err(|e| format!("Failed to set position: {e}"))?; webview - .set_size(tauri::LogicalSize::new(width, height)) + .set_size(tauri::PhysicalSize::new(width, height)) .map_err(|e| format!("Failed to set size: {e}"))?; } else { return Err(format!("Webview not found: {webview_label}")); @@ -452,6 +579,24 @@ pub async fn set_webview_visible( webview_label: String, visible: bool, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + webview + .set_visible(visible) + .map_err(|e| format!("Failed to update visibility: {e}")) + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview visibility: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview visibility channel dropped".to_string())?; + } + if let Some(webview) = app.get_webview(&webview_label) { if visible { webview @@ -473,6 +618,29 @@ pub async fn open_webview_devtools( app: tauri::AppHandle, webview_label: String, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + #[cfg(any(debug_assertions, feature = "devtools"))] + let result = with_linux_embedded_webview(&webview_label, |webview| { + webview.open_devtools(); + Ok(()) + }); + + #[cfg(not(any(debug_assertions, feature = "devtools")))] + let result: Result<(), String> = + Err("Webview devtools are unavailable in release builds".to_string()); + + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview devtools: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview devtools channel dropped".to_string())?; + } + if let Some(webview) = app.get_webview(&webview_label) { #[cfg(any(debug_assertions, feature = "devtools"))] { @@ -569,6 +737,24 @@ pub async fn set_webview_zoom( webview_label: String, zoom_level: f64, ) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + webview + .zoom(zoom_level) + .map_err(|e| format!("Failed to set zoom: {e}")) + }); + let _ = tx.send(result); + }) + .map_err(|e| format!("Failed to schedule embedded webview zoom: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview zoom channel dropped".to_string())?; + } + if let Some(webview) = app.get_webview(&webview_label) { webview .set_zoom(zoom_level) @@ -578,3 +764,220 @@ pub async fn set_webview_zoom( Err(format!("Webview not found: {webview_label}")) } } + +#[derive(Deserialize, Serialize)] +pub struct WebviewMetadata { + pub title: String, + pub favicon: Option, +} + +#[command] +pub async fn poll_webview_metadata( + app: tauri::AppHandle, + webview_label: String, +) -> Result, String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + let tx = tx.clone(); + webview + .evaluate_script_with_callback( + r#" + (() => { + const title = document.title || ""; + if (!title) { + return null; + } + + const iconElement = + document.querySelector('link[rel~="icon"]') || + document.querySelector('link[rel="shortcut icon"]'); + + return { + title, + favicon: iconElement?.href || null, + }; + })() + "#, + move |value: String| { + let parsed = serde_json::from_str::>(&value) + .map_err(|e| format!("Failed to parse metadata: {e}")); + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(parsed); + } + }, + ) + .map_err(|e| format!("Failed to get metadata: {e}")) + }); + + if let Err(error) = result { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(Err(error)); + } + } + }) + .map_err(|e| format!("Failed to schedule embedded webview metadata poll: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview metadata channel dropped".to_string())?; + } + + if let Some(webview) = app.get_webview(&webview_label) { + // Store metadata in a dedicated global (does not touch location.hash + // to avoid conflicts with the shortcut polling mechanism). + webview + .eval( + r#" + (function() { + var t = document.title || ''; + var icon = ''; + var el = document.querySelector('link[rel~="icon"]') || document.querySelector('link[rel="shortcut icon"]'); + if (el && el.href) { icon = el.href; } + window.__ATHAS_PAGE_META__ = JSON.stringify({t:t,i:icon}); + })(); + "#, + ) + .map_err(|e| format!("Failed to get metadata: {e}"))?; + + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + + // Read back via a hash round-trip (single step) + webview + .eval( + r#" + (function() { + var m = window.__ATHAS_PAGE_META__; + window.__ATHAS_PAGE_META__ = null; + if (m) window.location.hash = '__athas_meta=' + encodeURIComponent(m); + })(); + "#, + ) + .map_err(|e| format!("Failed to read metadata: {e}"))?; + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + let url = webview + .url() + .map_err(|e| format!("Failed to get URL: {e}"))?; + let hash = url.fragment().unwrap_or(""); + + if let Some(encoded) = hash.strip_prefix("__athas_meta=") { + webview + .eval("window.location.hash = '';") + .map_err(|e| format!("Failed to clear hash: {e}"))?; + + let decoded = percent_encoding::percent_decode_str(encoded) + .decode_utf8() + .unwrap_or_default(); + + #[derive(Deserialize)] + struct Meta { + t: String, + i: String, + } + + if let Ok(meta) = serde_json::from_str::(&decoded) { + if meta.t.is_empty() { + return Ok(None); + } + return Ok(Some(WebviewMetadata { + title: meta.t, + favicon: if meta.i.is_empty() { + None + } else { + Some(meta.i) + }, + })); + } + } + + Ok(None) + } else { + Err(format!("Webview not found: {webview_label}")) + } +} + +#[command] +pub async fn poll_webview_shortcut( + app: tauri::AppHandle, + webview_label: String, +) -> Result, String> { + #[cfg(target_os = "linux")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + + app.run_on_main_thread(move || { + let result = with_linux_embedded_webview(&webview_label, |webview| { + let tx = tx.clone(); + webview + .evaluate_script_with_callback( + "window.__ATHAS_GET_SHORTCUT__ ? window.__ATHAS_GET_SHORTCUT__() : null", + move |value: String| { + let parsed = serde_json::from_str::>(&value) + .map_err(|e| format!("Failed to parse shortcut: {e}")); + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(parsed); + } + }, + ) + .map_err(|e| format!("Failed to get shortcut: {e}")) + }); + + if let Err(error) = result { + if let Some(tx) = tx.lock().unwrap().take() { + let _ = tx.send(Err(error)); + } + } + }) + .map_err(|e| format!("Failed to schedule embedded webview shortcut poll: {e}"))?; + + return rx + .await + .map_err(|_| "Embedded webview shortcut channel dropped".to_string())?; + } + + if let Some(webview) = app.get_webview(&webview_label) { + // Check if there's a pending shortcut and move it to the URL hash + webview + .eval( + r#" + (function() { + var s = window.__ATHAS_PENDING_SHORTCUT__; + if (s) { + window.__ATHAS_PENDING_SHORTCUT__ = null; + window.location.hash = '__athas_shortcut=' + s; + } + })(); + "#, + ) + .map_err(|e| format!("Failed to check shortcut: {e}"))?; + + // Small delay to allow the hash change to take effect + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // Get the URL and check for shortcut in hash + let url = webview + .url() + .map_err(|e| format!("Failed to get URL: {e}"))?; + let hash = url.fragment().unwrap_or(""); + + if let Some(shortcut) = hash.strip_prefix("__athas_shortcut=") { + // Clear the hash + webview + .eval("window.location.hash = '';") + .map_err(|e| format!("Failed to clear hash: {e}"))?; + + return Ok(Some(shortcut.to_string())); + } + + Ok(None) + } else { + Err(format!("Webview not found: {webview_label}")) + } +} diff --git a/src/features/ai/components/chat/ai-chat.tsx b/src/features/ai/components/chat/ai-chat.tsx index a4f1f9e5..57fd0b0b 100644 --- a/src/features/ai/components/chat/ai-chat.tsx +++ b/src/features/ai/components/chat/ai-chat.tsx @@ -3,11 +3,14 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import ProviderApiKeyModal from "@/features/ai/components/provider-api-key-modal"; import { appendChatAcpEvent, + refreshRunningAcpEvent, type ChatAcpEventInput, completeThinkingAcpEvents, truncateDetail, + updateAcpEventState, updateToolCompletionAcpEvent, } from "@/features/ai/lib/acp-event-timeline"; +import { isContextEligibleBuffer } from "@/features/ai/lib/context-buffers"; import { getChatTitleFromSessionInfo } from "@/features/ai/lib/acp-session-info"; import { parseDirectAcpUiAction } from "@/features/ai/lib/acp-ui-intents"; import { parseMentionsAndLoadFiles } from "@/features/ai/lib/file-mentions"; @@ -65,6 +68,7 @@ const AIChat = memo(function AIChat({ >([]); const [acpEvents, setAcpEvents] = useState([]); const activeToolEventIdsRef = useRef>(new Map()); + const activeThinkingEventIdRef = useRef(null); useEffect(() => { if (activeBuffer) { @@ -81,6 +85,7 @@ const AIChat = memo(function AIChat({ useEffect(() => { setAcpEvents([]); activeToolEventIdsRef.current.clear(); + activeThinkingEventIdRef.current = null; }, [chatState.currentChatId]); useEffect(() => { @@ -146,6 +151,17 @@ const AIChat = memo(function AIChat({ setAcpEvents((prev) => appendChatAcpEvent(prev, event)); }, []); + const completeThinkingEvent = useCallback( + (state: ChatAcpEvent["state"] = "success", detail = "completed") => { + const activeThinkingEventId = activeThinkingEventIdRef.current; + if (!activeThinkingEventId) return; + + setAcpEvents((prev) => updateAcpEventState(prev, activeThinkingEventId, { detail, state })); + activeThinkingEventIdRef.current = null; + }, + [], + ); + // Agent availability is now handled dynamically by the model-provider-selector component // No need to check Claude Code status on mount @@ -173,7 +189,7 @@ const AIChat = memo(function AIChat({ const buildContext = async (agentId: string): Promise => { const selectedBuffers = buffers.filter( - (buffer) => buffer.type !== "agent" && chatState.selectedBufferIds.has(buffer.id), + (buffer) => chatState.selectedBufferIds.has(buffer.id) && isContextEligibleBuffer(buffer), ); // Build active buffer context, including web viewer content if applicable @@ -372,6 +388,7 @@ const AIChat = memo(function AIChat({ if (isAcp) { setAcpEvents([]); activeToolEventIdsRef.current.clear(); + activeThinkingEventIdRef.current = null; } await getChatCompletionStream( @@ -408,6 +425,7 @@ const AIChat = memo(function AIChat({ content: fallbackContent, isStreaming: false, })); + completeThinkingEvent(); chatActions.setIsTyping(false); chatActions.setStreamingMessageId(null); abortControllerRef.current = null; @@ -425,6 +443,7 @@ details: The agent session started, but no content, tool output, or resource was [/ERROR_BLOCK]`, isStreaming: false, })); + completeThinkingEvent("error", "failed"); chatActions.setIsTyping(false); chatActions.setStreamingMessageId(null); abortControllerRef.current = null; @@ -435,6 +454,7 @@ details: The agent session started, but no content, tool output, or resource was chatActions.updateMessage(chatId, currentAssistantMessageId, { isStreaming: false, }); + completeThinkingEvent(); chatActions.setIsTyping(false); chatActions.setStreamingMessageId(null); setAcpEvents((prev) => completeThinkingAcpEvents(prev)); @@ -536,6 +556,7 @@ details: ${errorDetails || mainError} type: "error", }); } + completeThinkingEvent("error", "failed"); chatActions.setIsTyping(false); chatActions.setStreamingMessageId(null); abortControllerRef.current = null; @@ -597,22 +618,33 @@ details: ${errorDetails || mainError} event.type === "user_message_chunk" || event.type === "session_complete" ) { + if (event.type === "content_chunk") { + completeThinkingEvent(); + } else if (event.type === "session_complete") { + completeThinkingEvent(); + } return; } switch (event.type) { case "thought_chunk": { - if (event.isComplete) { - setAcpEvents((prev) => completeThinkingAcpEvents(prev)); - } else { - appendAcpEvent({ - kind: "thinking", - label: "Thinking", - state: "running", - }); + const activeThinkingEventId = activeThinkingEventIdRef.current; + if (activeThinkingEventId) { + setAcpEvents((prev) => refreshRunningAcpEvent(prev, activeThinkingEventId)); + break; } + + const thinkingEventId = `thinking-${Date.now()}`; + activeThinkingEventIdRef.current = thinkingEventId; + appendAcpEvent({ + id: thinkingEventId, + kind: "thinking", + label: "Thinking", + state: "running", + }); break; } case "tool_start": { + completeThinkingEvent(); const activityId = `tool-${event.toolId}`; activeToolEventIdsRef.current.set(event.toolId, activityId); appendAcpEvent({ @@ -634,6 +666,7 @@ details: ${errorDetails || mainError} case "permission_request": break; // Handled separately with permission UI case "prompt_complete": + completeThinkingEvent(); break; // Not useful to show case "session_mode_update": acpProducedStateOnlyUpdate = true; @@ -689,6 +722,7 @@ details: ${errorDetails || mainError} case "status_changed": break; // internal state sync case "error": + completeThinkingEvent("error", "failed"); appendAcpEvent({ kind: "error", label: "Agent error", @@ -722,6 +756,7 @@ details: ${errorDetails || mainError} content: "Error: Failed to connect to AI service. Please check your API key and try again.", isStreaming: false, }); + completeThinkingEvent("error", "failed"); chatActions.setIsTyping(false); chatActions.setStreamingMessageId(null); abortControllerRef.current = null; diff --git a/src/features/ai/components/history/sidebar.tsx b/src/features/ai/components/history/sidebar.tsx index a912da6d..296e9bbe 100644 --- a/src/features/ai/components/history/sidebar.tsx +++ b/src/features/ai/components/history/sidebar.tsx @@ -1,4 +1,5 @@ -import { Check, Search, Trash2 } from "lucide-react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Check, Search, Trash2, X } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { getRelativeTime } from "@/features/ai/lib/formatting"; import type { Chat } from "@/features/ai/types/ai-chat"; @@ -103,85 +104,127 @@ export default function ChatHistoryDropdown({ }, [filteredChats.length, selectedIndex]); return ( - - - - - - - - {chats.length === 0 ? ( - No chat history yet - ) : filteredChats.length === 0 ? ( - No chats match "{searchQuery}" - ) : ( - filteredChats.map((chat, index) => { - const isCurrent = chat.id === currentChatId; - const isSelected = index === selectedIndex; - const providerLabel = (chat.agentId || "custom").replace(/-/g, " "); - - return ( - { - onSwitchToChat(chat.id); - handleClose(); - }} - onMouseEnter={() => setSelectedIndex(index)} - isSelected={isSelected} - className={cn( - "group mb-1 px-3 py-1.5 last:mb-0", - isCurrent && !isSelected && "bg-accent/10 text-text", - )} - aria-current={isCurrent} - > -
- {isCurrent ? ( - - ) : ( - - )} -
- -
-
- {chat.title} -
-
- - {providerLabel} - - {getRelativeTime(chat.lastMessageAt)} - - - -
- ); - }) - )} -
-
+ + + + +
+ {chats.length === 0 ? ( +
+ No chat history yet +
+ ) : filteredChats.length === 0 ? ( +
+ No chats match "{searchQuery}" +
+ ) : ( + filteredChats.map((chat, index) => { + const isCurrent = chat.id === currentChatId; + const isSelected = index === selectedIndex; + + return ( +
{ + onSwitchToChat(chat.id); + handleClose(); + }} + onMouseEnter={() => setSelectedIndex(index)} + className={cn( + "group relative mb-0.5 flex cursor-pointer items-start gap-3 rounded-xl px-4 py-3 transition-colors", + isSelected ? "bg-hover/80" : "hover:bg-hover/40", + isCurrent && "bg-accent/5 hover:bg-accent/10", + )} + > + {isCurrent && ( +
+ )} + +
+ {isCurrent ? ( + + ) : ( + + )} +
+ +
+
+ + {chat.title} + + + {getRelativeTime(chat.lastMessageAt)} + +
+ +
+ + {(chat.agentId || "custom").replace(/-/g, " ")} + + {isCurrent && ( + <> + + Current chat + + )} +
+
+ + +
+ ); + }) + )} +
+ +
+ + )} + ); } diff --git a/src/features/ai/components/selectors/provider-model-selector.tsx b/src/features/ai/components/selectors/provider-model-selector.tsx index 89c9b830..084625ef 100644 --- a/src/features/ai/components/selectors/provider-model-selector.tsx +++ b/src/features/ai/components/selectors/provider-model-selector.tsx @@ -47,7 +47,7 @@ import { checkOllamaConnection } from "@/features/ai/services/providers/ollama-p interface ProviderModelSelectorProps { providerId: string; modelId: string; - onProviderChange: (id: string) => void; + onProviderChange: (id: string, preferredModelId?: string) => void; onModelChange: (id: string) => void; disabled?: boolean; } @@ -104,6 +104,9 @@ export function ProviderModelSelector({ const removeApiKey = useAIChatStore((state) => state.removeApiKey); const { settings, updateSetting } = useSettingsStore(); + const modelIdRef = useRef(modelId); + modelIdRef.current = modelId; + const triggerRef = useRef(null); const dropdownRef = useRef(null); const inputRef = useRef(null); @@ -137,7 +140,7 @@ export function ProviderModelSelector({ const models = await instance.getModels(); if (models.length > 0) { setDynamicModels(providerId, models); - if (!models.find((model) => model.id === modelId)) { + if (!models.find((model) => model.id === modelIdRef.current)) { onModelChange(models[0].id); } } else { @@ -153,7 +156,7 @@ export function ProviderModelSelector({ } finally { setIsLoadingModels(false); } - }, [modelId, onModelChange, providerId, setDynamicModels]); + }, [onModelChange, providerId, setDynamicModels]); useEffect(() => { void fetchDynamicModels(); @@ -241,26 +244,41 @@ export function ProviderModelSelector({ if (!trigger) return; const rect = trigger.getBoundingClientRect(); + + // When portaled into a transformed ancestor (dialog), fixed positioning + // is relative to that ancestor rather than the viewport. + const container = trigger.closest("[data-dialog-content]"); + const containerRect = container?.getBoundingClientRect(); + const offsetX = containerRect?.left ?? 0; + const offsetY = containerRect?.top ?? 0; + const boundsWidth = containerRect?.width ?? window.innerWidth; + const boundsHeight = containerRect?.height ?? window.innerHeight; + const viewportPadding = 8; + const localLeft = rect.left - offsetX; + const localBottom = rect.bottom - offsetY; + const localTop = rect.top - offsetY; + const minWidth = Math.max(rect.width, 300); - const maxWidth = Math.min(420, window.innerWidth - viewportPadding * 2); + const maxWidth = Math.min(420, boundsWidth - viewportPadding * 2); const safeWidth = Math.max(Math.min(minWidth, maxWidth), Math.min(280, maxWidth)); - const estimatedHeight = 480; - const availableBelow = window.innerHeight - rect.bottom - viewportPadding; - const availableAbove = rect.top - viewportPadding; - const openUp = - availableBelow < Math.min(estimatedHeight, 240) && availableAbove > availableBelow; - const maxHeight = Math.max( - 160, - Math.min(estimatedHeight, openUp ? availableAbove - 6 : availableBelow - 6), - ); + + const estimatedHeight = 520; + const availableBelow = boundsHeight - localBottom - viewportPadding; + const availableAbove = localTop - viewportPadding; + const openUp = availableBelow < estimatedHeight && availableAbove > availableBelow; + const availableSpace = (openUp ? availableAbove : availableBelow) - 6; + const minDropdownHeight = 120; + const maxHeight = Math.min(estimatedHeight, Math.max(availableSpace, minDropdownHeight)); + const measuredHeight = dropdownRef.current?.getBoundingClientRect().height ?? estimatedHeight; const visibleHeight = Math.min(maxHeight, measuredHeight); + const left = Math.max( viewportPadding, - Math.min(rect.left, window.innerWidth - safeWidth - viewportPadding), + Math.min(localLeft, boundsWidth - safeWidth - viewportPadding), ); - const top = openUp ? Math.max(viewportPadding, rect.top - visibleHeight - 6) : rect.bottom + 6; + const top = openUp ? Math.max(viewportPadding, localTop - visibleHeight - 6) : localBottom + 6; setPosition({ left, top, width: safeWidth, maxHeight }); }, []); @@ -313,9 +331,10 @@ export function ProviderModelSelector({ const handleModelSelect = useCallback( (selectedProviderId: string, selectedModelId: string) => { if (selectedProviderId !== providerId) { - onProviderChange(selectedProviderId); + onProviderChange(selectedProviderId, selectedModelId); + } else { + onModelChange(selectedModelId); } - onModelChange(selectedModelId); setIsOpen(false); }, [onModelChange, onProviderChange, providerId], @@ -529,7 +548,7 @@ export function ProviderModelSelector({
event.stopPropagation()} > {modelFetchError && ( diff --git a/src/features/ai/lib/acp-event-timeline.test.ts b/src/features/ai/lib/acp-event-timeline.test.ts new file mode 100644 index 00000000..1ce8d3c5 --- /dev/null +++ b/src/features/ai/lib/acp-event-timeline.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + appendChatAcpEvent, + refreshRunningAcpEvent, + updateAcpEventState, +} from "./acp-event-timeline"; + +describe("acp event timeline", () => { + test("refreshes an existing running event in place", () => { + const events = appendChatAcpEvent([], { + id: "thinking-1", + kind: "thinking", + label: "Thinking", + state: "running", + }); + + const refreshed = refreshRunningAcpEvent(events, "thinking-1"); + + expect(refreshed).toHaveLength(1); + expect(refreshed[0]?.id).toBe("thinking-1"); + expect(refreshed[0]?.state).toBe("running"); + expect(refreshed[0]?.timestamp.getTime()).toBeGreaterThanOrEqual( + events[0]?.timestamp.getTime() ?? 0, + ); + }); + + test("marks an active event complete without appending a new row", () => { + const events = appendChatAcpEvent([], { + id: "thinking-1", + kind: "thinking", + label: "Thinking", + state: "running", + }); + + const completed = updateAcpEventState(events, "thinking-1", { + detail: "completed", + state: "success", + }); + + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + id: "thinking-1", + kind: "thinking", + label: "Thinking", + detail: "completed", + state: "success", + }); + }); +}); diff --git a/src/features/ai/lib/acp-event-timeline.ts b/src/features/ai/lib/acp-event-timeline.ts index 5520fd2e..aef9f55d 100644 --- a/src/features/ai/lib/acp-event-timeline.ts +++ b/src/features/ai/lib/acp-event-timeline.ts @@ -83,3 +83,42 @@ export const updateToolCompletionAcpEvent = ( : event, ); }; + +export const refreshRunningAcpEvent = ( + previousEvents: ChatAcpEvent[], + activityId: string, +): ChatAcpEvent[] => { + const now = new Date(); + return previousEvents.map((event) => + event.id === activityId + ? { + ...event, + timestamp: now, + } + : event, + ); +}; + +export const updateAcpEventState = ( + previousEvents: ChatAcpEvent[], + activityId: string, + { + detail, + state, + }: { + detail?: string; + state: ChatAcpEvent["state"]; + }, +): ChatAcpEvent[] => { + const now = new Date(); + return previousEvents.map((event) => + event.id === activityId + ? { + ...event, + detail: detail ? truncateDetail(detail) : event.detail, + state, + timestamp: now, + } + : event, + ); +}; diff --git a/src/features/ai/lib/context-buffers.test.ts b/src/features/ai/lib/context-buffers.test.ts new file mode 100644 index 00000000..cb348268 --- /dev/null +++ b/src/features/ai/lib/context-buffers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vite-plus/test"; +import type { PaneContent } from "@/features/panes/types/pane-content"; +import { isContextEligibleBuffer } from "./context-buffers"; + +const createBuffer = (type: PaneContent["type"]): PaneContent => + ({ + id: `${type}-1`, + type, + path: `/tmp/${type}`, + name: type, + isPinned: false, + isPreview: false, + isActive: false, + ...(type === "agent" + ? { sessionId: "session-1" } + : type === "terminal" + ? { sessionId: "terminal-1" } + : type === "webViewer" + ? { url: "https://example.com" } + : type === "diff" + ? { content: "", savedContent: "" } + : type === "editor" + ? { + content: "", + savedContent: "", + isDirty: false, + isVirtual: false, + tokens: [], + } + : type === "markdownPreview" || type === "htmlPreview" || type === "csvPreview" + ? { content: "", sourceFilePath: "/tmp/source" } + : type === "externalEditor" + ? { terminalConnectionId: "terminal-1" } + : type === "database" + ? { databaseType: "sqlite" as const } + : type === "githubIssue" + ? { issueNumber: 1 } + : type === "githubAction" + ? { runId: 1 } + : type === "pullRequest" + ? { prNumber: 1 } + : {}), + }) as PaneContent; + +describe("context buffers", () => { + test("excludes agent buffers from chat context", () => { + expect(isContextEligibleBuffer(createBuffer("agent"))).toBe(false); + }); + + test("keeps normal editor buffers eligible for chat context", () => { + expect(isContextEligibleBuffer(createBuffer("editor"))).toBe(true); + }); +}); diff --git a/src/features/ai/lib/context-buffers.ts b/src/features/ai/lib/context-buffers.ts new file mode 100644 index 00000000..f0bebd36 --- /dev/null +++ b/src/features/ai/lib/context-buffers.ts @@ -0,0 +1,4 @@ +type ContextBufferCandidate = { type?: string | null }; + +export const isContextEligibleBuffer = (buffer: ContextBufferCandidate): boolean => + buffer.type !== "agent"; diff --git a/src/features/ai/store/store.ts b/src/features/ai/store/store.ts index e31008a7..9acdb8fe 100644 --- a/src/features/ai/store/store.ts +++ b/src/features/ai/store/store.ts @@ -840,7 +840,11 @@ export const useAIChatStore = create()( getWorkspaceSessionSnapshot: (buffers) => { const state = get(); const selectedBufferPaths = buffers - .filter((buffer) => state.selectedBufferIds.has(buffer.id)) + .filter( + (buffer) => + state.selectedBufferIds.has(buffer.id) && + (!("type" in buffer) || buffer.type !== "agent"), + ) .map((buffer) => buffer.path); return { @@ -855,7 +859,11 @@ export const useAIChatStore = create()( restoreWorkspaceSession: (snapshot, buffers) => { const selectedBufferIds = new Set( buffers - .filter((buffer) => snapshot?.selectedBufferPaths.includes(buffer.path)) + .filter( + (buffer) => + snapshot?.selectedBufferPaths.includes(buffer.path) && + (!("type" in buffer) || buffer.type !== "agent"), + ) .map((buffer) => buffer.id), ); diff --git a/src/features/editor/hooks/use-lsp-integration.ts b/src/features/editor/hooks/use-lsp-integration.ts index f106c8d3..d9760c33 100644 --- a/src/features/editor/hooks/use-lsp-integration.ts +++ b/src/features/editor/hooks/use-lsp-integration.ts @@ -15,6 +15,7 @@ import { useLspStore } from "@/features/editor/lsp/lsp-store"; import { useDefinitionLink } from "@/features/editor/lsp/use-definition-link"; import { useGoToDefinition } from "@/features/editor/lsp/use-go-to-definition"; import { useHover } from "@/features/editor/lsp/use-hover"; +import { lspStartupNotifier } from "@/features/editor/lsp/lsp-startup-reporting"; import { useBufferStore } from "@/features/editor/stores/buffer-store"; import { useEditorUIStore } from "@/features/editor/stores/ui-store"; import { useFileSystemStore } from "@/features/file-system/controllers/store"; @@ -138,6 +139,7 @@ export const useLspIntegration = ({ if (!workspacePath) { console.warn("LSP: Could not determine workspace path for", filePath); + lspStartupNotifier.reportMissingWorkspace(filePath); return; } @@ -184,6 +186,7 @@ export const useLspIntegration = ({ await lspClient.notifyDocumentOpen(filePath, value); // Mark document as opened so changes can be sent openedDocumentsRef.current.add(filePath); + lspStartupNotifier.clearForFile(filePath); logger.debug("LspIntegration", `LSP started and document opened for ${filePath}`); } catch (error) { console.error("LSP initialization error:", error); diff --git a/src/features/editor/lsp/lsp-client.ts b/src/features/editor/lsp/lsp-client.ts index 6313f267..0828a6b0 100644 --- a/src/features/editor/lsp/lsp-client.ts +++ b/src/features/editor/lsp/lsp-client.ts @@ -18,6 +18,7 @@ import { hasTextContent } from "@/features/panes/types/pane-content"; import { useBufferStore } from "../stores/buffer-store"; import { logger } from "../utils/logger"; import { useLspStore } from "./lsp-store"; +import { lspStartupNotifier } from "./lsp-startup-reporting"; export interface LspError { message: string; @@ -376,6 +377,7 @@ export class LspClient { workspacePath: string, options: { forceRetry?: boolean } = {}, ): Promise { + let languageId: string | undefined; try { logger.debug("LSPClient", "Starting LSP for file:", filePath); @@ -384,23 +386,13 @@ export class LspClient { const serverPath = extensionRegistry.getLspServerPath(filePath) || undefined; const serverArgs = extensionRegistry.getLspServerArgs(filePath); - const languageId = extensionRegistry.getLanguageId(filePath) || undefined; + languageId = extensionRegistry.getLanguageId(filePath) || undefined; const initializationOptions = extensionRegistry.getLspInitializationOptions(filePath); // If no LSP server is configured for this file type, return early if (!serverPath) { - const message = languageId - ? `Language server for ${this.getLanguageDisplayName(languageId)} could not be resolved.` - : "No language server is configured for this file."; - if (languageId) { - logger.warn( - "LSPClient", - `LSP configured for language '${languageId}' but server binary is missing (file: ${filePath})`, - ); - } else { - logger.debug("LSPClient", `No LSP server configured for ${filePath}`); - } - throw new Error(message); + lspStartupNotifier.reportMissingServer({ filePath, languageId }); + return false; } logger.debug("LSPClient", `Using LSP server: ${serverPath} for language: ${languageId}`); @@ -456,11 +448,11 @@ export class LspClient { } logger.debug("LSPClient", "LSP started successfully for file:", filePath); + lspStartupNotifier.clearForFile(filePath); return true; } catch (error) { logger.error("LSPClient", "Failed to start LSP for file:", error); - const { actions } = useLspStore.getState(); - actions.setLspError(getUserFacingLspErrorMessage(error)); + lspStartupNotifier.reportStartFailure({ filePath, languageId, error }); throw error; } } diff --git a/src/features/editor/lsp/lsp-startup-reporting.test.ts b/src/features/editor/lsp/lsp-startup-reporting.test.ts new file mode 100644 index 00000000..7ad72b2a --- /dev/null +++ b/src/features/editor/lsp/lsp-startup-reporting.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@/features/editor/lsp/lsp-store", () => ({ + useLspStore: { + getState: () => ({ + actions: { + setLspError: vi.fn(), + }, + }), + }, +})); + +vi.mock("@/ui/toast", () => ({ + toast: { + error: vi.fn(), + warning: vi.fn(), + }, +})); +import { + buildMissingServerMessage, + buildMissingServerStatusMessage, + buildStartFailureMessage, + buildStartFailureStatusMessage, + buildWorkspacePathMessage, + buildWorkspacePathStatusMessage, + createLspStartupNotifier, +} from "./lsp-startup-reporting"; + +describe("lsp startup reporting", () => { + it("formats missing-server messages with file context", () => { + expect( + buildMissingServerMessage({ + filePath: "/workspace/src/main.rs", + languageId: "rust", + }), + ).toBe( + "Could not start rust language server for main.rs because no server binary is configured or installed.", + ); + }); + + it("formats startup failures with the original error message", () => { + expect( + buildStartFailureMessage({ + filePath: "/workspace/src/app.ts", + languageId: "typescript", + error: new Error("spawn ENOENT"), + }), + ).toBe("Failed to start typescript language server for app.ts: spawn ENOENT"); + }); + + it("formats missing-workspace messages", () => { + expect(buildWorkspacePathMessage("/workspace/README.md")).toBe( + "Could not start language server for README.md because no workspace path could be determined.", + ); + }); + + it("formats compact status messages for toolbar state", () => { + expect( + buildMissingServerStatusMessage({ + filePath: "/workspace/src/main.rs", + languageId: "rust", + }), + ).toBe("rust language server unavailable for main.rs"); + + expect( + buildStartFailureStatusMessage({ + filePath: "/workspace/src/app.ts", + languageId: "typescript", + error: new Error("spawn ENOENT"), + }), + ).toBe("Failed to start typescript language server for app.ts"); + + expect(buildWorkspacePathStatusMessage("/workspace/README.md")).toBe( + "Language server unavailable for README.md", + ); + }); + + it("deduplicates repeated notifications for the same file issue", () => { + const setError = vi.fn(); + const showError = vi.fn(); + const showWarning = vi.fn(); + const notifier = createLspStartupNotifier({ + setError, + showError, + showWarning, + }); + + notifier.reportMissingServer({ + filePath: "/workspace/main.go", + languageId: "go", + }); + notifier.reportMissingServer({ + filePath: "/workspace/main.go", + languageId: "go", + }); + + expect(setError).toHaveBeenCalledTimes(2); + expect(setError).toHaveBeenNthCalledWith(1, "go language server unavailable for main.go"); + expect(setError).toHaveBeenNthCalledWith(2, "go language server unavailable for main.go"); + expect(showError).toHaveBeenCalledTimes(1); + expect(showError).toHaveBeenCalledWith( + "Could not start go language server for main.go because no server binary is configured or installed.", + ); + }); + + it("allows notifications again after a successful clear", () => { + const notifier = createLspStartupNotifier({ + setError: vi.fn(), + showError: vi.fn(), + showWarning: vi.fn(), + }); + + notifier.reportMissingWorkspace("/workspace/src/lib.rs"); + notifier.clearForFile("/workspace/src/lib.rs"); + notifier.reportMissingWorkspace("/workspace/src/lib.rs"); + + expect(notifier.reportMissingWorkspace("/workspace/src/lib.rs")).toBe( + "Could not start language server for lib.rs because no workspace path could be determined.", + ); + }); +}); diff --git a/src/features/editor/lsp/lsp-startup-reporting.ts b/src/features/editor/lsp/lsp-startup-reporting.ts new file mode 100644 index 00000000..e8341bd8 --- /dev/null +++ b/src/features/editor/lsp/lsp-startup-reporting.ts @@ -0,0 +1,127 @@ +import { useLspStore } from "@/features/editor/lsp/lsp-store"; +import { toast } from "@/ui/toast"; + +interface LspStartupNotifierDeps { + setError: (message: string) => void; + showError: (message: string) => void; + showWarning: (message: string) => void; +} + +interface MissingServerParams { + filePath: string; + languageId?: string; +} + +interface StartFailureParams { + filePath: string; + languageId?: string; + error: unknown; +} + +const getFileLabel = (filePath: string) => { + const segments = filePath.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || filePath; +}; + +const getLanguageLabel = (languageId?: string) => { + if (!languageId) return "language"; + return languageId; +}; + +export function stringifyStartupError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (error && typeof error === "object") { + const candidate = (error as { message?: unknown }).message; + if (typeof candidate === "string" && candidate.trim()) return candidate; + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + return String(error); +} + +export function buildMissingServerMessage({ filePath, languageId }: MissingServerParams) { + return `Could not start ${getLanguageLabel(languageId)} language server for ${getFileLabel(filePath)} because no server binary is configured or installed.`; +} + +export function buildStartFailureMessage({ filePath, languageId, error }: StartFailureParams) { + return `Failed to start ${getLanguageLabel(languageId)} language server for ${getFileLabel(filePath)}: ${stringifyStartupError(error)}`; +} + +export function buildWorkspacePathMessage(filePath: string) { + return `Could not start language server for ${getFileLabel(filePath)} because no workspace path could be determined.`; +} + +export function buildMissingServerStatusMessage({ filePath, languageId }: MissingServerParams) { + return `${getLanguageLabel(languageId)} language server unavailable for ${getFileLabel(filePath)}`; +} + +export function buildStartFailureStatusMessage({ filePath, languageId }: StartFailureParams) { + return `Failed to start ${getLanguageLabel(languageId)} language server for ${getFileLabel(filePath)}`; +} + +export function buildWorkspacePathStatusMessage(filePath: string) { + return `Language server unavailable for ${getFileLabel(filePath)}`; +} + +export function createLspStartupNotifier({ + setError, + showError, + showWarning, +}: LspStartupNotifierDeps) { + const reportedIssueKeys = new Set(); + + const notifyOnce = (issueKey: string, notify: () => void) => { + if (reportedIssueKeys.has(issueKey)) return; + reportedIssueKeys.add(issueKey); + notify(); + }; + + return { + reportMissingServer(params: MissingServerParams) { + const message = buildMissingServerMessage(params); + const statusMessage = buildMissingServerStatusMessage(params); + const issueKey = `missing-server:${params.filePath}`; + setError(statusMessage); + notifyOnce(issueKey, () => showError(message)); + return message; + }, + + reportStartFailure(params: StartFailureParams) { + const message = buildStartFailureMessage(params); + const statusMessage = buildStartFailureStatusMessage(params); + const issueKey = `start-failure:${params.filePath}`; + setError(statusMessage); + notifyOnce(issueKey, () => showError(message)); + return message; + }, + + reportMissingWorkspace(filePath: string) { + const message = buildWorkspacePathMessage(filePath); + const statusMessage = buildWorkspacePathStatusMessage(filePath); + const issueKey = `missing-workspace:${filePath}`; + setError(statusMessage); + notifyOnce(issueKey, () => showWarning(message)); + return message; + }, + + clearForFile(filePath: string) { + reportedIssueKeys.delete(`missing-server:${filePath}`); + reportedIssueKeys.delete(`start-failure:${filePath}`); + reportedIssueKeys.delete(`missing-workspace:${filePath}`); + }, + }; +} + +export const lspStartupNotifier = createLspStartupNotifier({ + setError: (message) => useLspStore.getState().actions.setLspError(message), + showError: (message) => { + toast.error(message); + }, + showWarning: (message) => { + toast.warning(message); + }, +}); diff --git a/src/features/editor/markdown/parser.test.ts b/src/features/editor/markdown/parser.test.ts new file mode 100644 index 00000000..e518b2b6 --- /dev/null +++ b/src/features/editor/markdown/parser.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("dompurify", () => ({ + default: { + sanitize: (html: string) => html, + }, +})); + +import { parseMarkdown } from "./parser"; + +describe("parseMarkdown", () => { + it("keeps ordered lists counting across blank lines", () => { + const html = parseMarkdown("1. One\n\n2. Two\n\n3. Three"); + + expect(html).toContain('
    '); + expect(html).toContain("
  1. One
  2. "); + expect(html).toContain("
  3. Two
  4. "); + expect(html).toContain("
  5. Three
  6. "); + expect((html.match(/
      /g) ?? []).length).toBe(1); + }); +}); diff --git a/src/features/editor/markdown/parser.ts b/src/features/editor/markdown/parser.ts index c2d94706..5fd309d7 100644 --- a/src/features/editor/markdown/parser.ts +++ b/src/features/editor/markdown/parser.ts @@ -164,7 +164,8 @@ export function parseMarkdown(content: string): string { processedLines.push(`
    1. ${processInline(line.replace(/^\s*[-*+]\s/, ""), footnotes)}
    2. `); } else if (isOrderedListLine(line)) { if (!inOrderedList) { - processedLines.push("
        "); + const startNum = line.match(/^\s*(\d+)\.\s/)?.[1] || "1"; + processedLines.push(`
          `); inOrderedList = true; } processedLines.push(`
        1. ${processInline(line.replace(/^\s*\d+\.\s/, ""), footnotes)}
        2. `); diff --git a/src/features/editor/markdown/styles.css b/src/features/editor/markdown/styles.css index 192bcf72..2e31c825 100644 --- a/src/features/editor/markdown/styles.css +++ b/src/features/editor/markdown/styles.css @@ -11,7 +11,7 @@ .markdown-preview h1 { font-size: 1.6em; font-weight: 700; - margin: 0.4em 0 0.12em 0; + margin: 1.2em 0 0.35em 0; padding-bottom: 0; border-bottom: 0; color: var(--color-text); @@ -25,7 +25,7 @@ .markdown-preview h2 { font-size: 1.4em; font-weight: 700; - margin: 0.35em 0 0.12em 0; + margin: 1em 0 0.3em 0; padding-bottom: 0; border-bottom: 0; color: var(--color-text); @@ -35,7 +35,7 @@ .markdown-preview h3 { font-size: 1.2em; font-weight: 600; - margin: 0.28em 0 0.1em 0; + margin: 0.85em 0 0.25em 0; color: var(--color-text); line-height: 1.3; } @@ -43,7 +43,7 @@ .markdown-preview h4 { font-size: 1.1em; font-weight: 600; - margin: 0.24em 0 0.08em 0; + margin: 0.75em 0 0.2em 0; color: var(--color-text); line-height: 1.3; } @@ -51,14 +51,14 @@ .markdown-preview h5 { font-size: 1em; font-weight: 600; - margin: 0.22em 0 0.08em 0; + margin: 0.65em 0 0.15em 0; color: var(--color-text); } .markdown-preview h6 { font-size: 0.95em; font-weight: 600; - margin: 0.2em 0 0.08em 0; + margin: 0.65em 0 0.15em 0; color: var(--color-text-light); } diff --git a/src/features/git/components/diff/diff-line-background-layer.tsx b/src/features/git/components/diff/diff-line-background-layer.tsx index d2618100..5a921d14 100644 --- a/src/features/git/components/diff/diff-line-background-layer.tsx +++ b/src/features/git/components/diff/diff-line-background-layer.tsx @@ -9,7 +9,7 @@ interface DiffLineBackgroundLayerProps { const backgroundClassByKind: Record = { context: "", - spacer: "", + spacer: "bg-secondary-bg/40", added: "bg-git-added/18", removed: "bg-git-deleted/18", }; diff --git a/src/features/git/components/diff/git-diff-line.tsx b/src/features/git/components/diff/git-diff-line.tsx index f7a0176c..e13aabfe 100644 --- a/src/features/git/components/diff/git-diff-line.tsx +++ b/src/features/git/components/diff/git-diff-line.tsx @@ -9,6 +9,8 @@ export const getLineBackground = (type: string) => { return "bg-git-added/15"; case "removed": return "bg-git-deleted/15"; + case "spacer": + return "bg-secondary-bg/40"; default: return ""; } @@ -20,6 +22,8 @@ export const getGutterBackground = (type: string) => { return "bg-git-added/25"; case "removed": return "bg-git-deleted/25"; + case "spacer": + return "bg-secondary-bg/50"; default: return "bg-primary-bg"; } @@ -31,6 +35,8 @@ export const getContentColor = (type: string) => { return "text-git-added"; case "removed": return "text-git-deleted"; + case "spacer": + return ""; default: return "text-text"; } @@ -117,13 +123,15 @@ export function getSplitLineMeta(line: DiffLineProps["line"], splitSide: "left" const isLeft = splitSide === "left"; const isVisible = isLeft ? line.line_type !== "added" : line.line_type !== "removed"; const gutterNumber = isLeft ? line.old_line_number : line.new_line_number; - const diffType = isLeft - ? line.line_type === "removed" - ? "removed" - : "context" - : line.line_type === "added" - ? "added" - : "context"; + const diffType = !isVisible + ? "spacer" + : isLeft + ? line.line_type === "removed" + ? "removed" + : "context" + : line.line_type === "added" + ? "added" + : "context"; return { isVisible, @@ -160,16 +168,7 @@ const DiffLine = memo( }, [line.content, tokens, showWhitespace]); if (viewMode === "split" && splitSide) { - const isLeft = splitSide === "left"; - const isVisible = isLeft ? line.line_type !== "added" : line.line_type !== "removed"; - const gutterNumber = isLeft ? line.old_line_number : line.new_line_number; - const diffType = isLeft - ? line.line_type === "removed" - ? "removed" - : "context" - : line.line_type === "added" - ? "added" - : "context"; + const { isVisible, gutterNumber, diffType } = getSplitLineMeta(line, splitSide); return (
          diff --git a/src/features/git/components/diff/git-diff-text.tsx b/src/features/git/components/diff/git-diff-text.tsx index d28cb6e4..89d47c01 100644 --- a/src/features/git/components/diff/git-diff-text.tsx +++ b/src/features/git/components/diff/git-diff-text.tsx @@ -9,6 +9,7 @@ import { groupLinesIntoHunks } from "../../utils/git-diff-helpers"; import DiffHunkHeader from "./git-diff-hunk-header"; import DiffLine, { getContentColor, + getGutterBackground, getLineBackground, getSplitLineMeta, renderDiffLineContent, @@ -42,13 +43,13 @@ function SplitDiffCodePanel({ return (
          -
          +
          {lines.map((line, index) => { const meta = getSplitLineMeta(line, side); return (
          - files.map((file) => ({ - ...file, - staged: optimisticStageMap[file.path] ?? file.staged, - })), + () => applyOptimisticStageMap(files, optimisticStageMap), [files, optimisticStageMap], ); const stagedFiles = useMemo(() => displayFiles.filter((f) => f.staged), [displayFiles]); const unstagedFiles = useMemo(() => displayFiles.filter((f) => !f.staged), [displayFiles]); const groupedAllFiles = useMemo(() => groupFilesByStatus(displayFiles), [displayFiles]); - const getDiffStats = (file: GitFile) => - fileDiffStats?.[`staged:${file.path}`] ?? fileDiffStats?.[`unstaged:${file.path}`]; + const getDiffStats = (file: GitFile) => getGitFileDiffStats(file, fileDiffStats); - const setOptimisticStage = (filePaths: string[], staged: boolean) => { + const setOptimisticStage = (targetFiles: GitFile[], staged: boolean) => { setOptimisticStageMap((current) => { const next = { ...current }; - for (const filePath of filePaths) { - next[filePath] = staged; + for (const file of targetFiles) { + next[getGitFileRowKey(file)] = staged; } return next; }); @@ -185,7 +185,10 @@ const GitStatusPanel = ({ const handleStageFile = async (filePath: string) => { if (!repoPath) return; - setOptimisticStage([filePath], true); + const file = files.find((entry) => entry.path === filePath && !entry.staged); + if (file) { + setOptimisticStage([file], true); + } setIsLoading(true); try { await stageFile(repoPath, filePath); @@ -197,7 +200,10 @@ const GitStatusPanel = ({ const handleUnstageFile = async (filePath: string) => { if (!repoPath) return; - setOptimisticStage([filePath], false); + const file = files.find((entry) => entry.path === filePath && entry.staged); + if (file) { + setOptimisticStage([file], false); + } setIsLoading(true); try { await unstageFile(repoPath, filePath); @@ -207,15 +213,15 @@ const GitStatusPanel = ({ } }; - const handleSetFilesStaged = async (filePaths: string[], staged: boolean) => { - if (!repoPath || filePaths.length === 0) return; + const handleSetFilesStaged = async (targetFiles: GitFile[], staged: boolean) => { + if (!repoPath || targetFiles.length === 0) return; - setOptimisticStage(filePaths, staged); + setOptimisticStage(targetFiles, staged); setIsLoading(true); try { await Promise.all( - filePaths.map((filePath) => - staged ? stageFile(repoPath, filePath) : unstageFile(repoPath, filePath), + targetFiles.map((file) => + staged ? stageFile(repoPath, file.path) : unstageFile(repoPath, file.path), ), ); onRefresh?.(); @@ -226,10 +232,7 @@ const GitStatusPanel = ({ const handleStageAll = async () => { if (!repoPath) return; - setOptimisticStage( - unstagedFiles.map((file) => file.path), - true, - ); + setOptimisticStage(unstagedFiles, true); setIsLoading(true); try { await stageAllFiles(repoPath); @@ -241,10 +244,7 @@ const GitStatusPanel = ({ const handleUnstageAll = async () => { if (!repoPath) return; - setOptimisticStage( - stagedFiles.map((file) => file.path), - false, - ); + setOptimisticStage(stagedFiles, false); setIsLoading(true); try { await unstageAllFiles(repoPath); @@ -312,7 +312,7 @@ const GitStatusPanel = ({ }); }; - const toggleFolderCollapsed = (section: "changes", folderPath: string) => { + const toggleFolderCollapsed = (section: string, folderPath: string) => { const key = `${section}:${folderPath}`; setCollapsedFolders((previous) => { const next = new Set(previous); @@ -350,13 +350,14 @@ const GitStatusPanel = ({ }); }; - const renderSectionHeader = (title: string) => ( + const renderSectionHeader = (title: string, count?: number) => (
          {title} + {count != null && count > 0 && {count}}
          ); - const renderFolderTree = (fileList: GitFile[], section: "changes") => { + const renderFolderTree = (fileList: GitFile[], section: string) => { const rootNode = buildGitFolderTree(fileList); const renderNode = (node: GitFolderNode, depth: number): React.ReactNode => { @@ -386,12 +387,7 @@ const GitStatusPanel = ({
          e.stopPropagation()}> - void handleSetFilesStaged( - folderFiles.map((file) => file.path), - checked, - ) - } + onChange={(checked) => void handleSetFilesStaged(folderFiles, checked)} disabled={isLoading || folderFiles.length === 0} ariaLabel={ areAllFolderFilesStaged @@ -434,23 +430,18 @@ const GitStatusPanel = ({ }; const hasFiles = files.length > 0; - const trackedFiles = useMemo( - () => displayFiles.filter((file) => file.status !== "untracked"), - [displayFiles], + const unstagedTrackedFiles = useMemo( + () => unstagedFiles.filter((file) => file.status !== "untracked"), + [unstagedFiles], ); const untrackedFiles = useMemo( () => displayFiles.filter((file) => file.status === "untracked"), [displayFiles], ); - const groupedTrackedFiles = useMemo( - () => ({ - ...createEmptyStatusGroups(), - added: groupedAllFiles.added, - modified: groupedAllFiles.modified, - deleted: groupedAllFiles.deleted, - renamed: groupedAllFiles.renamed, - }), - [groupedAllFiles], + const groupedStagedFiles = useMemo(() => groupFilesByStatus(stagedFiles), [stagedFiles]); + const groupedUnstagedTrackedFiles = useMemo( + () => groupFilesByStatus(unstagedTrackedFiles), + [unstagedTrackedFiles], ); const groupedUntrackedFiles = useMemo( () => ({ @@ -522,19 +513,27 @@ const GitStatusPanel = ({ {hasFiles ? (
          - {trackedFiles.length > 0 && ( + {stagedFiles.length > 0 && ( + <> + {renderSectionHeader("Staged", stagedFiles.length)} + {gitChangesFolderView + ? renderFolderTree(stagedFiles, "staged") + : renderFlatFileList(groupedStagedFiles)} + + )} + {unstagedTrackedFiles.length > 0 && ( <> - {renderSectionHeader("Tracked")} + {renderSectionHeader("Unstaged", unstagedTrackedFiles.length)} {gitChangesFolderView - ? renderFolderTree(trackedFiles, "changes") - : renderFlatFileList(groupedTrackedFiles)} + ? renderFolderTree(unstagedTrackedFiles, "unstaged") + : renderFlatFileList(groupedUnstagedTrackedFiles)} )} {untrackedFiles.length > 0 && ( <> - {renderSectionHeader("Untracked")} + {renderSectionHeader("Untracked", untrackedFiles.length)} {gitChangesFolderView - ? renderFolderTree(untrackedFiles, "changes") + ? renderFolderTree(untrackedFiles, "untracked") : renderFlatFileList(groupedUntrackedFiles)} )} diff --git a/src/features/git/tests/git-status-panel-state.test.ts b/src/features/git/tests/git-status-panel-state.test.ts new file mode 100644 index 00000000..2585b1d9 --- /dev/null +++ b/src/features/git/tests/git-status-panel-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vite-plus/test"; +import type { GitFile } from "../types/git-types"; +import { + applyOptimisticStageMap, + getGitFileDiffStats, + getGitFileRowKey, +} from "../utils/git-status-panel-state"; + +const createFile = ( + path: string, + staged: boolean, + status: GitFile["status"] = "modified", +): GitFile => ({ + path, + staged, + status, +}); + +describe("git status panel state", () => { + test("resolves row keys by staged state and path", () => { + expect(getGitFileRowKey(createFile("src/a.ts", true))).toBe("staged:src/a.ts"); + expect(getGitFileRowKey(createFile("src/a.ts", false))).toBe("unstaged:src/a.ts"); + }); + + test("prefers exact diff stats for a row before falling back to the sibling entry", () => { + const file = createFile("src/a.ts", false); + + expect( + getGitFileDiffStats(file, { + "staged:src/a.ts": { additions: 1, deletions: 2 }, + "unstaged:src/a.ts": { additions: 3, deletions: 4 }, + }), + ).toEqual({ additions: 3, deletions: 4 }); + }); + + test("falls back to the sibling diff stats when the exact row is missing", () => { + expect( + getGitFileDiffStats(createFile("src/a.ts", true), { + "unstaged:src/a.ts": { additions: 3, deletions: 4 }, + }), + ).toEqual({ additions: 3, deletions: 4 }); + }); + + test("applies optimistic state for a file that only has one row", () => { + expect( + applyOptimisticStageMap([createFile("src/a.ts", false)], { + "unstaged:src/a.ts": true, + }), + ).toEqual([createFile("src/a.ts", true)]); + }); + + test("does not collapse mixed staged and unstaged rows for the same path", () => { + expect( + applyOptimisticStageMap([createFile("src/a.ts", true), createFile("src/a.ts", false)], { + "unstaged:src/a.ts": true, + }), + ).toEqual([createFile("src/a.ts", true), createFile("src/a.ts", false)]); + }); +}); diff --git a/src/features/git/tests/split-diff-marking.test.ts b/src/features/git/tests/split-diff-marking.test.ts new file mode 100644 index 00000000..9ac575ff --- /dev/null +++ b/src/features/git/tests/split-diff-marking.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vite-plus/test"; +import type { GitDiff, GitDiffLine } from "../types/git-types"; +import { + getSplitLineMeta, + getLineBackground, + getGutterBackground, + getContentColor, +} from "../components/diff/git-diff-line"; +import { serializeGitDiffSourceForSplitEditor } from "../utils/diff-editor-content"; + +const makeLine = ( + overrides: Partial & Pick, +): GitDiffLine => ({ + content: overrides.content ?? "code", + ...overrides, +}); + +describe("getSplitLineMeta", () => { + test("added line on left side is spacer", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "added", new_line_number: 3 }), + "left", + ); + expect(meta.isVisible).toBe(false); + expect(meta.diffType).toBe("spacer"); + }); + + test("added line on right side is added", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "added", new_line_number: 3 }), + "right", + ); + expect(meta.isVisible).toBe(true); + expect(meta.diffType).toBe("added"); + }); + + test("removed line on left side is removed", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "removed", old_line_number: 5 }), + "left", + ); + expect(meta.isVisible).toBe(true); + expect(meta.diffType).toBe("removed"); + }); + + test("removed line on right side is spacer", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "removed", old_line_number: 5 }), + "right", + ); + expect(meta.isVisible).toBe(false); + expect(meta.diffType).toBe("spacer"); + }); + + test("context line on left side is context", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "context", old_line_number: 4, new_line_number: 6 }), + "left", + ); + expect(meta.isVisible).toBe(true); + expect(meta.diffType).toBe("context"); + }); + + test("context line on right side is context", () => { + const meta = getSplitLineMeta( + makeLine({ line_type: "context", old_line_number: 4, new_line_number: 6 }), + "right", + ); + expect(meta.isVisible).toBe(true); + expect(meta.diffType).toBe("context"); + }); +}); + +describe("serializeGitDiffSourceForSplitEditor context line mapping", () => { + test("left panel uses old_line_number and right uses new_line_number", () => { + const diff: GitDiff = { + file_path: "test.ts", + is_new: false, + is_deleted: false, + is_renamed: false, + lines: [ + { line_type: "context", content: "shared", old_line_number: 5, new_line_number: 7 }, + ], + }; + const result = serializeGitDiffSourceForSplitEditor(diff); + expect(result.left.actualLines[0]).toBe(5); + expect(result.right.actualLines[0]).toBe(7); + }); +}); + +describe("styling functions handle spacer type", () => { + test("getLineBackground returns muted background for spacer", () => { + expect(getLineBackground("spacer")).toBe("bg-secondary-bg/40"); + }); + + test("getGutterBackground returns muted background for spacer", () => { + expect(getGutterBackground("spacer")).toBe("bg-secondary-bg/50"); + }); + + test("getContentColor returns empty for spacer", () => { + expect(getContentColor("spacer")).toBe(""); + }); +}); diff --git a/src/features/git/utils/diff-editor-content.ts b/src/features/git/utils/diff-editor-content.ts index d36db397..2bf4ddb2 100644 --- a/src/features/git/utils/diff-editor-content.ts +++ b/src/features/git/utils/diff-editor-content.ts @@ -184,7 +184,7 @@ export function serializeGitDiffSourceForSplitEditor( leftActualLines, line.content, "context", - line.new_line_number ?? line.old_line_number ?? null, + line.old_line_number ?? line.new_line_number ?? null, ); pushLine( rightLines, diff --git a/src/features/git/utils/git-status-panel-state.ts b/src/features/git/utils/git-status-panel-state.ts new file mode 100644 index 00000000..d12461bb --- /dev/null +++ b/src/features/git/utils/git-status-panel-state.ts @@ -0,0 +1,61 @@ +import type { GitFile } from "../types/git-types"; + +interface GitFileDiffStats { + additions: number; + deletions: number; +} + +export const getGitFileRowKey = (file: Pick): string => + `${file.staged ? "staged" : "unstaged"}:${file.path}`; + +const getPathEntryCounts = (files: GitFile[]) => { + const counts = new Map(); + + for (const file of files) { + counts.set(file.path, (counts.get(file.path) ?? 0) + 1); + } + + return counts; +}; + +export const applyOptimisticStageMap = ( + files: GitFile[], + optimisticStageMap: Record, +): GitFile[] => { + const pathEntryCounts = getPathEntryCounts(files); + + return files.map((file) => { + const rowKey = getGitFileRowKey(file); + const optimisticStage = optimisticStageMap[rowKey]; + + if (optimisticStage === undefined) { + return file; + } + + // When a file appears in both staged and unstaged groups, keep the server + // state until refresh so we do not collapse both rows into the same state. + if ((pathEntryCounts.get(file.path) ?? 0) > 1) { + return file; + } + + return { + ...file, + staged: optimisticStage, + }; + }); +}; + +export const getGitFileDiffStats = ( + file: Pick, + fileDiffStats?: Record, +): GitFileDiffStats | undefined => { + if (!fileDiffStats) return undefined; + + const exactKey = getGitFileRowKey(file); + if (fileDiffStats[exactKey]) { + return fileDiffStats[exactKey]; + } + + const fallbackKey = `${file.staged ? "unstaged" : "staged"}:${file.path}`; + return fileDiffStats[fallbackKey]; +}; diff --git a/src/features/keymaps/commands/command-registry.ts b/src/features/keymaps/commands/command-registry.ts index 55460829..ed20942a 100644 --- a/src/features/keymaps/commands/command-registry.ts +++ b/src/features/keymaps/commands/command-registry.ts @@ -390,6 +390,28 @@ const viewCommands: Command[] = [ state.setIsGlobalSearchVisible(!state.isGlobalSearchVisible); }, }, + { + id: "workbench.showFiles", + title: "Show Files", + category: "View", + keybinding: "cmd+shift+e", + execute: () => { + const state = useUIState.getState(); + state.setActiveView("files"); + state.setIsSidebarVisible(true); + }, + }, + { + id: "workbench.showGit", + title: "Show Git", + category: "View", + keybinding: "cmd+shift+g", + execute: () => { + const state = useUIState.getState(); + state.setActiveView("git"); + state.setIsSidebarVisible(true); + }, + }, { id: "workbench.showProjectSearch", title: "Project Search", diff --git a/src/features/keymaps/defaults/default-keymaps-zoom.test.ts b/src/features/keymaps/defaults/default-keymaps-zoom.test.ts new file mode 100644 index 00000000..5cbe4f2c --- /dev/null +++ b/src/features/keymaps/defaults/default-keymaps-zoom.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; +import { defaultKeymaps } from "./default-keymaps"; + +describe("default keymaps zoom bindings", () => { + it("supports both equals and shifted plus for zooming in", () => { + expect(defaultKeymaps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "cmd+=", + command: "workbench.zoomIn", + source: "default", + }), + expect.objectContaining({ + key: "cmd+shift+=", + command: "workbench.zoomIn", + source: "default", + }), + ]), + ); + }); +}); diff --git a/src/features/keymaps/defaults/default-keymaps.test.ts b/src/features/keymaps/defaults/default-keymaps.test.ts new file mode 100644 index 00000000..ad0f24d3 --- /dev/null +++ b/src/features/keymaps/defaults/default-keymaps.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; +import { defaultKeymaps } from "./default-keymaps"; + +describe("default keymaps", () => { + it("includes bindings for showing the files and git sidebars", () => { + expect(defaultKeymaps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "cmd+shift+e", + command: "workbench.showFiles", + source: "default", + }), + expect.objectContaining({ + key: "cmd+shift+g", + command: "workbench.showGit", + source: "default", + }), + ]), + ); + }); +}); diff --git a/src/features/keymaps/defaults/default-keymaps.ts b/src/features/keymaps/defaults/default-keymaps.ts index f30bfb98..f8fc37ba 100644 --- a/src/features/keymaps/defaults/default-keymaps.ts +++ b/src/features/keymaps/defaults/default-keymaps.ts @@ -190,6 +190,16 @@ export const defaultKeymaps: Keybinding[] = [ command: "workbench.showGlobalSearch", source: "default", }, + { + key: "cmd+shift+e", + command: "workbench.showFiles", + source: "default", + }, + { + key: "cmd+shift+g", + command: "workbench.showGit", + source: "default", + }, { key: "cmd+shift+h", command: "workbench.showProjectSearch", @@ -221,6 +231,7 @@ export const defaultKeymaps: Keybinding[] = [ source: "default", }, { key: "cmd+=", command: "workbench.zoomIn", source: "default" }, + { key: "cmd+shift+=", command: "workbench.zoomIn", source: "default" }, { key: "cmd+-", command: "workbench.zoomOut", source: "default" }, { key: "cmd+0", command: "workbench.zoomReset", source: "default" }, diff --git a/src/features/panes/components/empty-editor-state.tsx b/src/features/panes/components/empty-editor-state.tsx index fbc938fc..68bea7f1 100644 --- a/src/features/panes/components/empty-editor-state.tsx +++ b/src/features/panes/components/empty-editor-state.tsx @@ -15,6 +15,7 @@ import { createPortal } from "react-dom"; import { useBufferStore } from "@/features/editor/stores/buffer-store"; import { readFileContent } from "@/features/file-system/controllers/file-operations"; import { useFileSystemStore } from "@/features/file-system/controllers/store"; +import { useAIChatStore } from "@/features/ai/store/store"; import { useCustomActionsStore } from "@/features/terminal/stores/custom-actions-store"; import { useUIState } from "@/features/window/stores/ui-state-store"; import { Button } from "@/ui/button"; @@ -58,6 +59,7 @@ export function EmptyEditorState() { }, [openTerminalBuffer]); const handleOpenAgent = useCallback(() => { + useAIChatStore.getState().createNewChat(); openAgentBuffer(); }, [openAgentBuffer]); diff --git a/src/features/panes/components/pane-container.tsx b/src/features/panes/components/pane-container.tsx index 8091b1d9..a60bd2d5 100644 --- a/src/features/panes/components/pane-container.tsx +++ b/src/features/panes/components/pane-container.tsx @@ -215,6 +215,9 @@ export function PaneContainer({ pane }: PaneContainerProps) { const rootFolderPath = useFileSystemStore.use.rootFolderPath?.(); const handleFileOpen = useFileSystemStore.use.handleFileOpen?.(); const horizontalBufferCarousel = useSettingsStore((state) => state.settings.horizontalTabScroll); + const carouselFocusOnHover = useSettingsStore( + (state) => state.settings.bufferCarouselFocusOnHover, + ); const [isDragOver, setIsDragOver] = useState(false); const [isTabDragOver, setIsTabDragOver] = useState(false); @@ -321,9 +324,16 @@ export function PaneContainer({ pane }: PaneContainerProps) { const card = viewport.querySelector(`[data-buffer-card-id="${bufferId}"]`); if (!card) return; - const viewportRect = viewport.getBoundingClientRect(); - const cardRect = card.getBoundingClientRect(); - const targetLeft = card.offsetLeft - (viewportRect.width - cardRect.width) / 2; + const cardLeft = card.offsetLeft; + const cardRight = cardLeft + card.offsetWidth; + const viewLeft = viewport.scrollLeft; + const viewRight = viewLeft + viewport.clientWidth; + + // Already fully visible -- nothing to do + if (cardLeft >= viewLeft && cardRight <= viewRight) return; + + // Center the card in the viewport + const targetLeft = cardLeft - (viewport.clientWidth - card.offsetWidth) / 2; viewport.scrollTo({ left: Math.max(0, targetLeft), @@ -643,12 +653,19 @@ export function PaneContainer({ pane }: PaneContainerProps) { const handleCarouselCardActivate = useCallback( (bufferId: string) => { + if (!carouselFocusOnHover) return; if (draggedCarouselBufferId || isCarouselResizing) return; if (bufferId === pane.activeBufferId) return; suppressAutoCenterRef.current = true; handleTabClick(bufferId); }, - [draggedCarouselBufferId, handleTabClick, isCarouselResizing, pane.activeBufferId], + [ + carouselFocusOnHover, + draggedCarouselBufferId, + handleTabClick, + isCarouselResizing, + pane.activeBufferId, + ], ); const handleCarouselCardDragStart = useCallback( @@ -932,6 +949,9 @@ export function PaneContainer({ pane }: PaneContainerProps) { )}
          + {!isActiveBuffer && ( +
          + )}
          void; @@ -46,6 +49,8 @@ interface SettingRowProps { export function SettingRow({ label, description, + info, + infoStyle = "icon", children, className, onReset, @@ -57,6 +62,23 @@ export function SettingRow({
          {label}
          + {info && infoStyle === "icon" && ( + + + + )} + {info && infoStyle === "chip" && ( + + + + )} {onReset && canReset && (