diff --git a/package.json b/package.json index ce84ebec0..66f92dadc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeg", "private": true, - "version": "0.21.1", + "version": "0.21.2", "packageManager": "pnpm@11.9.0", "scripts": { "dev": "next dev --turbopack", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dc47bd6e1..60d7f8a7b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -710,6 +710,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -1004,7 +1014,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "codeg" -version = "0.21.1" +version = "0.21.2" dependencies = [ "aes-gcm", "agent-client-protocol-schema", @@ -1025,6 +1035,7 @@ dependencies = [ "futures-lite", "futures-util", "if-addrs", + "ignore", "image", "include_dir", "insta", @@ -1273,6 +1284,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -2389,6 +2419,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2870,6 +2913,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ignore" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 64daff298..232b6bd11 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codeg" -version = "0.21.1" +version = "0.21.2" description = "Agent Code Generation App" authors = ["feitao"] edition = "2021" @@ -68,6 +68,7 @@ thiserror = "2" dirs = "6" if-addrs = "0.13" walkdir = "2" +ignore = "0.4" sacp = "11.0.0" sacp-tokio = "11.0.0" tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt", "net", "rt-multi-thread"] } diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 3763e0d99..5a52fda29 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -105,6 +105,29 @@ fn apply_cursor_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTr } } +/// Grok's launch-time credential policy, mirroring [`apply_cursor_env_policy`]. +/// When the user picked the `grok login` subscription (recorded as +/// `GROK_AUTH_MODE=subscription` by the Grok settings panel), scrub any +/// `XAI_API_KEY` inherited from this process's environment so the CLI falls back +/// to the browser-login credential in `~/.grok/auth.json` rather than a leaked +/// shell/container export. An empty value tells the spawn layer (vendored +/// sacp-tokio) to `env_remove` the inherited var. In api_key mode the key is +/// present and non-empty, so nothing is cleared; legacy/no-mode rows are left +/// untouched. +fn apply_grok_env_policy(merged: &mut Vec<(String, String)>, runtime_env: &BTreeMap) { + if runtime_env.get("GROK_AUTH_MODE").map(String::as_str) != Some("subscription") { + return; + } + let key = "XAI_API_KEY"; + let already_set = merged + .iter() + .any(|(k, v)| k == key && !v.trim().is_empty()); + if !already_set { + merged.retain(|(k, _)| k != key); + merged.push((key.to_string(), String::new())); + } +} + /// Prepend `dir` to the PATH entry of `env`, seeding from `fallback_path` when /// `env` has no PATH key of its own. Removes any pre-existing PATH key first /// (case-insensitively when `windows`, since Windows env keys are @@ -553,6 +576,8 @@ async fn build_agent( let mut merged_env = merge_agent_env(env, runtime_env); if agent_type == AgentType::Cursor { apply_cursor_env_policy(&mut merged_env, runtime_env); + } else if agent_type == AgentType::Grok { + apply_grok_env_policy(&mut merged_env, runtime_env); } let env_key_list: Vec<&str> = merged_env.iter().map(|(k, _)| k.as_str()).collect(); if !merged_env.is_empty() { @@ -6589,6 +6614,35 @@ mod tests { } } + #[test] + fn grok_env_policy_clears_inherited_key_only_in_subscription() { + let sub: BTreeMap = + [("GROK_AUTH_MODE".to_string(), "subscription".to_string())].into(); + + // Subscription with no configured key → inject empty (⇒ spawn strips the + // inherited XAI_API_KEY so `grok login` is used). + let mut merged = vec![("PATH".to_string(), "/usr/bin".to_string())]; + apply_grok_env_policy(&mut merged, &sub); + assert!(merged.iter().any(|(k, v)| k == "XAI_API_KEY" && v.is_empty())); + + // A configured key is preserved even in subscription mode (explicit wins). + let mut with_key = vec![("XAI_API_KEY".to_string(), "xai-abc".to_string())]; + apply_grok_env_policy(&mut with_key, &sub); + assert!(with_key + .iter() + .any(|(k, v)| k == "XAI_API_KEY" && v == "xai-abc")); + + // api_key mode and legacy/no-mode rows are left untouched. + for mode in [Some("api_key"), None] { + let rt: BTreeMap = mode + .map(|m| [("GROK_AUTH_MODE".to_string(), m.to_string())].into()) + .unwrap_or_default(); + let mut env = vec![("PATH".to_string(), "/usr/bin".to_string())]; + apply_grok_env_policy(&mut env, &rt); + assert!(!env.iter().any(|(k, _)| k == "XAI_API_KEY")); + } + } + #[test] fn synthesize_edit_single_diff_makes_canonical_edit() { let content = vec![diff_content("/a.rs", Some("old line\n"), "new line\n")]; diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index ff1219f15..b1f254a1c 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -8,6 +8,7 @@ use std::sync::LazyLock; use std::time::UNIX_EPOCH; use base64::Engine as _; +use ignore::WalkBuilder; use serde::Serialize; use tokio::sync::Semaphore; @@ -250,6 +251,23 @@ pub enum FileTreeNode { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceEntryKind { + File, + Dir, +} + +/// A flat workspace entry produced by `list_workspace_files`. Unlike the nested +/// `FileTreeNode`, this is a single flat record suited to fuzzy file search. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkspaceFileEntry { + pub name: String, + /// Path relative to the workspace root, always forward-slashed. + pub path: String, + pub kind: WorkspaceEntryKind, +} + #[derive(Debug, Serialize)] pub struct FilePreviewContent { pub path: String, @@ -3369,6 +3387,77 @@ pub async fn get_file_tree( Ok(dir_children.remove(&root).unwrap_or_default()) } +/// Flat, gitignore-aware listing of every file and directory under `path`, for +/// fuzzy file search. Unlike [`get_file_tree`], ignored directories +/// (`node_modules`, `target`, `dist`, …) are pruned *during* the walk, so no +/// depth cap is needed: deep files stay reachable while the heavy trees are +/// never descended and the payload stays small. Gitignore handling that used to +/// run client-side now happens here at native speed in a single pass. +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_workspace_files( + path: String, +) -> Result, AppCommandError> { + let root = PathBuf::from(&path); + + // Conservative gitignore parity with the previous client-side pass: respect + // in-tree `.gitignore`/`.ignore`/`.git/info/exclude`, but not the global + // gitignore or parent-directory ignores (the workspace root is the + // boundary). `require_git(false)` keeps `.gitignore` effective even outside + // a git repo. `hidden(false)` keeps dotfiles visible. The `filter_entry` + // mirrors `get_file_tree`'s hardcoded ignores exactly. + let walker = WalkBuilder::new(&root) + .hidden(false) + .parents(false) + .ignore(true) + .git_ignore(true) + .git_exclude(true) + .git_global(false) + .require_git(false) + .sort_by_file_name(|a, b| a.cmp(b)) + .filter_entry(|e| { + let name = e.file_name().to_string_lossy(); + if e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + !FILE_TREE_IGNORED_DIRS.contains(&name.as_ref()) + } else { + name != ".DS_Store" + } + }) + .build(); + + let mut entries: Vec = Vec::new(); + for result in walker { + // Skip unreadable entries (permission errors, transient races) rather + // than failing the whole search. + let Ok(entry) = result else { continue }; + let entry_path = entry.path(); + + // Skip the root directory itself. + if entry_path == root { + continue; + } + + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + let name = entry.file_name().to_string_lossy().to_string(); + let rel_path = entry_path + .strip_prefix(&root) + .unwrap_or(entry_path) + .to_string_lossy() + .replace('\\', "/"); + + entries.push(WorkspaceFileEntry { + name, + path: rel_path, + kind: if is_dir { + WorkspaceEntryKind::Dir + } else { + WorkspaceEntryKind::File + }, + }); + } + + Ok(entries) +} + #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn read_file_base64( path: String, @@ -4023,12 +4112,16 @@ pub async fn git_log( limit: Option, branch: Option, remote: Option, + skip: Option, ) -> Result { ensure_git_repo(&path)?; const COMMIT_META_PREFIX: &str = "__COMMIT__\0"; const MESSAGE_END_MARKER: &str = "__COMMIT_MESSAGE_END__"; + // Offset for paginated (infinite-scroll) loading: the frontend requests + // successive pages by their running commit count. + let skip = skip.unwrap_or(0); let limit_str = format!("-{}", limit.unwrap_or(100)); let mut args = vec![ "log".to_string(), @@ -4038,6 +4131,9 @@ pub async fn git_log( "--numstat".to_string(), "--no-renames".to_string(), ]; + if skip > 0 { + args.push(format!("--skip={}", skip)); + } if let Some(ref b) = branch { args.push(b.clone()); } @@ -4120,7 +4216,9 @@ pub async fn git_log( entries.push(entry.finish()); } - let log_limit = limit.unwrap_or(100); + // Cover the full fetched range (skip + limit) so pushed status stays correct + // on deeper pages — get_unpushed_hashes caps its rev-list to this count. + let log_limit = skip.saturating_add(limit.unwrap_or(100)); let (unpushed_hashes, has_upstream) = get_unpushed_hashes(&path, log_limit, remote.as_deref(), branch.as_deref()) .await @@ -4495,6 +4593,83 @@ mod tests { assert_eq!(p["folder"]["parent_id"], 1); } + /// Create `rel` (relative to `root`) as a file, making parent dirs. + fn write_file(root: &std::path::Path, rel: &str, contents: &str) { + let full = root.join(rel); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).expect("create parent dirs"); + } + std::fs::write(full, contents).expect("write file"); + } + + #[tokio::test] + async fn list_workspace_files_includes_deep_and_prunes_ignored() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + + // A file nested 12 levels deep — beyond the old hardcoded depth-10 cap + // this command replaces. It must still be discovered (the core fix). + let deep_rel = "a/b/c/d/e/f/g/h/i/j/k/deep.txt"; + write_file(root, deep_rel, "deep"); + + // Gitignore rules must be honored even without a real git repo + // (`require_git(false)`), pruning ignored trees during the walk. + write_file(root, ".gitignore", "node_modules/\nignored.txt\n"); + write_file(root, "node_modules/pkg/index.js", "junk"); + write_file(root, "ignored.txt", "junk"); + + // The hardcoded ignores (mirroring FILE_TREE_IGNORED_DIRS) must be pruned. + write_file(root, ".git/config", "[core]"); + write_file(root, "src/.DS_Store", "junk"); + + // A normal file that must survive. + write_file(root, "src/main.rs", "fn main() {}"); + + let entries = list_workspace_files(root.to_string_lossy().to_string()) + .await + .expect("list_workspace_files"); + let paths: std::collections::HashSet<&str> = + entries.iter().map(|e| e.path.as_str()).collect(); + + // Deep and normal files are reachable. + assert!( + paths.contains(deep_rel), + "deep file must be listed, got {paths:?}" + ); + assert!(paths.contains("src/main.rs"), "normal file must be listed"); + + // Gitignored entries are pruned during traversal. + assert!( + !paths.iter().any(|p| p.starts_with("node_modules")), + "gitignored node_modules must be pruned, got {paths:?}" + ); + assert!( + !paths.contains("ignored.txt"), + "gitignored file must be pruned" + ); + + // Hardcoded ignores are pruned. + // Precisely the `.git` dir / its contents — not `.gitignore`, which is + // intentionally listed. + assert!( + !paths.iter().any(|p| *p == ".git" || p.starts_with(".git/")), + ".git dir must be pruned, got {paths:?}" + ); + assert!( + !paths.iter().any(|p| p.ends_with(".DS_Store")), + ".DS_Store must be pruned" + ); + + // Intermediate directory entries are emitted alongside files — the + // `@`-mention picker relies on directories being present. + assert!( + entries.iter().any( + |e| e.path == "a" && matches!(e.kind, WorkspaceEntryKind::Dir) + ), + "directory entries must be present" + ); + } + /// Run a git command in `dir`, supplying identity via env so the test does /// not depend on (or mutate) the developer's global git config. fn git_run(dir: &std::path::Path, args: &[&str]) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5c1fc67c..e501c5ec5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -963,6 +963,7 @@ mod tauri_app { folders::list_directory_entries, folders::list_directory_with_files, folders::get_file_tree, + folders::list_workspace_files, folders::read_file_base64, folders::read_workspace_file_base64, folders::read_file_preview, diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index 5e08f68e1..726954943 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -280,6 +280,19 @@ pub async fn get_file_tree( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListWorkspaceFilesParams { + pub path: String, +} + +pub async fn list_workspace_files( + Json(params): Json, +) -> Result>, AppCommandError> { + let result = folder_commands::list_workspace_files(params.path).await?; + Ok(Json(result)) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenSettingsWindowParams { diff --git a/src-tauri/src/web/handlers/git.rs b/src-tauri/src/web/handlers/git.rs index a7e69c3da..73372b7d5 100644 --- a/src-tauri/src/web/handlers/git.rs +++ b/src-tauri/src/web/handlers/git.rs @@ -149,13 +149,20 @@ pub struct GitLogParams { pub limit: Option, pub branch: Option, pub remote: Option, + pub skip: Option, } pub async fn git_log( Json(params): Json, ) -> Result, AppCommandError> { - let result = - folder_commands::git_log(params.path, params.limit, params.branch, params.remote).await?; + let result = folder_commands::git_log( + params.path, + params.limit, + params.branch, + params.remote, + params.skip, + ) + .await?; Ok(Json(result)) } diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 7e6606d59..8d16a189f 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -222,6 +222,10 @@ pub fn build_router( post(handlers::folders::list_directory_with_files), ) .route("/get_file_tree", post(handlers::folders::get_file_tree)) + .route( + "/list_workspace_files", + post(handlers::folders::list_workspace_files), + ) .route( "/start_workspace_state_stream", post(handlers::workspace_state::start_workspace_state_stream), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ac685facd..8785ad802 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": "codeg", - "version": "0.21.1", + "version": "0.21.2", "identifier": "app.codeg", "build": { "beforeDevCommand": "pnpm tauri:before-dev", diff --git a/src/app/globals.css b/src/app/globals.css index 1d7f22979..08b12f959 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -36,6 +36,11 @@ --ws-surface-alpha 由 inline 脚本 / AppearanceProvider 覆盖为用户设置。 */ --ws-surface-alpha: 0.3; --ws-surface-blur: 8px; + + /* 消息流里的内联代码 / 用户气泡 / 委派卡片的固定半透明度:刻意不随「面板不透明度」滑块 + (--ws-surface-alpha)变化,让这些消息内小面在任何面板透明度下都稳定。配 backdrop 磨砂 + + 1px 描边(见下方各规则),在忙碌背景图上仍看得清、分得出边界。 */ + --ws-capsule-alpha: 0.6; } /* =========================================================================== @@ -1080,6 +1085,56 @@ backdrop-filter: blur(var(--ws-surface-blur)); } +/* 会话内联代码(Streamdown 的 inline-code,元素自带 bg-muted):开图转半透明磨砂。忙碌背景 + 图上单靠半透明色块看不清、看不出边界(用户反馈),故 backdrop 磨砂把身后背景糊开、与之 + 分离,再用 inset 1px ring 描出边界(ring 用纯色 var(--border),不改盒模型、无回流)。组合式 + 无 base 规则,关图零回归(bg-muted 原样保留)。代码块(code-block-body)有 shiki 底色,不在此列。 */ +[data-workspace-bg="on"] [data-streamdown="inline-code"] { + background-color: color-mix( + in oklch, + var(--muted) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + box-shadow: inset 0 0 0 1px var(--border); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); + /* inline-code 是行内元素,跨行折断时默认(slice)会把底色/ring/圆角当一个盒切开——每段行 + 片只画到断点,ring 在断处豁口。clone 让每个行片各自成完整圆角 chip(自带底色 + ring), + 折行时也不露豁口。仅 bg-on 生效,关图零回归。 */ + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} + +/* 用户消息气泡(MessageContent 上的 ws-surface-secondary,配对元素自带的 + group-[.is-user]:bg-secondary):开图转半透明磨砂。忙碌背景图上单靠半透明看不清、看不出 + 边界(用户反馈),故加 backdrop 磨砂(与背景分离)+ inset 1px ring 描边。以 .is-user 祖先 + 限定——助手消息(.is-assistant)本无气泡底色,须保持画布透出,不受影响。组合式无 base 规则,关图零回归。 */ +[data-workspace-bg="on"] .is-user .ws-surface-secondary { + background-color: color-mix( + in oklch, + var(--secondary) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + box-shadow: inset 0 0 0 1px var(--border); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + +/* 消息流委派卡片(delegate_to_agent 的卡片,元素自带 bg-card + border-border):开图转半透明 + 磨砂——backdrop 磨砂把身后背景糊开、与之分离(卡片自带 border 已描边界,无需 ring)。alpha + 固定(--ws-capsule-alpha),与消息里的内联代码 / 气泡统一,区别于面板级 ws-surface-card 的 + 滑块磨砂玻璃。组合式无 base 规则,关图零回归(bg-card 原样保留)。仅委派卡片用它;提问卡片 / + git-log 面板仍用 ws-surface-card。 */ +[data-workspace-bg="on"] .ws-surface-capsule { + background-color: color-mix( + in oklch, + var(--card) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + /* Canvas 表面:开启背景图时强制透明,让底层背景图 + 遮罩直接透出(会话/文件 overlay section、侧栏会话行)。无 base 规则 —— 与元素上配对的 bg-*(bg-background / bg-sidebar)组合,未启用背景图时完全等价那个纯色(零回归)。透明不依赖 @@ -1099,6 +1154,34 @@ background-color: transparent; } +/* Monaco sticky scroll(钉住的父级作用域行):开背景图时编辑器画布透明(WSBG_CANVAS_ALPHA=0), + sticky 头部也随画布透明(monaco-themes.ts 里 sticky 背景跟随 canvas)。放任透明则滚动的代码 + 从钉住行下方透出、两者重叠看不清;填成不透明画布色又是浮在背景图上一坨突兀的深色块(用户 + 反馈「一坨黑色」)。这里把整条 .sticky-widget 做成与消息内联代码/气泡同族的磨砂表面:半透明 + 画布色(--card,与编辑器画布同源)+ backdrop 磨砂,把身后滚动代码与背景图糊开,钉住行文字 + 清晰浮于其上。alpha 固定(--ws-capsule-alpha),与消息内小面统一、不随面板滑块。主题侧 sticky + 背景已随画布透明,故此处单层着色、无接缝。关图无 data-workspace-bg,规则不生效,零回归。 + + ★覆盖到滚动条条带:Monaco 运行时给 .sticky-widget 打 inline width = + layoutInfo.width − verticalScrollbarWidth(stickyScrollWidget.js:160,即内容宽,右侧漏出 + 一条竖向滚动条宽的条带),CSS 里的 width:100% 被这条 inline 盖过 → 磨砂层到不了右边缘, + 滚动代码从那条缝透出(用户反馈「右边显示不全」)。故用 width:100% !important 压过 inline + width,把磨砂层铺到编辑器右缘补上那条缝。安全性:可见滚动条 .monaco-scrollable-element>.visible + 是 z-index:11(scrollbars.css),高于 sticky-widget 的 z-index:4——磨砂层永远在滚动条之下, + 滑块照常浮在其上、可拖可点;滚动条隐藏时(opacity:0 不绘制)磨砂层正好补上条带。backdrop + 只糊身后的代码,不糊上层滑块。 */ +[data-workspace-bg="on"] .monaco-editor .sticky-widget { + /* 压过 Monaco 的 inline 内容宽,铺满到右缘(含滚动条条带)。见上方安全性说明。 */ + width: 100% !important; + background-color: color-mix( + in oklch, + var(--card) calc(var(--ws-capsule-alpha) * 100%), + transparent + ); + -webkit-backdrop-filter: blur(var(--ws-surface-blur)); + backdrop-filter: blur(var(--ws-surface-blur)); +} + /* 顶部标签条「浏览器拱门」下边框(组合式,无 base 规则,关图零回归)。整条标签条 + 所有 tab 开图皆透明(透出真背景图);靠一条细下边框把标签条与下方内容分隔,唯独激活 tab 处开口——线绕到激活 tab 顶部走一圈(拱门轮廓),激活 tab 靠这圈描边区分而非填充色。 @@ -1115,10 +1198,23 @@ /* 旧 WebKitGTK 无 color-mix 时降级为不透明(背景图仅在透明内容区透出,不崩)。 */ @supports not (background: color-mix(in oklch, white, transparent)) { + /* 老引擎关掉背景模糊(背景色 color-mix 已作废回落到不透明 bg-*)。内联代码 / 用户气泡的 + inset ring 用纯色 var(--border),不依赖 color-mix,照常描边(委派卡片自带 border 同理)。 */ [data-workspace-bg="on"] .ws-surface, [data-workspace-bg="on"] .ws-surface-muted, [data-workspace-bg="on"] .ws-surface-sidebar, - [data-workspace-bg="on"] .ws-surface-card { + [data-workspace-bg="on"] .ws-surface-card, + [data-workspace-bg="on"] [data-streamdown="inline-code"], + [data-workspace-bg="on"] .is-user .ws-surface-secondary, + [data-workspace-bg="on"] .ws-surface-capsule { + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + /* Monaco sticky scroll 是第三方 DOM,无自带 bg 工具类可回落——color-mix 作废后 + background-color 声明整条失效会让 .sticky-widget 透明(滚动代码透出)。故老引擎显式 + 回落到不透明画布色 var(--card)(即背景图开启前的老不透明band,仍可读),并关掉磨砂。 */ + [data-workspace-bg="on"] .monaco-editor .sticky-widget { + background-color: var(--card); -webkit-backdrop-filter: none; backdrop-filter: none; } @@ -1905,13 +2001,13 @@ /* The active tab's close button is always visible (an absolute `right-2` / `w-4` overlay). Reserve its footprint (0.5rem offset + 1rem width = 1.5rem) as the - content's right padding, overriding the default `px-2` right. The tab is - content-sized (`grow-0 basis-auto`), so this widens it just enough to fit the FULL - title AND the button side by side — the fix for an only/short active tab whose - title used to sit under the button and lose its tail to the fade. Non-active tabs - don't reserve it: their button only appears on hover, where the label widens its - fade to clear the button (above); idle titles keep the full width (R40 "收回按钮 - 空间") and tabs never reflow-widen on hover. General (not workspace-bg gated). */ + content's right padding, overriding the default `px-2` right, so the title ends + left of the button (its tail fades under the reserved zone) instead of sitting + under it. Tabs are fixed-width (`grow-0 basis-48`); this padding just carves out + the button's column. Non-active tabs don't reserve it: their button only appears + on hover, where the label widens its fade to clear the button (above); idle + titles keep the full width and tabs never reflow-widen on hover. General (not + workspace-bg gated). */ .browser-tab-item[data-active="true"] .browser-tab-content { padding-right: 1.5rem; } @@ -2060,13 +2156,17 @@ overflow-hidden,脚可外飘)。top:0 = group pt-1.5 下沿(y6),bottom:0.5rem → 竖边止于 y32,正好接上 seat 的反向圆角脚(y32→40);竖边不再越过脚部下探到 y40,消除脚部两侧的 「垂直角边框」毛刺。rounded-t 出拱顶。仅 bg-on + 激活生效,关图无 ::after(零回归)。 - 取代旧的 .ws-tab-arch(那是画在内容 div 上的整高边框,底部越界成毛刺)。 */ + ★left/right:-1px(而非 0):竖边描边画在盒内沿,若贴 tab 边(left:0)则落在 x∈[0,1](tab + 内侧),而 seat 脚的竖直切线描边在 tab 外沿 x∈[-1,0](脚 ::before/::after 往 gutter 外 + 飘 0.5rem,border 画在其内沿)——两者差 1px 形成 y32 处「水平错位」,正是用户反馈激活 tab + 底部两角弧度「断断续续」的成因。把两侧各外移 1px → 竖边与脚竖直切线共线(x∈[-1,0] / + [w,w+1]),拱门侧边与反向弧脚无缝相接。取代旧的 .ws-tab-arch(整高边框底部越界成毛刺)。 */ [data-workspace-bg="on"] .browser-tab-item[data-active="true"]::after { content: ""; position: absolute; top: 0; - left: 0; - right: 0; + left: -1px; + right: -1px; bottom: 0.5rem; border-top: 1px solid var(--border); border-left: 1px solid var(--border); @@ -2116,7 +2216,12 @@ [data-workspace-bg="on"] .browser-tab-item[data-adjacent-active] .ws-strip-line { - border-bottom-color: transparent; + /* border-bottom-WIDTH:0(而非仅 color:transparent):内缩基线走 ::after(bottom:0),其 + containing block 是本元素 padding box。若保留 1px 透明边框,padding box 比 border box + 矮 1px → ::after 落在 border box 底沿上方 1px,与其它 tab 的 border-bottom(落在 border + box 底沿)差 1px,即用户反馈「紧邻激活的 tab 下边框对不齐」。撤掉边框宽度 → padding box + 与 border box 同底,::after bottom:0 正落 border box 底沿,与普通 tab 基线共线。 */ + border-bottom-width: 0; } [data-workspace-bg="on"] .browser-tab-item[data-adjacent-active] diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index ccb108420..db5c7d082 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -52,7 +52,7 @@ import { } from "@/contexts/workspace-context" import { RemoteConnectionGate } from "@/contexts/remote-connection-context" import { UpdateProvider } from "@/components/providers/update-provider" -import { useWorkspaceBackground } from "@/hooks/use-appearance" +import { useWorkspaceBackground, useZoomLevel } from "@/hooks/use-appearance" import { FILL_MODE_STYLE } from "@/lib/workspace-background" import { TabBar } from "@/components/tabs/tab-bar" import { TerminalPanel } from "@/components/terminal/terminal-panel" @@ -264,14 +264,16 @@ function WorkspaceContent({ children }: { children: React.ReactNode }) { const { isOpen: sidebarOpen } = useSidebarContext() const { isOpen: auxOpen } = useAuxPanelContext() const { isMac, isWindows, isLinux } = usePlatform() + const { zoomLevel } = useZoomLevel() const hasConvTabs = useTabStore((s) => s.tabs.length > 0) const winLinuxControls = isDesktop() && (isWindows || isLinux) // The window chrome (toggle/remote left, terminal/aux/settings right) now // lives in fixed corner overlays (see FolderLayoutShell) that never move on // panel toggles. Each edge column just reserves the overlay's width so its - // tabs never render underneath. - const leftReserve = leftChromeReserve(isMac && isDesktop()) - const rightReserve = rightChromeReserve(winLinuxControls) + // tabs never render underneath. The reserve scales with the app zoom so it + // tracks the rem-sized overlay buttons (which grow with zoom). + const leftReserve = leftChromeReserve(isMac && isDesktop(), zoomLevel) + const rightReserve = rightChromeReserve(winLinuxControls, zoomLevel) // A middle column reserves the right overlay only when it (not the aux panel) // is the window's right edge: the file column in fusion, else conversation. const convReservesRight = !auxOpen && mode === "conversation" diff --git a/src/components/ai-elements/file-tree.test.tsx b/src/components/ai-elements/file-tree.test.tsx new file mode 100644 index 000000000..aa6d0da8e --- /dev/null +++ b/src/components/ai-elements/file-tree.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { FileTree, FileTreeFile, FileTreeFolder } from "./file-tree" + +function renderTree(keyboardNavigation: boolean) { + return render( + + + + + + ) +} + +describe("FileTree keyboard focus topology", () => { + it("collapses the opted-in tree to a single tab stop: only the container", () => { + renderTree(true) + + const container = screen.getByRole("tree") + const [folderItem, fileItem] = screen.getAllByRole("treeitem") + const folderButton = screen.getByRole("button", { name: "dir" }) + + // The container is the sole focus host... + expect(container.tabIndex).toBe(0) + // ...and every row — including the folder's native + {isSelected && } + ) } - const localSectionKey = sectionKey("local") - const remoteSectionKey = sectionKey("remote") - return ( -
+ // Borderless, edge-aligned header mirroring the session-details tab: the + // branch trigger sits flush-left (px-0 lands its icon on the header's px-3 + // inset) and the refresh button flush-right, spread by justify-between. +
-
- {branchList.local.length > 0 && ( - toggle(localSectionKey)} - > - - - {t("localBranches")} - - - - - - )} - {branchList.remote.length > 0 && ( - toggle(remoteSectionKey)} - > - - - {t("remoteBranches")} - - - {remoteSections.map((section) => - section.remoteName == null ? ( - - ) : ( - toggle(section.key)} - > - - - {section.remoteName} ({section.count}) - - - - - - ) - )} - - - )} -
+ + + + {t("noBranches")} + {branchList.local.length > 0 && ( + + {isSearching + ? branchList.local.map((b) => renderFlatItem(b, "local")) + : renderTreeItems(localNodes, 0)} + + )} + {branchList.remote.length > 0 && ( + + {isSearching + ? branchList.remote.map((b) => renderFlatItem(b, "remote")) + : remoteSections.map((section) => + section.remoteName == null ? ( + + {renderTreeItems(section.nodes, 0)} + + ) : ( + + toggle(section.key)} + style={indentStyle(0)} + > + + + {section.remoteName} + + + {section.count} + + + {isExpanded(section.key) && + renderTreeItems(section.nodes, 1)} + + ) + )} + + )} + +
+ {/* justify-end flushes the glyph to the trailing edge (13px inset, + mirroring the branch trigger's flush-left glyph), and a text-only hover + drops ghost's padded bg box — hover:bg-transparent + its dark twin — so + the two header edges read symmetrically instead of the right growing a + wider box on hover. size-8 stays for the click target. */}
) @@ -754,6 +807,7 @@ function BranchSelector({ export function GitLogTab() { const t = useTranslations("Folder.gitLogTab") const tCommon = useTranslations("Folder.common") + const isMobile = useIsMobile() // Defer the folder so a cross-folder conversation-tab switch commits first and // this tab's git-log refetch + commit-row render runs in a non-blocking // transition a frame later instead of janking the switch (see the file-tree / @@ -776,7 +830,6 @@ export function GitLogTab() { const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [notAGitRepo, setNotAGitRepo] = useState(false) - const [scrolled, setScrolled] = useState(false) const [openByCommit, setOpenByCommit] = useState>({}) const [branchesByCommit, setBranchesByCommit] = useState< Record @@ -802,6 +855,40 @@ export function GitLogTab() { const [resetMode, setResetMode] = useState("mixed") const [resetting, setResetting] = useState(false) + // ── Pagination (infinite scroll) ────────────────────────────────────────── + // The log loads in PAGE_SIZE pages via the backend `skip` offset and appends + // older commits as the scroll nears the end (see loadMore / handleVirtuaScroll). + const [hasMore, setHasMore] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + // virtua binds to the real OverlayScrollbars viewport (surfaced via the + // ScrollArea onViewportRef bridge once OS initializes): a ref for the + // Virtualizer scrollRef + a state flag so it only mounts once the viewport + // exists (mirrors the sidebar conversation list). + const viewportRef = useRef(null) + const [viewportEl, setViewportEl] = useState(null) + const handleViewportRef = useCallback((element: HTMLElement | null) => { + viewportRef.current = element + setViewportEl(element) + }, []) + const virtualizerRef = useRef(null) + // Generation guard: bumped on every page-0 (re)load so an in-flight loadMore + // can't append stale commits after a refresh / branch switch. Live snapshots + // (entries length, hasMore, in-flight) are mirrored to refs so the scroll + // callback stays referentially stable and never acts on stale state. + const logGenRef = useRef(0) + const loadingMoreRef = useRef(false) + // True while a page-0 (re)load is in flight: appends must not start (their + // captured length would be reset by the load, punching an offset gap) until it + // settles. + const fetchingRef = useRef(false) + // Kept in sync with state every render AND eagerly on each mutation below, so + // the scroll callback / loadMore read the just-committed length within the same + // tick (before React re-renders) and request the correct next offset. + const entriesRef = useRef(entries) + entriesRef.current = entries + const hasMoreRef = useRef(hasMore) + hasMoreRef.current = hasMore + // Close the create-branch / reset dialogs the instant the ACTIVE folder // changes (keyed on the live id, not the deferred `folder`) so a dialog opened // under the previous folder can't create-branch / reset against the new one @@ -901,9 +988,27 @@ export function GitLogTab() { } setError(null) setNotAGitRepo(false) + // New generation: this page-0 (re)load supersedes any in-flight loadMore + // and blocks new appends (via fetchingRef) until it settles. It does NOT + // touch loadingMoreRef — a superseded append still owns and releases its + // own lock, so freeing it here could free a newer append's lock. + const gen = ++logGenRef.current + fetchingRef.current = true + setLoadingMore(false) try { - const result = await gitLog(folder.path, 100, branch ?? undefined) + const result = await gitLog( + folder.path, + PAGE_SIZE, + branch ?? undefined, + undefined, + 0 + ) + if (gen !== logGenRef.current) return setEntries(result.entries) + entriesRef.current = result.entries + const more = result.entries.length >= PAGE_SIZE + setHasMore(more) + hasMoreRef.current = more if (inline) { const commitHashes = new Set( result.entries.map((entry) => entry.full_hash) @@ -922,18 +1027,25 @@ export function GitLogTab() { ) } } catch (e) { + if (gen !== logGenRef.current) return if (isNotAGitRepoError(e)) { setNotAGitRepo(true) // Workspace state will flip isGitRepo within the next watch flush; // clear entries so stale log data does not linger while we wait. setEntries([]) + entriesRef.current = [] + setHasMore(false) + hasMoreRef.current = false } else { setError(toErrorMessage(e)) } } finally { - if (inline) { + // Only the latest generation owns the UI. Clear BOTH mode flags (so an + // inline refresh superseded by a full reload — or vice versa — can't + // latch a spinner/skeleton) and reopen appends. + if (gen === logGenRef.current) { + fetchingRef.current = false setRefreshing(false) - } else { setLoading(false) } } @@ -949,6 +1061,69 @@ export function GitLogTab() { void fetchLog({ inline: true }) }, [fetchLog]) + // Append the next page (older commits) via the backend `skip` offset. Reads + // live length/flags from refs; a generation mismatch means a page-0 reload + // (refresh / branch switch) superseded us, so the result is discarded. + const loadMore = useCallback(async () => { + if (!folder?.path) return + // Bail while a page-0 (re)load is in flight — its result resets the list, so + // the length captured here would request a wrong offset and drop commits. + // Single-flight via loadingMoreRef; fetchLog never touches that lock, so this + // call is its sole owner and releasing it below can't free a newer append. + if (fetchingRef.current || loadingMoreRef.current || !hasMoreRef.current) { + return + } + loadingMoreRef.current = true + setLoadingMore(true) + const gen = logGenRef.current + const skip = entriesRef.current.length + try { + const result = await gitLog( + folder.path, + PAGE_SIZE, + selectedBranch ?? undefined, + undefined, + skip + ) + if (gen !== logGenRef.current) return + const next = [...entriesRef.current, ...result.entries] + entriesRef.current = next + setEntries(next) + const more = result.entries.length >= PAGE_SIZE + hasMoreRef.current = more + setHasMore(more) + } catch { + // Stop auto-paginating on error; the manual refresh retries from page 0. + if (gen === logGenRef.current) { + hasMoreRef.current = false + setHasMore(false) + } + } finally { + loadingMoreRef.current = false + setLoadingMore(false) + } + }, [folder?.path, selectedBranch]) + + // virtua reports the scroll offset each frame; fetch the next page once the + // window nears the estimated end. + const handleVirtuaScroll = useCallback( + (offset: number) => { + const handle = virtualizerRef.current + if ( + !handle || + fetchingRef.current || + loadingMoreRef.current || + !hasMoreRef.current + ) { + return + } + if (offset + handle.viewportSize >= handle.scrollSize - LOAD_MORE_PX) { + void loadMore() + } + }, + [loadMore] + ) + const handleOpenNewBranchDialog = useCallback((entry: GitLogEntry) => { setNewBranchName("") setNewBranchTarget({ @@ -1103,12 +1278,6 @@ export function GitLogTab() { } }, [folder, refreshBranches, fetchLog]) - const handleScroll = useCallback((e: Event) => { - const target = e.target as HTMLElement - const nextScrolled = target.scrollTop > 0 - setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)) - }, []) - if (!folder) { return } @@ -1117,27 +1286,41 @@ export function GitLogTab() { // render catches up to the switch (see the declaration above). if (loading || folderStale) { return ( - +
{hasBranches && ( - +
+ +
)} -
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - -
- ))} -
- + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + + +
+
+ ))} +
+
+
) } @@ -1167,10 +1350,15 @@ export function GitLogTab() { ) } - if (error) { - return ( - - {hasBranches && ( + return ( +
+ {hasBranches && ( +
- )} -
-

{error}

-
- - ) - } - - if (entries.length === 0) { - return ( - -
- {hasBranches && ( - - )} -
-

- {t("noCommitsFound")} -

+ )} + {error ? ( + +
+

{error}

+
+
+ ) : entries.length === 0 ? ( +
+

+ {t("noCommitsFound")} +

- - ) - } - - return ( -
- - - -
- {hasBranches && ( -
+ + + {/* Full-bleed, virtualized commit timeline: one continuous rail, + windowed by virtua bound to the OverlayScrollbars viewport. + Older pages load on demand as the scroll nears the end (see + handleVirtuaScroll), so the whole history stays reachable. */} + {viewportEl ? ( + - -
- )} - {entries.map((entry) => { - const commitKey = entry.full_hash - const commitDate = parseDate(entry.date) - const pushStatus = getPushStatusMeta( - entry.pushed, - pushStatusLabels - ) - const PushStatusIcon = pushStatus.icon - const commitBranches = branchesByCommit[commitKey] - const isBranchLoading = !!branchesLoading[commitKey] - const branchError = branchesError[commitKey] - const isOpen = !!openByCommit[commitKey] - - return ( - - -
- { - setOpenByCommit((prev) => ({ - ...prev, - [commitKey]: open, - })) - if (open) { - void fetchCommitBranches(commitKey) - } - }} - open={isOpen} - > - - - - {entry.message} - - - - - - {entry.author} - - {formatRelativeTime(entry.date, t)} - - - {entry.hash} - - - - - - - - -
-
- - {t("hash")} - - - + {/* Push-status glyph leads the row — pushed / + not-pushed / unknown, tinted by + getPushStatusMeta. mt-0.5 centers it on the + first message line. */} + - {entry.full_hash} - - - - - {t("author")} - - - - {entry.author} - - - · + - - -
-
-

- {entry.message} -

- -
- {entry.files.length === 0 ? ( -
-

- {t("filesTitle")} -

-

- {t("noFileChangeDetails")} -

-
- ) : ( - - )} -
-

- {t("branchesTitle")} -

- {isBranchLoading ? ( -

- {t("loadingBranches")} -

- ) : branchError ? ( -

- {branchError} -

- ) : commitBranches && - commitBranches.length > 0 ? ( -
- {commitBranches.map((branch) => ( +
+ {/* Dim by default, darken on hover/open + (group = CommitHeader) instead of a hover + background — widens the readable area. + Single line: the subject. */} +

+ {entry.message} +

+
+ + {entry.author} + + + · + + + {/* Short hash pinned to this line's end: + a # glyph + the abbrev, no chip + background. ms-auto (not ml-auto) so it + stays on the trailing edge under RTL. */} - {branch} + + {entry.hash} - ))} +
- ) : ( -

- {t("noContainingBranches")} -

- )} -
+ + + +
+
+ + {t("hash")} + + + + {entry.full_hash} + + + + + {t("author")} + + + + {entry.author} + + + · + + + +
+
+

+ {entry.message} +

+ +
+ {entry.files.length === 0 ? ( +
+

+ {t("filesTitle")} +

+

+ {t("noFileChangeDetails")} +

+
+ ) : ( + + )} +
+

+ {t("branchesTitle")} +

+ {isBranchLoading ? ( +

+ {t("loadingBranches")} +

+ ) : branchError ? ( +

+ {branchError} +

+ ) : commitBranches && + commitBranches.length > 0 ? ( +
+ {commitBranches.map((branch) => ( + + {branch} + + ))} +
+ ) : ( +

+ {t("noContainingBranches")} +

+ )} +
+
+
+
- - + + + { + handleOpenNewBranchDialog(entry) + }} + > + + {t("newBranch")} + + { + void openCommitDiff( + entry.full_hash, + undefined, + entry.message + ) + }} + > + + {tCommon("viewDiff")} + + { + handleOpenResetDialog(entry) + }} + > + + {t("resetToHere")} + + {!isResetAllowed && ( + + {t("resetDisabledReasonNotCurrentBranchView")} + + )} + { + void fetchLog() + }} + > + + {tCommon("refresh")} + + { + if (!folder) return + openPushWindow(folder.id).catch((err) => { + const msg = toErrorMessage(err) + toast.error( + t("toasts.openPushWindowFailed"), + { + description: msg, + } + ) + }) + }} + > + + {tCommon("push")} + + +
- - - { - handleOpenNewBranchDialog(entry) - }} - > - - {t("newBranch")} - - { - void openCommitDiff( - entry.full_hash, - undefined, - entry.message - ) - }} - > - - {tCommon("viewDiff")} - - { - handleOpenResetDialog(entry) - }} - > - - {t("resetToHere")} - - {!isResetAllowed && ( - - {t("resetDisabledReasonNotCurrentBranchView")} - - )} - { - void fetchLog() - }} - > - - {tCommon("refresh")} - - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, - }) - }) - }} - > - - {tCommon("push")} - - - - ) - })} -
- -
- - { - void fetchLog() - }} - > - - {tCommon("refresh")} - - { - if (!folder) return - openPushWindow(folder.id).catch((err) => { - const msg = toErrorMessage(err) - toast.error(t("toasts.openPushWindowFailed"), { - description: msg, + ) + }} + + ) : null} + {loadingMore && ( +
+ +
+ )} + + + + { + void fetchLog() + }} + > + + {tCommon("refresh")} + + { + if (!folder) return + openPushWindow(folder.id).catch((err) => { + const msg = toErrorMessage(err) + toast.error(t("toasts.openPushWindowFailed"), { + description: msg, + }) }) - }) - }} - > - - {tCommon("push")} - - -
+ }} + > + + {tCommon("push")} + + + + )} >( () => new Set(LAZY_TABS.filter((tab) => tab === activeTab)) ) @@ -179,7 +181,7 @@ export function AuxPanel() { // a dropdown. Only relevant to the desktop layout (mobile is a full-width // Sheet), and only when there's more than the lone Session Details tab. const winLinuxControls = isDesktop() && (isWindows || isLinux) - const rightReserve = rightChromeReserve(winLinuxControls) + const rightReserve = rightChromeReserve(winLinuxControls, zoomLevel) const collapsed = !isMobile && showFolderTabs && diff --git a/src/components/layout/git-log-timeline.test.ts b/src/components/layout/git-log-timeline.test.ts new file mode 100644 index 000000000..390df93b6 --- /dev/null +++ b/src/components/layout/git-log-timeline.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" +import { parseDate } from "./git-log-timeline" + +describe("parseDate", () => { + it("parses a valid ISO string into a Date", () => { + const parsed = parseDate("2026-07-20T10:30:00.000Z") + expect(parsed).toBeInstanceOf(Date) + expect(parsed?.toISOString()).toBe("2026-07-20T10:30:00.000Z") + }) + + it("returns null for an unparseable string", () => { + expect(parseDate("not a date")).toBeNull() + }) +}) diff --git a/src/components/layout/git-log-timeline.ts b/src/components/layout/git-log-timeline.ts new file mode 100644 index 000000000..3a021d29a --- /dev/null +++ b/src/components/layout/git-log-timeline.ts @@ -0,0 +1,7 @@ +// Pure helpers behind the git-log timeline UI. Kept out of the component so they +// can be unit-tested without pulling the whole "use client" module graph. + +export function parseDate(dateStr: string): Date | null { + const date = new Date(dateStr) + return Number.isNaN(date.getTime()) ? null : date +} diff --git a/src/components/layout/left-edge-chrome.tsx b/src/components/layout/left-edge-chrome.tsx index 7f997569c..b5ded408a 100644 --- a/src/components/layout/left-edge-chrome.tsx +++ b/src/components/layout/left-edge-chrome.tsx @@ -8,6 +8,7 @@ import { useSidebarContext } from "@/contexts/sidebar-context" import { useIsMac } from "@/hooks/use-is-mac" import { usePlatform } from "@/hooks/use-platform" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" +import { useZoomLevel } from "@/hooks/use-appearance" import { formatShortcutLabel } from "@/lib/keyboard-shortcuts" import { MAC_TRAFFIC_LIGHT_INSET, leftChromeReserve } from "@/lib/window-chrome" import { RemoteWorkspaceDropdown } from "./remote-workspace-dropdown" @@ -33,6 +34,7 @@ export function LeftEdgeChrome() { const isMac = useIsMac() const { shortcuts } = useShortcutSettings() const { isMac: platformIsMac } = usePlatform() + const { zoomLevel } = useZoomLevel() // The traffic lights only exist on the macOS desktop runtime (not web / not // Windows-Linux), so only reserve their inset there. const showMacInset = platformIsMac && isDesktop() @@ -40,7 +42,7 @@ export function LeftEdgeChrome() { return (
{showMacInset && (
{ openSettingsWindow().catch((err) => { @@ -41,7 +43,7 @@ export function RightEdgeChrome() { return (
{/* Empty head is a window-drag region; buttons stay flush right. */}
diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index 4b3871f14..542c090a3 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -60,6 +60,9 @@ vi.mock("@/hooks/use-shortcut-settings", () => ({ }), })) vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false })) +vi.mock("@/hooks/use-appearance", () => ({ + useZoomLevel: () => ({ zoomLevel: 100, setZoomLevel: () => {} }), +})) function renderSidebar() { return render( diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 14487c817..3866705c2 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -37,6 +37,7 @@ import { import { useIsMobile } from "@/hooks/use-mobile" import { useIsMac } from "@/hooks/use-is-mac" import { usePlatform } from "@/hooks/use-platform" +import { useZoomLevel } from "@/hooks/use-appearance" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { formatShortcutLabel } from "@/lib/keyboard-shortcuts" import { isDesktop } from "@/lib/platform" @@ -119,13 +120,15 @@ export function Sidebar() { const { routeId, setRoute, openConversations } = useWorkbenchRoute() const isMac = useIsMac() const { isMac: platformIsMac } = usePlatform() + const { zoomLevel } = useZoomLevel() const { shortcuts } = useShortcutSettings() const isMobile = useIsMobile() const listRef = useRef(null) // On desktop the header's top-left is owned by the fixed window-chrome overlay // (sidebar toggle + remote); reserve exactly its width so the view controls - // and drag region clear it. Mobile has no overlay (the sidebar is a Sheet). - const leftReserve = leftChromeReserve(platformIsMac && isDesktop()) + // and drag region clear it. The reserve scales with the app zoom to track the + // rem-sized overlay buttons. Mobile has no overlay (the sidebar is a Sheet). + const leftReserve = leftChromeReserve(platformIsMac && isDesktop(), zoomLevel) const [showCompleted, setShowCompleted] = useState(false) const [sortMode, setSortMode] = useState("created") diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx index 91cce94ec..427ad9cf0 100644 --- a/src/components/layout/status-bar-stats.tsx +++ b/src/components/layout/status-bar-stats.tsx @@ -1,68 +1,92 @@ "use client" import { useMemo } from "react" -import { BarChart3 } from "lucide-react" +import { BarChart3, MonitorCloud } from "lucide-react" import { useTranslations } from "next-intl" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { AGENT_LABELS } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" +import { useRemoteConnection } from "@/contexts/remote-connection-context" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" export function StatusBarStats() { const t = useTranslations("Folder.statusBar.stats") const stats = useAppWorkspaceStore((s) => s.stats) + // Non-null only in a remote-desktop window (a Tauri client bound to a remote + // codeg-server); local windows have no RemoteConnection in context. + const remoteConnection = useRemoteConnection()?.connection ?? null const activeAgents = useMemo( () => stats?.by_agent.filter((a) => a.conversation_count > 0) ?? [], [stats] ) - if (!stats) return null + if (!remoteConnection && !stats) return null return ( - - - - - -
- {t("summary", { - conversations: stats.total_conversations, - messages: stats.total_messages, - })} -
-
- {activeAgents.map((a) => ( -
- - - {AGENT_LABELS[a.agent_type]} +
+ {remoteConnection && ( + + + + + + {remoteConnection.name} - - {a.conversation_count} + + +

{remoteConnection.name}

+

{remoteConnection.base_url}

+
+
+
+ )} + {stats && ( + + + + + +
+ {t("summary", { + conversations: stats.total_conversations, + messages: stats.total_messages, + })} +
+
+ {activeAgents.map((a) => ( +
+ + + {AGENT_LABELS[a.agent_type]} + + + {a.conversation_count} + +
+ ))}
- ))} -
- - + + + )} +
) } diff --git a/src/components/message/delegated-sub-thread.tsx b/src/components/message/delegated-sub-thread.tsx index a9657ea33..e4b4e7d9a 100644 --- a/src/components/message/delegated-sub-thread.tsx +++ b/src/components/message/delegated-sub-thread.tsx @@ -88,7 +88,7 @@ export function DelegatedSubThread({ return (
diff --git a/src/components/message/delegation-status-card.tsx b/src/components/message/delegation-status-card.tsx index d8456de3b..d1e31213e 100644 --- a/src/components/message/delegation-status-card.tsx +++ b/src/components/message/delegation-status-card.tsx @@ -100,7 +100,7 @@ export function DelegationStatusCard({ "overflow-hidden rounded-lg border text-xs", isError ? "border-destructive/30 bg-destructive/5" - : "border-border bg-card" + : "border-border bg-card ws-surface-capsule" )} > {rows.map((r, i) => ( diff --git a/src/components/settings/acp-agent-settings.test.tsx b/src/components/settings/acp-agent-settings.test.tsx index 16b77e0b1..e07050e53 100644 --- a/src/components/settings/acp-agent-settings.test.tsx +++ b/src/components/settings/acp-agent-settings.test.tsx @@ -7,6 +7,7 @@ import { buildVersionCheck, configTextForClaudeSave, getAgentChecks, + inferGrokMode, patchImportantConfigText, } from "./acp-agent-settings" import type { AcpAgentInfo, AgentType, PreflightResult } from "@/lib/types" @@ -34,6 +35,8 @@ function makeAgent(overrides: Partial): AcpAgentInfo { grok_config_toml: null, grok_settings: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, ...overrides, } @@ -52,10 +55,13 @@ function fixDisabled(fix: unknown): boolean { // A full Grok draft, custom-model + compaction fields defaulted empty. Typed // from the function's own parameter so it stays in sync with the panel shape. +// Defaults to the `custom` auth method so the custom-model save cases below +// exercise the [model.] path; non-custom gating is covered separately. type GrokDraftInput = Parameters[0] const grokDraft = ( overrides: Partial = {} ): GrokDraftInput => ({ + grokAuthMode: "custom", grokPermissionMode: "", grokReasoningEffort: "", grokCustomModelId: "", @@ -173,6 +179,35 @@ describe("buildGrokStructuredConfig — Grok panel save payload", () => { autoCompactThresholdPercent: 100, }) }) + + // The custom-model group is written ONLY in the `custom` auth method. In + // subscription / api_key mode the [model.] block is dropped even if the + // (now-hidden) custom fields still hold values — so switching away removes it, + // while method-independent fields (reasoning effort) still pass through. + it("drops the custom model when the auth method isn't custom", () => { + const filled = { + grokCustomModelId: "my-grok", + grokCustomBaseUrl: "https://gw/v1", + grokCustomApiKey: "xai-123", + grokCustomApiBackend: "chat_completions", + grokCustomContextWindow: "131072", + } + for (const mode of ["subscription", "api_key"] as const) { + expect( + buildGrokStructuredConfig( + grokDraft({ + ...filled, + grokAuthMode: mode, + grokReasoningEffort: "high", + }) + ) + ).toEqual({ + permissionMode: null, + defaultReasoningEffort: "high", + ...emptyCustoms, + }) + } + }) }) describe("buildGrokSaveOptions — one save persists both surfaces", () => { @@ -221,6 +256,48 @@ describe("buildGrokSaveOptions — one save persists both surfaces", () => { }) }) +describe("inferGrokMode — Grok auth-method recognition", () => { + // An explicit knob always wins, even against a present/absent key. + it("honors an explicit GROK_AUTH_MODE knob", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "subscription" })).toBe( + "subscription" + ) + expect(inferGrokMode({ GROK_AUTH_MODE: "api_key", XAI_API_KEY: "" })).toBe( + "api_key" + ) + // Subscription wins even if a stale key lingers in env. + expect( + inferGrokMode({ GROK_AUTH_MODE: "subscription", XAI_API_KEY: "xai-1" }) + ).toBe("subscription") + }) + + // Legacy rows (no knob): a saved key implies api_key, else subscription. + it("falls back to XAI_API_KEY presence for legacy rows", () => { + expect(inferGrokMode({ XAI_API_KEY: "xai-abc" })).toBe("api_key") + expect(inferGrokMode({ XAI_API_KEY: " " })).toBe("subscription") + expect(inferGrokMode({})).toBe("subscription") + }) + + // An unrecognized knob value is ignored, falling through to key presence. + it("ignores an unknown knob value", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "bogus" })).toBe("subscription") + expect( + inferGrokMode({ GROK_AUTH_MODE: "bogus", XAI_API_KEY: "xai-1" }) + ).toBe("api_key") + }) + + // The custom-endpoint method: an explicit knob wins; else a configured custom + // model (in config.toml, surfaced via the hasCustomModel flag) implies it and + // outranks a lingering key. + it("recognizes the custom endpoint method", () => { + expect(inferGrokMode({ GROK_AUTH_MODE: "custom" })).toBe("custom") + expect(inferGrokMode({}, true)).toBe("custom") + expect(inferGrokMode({ XAI_API_KEY: "xai-1" }, true)).toBe("custom") + // An explicit knob still wins over the custom-model flag. + expect(inferGrokMode({ GROK_AUTH_MODE: "api_key" }, true)).toBe("api_key") + }) +}) + describe("buildVersionCheck", () => { // uv runtime not ready: a uvx agent (Hermes) must surface a blocked // version-status with the agent-install action DISABLED — the actual install diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 4289c2217..08d5246b1 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -188,6 +188,10 @@ interface AgentDraft { * Drives the model editor + `model_catalog_json` generation on save. */ codexModelList: CodexModelConfig grokConfigTomlText: string + // Grok authentication method (subscription via `grok login` vs XAI_API_KEY). + // Recorded in env as GROK_AUTH_MODE; drives which credential body renders and + // whether XAI_API_KEY is stripped from env on subscription. + grokAuthMode: GrokAuthMethod // Grok structured controls (empty string = "unset / use default"). Backed by // ~/.grok/config.toml [ui].permission_mode / [models].default_reasoning_effort; // merged onto the current on-disk config server-side on save. @@ -429,6 +433,51 @@ const GROK_UNSET = "__grok_unset__" * `chat_completions`, an Anthropic-format endpoint `messages`. */ const GROK_DEFAULT_API_BACKEND = "responses" +/** Grok's real credential env var (mirrors the backend `agent_env_keys(Grok)` + * and `importantEnvKeysByAgent`). */ +const GROK_API_KEY_ENV = "XAI_API_KEY" + +/** codeg-side knob recording the chosen authentication method. Read by the + * launch path (`apply_grok_env_policy`): in `subscription` mode it clears any + * inherited XAI_API_KEY so the CLI uses the `grok login` browser credential. + * The Grok binary itself ignores this var. Mirrors Cursor's CURSOR_AUTH_MODE. */ +const GROK_AUTH_MODE_ENV = "GROK_AUTH_MODE" + +/** The subscription sign-in command shown (and copied) in the auth card. Grok's + * `login` is a root subcommand; a bare `grok login` matches the panel's existing + * hint wording (codeg doesn't resolve the managed binary path here). */ +const GROK_LOGIN_COMMAND = "grok login" + +/** Grok's three authentication methods: + * - `subscription` — sign in with `grok login` (SuperGrok / X Premium+), + * whose session lives in `~/.grok/auth.json` (untouched by codeg); + * - `api_key` — a non-interactive XAI_API_KEY from the xAI console; + * - `custom` — a bring-your-own endpoint: a custom `[model.]` in + * ~/.grok/config.toml with its own base_url/api_key (the custom-model card). */ +export type GrokAuthMethod = "subscription" | "api_key" | "custom" + +/** Resolve the persisted Grok authentication method, tolerant of legacy rows: + * an explicit `GROK_AUTH_MODE` wins; otherwise a configured custom model implies + * `custom`, a saved XAI_API_KEY implies `api_key`, and an empty env means the + * user relies on `grok login`. `hasCustomModel` reflects whether a codeg-managed + * `[model.]` is set (it lives in config.toml, not env). Mirrors + * `inferCursorMode`. */ +export function inferGrokMode( + env: Record, + hasCustomModel = false +): GrokAuthMethod { + const explicit = (env[GROK_AUTH_MODE_ENV] ?? "").trim() + if ( + explicit === "subscription" || + explicit === "api_key" || + explicit === "custom" + ) { + return explicit + } + if (hasCustomModel) return "custom" + return (env[GROK_API_KEY_ENV] ?? "").trim() ? "api_key" : "subscription" +} + function importantEnvKeysByAgent(agentType: AgentType): ImportantEnvKeys { if (agentType === "claude_code") { return { @@ -2345,6 +2394,7 @@ function patchCodexConfigTomlText( * for the two managed keys. */ export function buildGrokStructuredConfig(draft: { + grokAuthMode: GrokAuthMethod grokPermissionMode: string grokReasoningEffort: string grokCustomModelId: string @@ -2354,7 +2404,12 @@ export function buildGrokStructuredConfig(draft: { grokCustomContextWindow: string grokAutoCompactThreshold: string }): GrokStructuredConfig { - const modelId = draft.grokCustomModelId.trim() + // The custom-model group applies only in the `custom` auth method; the + // subscription / api_key methods omit the codeg-managed [model.] block + // (an empty id → the backend removes it). Permission mode, reasoning effort + // and compaction below stay independent of the auth method. + const modelId = + draft.grokAuthMode === "custom" ? draft.grokCustomModelId.trim() : "" const positiveInt = (raw: string): number | null => { const n = Number.parseInt(raw.trim(), 10) return Number.isFinite(n) && n > 0 ? n : null @@ -2395,6 +2450,7 @@ export function buildGrokStructuredConfig(draft: { */ export function buildGrokSaveOptions( draft: { + grokAuthMode: GrokAuthMethod grokPermissionMode: string grokReasoningEffort: string grokCustomModelId: string @@ -2704,15 +2760,30 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { : agent.agent_type === "codex" ? inferCodexAuthMode(codexAuthJsonText) : "api_key" + const grokAuthMode: GrokAuthMethod = + agent.agent_type === "grok" + ? inferGrokMode( + agent.env, + Boolean(agent.grok_settings?.custom_model_id?.trim()) + ) + : "api_key" const rawEnvText = envMapToText(agent.env) - // When codex is in official subscription mode, clean up API keys/URLs from env + // When codex is in official subscription mode, clean up API keys/URLs from env. + // Grok mirrors this: record the auth-method knob, and in subscription mode + // strip XAI_API_KEY so the editable env can't override the `grok login` + // credential (the launch path enforces the same — see apply_grok_env_policy). const envText = agent.agent_type === "codex" && codexAuthMode === "chatgpt_subscription" ? patchEnvText(rawEnvText, { OPENAI_API_KEY: "", OPENAI_BASE_URL: "", }) - : rawEnvText + : agent.agent_type === "grok" + ? patchEnvText(rawEnvText, { + GROK_AUTH_MODE: grokAuthMode, + ...(grokAuthMode === "subscription" ? { XAI_API_KEY: "" } : {}), + }) + : rawEnvText return { enabled: agent.enabled, envText, @@ -2781,6 +2852,7 @@ function buildAgentDraft(agent: AcpAgentInfo): AgentDraft { codexConfigTomlText, codexModelList: parseCodexModelConfig(agent.codex_model_catalog ?? null), grokConfigTomlText, + grokAuthMode, grokPermissionMode, grokReasoningEffort, grokCustomModelId: agent.grok_settings?.custom_model_id ?? "", @@ -5261,6 +5333,31 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, t, updateSelectedDraft] ) + const handleGrokAuthModeChange = useCallback( + (nextMode: GrokAuthMethod) => { + if ( + !selectedAgent || + !selectedDraft || + selectedAgent.agent_type !== "grok" + ) + return + // Record the method knob in env; on subscription strip XAI_API_KEY so the + // editable env can't override the `grok login` credential (the launch path + // enforces the same via apply_grok_env_policy). Clearing the draft apiKey + // keeps the now-hidden key input from resurrecting a stale value. + updateSelectedDraft((current) => ({ + ...current, + grokAuthMode: nextMode, + apiKey: nextMode === "subscription" ? "" : current.apiKey, + envText: patchEnvText(current.envText, { + GROK_AUTH_MODE: nextMode, + ...(nextMode === "subscription" ? { XAI_API_KEY: "" } : {}), + }), + })) + }, + [selectedAgent, selectedDraft, updateSelectedDraft] + ) + const handleClaudeAuthModeChange = useCallback( (nextMode: ClaudeAuthMode) => { if ( @@ -9722,248 +9819,316 @@ supports_websockets = true`}
- {/* Authentication — status + inline XAI_API_KEY */} -
-
+ {/* Authentication — method selector + method-specific body. + Mirrors the Cursor panel: an explicit choice between the + `grok login` subscription and an XAI_API_KEY, recognized + on load via inferGrokMode and recorded as GROK_AUTH_MODE. */} +
+
-

- {selectedDraft.apiKey.trim() - ? t("grok.authKeyConfigured") - : t("grok.authKeyMissing")} + +

+ {selectedDraft.grokAuthMode === "subscription" + ? t("grok.subscriptionHint") + : selectedDraft.grokAuthMode === "custom" + ? t("grok.authModeCustomHint") + : t("grok.authModeApiKeyHint")}

-
- -
- - handleImportantConfigChange( - "apiKey", - event.target.value - ) - } - placeholder="xai-..." - aria-label="XAI_API_KEY" - name="grok-xai-api-key" - autoComplete="off" - spellCheck={false} - disabled={grokSaving} - /> - + + {selectedDraft.grokAuthMode === "subscription" ? ( + // Subscription: a copyable `grok login` command. Its + // session lives in ~/.grok/auth.json (untouched here); the + // launch path strips any inherited XAI_API_KEY. +
+

+ {t("grok.loginHint")} +

+
+ + {GROK_LOGIN_COMMAND} + + +
-
-

- {t("grok.authLoginHint")} -

+ ) : selectedDraft.grokAuthMode === "api_key" ? ( + // API key: the non-interactive XAI_API_KEY credential. +
+ +
+ + handleImportantConfigChange( + "apiKey", + event.target.value + ) + } + placeholder="xai-..." + aria-label="XAI_API_KEY" + name="grok-xai-api-key" + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> + +
+

+ {selectedDraft.apiKey.trim() + ? t("grok.authKeyConfigured") + : t("grok.authKeyMissing")} +

+
+ ) : null}
{/* Custom model (BYO endpoint) → [model.] + [models].default. - A model id here registers a custom Grok model and makes it - the default; empty id removes the codeg-managed block. */} -
-
- -

- {t("grok.customModelHint")} -

-
- -
- - - updateSelectedDraft((current) => ({ - ...current, - grokCustomModelId: event.target.value, - })) - } - placeholder={t("grok.customModelIdPlaceholder")} - aria-label={t("grok.customModelIdLabel")} - autoComplete="off" - spellCheck={false} - disabled={grokSaving} - /> -

- {t("grok.customModelIdHint")} -

-
+ Only shown (and saved) in the `custom` auth method: a model + id registers a custom Grok model as the default; the other + methods omit the codeg-managed block. */} + {selectedDraft.grokAuthMode === "custom" ? ( +
+
+ +

+ {t("grok.customModelHint")} +

+
-
updateSelectedDraft((current) => ({ ...current, - grokCustomBaseUrl: event.target.value, + grokCustomModelId: event.target.value, })) } - placeholder={t("grok.customBaseUrlPlaceholder")} - aria-label={t("grok.customBaseUrlLabel")} + placeholder={t("grok.customModelIdPlaceholder")} + aria-label={t("grok.customModelIdLabel")} autoComplete="off" spellCheck={false} disabled={grokSaving} /> +

+ {t("grok.customModelIdHint")} +

+ +
+
+ + + updateSelectedDraft((current) => ({ + ...current, + grokCustomBaseUrl: event.target.value, + })) + } + placeholder={t("grok.customBaseUrlPlaceholder")} + aria-label={t("grok.customBaseUrlLabel")} + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> +
+
+ + +
+
+
- + updateSelectedDraft((current) => ({ + ...current, + grokCustomApiKey: event.target.value, + })) + } + placeholder="xai-..." + aria-label={t("grok.customApiKeyLabel")} + name="grok-custom-api-key" + autoComplete="off" + spellCheck={false} + disabled={grokSaving} + /> + +
+

+ {t("grok.customApiKeyHint")} +

-
-
- -
+
+ updateSelectedDraft((current) => ({ ...current, - grokCustomApiKey: event.target.value, + grokCustomContextWindow: event.target.value, })) } - placeholder="xai-..." - aria-label={t("grok.customApiKeyLabel")} - name="grok-custom-api-key" - autoComplete="off" - spellCheck={false} + placeholder="500000" + aria-label={t("grok.customContextWindowLabel")} disabled={grokSaving} /> - +

+ {t("grok.customContextWindowHint")} +

-

- {t("grok.customApiKeyHint")} -

- -
- - - updateSelectedDraft((current) => ({ - ...current, - grokCustomContextWindow: event.target.value, - })) - } - placeholder="500000" - aria-label={t("grok.customContextWindowLabel")} - disabled={grokSaving} - /> -

- {t("grok.customContextWindowHint")} -

-
-
+ ) : null} {/* Compaction — session-global auto-compact threshold. */}
diff --git a/src/components/settings/codebuddy-config-panel.test.tsx b/src/components/settings/codebuddy-config-panel.test.tsx index b757de825..2f01b1a71 100644 --- a/src/components/settings/codebuddy-config-panel.test.tsx +++ b/src/components/settings/codebuddy-config-panel.test.tsx @@ -34,6 +34,8 @@ function makeAgent(env: Record): AcpAgentInfo { grok_settings: null, cline_secrets_json: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, } } diff --git a/src/components/tabs/tab-bar.tsx b/src/components/tabs/tab-bar.tsx index f3e0a30db..be4f6dfeb 100644 --- a/src/components/tabs/tab-bar.tsx +++ b/src/components/tabs/tab-bar.tsx @@ -220,7 +220,7 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { if (!embedded) return group - // Title-bar strip: the tabs sit at their natural width; a new-conversation + // Title-bar strip: the tabs are equal width (fixed basis); a new-conversation // button follows the last tab (browser-style), then the trailing spacer fills // the leftover row and stays a window-drag region so a lightly-tabbed bar can // still be grabbed to move the window. @@ -232,15 +232,15 @@ export function TabBar({ embedded = false }: { embedded?: boolean } = {}) { Wrapped in one `flex-1` box so the workspace-bg bottom hairline (ws-strip-line) runs unbroken under both — the short `self-center h-7` button can't carry the line at the strip's bottom edge itself. NO - `min-w-0`: the wrapper's min-content (the shrink-0 button) is its floor, - so under many-tab overflow the group shrinks to reserve the button's - width instead of the wrapper collapsing to 0 and clipping it — matching - the old direct-child sizing. Off (no bg image): ws-strip-line is inert. */} + `min-w-0`: the wrapper's min-content (the shrink-0 button + the spacer's + `min-w-10`) is its floor, so under many-tab overflow the group shrinks to + reserve them instead of the wrapper collapsing to 0 and clipping. Off (no + bg image): ws-strip-line is inert. */}
-
+ {/* Drag spacer, floored at `min-w-10` (40px) instead of `min-w-0`: even + when many tabs overflow and squeeze this region, a grabbable + window-drag gap always remains to the RIGHT of the new-conversation + button, so the button never reaches the strip's right edge and the + packed title bar stays draggable. */} +
) diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx index 96800e5bd..1474657d9 100644 --- a/src/components/tabs/tab-item.tsx +++ b/src/components/tabs/tab-item.tsx @@ -128,19 +128,20 @@ export const TabItem = memo(function TabItem({ data-adjacent-active={embedded ? adjacentActive : undefined} className={cn( "cursor-grab active:cursor-grabbing", - // Embedded (browser-style): each tab sizes to its content (`basis-auto`) - // up to `max-w-[15rem]`, so a few tabs sit at their natural width and the - // new-conversation button hugs the last one — the leftover row stays a - // window-drag region. `grow-0` keeps them from stretching to fill; they - // still `shrink` together (down to `min-w-0`, the label truncates) once - // the row fills. `browser-tab-item` draws the left-edge hairline - // separator (globals.css) as a 1px divider at each shared edge; tabs sit - // flush (no gutter) so the line is the only separation, and the inner row - // owns its own `overflow-hidden`. The active tab is raised (`z-10`) so - // its reverse-corner seat is never covered by a hovered neighbour's flare. - // Standalone: rounded pill, intrinsic size + horizontal scroll (mobile). + // Embedded (browser-style): every tab is EQUAL width (`basis-48` = 12rem, + // `grow-0` so they don't stretch to fill), so a long title and a short one + // read uniform instead of one wide / one narrow. They still `shrink` + // together (down to `min-w-0`, the label fades) once the row fills; above + // that the fixed basis keeps them equal. The new-conversation button hugs + // the last tab and the leftover row stays a window-drag region. + // `browser-tab-item` draws the left-edge hairline separator (globals.css) + // as a 1px divider at each shared edge; tabs sit flush (no gutter) so the + // line is the only separation, and the inner row owns its own + // `overflow-hidden`. The active tab is raised (`z-10`) so its reverse-corner + // seat is never covered by a hovered neighbour's flare. Standalone: rounded + // pill, intrinsic size + horizontal scroll (mobile). embedded - ? "browser-tab-item min-w-0 grow-0 shrink basis-auto max-w-[15rem] data-[active=true]:z-10" + ? "browser-tab-item min-w-0 grow-0 shrink basis-48 data-[active=true]:z-10" : "rounded-full shrink-0", !isCoarsePointer && (embedded diff --git a/src/hooks/use-acp-agents.test.ts b/src/hooks/use-acp-agents.test.ts index b758fa403..5537c2846 100644 --- a/src/hooks/use-acp-agents.test.ts +++ b/src/hooks/use-acp-agents.test.ts @@ -57,6 +57,8 @@ function makeAgent(agentType: AgentType, sortOrder: number): AcpAgentInfo { grok_settings: null, cline_secrets_json: null, hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, model_provider_id: null, } } diff --git a/src/hooks/use-file-tree.ts b/src/hooks/use-file-tree.ts index 8689d72fd..e1d9a9ba3 100644 --- a/src/hooks/use-file-tree.ts +++ b/src/hooks/use-file-tree.ts @@ -1,13 +1,12 @@ "use client" import { useState, useEffect, useRef, useCallback } from "react" -import ig from "ignore" -import { getFileTree, readFilePreview } from "@/lib/api" -import type { FileTreeNode } from "@/lib/types" +import { listWorkspaceFiles } from "@/lib/api" +import type { WorkspaceFileEntry } from "@/lib/types" export interface FlatFileEntry { name: string - /** Relative path from folder root (same as FileTreeNode.path) */ + /** Relative path from folder root (same as WorkspaceFileEntry.path) */ relativePath: string kind: "file" | "dir" /** Pre-computed lowercase relativePath for filtering */ @@ -16,41 +15,6 @@ export interface FlatFileEntry { lowerName: string } -export function flattenTree(nodes: FileTreeNode[]): FlatFileEntry[] { - const entries: FlatFileEntry[] = [] - function walk(node: FileTreeNode) { - entries.push({ - name: node.name, - relativePath: node.path, - kind: node.kind, - lowerPath: node.path.toLowerCase(), - lowerName: node.name.toLowerCase(), - }) - if (node.kind === "dir" && node.children) { - for (const child of node.children) { - walk(child) - } - } - } - for (const node of nodes) { - walk(node) - } - return entries -} - -/** Check whether any ancestor directory of `path` is in `ignoredDirs`. */ -export function hasIgnoredAncestor( - path: string, - ignoredDirs: Set -): boolean { - let idx = path.indexOf("/") - while (idx !== -1) { - if (ignoredDirs.has(path.slice(0, idx))) return true - idx = path.indexOf("/", idx + 1) - } - return false -} - interface UseFileTreeOptions { folderPath: string | undefined enabled: boolean @@ -64,6 +28,17 @@ interface UseFileTreeResult { reset: () => void } +/** + * Loads a flat, gitignore-aware listing of every file/dir under `folderPath` + * (lazily, when `enabled`) for in-memory file search — shared by the search + * dialog and the composer `@`-mention picker. + * + * Discovery and gitignore filtering run on the backend (`list_workspace_files`), + * which prunes ignored directories *during* the walk and applies no depth cap, + * so deeply nested files are reachable while `node_modules`/`target`/… are never + * descended. The result is cached per folder path; a folder switch keeps showing + * the previous list until the new one loads (`loaded` gates that transition). + */ export function useFileTree({ folderPath, enabled, @@ -81,62 +56,19 @@ export function useFileTree({ async function load() { try { - const tree = await getFileTree(folderPath!, 10) - const flat = flattenTree(tree) - - // Collect all .gitignore files from the tree - const gitignoreEntries = flat.filter( - (f) => f.kind === "file" && f.name === ".gitignore" + const files: WorkspaceFileEntry[] = await listWorkspaceFiles( + folderPath! ) - - // Build matchers keyed by directory prefix - const matchers: { - prefix: string - matcher: ReturnType - }[] = [] - await Promise.all( - gitignoreEntries.map(async (entry) => { - try { - const result = await readFilePreview( - folderPath!, - entry.relativePath - ) - const lastSlash = entry.relativePath.lastIndexOf("/") - const dir = - lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash) - matchers.push({ - prefix: dir ? dir + "/" : "", - matcher: ig().add(result.content), - }) - } catch { - // skip unreadable .gitignore - } - }) - ) - - // Sort matchers by prefix length (shortest/root first) - matchers.sort((a, b) => a.prefix.length - b.prefix.length) - - // Filter: check each entry against all applicable .gitignore matchers - const ignoredDirs = new Set() - const filtered = flat.filter((f) => { - if (f.name === ".gitignore") return false - if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false - for (const { prefix, matcher } of matchers) { - if (!f.relativePath.startsWith(prefix)) continue - const relPath = f.relativePath.slice(prefix.length) - if (!relPath) continue - const testPath = f.kind === "dir" ? `${relPath}/` : relPath - if (matcher.ignores(testPath)) { - if (f.kind === "dir") ignoredDirs.add(f.relativePath) - return false - } - } - return true - }) + const flat: FlatFileEntry[] = files.map((f) => ({ + name: f.name, + relativePath: f.path, + kind: f.kind, + lowerPath: f.path.toLowerCase(), + lowerName: f.name.toLowerCase(), + })) if (!canceled) { - setAllFiles(filtered) + setAllFiles(flat) loadedForPathRef.current = folderPath! } } catch { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 93e4d7385..3c728a4a5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -638,9 +638,17 @@ "effortXhigh": "الأقصى", "optionDefault": "استخدام الافتراضي", "authTitle": "المصادقة", + "authMode": "طريقة المصادقة", + "authModeApiKey": "مفتاح XAI API", + "authModeApiKeyHint": "المصادقة باستخدام XAI_API_KEY من وحدة تحكم xAI — للتشغيل غير التفاعلي أو بدون واجهة. يُخزَّن في بيئة هذا الوكيل.", + "authModeCustom": "نقطة نهاية مخصّصة", + "authModeCustomHint": "استخدم نقطة نهاية خاصة بك (BYO): عرّف نموذجًا مخصّصًا في الأسفل مع عنوان URL أساسي ومفتاح API خاصين به. يصبح النموذج الافتراضي لـ Grok.", + "subscriptionHint": "سجّل الدخول باستخدام `grok login` (SuperGrok / X Premium+). لا يُخزَّن أي مفتاح API.", + "loginHint": "نفّذ هذا في الطرفية لتسجيل الدخول، ثم أعد فتح هذه الإعدادات:", + "commandCopied": "تم نسخ الأمر إلى الحافظة", + "copyCommand": "نسخ الأمر", "authKeyConfigured": "XAI_API_KEY مُهيَّأ.", "authKeyMissing": "لم يتم تعيين XAI_API_KEY.", - "authLoginHint": "عيّن XAI_API_KEY هنا، أو نفّذ `grok login` لتسجيل الدخول بالاشتراك (SuperGrok / X Premium+).", "advancedToggle": "متقدم (config.toml الخام)", "configTomlNative": "config.toml (أصلي)", "configTomlHint": "يُحفظ حرفيًا كملف ~/.grok/config.toml بالكامل. يُرفض TOML غير الصالح حتى لا يقتطع خطأ مطبعي ملفك أبدًا.", @@ -1778,7 +1786,6 @@ "newBranch": "فرع جديد...", "resetToHere": "إعادة الضبط إلى هنا", "resetDisabledReasonNotCurrentBranchView": "متاح فقط عند عرض الفرع الحالي", - "viewCommitDiffAria": "عرض diff للالتزام {hash}", "copyFullCommitHashAria": "نسخ hash الكامل للالتزام {hash}", "pushStatus": { "pushed": "تم الدفع إلى البعيد", @@ -1802,6 +1809,8 @@ "resetFailed": "فشلت إعادة الضبط" }, "branchSelector": { + "searchBranch": "بحث عن فرع...", + "noBranches": "لا توجد فروع", "selectBranchPlaceholder": "اختر فرعًا...", "localBranches": "الفروع المحلية", "current": "الحالي", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index edff4f922..ac3884473 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -638,9 +638,17 @@ "effortXhigh": "Maximal", "optionDefault": "Standard verwenden", "authTitle": "Authentifizierung", + "authMode": "Authentifizierungsmethode", + "authModeApiKey": "XAI-API-Schlüssel", + "authModeApiKeyHint": "Authentifiziere dich mit einem XAI_API_KEY aus der xAI-Konsole – für nicht-interaktive oder Headless-Läufe. Wird in der Umgebung dieses Agenten gespeichert.", + "authModeCustom": "Benutzerdefinierter Endpunkt", + "authModeCustomHint": "Verwende einen eigenen Endpunkt (BYO): Definiere unten ein benutzerdefiniertes Modell mit eigener Basis-URL und eigenem API-Schlüssel. Es wird zum Standardmodell von Grok.", + "subscriptionHint": "Melde dich mit `grok login` an (SuperGrok / X Premium+). Es wird kein API-Schlüssel gespeichert.", + "loginHint": "Führe dies in einem Terminal aus, um dich anzumelden, und öffne dann diese Einstellungen erneut:", + "commandCopied": "Befehl in die Zwischenablage kopiert", + "copyCommand": "Befehl kopieren", "authKeyConfigured": "XAI_API_KEY ist konfiguriert.", "authKeyMissing": "Kein XAI_API_KEY festgelegt.", - "authLoginHint": "Lege hier XAI_API_KEY fest oder führe `grok login` für die Abo-Anmeldung aus (SuperGrok / X Premium+).", "advancedToggle": "Erweitert (rohe config.toml)", "configTomlNative": "config.toml (nativ)", "configTomlHint": "Wird wortwörtlich als gesamte ~/.grok/config.toml gespeichert. Ungültiges TOML wird abgelehnt, sodass ein Tippfehler deine Datei nie abschneidet.", @@ -1778,7 +1786,6 @@ "newBranch": "Neuer Branch...", "resetToHere": "Auf diesen Stand zurücksetzen", "resetDisabledReasonNotCurrentBranchView": "Nur in der Ansicht des aktuellen Branches verfügbar", - "viewCommitDiffAria": "Diff für Commit {hash} anzeigen", "copyFullCommitHashAria": "Vollständigen Commit-Hash {hash} kopieren", "pushStatus": { "pushed": "Zum Remote gepusht", @@ -1802,6 +1809,8 @@ "resetFailed": "Zurücksetzen fehlgeschlagen" }, "branchSelector": { + "searchBranch": "Branch suchen...", + "noBranches": "Keine Branches", "selectBranchPlaceholder": "Branch auswählen...", "localBranches": "Lokale Branches", "current": "Aktuell", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 182503e5c..91c102404 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -638,9 +638,17 @@ "effortXhigh": "Max", "optionDefault": "Use default", "authTitle": "Authentication", + "authMode": "Authentication method", + "authModeApiKey": "XAI API key", + "authModeApiKeyHint": "Authenticate with an XAI_API_KEY from the xAI console — for non-interactive or headless runs. Stored in this agent's environment.", + "authModeCustom": "Custom endpoint", + "authModeCustomHint": "Use a bring-your-own endpoint: define a custom model below with its own base URL and API key. It becomes Grok's default model.", + "subscriptionHint": "Sign in with `grok login` (SuperGrok / X Premium+). No API key is stored.", + "loginHint": "Run this in a terminal to sign in, then reopen these settings:", + "commandCopied": "Command copied to clipboard", + "copyCommand": "Copy command", "authKeyConfigured": "XAI_API_KEY is configured.", "authKeyMissing": "No XAI_API_KEY set.", - "authLoginHint": "Set XAI_API_KEY here, or run `grok login` for subscription sign-in (SuperGrok / X Premium+).", "advancedToggle": "Advanced (raw config.toml)", "configTomlNative": "config.toml (native)", "configTomlHint": "Saved verbatim as the whole ~/.grok/config.toml. Invalid TOML is rejected so a typo never truncates your file.", @@ -1778,7 +1786,6 @@ "newBranch": "New branch...", "resetToHere": "Reset to Here", "resetDisabledReasonNotCurrentBranchView": "Available only when viewing current branch", - "viewCommitDiffAria": "View diff for commit {hash}", "copyFullCommitHashAria": "Copy full commit hash {hash}", "pushStatus": { "pushed": "Pushed to remote", @@ -1802,6 +1809,8 @@ "resetFailed": "Reset failed" }, "branchSelector": { + "searchBranch": "Search branch...", + "noBranches": "No branches", "selectBranchPlaceholder": "Select branch...", "localBranches": "Local branches", "current": "Current", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 79ae3d89d..734de0e35 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -638,9 +638,17 @@ "effortXhigh": "Máximo", "optionDefault": "Usar predeterminado", "authTitle": "Autenticación", + "authMode": "Método de autenticación", + "authModeApiKey": "Clave de API de XAI", + "authModeApiKeyHint": "Autentícate con una XAI_API_KEY de la consola de xAI, para ejecuciones no interactivas o headless. Se guarda en el entorno de este agente.", + "authModeCustom": "Endpoint personalizado", + "authModeCustomHint": "Usa un endpoint propio (BYO): define abajo un modelo personalizado con su propia base URL y clave de API. Se convierte en el modelo predeterminado de Grok.", + "subscriptionHint": "Inicia sesión con `grok login` (SuperGrok / X Premium+). No se guarda ninguna clave de API.", + "loginHint": "Ejecuta esto en una terminal para iniciar sesión y luego vuelve a abrir esta configuración:", + "commandCopied": "Comando copiado al portapapeles", + "copyCommand": "Copiar comando", "authKeyConfigured": "XAI_API_KEY está configurada.", "authKeyMissing": "No hay XAI_API_KEY configurada.", - "authLoginHint": "Configura XAI_API_KEY aquí o ejecuta `grok login` para iniciar sesión con suscripción (SuperGrok / X Premium+).", "advancedToggle": "Avanzado (config.toml sin procesar)", "configTomlNative": "config.toml (nativo)", "configTomlHint": "Se guarda literalmente como todo el ~/.grok/config.toml. El TOML no válido se rechaza para que una errata no trunque tu archivo.", @@ -1778,7 +1786,6 @@ "newBranch": "Nueva rama...", "resetToHere": "Resetear aquí", "resetDisabledReasonNotCurrentBranchView": "Disponible solo al ver la rama actual", - "viewCommitDiffAria": "Ver diff del commit {hash}", "copyFullCommitHashAria": "Copiar hash completo del commit {hash}", "pushStatus": { "pushed": "Enviado al remoto", @@ -1802,6 +1809,8 @@ "resetFailed": "Falló el reset" }, "branchSelector": { + "searchBranch": "Buscar rama...", + "noBranches": "Sin ramas", "selectBranchPlaceholder": "Seleccionar rama...", "localBranches": "Ramas locales", "current": "Actual", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 345441311..629ff2fa2 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -638,9 +638,17 @@ "effortXhigh": "Maximum", "optionDefault": "Utiliser la valeur par défaut", "authTitle": "Authentification", + "authMode": "Méthode d'authentification", + "authModeApiKey": "Clé API XAI", + "authModeApiKeyHint": "Authentifiez-vous avec une XAI_API_KEY de la console xAI, pour les exécutions non interactives ou headless. Enregistrée dans l'environnement de cet agent.", + "authModeCustom": "Point de terminaison personnalisé", + "authModeCustomHint": "Utilisez votre propre point de terminaison (BYO) : définissez ci-dessous un modèle personnalisé avec sa propre URL de base et sa clé API. Il devient le modèle par défaut de Grok.", + "subscriptionHint": "Connectez-vous avec `grok login` (SuperGrok / X Premium+). Aucune clé API n'est enregistrée.", + "loginHint": "Exécutez ceci dans un terminal pour vous connecter, puis rouvrez ces paramètres :", + "commandCopied": "Commande copiée dans le presse-papiers", + "copyCommand": "Copier la commande", "authKeyConfigured": "XAI_API_KEY est configurée.", "authKeyMissing": "Aucune XAI_API_KEY définie.", - "authLoginHint": "Définissez XAI_API_KEY ici, ou exécutez `grok login` pour la connexion par abonnement (SuperGrok / X Premium+).", "advancedToggle": "Avancé (config.toml brut)", "configTomlNative": "config.toml (natif)", "configTomlHint": "Enregistré tel quel comme l'intégralité de ~/.grok/config.toml. Le TOML invalide est rejeté afin qu'une faute de frappe ne tronque jamais votre fichier.", @@ -1778,7 +1786,6 @@ "newBranch": "Nouvelle branche...", "resetToHere": "Réinitialiser ici", "resetDisabledReasonNotCurrentBranchView": "Disponible uniquement en affichant la branche actuelle", - "viewCommitDiffAria": "Voir le diff du commit {hash}", "copyFullCommitHashAria": "Copier le hash complet du commit {hash}", "pushStatus": { "pushed": "Poussé vers le remote", @@ -1802,6 +1809,8 @@ "resetFailed": "Échec de la réinitialisation" }, "branchSelector": { + "searchBranch": "Rechercher une branche...", + "noBranches": "Aucune branche", "selectBranchPlaceholder": "Sélectionner une branche...", "localBranches": "Branches locales", "current": "Actuelle", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5b2dfcb27..a70118def 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -638,9 +638,17 @@ "effortXhigh": "最大", "optionDefault": "デフォルトを使用", "authTitle": "認証", + "authMode": "認証方式", + "authModeApiKey": "XAI API キー", + "authModeApiKeyHint": "xAI コンソールの XAI_API_KEY で認証します。非対話・ヘッドレス実行向けで、このエージェントの環境変数に保存されます。", + "authModeCustom": "カスタムエンドポイント", + "authModeCustomHint": "独自のエンドポイント(BYO)を使用します。下でベース URL と API キーを持つカスタムモデルを定義すると、それが Grok の既定モデルになります。", + "subscriptionHint": "`grok login` でサインインします(SuperGrok / X Premium+)。API キーは保存されません。", + "loginHint": "ターミナルで次のコマンドを実行してサインインし、この設定を開き直してください:", + "commandCopied": "コマンドをクリップボードにコピーしました", + "copyCommand": "コマンドをコピー", "authKeyConfigured": "XAI_API_KEY が設定されています。", "authKeyMissing": "XAI_API_KEY が未設定です。", - "authLoginHint": "ここで XAI_API_KEY を設定するか、`grok login` でサブスクリプションサインイン(SuperGrok / X Premium+)を行います。", "advancedToggle": "詳細(生の config.toml)", "configTomlNative": "config.toml(ネイティブ)", "configTomlHint": "~/.grok/config.toml 全体としてそのまま書き込まれます。無効な TOML は拒否され、タイプミスでファイルが切り詰められることはありません。", @@ -1778,7 +1786,6 @@ "newBranch": "新規ブランチ...", "resetToHere": "ここにリセット", "resetDisabledReasonNotCurrentBranchView": "現在のブランチ表示時のみ利用できます", - "viewCommitDiffAria": "コミット {hash} の差分を表示", "copyFullCommitHashAria": "完全なコミットハッシュ {hash} をコピー", "pushStatus": { "pushed": "リモートにプッシュ済み", @@ -1802,6 +1809,8 @@ "resetFailed": "リセットに失敗しました" }, "branchSelector": { + "searchBranch": "ブランチを検索...", + "noBranches": "ブランチがありません", "selectBranchPlaceholder": "ブランチを選択...", "localBranches": "ローカルブランチ", "current": "現在", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index af4e955a1..1b975bf8b 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -638,9 +638,17 @@ "effortXhigh": "최대", "optionDefault": "기본값 사용", "authTitle": "인증", + "authMode": "인증 방식", + "authModeApiKey": "XAI API 키", + "authModeApiKeyHint": "xAI 콘솔의 XAI_API_KEY로 인증합니다. 비대화형·헤드리스 실행에 적합하며 이 에이전트의 환경 변수에 저장됩니다.", + "authModeCustom": "사용자 지정 엔드포인트", + "authModeCustomHint": "자체 엔드포인트(BYO)를 사용합니다. 아래에서 자체 base URL과 API 키를 가진 사용자 지정 모델을 정의하면 Grok의 기본 모델이 됩니다.", + "subscriptionHint": "`grok login`으로 로그인합니다(SuperGrok / X Premium+). API 키는 저장되지 않습니다.", + "loginHint": "터미널에서 다음 명령을 실행해 로그인한 뒤 이 설정을 다시 여세요:", + "commandCopied": "명령을 클립보드에 복사했습니다", + "copyCommand": "명령 복사", "authKeyConfigured": "XAI_API_KEY가 구성되어 있습니다.", "authKeyMissing": "XAI_API_KEY가 설정되지 않았습니다.", - "authLoginHint": "여기에서 XAI_API_KEY를 설정하거나 `grok login`으로 구독 로그인(SuperGrok / X Premium+)을 실행하세요.", "advancedToggle": "고급(원본 config.toml)", "configTomlNative": "config.toml(네이티브)", "configTomlHint": "~/.grok/config.toml 전체로 그대로 저장됩니다. 잘못된 TOML은 거부되어 오타로 파일이 잘리지 않습니다.", @@ -1778,7 +1786,6 @@ "newBranch": "새 브랜치...", "resetToHere": "여기로 리셋", "resetDisabledReasonNotCurrentBranchView": "현재 브랜치 보기에서만 사용할 수 있습니다", - "viewCommitDiffAria": "커밋 {hash}의 diff 보기", "copyFullCommitHashAria": "전체 커밋 해시 {hash} 복사", "pushStatus": { "pushed": "원격에 푸시됨", @@ -1802,6 +1809,8 @@ "resetFailed": "리셋 실패" }, "branchSelector": { + "searchBranch": "브랜치 검색...", + "noBranches": "브랜치 없음", "selectBranchPlaceholder": "브랜치 선택...", "localBranches": "로컬 브랜치", "current": "현재", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 6e010b7ed..73b109e1e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -638,9 +638,17 @@ "effortXhigh": "Máximo", "optionDefault": "Usar padrão", "authTitle": "Autenticação", + "authMode": "Método de autenticação", + "authModeApiKey": "Chave de API da XAI", + "authModeApiKeyHint": "Autentique-se com uma XAI_API_KEY do console da xAI, para execuções não interativas ou headless. Armazenada no ambiente deste agente.", + "authModeCustom": "Endpoint personalizado", + "authModeCustomHint": "Use um endpoint próprio (BYO): defina abaixo um modelo personalizado com a sua própria base URL e chave de API. Ele se torna o modelo padrão do Grok.", + "subscriptionHint": "Entre com `grok login` (SuperGrok / X Premium+). Nenhuma chave de API é armazenada.", + "loginHint": "Execute isto num terminal para entrar e depois reabra estas configurações:", + "commandCopied": "Comando copiado para a área de transferência", + "copyCommand": "Copiar comando", "authKeyConfigured": "XAI_API_KEY está configurada.", "authKeyMissing": "Nenhuma XAI_API_KEY definida.", - "authLoginHint": "Defina XAI_API_KEY aqui ou execute `grok login` para login por assinatura (SuperGrok / X Premium+).", "advancedToggle": "Avançado (config.toml bruto)", "configTomlNative": "config.toml (nativo)", "configTomlHint": "Salvo literalmente como todo o ~/.grok/config.toml. TOML inválido é rejeitado para que um erro de digitação nunca trunque seu arquivo.", @@ -1778,7 +1786,6 @@ "newBranch": "Nova branch...", "resetToHere": "Resetar para aqui", "resetDisabledReasonNotCurrentBranchView": "Disponível somente ao visualizar a branch atual", - "viewCommitDiffAria": "Ver diff do commit {hash}", "copyFullCommitHashAria": "Copiar hash completo do commit {hash}", "pushStatus": { "pushed": "Enviado para o remoto", @@ -1802,6 +1809,8 @@ "resetFailed": "Falha no reset" }, "branchSelector": { + "searchBranch": "Pesquisar branch...", + "noBranches": "Nenhum branch", "selectBranchPlaceholder": "Selecionar branch...", "localBranches": "Branches locais", "current": "Atual", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 6823dd61c..9e7d7cc77 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -638,9 +638,17 @@ "effortXhigh": "最高", "optionDefault": "使用默认", "authTitle": "认证", + "authMode": "认证方式", + "authModeApiKey": "XAI API 密钥", + "authModeApiKeyHint": "使用 xAI 控制台的 XAI_API_KEY 认证——适用于非交互/无头运行。保存在该智能体的环境变量中。", + "authModeCustom": "自定义接口", + "authModeCustomHint": "使用自定义接口(BYO 端点):在下方定义一个带有独立 base URL 和 API 密钥的自定义模型,它将成为 Grok 的默认模型。", + "subscriptionHint": "使用 `grok login` 登录(SuperGrok / X Premium+)。不保存 API 密钥。", + "loginHint": "在终端运行以下命令登录,然后重新打开此设置:", + "commandCopied": "命令已复制到剪贴板", + "copyCommand": "复制命令", "authKeyConfigured": "已配置 XAI_API_KEY。", "authKeyMissing": "未设置 XAI_API_KEY。", - "authLoginHint": "在此设置 XAI_API_KEY,或运行 `grok login` 进行订阅登录(SuperGrok / X Premium+)。", "advancedToggle": "高级(原始 config.toml)", "configTomlNative": "config.toml(原生)", "configTomlHint": "按原样整体写入 ~/.grok/config.toml。非法 TOML 会被拒绝,绝不因笔误截断文件。", @@ -1778,7 +1786,6 @@ "newBranch": "新建分支...", "resetToHere": "重置到此处", "resetDisabledReasonNotCurrentBranchView": "仅在查看当前分支时可用", - "viewCommitDiffAria": "查看提交 {hash} 的差异", "copyFullCommitHashAria": "复制完整提交哈希 {hash}", "pushStatus": { "pushed": "已推送到远程", @@ -1802,6 +1809,8 @@ "resetFailed": "重置失败" }, "branchSelector": { + "searchBranch": "搜索分支...", + "noBranches": "无分支", "selectBranchPlaceholder": "选择分支...", "localBranches": "本地分支", "current": "当前", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d50a070dc..a635a3f43 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -638,9 +638,17 @@ "effortXhigh": "最高", "optionDefault": "使用預設", "authTitle": "驗證", + "authMode": "認證方式", + "authModeApiKey": "XAI API 金鑰", + "authModeApiKeyHint": "使用 xAI 主控台的 XAI_API_KEY 認證——適用於非互動/無頭執行。儲存在該智慧體的環境變數中。", + "authModeCustom": "自訂接口", + "authModeCustomHint": "使用自訂接口(BYO 端點):在下方定義一個帶有獨立 base URL 和 API 金鑰的自訂模型,它將成為 Grok 的預設模型。", + "subscriptionHint": "使用 `grok login` 登入(SuperGrok / X Premium+)。不會儲存 API 金鑰。", + "loginHint": "在終端機執行以下指令登入,然後重新開啟此設定:", + "commandCopied": "指令已複製到剪貼簿", + "copyCommand": "複製指令", "authKeyConfigured": "已設定 XAI_API_KEY。", "authKeyMissing": "未設定 XAI_API_KEY。", - "authLoginHint": "在此設定 XAI_API_KEY,或執行 `grok login` 進行訂閱登入(SuperGrok / X Premium+)。", "advancedToggle": "進階(原始 config.toml)", "configTomlNative": "config.toml(原生)", "configTomlHint": "按原樣整體寫入 ~/.grok/config.toml。非法 TOML 會被拒絕,絕不因筆誤截斷檔案。", @@ -1778,7 +1786,6 @@ "newBranch": "新增分支...", "resetToHere": "重設到此處", "resetDisabledReasonNotCurrentBranchView": "僅在檢視目前分支時可用", - "viewCommitDiffAria": "查看提交 {hash} 的差異", "copyFullCommitHashAria": "複製完整提交雜湊 {hash}", "pushStatus": { "pushed": "已推送到遠端", @@ -1802,6 +1809,8 @@ "resetFailed": "重設失敗" }, "branchSelector": { + "searchBranch": "搜尋分支...", + "noBranches": "無分支", "selectBranchPlaceholder": "選擇分支...", "localBranches": "本地分支", "current": "目前", diff --git a/src/lib/api.ts b/src/lib/api.ts index 5da925755..03b7082fa 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -77,6 +77,7 @@ import type { TerminalInfo, PromptInputBlock, FileTreeNode, + WorkspaceFileEntry, DirectoryEntry, DirectoryItem, UploadAttachmentResult, @@ -2885,6 +2886,17 @@ export async function getFileTree( }) } +/** + * Flat, gitignore-aware listing of every file/dir under `path`. Ignored + * directories are pruned during the backend walk (no depth cap), so deeply + * nested files are reachable while the payload stays small. Used by file search. + */ +export async function listWorkspaceFiles( + path: string +): Promise { + return getTransport().call("list_workspace_files", { path }) +} + export async function startWorkspaceStateStream( rootPath: string, wantsTreeGit = true @@ -3035,13 +3047,15 @@ export async function gitLog( path: string, limit?: number, branch?: string, - remote?: string + remote?: string, + skip?: number ): Promise { return getTransport().call("git_log", { path, limit: limit ?? null, branch: branch ?? null, remote: remote ?? null, + skip: skip ?? null, }) } diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 00e3e575f..d04461633 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" +import { ALL_AGENT_TYPES } from "@/lib/types" import { parseDelegationMeta, parseInput } from "./delegation-card" describe("parseInput wrapper peeling", () => { @@ -37,6 +38,16 @@ describe("parseInput wrapper peeling", () => { expect(parsed.agentType).toBeNull() expect(parsed.task).toBeNull() }) + + // Guards the allowlist against drifting behind the canonical agent list — the + // regression that left `grok` and `cursor` delegation cards iconless. Every + // known agent must resolve so its sub-agent card shows the right icon/label. + it.each(ALL_AGENT_TYPES)("recognizes the %s agent_type", (agentType) => { + const parsed = parseInput( + JSON.stringify({ agent_type: agentType, task: "do the thing" }) + ) + expect(parsed.agentType).toBe(agentType) + }) }) describe("parseDelegationMeta task fields", () => { diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 76a910afe..424d82a5c 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -11,7 +11,7 @@ */ import { extractEmbeddedJsonObject } from "@/lib/embedded-json" -import { type AgentType } from "@/lib/types" +import { ALL_AGENT_TYPES, type AgentType } from "@/lib/types" import { type DelegationBinding, type DelegationStatus, @@ -37,18 +37,14 @@ export type ParsedInput = { workingDir: string | null } -const KNOWN_AGENT_TYPES: ReadonlySet = new Set([ - "claude_code", - "codex", - "open_code", - "gemini", - "cline", - "open_claw", - "hermes", - "code_buddy", - "kimi_code", - "pi", -]) +// Derived from the canonical `ALL_AGENT_TYPES` so a newly added agent is +// recognized here automatically. A hand-maintained duplicate previously drifted +// (it omitted `grok` and `cursor`), so their delegation cards resolved +// `agentType: null` — rendering the blank "unknown sub-agent" avatar/label +// instead of the agent's icon. Keep this sourced from one place. +const KNOWN_AGENT_TYPES: ReadonlySet = new Set( + ALL_AGENT_TYPES +) export type ParsedMeta = { status: DelegationStatus diff --git a/src/lib/file-search-match.test.ts b/src/lib/file-search-match.test.ts new file mode 100644 index 000000000..56794d081 --- /dev/null +++ b/src/lib/file-search-match.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" +import { + rankFileMatches, + scoreFileMatch, + type FileSearchCandidate, +} from "@/lib/file-search-match" + +/** Build a candidate from a relative path (basename derived from the path). */ +function cand(path: string): FileSearchCandidate { + const lowerPath = path.toLowerCase() + const lastSlash = lowerPath.lastIndexOf("/") + return { + lowerPath, + lowerName: lastSlash === -1 ? lowerPath : lowerPath.slice(lastSlash + 1), + } +} + +function rankPaths(query: string, paths: string[], limit = 100): string[] { + const items = paths.map((p) => ({ ...cand(p), path: p })) + return rankFileMatches(query, items, limit).map((i) => i.path) +} + +describe("scoreFileMatch tiers", () => { + it("orders exact > prefix > name-substring > path-substring > subsequence", () => { + const q = "app" + const exact = scoreFileMatch(q, "app", "src/app")! + const prefix = scoreFileMatch(q, "app.tsx", "src/app.tsx")! + const nameSub = scoreFileMatch(q, "myapp.tsx", "src/myapp.tsx")! + const pathSub = scoreFileMatch(q, "index.tsx", "app/index.tsx")! + const subseq = scoreFileMatch(q, "a-p-p.tsx", "x/a-p-p.tsx")! + + expect(exact).toBeGreaterThan(prefix) + expect(prefix).toBeGreaterThan(nameSub) + expect(nameSub).toBeGreaterThan(pathSub) + expect(pathSub).toBeGreaterThan(subseq) + }) + + it("returns null when nothing matches", () => { + expect(scoreFileMatch("zzz", "readme.md", "docs/readme.md")).toBeNull() + }) + + it("prefers earlier match position, then shorter candidate, within a tier", () => { + const early = scoreFileMatch("bar", "barstool.ts", "a/barstool.ts")! + const late = scoreFileMatch("bar", "foobar.ts", "a/foobar.ts")! + expect(early).toBeGreaterThan(late) // prefix beats substring anyway + + const short = scoreFileMatch("bar", "xbar.ts", "a/xbar.ts")! + const long = scoreFileMatch( + "bar", + "xbar-longer-name.ts", + "a/xbar-longer-name.ts" + )! + expect(short).toBeGreaterThan(long) // same position, shorter wins + }) +}) + +describe("rankFileMatches", () => { + it("returns the first `limit` items unchanged for an empty query", () => { + const paths = ["a.ts", "b.ts", "c.ts"] + expect(rankPaths("", paths, 2)).toEqual(["a.ts", "b.ts"]) + }) + + it("is case-insensitive on the query", () => { + expect(rankPaths("README", ["src/readme.md", "src/other.ts"])).toEqual([ + "src/readme.md", + ]) + }) + + it("surfaces a deeply nested file whose name matches ahead of loose path hits", () => { + const deep = "a/b/c/d/e/f/g/h/i/j/k/config.ts" + const paths = [ + "src/index.ts", + "src/configuration-loader.ts", // name substring "config" + deep, // exact-ish name "config.ts" (prefix match on name) + ] + const ranked = rankPaths("config", paths) + // The deep file's basename ("config.ts") is a prefix match, which outranks + // the shallower "configuration-loader.ts" name-substring match. Depth does + // not penalise it — the core fix. + expect(ranked[0]).toBe(deep) + expect(ranked).toContain("src/configuration-loader.ts") + expect(ranked).not.toContain("src/index.ts") + }) + + it("matches via subsequence when there is no substring hit", () => { + expect(rankPaths("fbz", ["x/foobarbaz.ts", "x/nope.ts"])).toEqual([ + "x/foobarbaz.ts", + ]) + }) + + it("matches on path segments, not just the basename", () => { + const ranked = rankPaths("components/", [ + "src/components/button.tsx", + "src/lib/util.ts", + ]) + expect(ranked).toEqual(["src/components/button.tsx"]) + }) + + it("caps results at `limit`, keeping the best matches", () => { + const paths = Array.from({ length: 50 }, (_, i) => `src/item-${i}.ts`) + const ranked = rankPaths("item", paths, 10) + expect(ranked).toHaveLength(10) + }) +}) diff --git a/src/lib/file-search-match.ts b/src/lib/file-search-match.ts new file mode 100644 index 000000000..140029993 --- /dev/null +++ b/src/lib/file-search-match.ts @@ -0,0 +1,117 @@ +/** + * Lightweight ranked matcher for the file-search picker. No external fuzzy + * dependency: a single pass scores each candidate into ordered tiers (exact name + * → name prefix → name substring → path substring → subsequence), so the most + * relevant files float to the top. Callers scan the *entire* candidate list and + * keep the best `limit`, which is what lets a deeply nested file surface even + * when many shallower files also match. + * + * Candidates carry pre-lowercased `lowerName`/`lowerPath` (see `FlatFileEntry`), + * so matching stays allocation-free and fast enough to run on every keystroke. + */ + +export interface FileSearchCandidate { + /** Lowercased basename. */ + lowerName: string + /** Lowercased path relative to the workspace root. */ + lowerPath: string +} + +// Tier dominates the score; within a tier, an earlier match position wins, then +// a shorter candidate. The multipliers leave a wide margin so position/length +// adjustments can never bump a candidate across a tier boundary. +const TIER_BASE = 100_000_000 + +const TIER_EXACT_NAME = 6 +const TIER_NAME_PREFIX = 5 +const TIER_NAME_SUBSTRING = 4 +const TIER_PATH_SUBSTRING = 3 +const TIER_NAME_SUBSEQUENCE = 2 +const TIER_PATH_SUBSEQUENCE = 1 + +function tierScore(tier: number, position: number, length: number): number { + return ( + tier * TIER_BASE - Math.min(position, 9_999) * 1_000 - Math.min(length, 999) + ) +} + +/** + * Index of the first matched character if every char of `query` appears in `s` + * in order (a subsequence), else -1. `query` must be non-empty. + */ +function subsequenceFirstIndex(query: string, s: string): number { + let qi = 0 + let firstIdx = -1 + for (let si = 0; si < s.length && qi < query.length; si++) { + if (s[si] === query[qi]) { + if (qi === 0) firstIdx = si + qi++ + } + } + return qi === query.length ? firstIdx : -1 +} + +/** + * Score one candidate against an already-lowercased, non-empty `query`. + * Higher is better; returns `null` when the candidate does not match at all. + */ +export function scoreFileMatch( + query: string, + lowerName: string, + lowerPath: string +): number | null { + if (!query) return null + + if (lowerName === query) { + return tierScore(TIER_EXACT_NAME, 0, lowerName.length) + } + if (lowerName.startsWith(query)) { + return tierScore(TIER_NAME_PREFIX, 0, lowerName.length) + } + const nameIdx = lowerName.indexOf(query) + if (nameIdx !== -1) { + return tierScore(TIER_NAME_SUBSTRING, nameIdx, lowerName.length) + } + const pathIdx = lowerPath.indexOf(query) + if (pathIdx !== -1) { + return tierScore(TIER_PATH_SUBSTRING, pathIdx, lowerPath.length) + } + const nameSub = subsequenceFirstIndex(query, lowerName) + if (nameSub !== -1) { + return tierScore(TIER_NAME_SUBSEQUENCE, nameSub, lowerName.length) + } + const pathSub = subsequenceFirstIndex(query, lowerPath) + if (pathSub !== -1) { + return tierScore(TIER_PATH_SUBSEQUENCE, pathSub, lowerPath.length) + } + return null +} + +/** + * Rank `items` against `query`, returning the best `limit` matches (highest + * score first). An empty query returns the first `limit` items unchanged, so the + * picker shows a sensible default listing before the user types. + */ +export function rankFileMatches( + query: string, + items: readonly T[], + limit: number +): T[] { + const q = query.trim().toLowerCase() + if (!q) return items.slice(0, limit) + + const scored: { item: T; score: number }[] = [] + for (const item of items) { + const score = scoreFileMatch(q, item.lowerName, item.lowerPath) + if (score !== null) scored.push({ item, score }) + } + // ES2019 guarantees a stable sort, so equal scores keep the input order + // (alphabetical, as produced by the backend walk). + scored.sort((a, b) => b.score - a.score) + + const result: T[] = [] + for (let i = 0; i < scored.length && i < limit; i++) { + result.push(scored[i].item) + } + return result +} diff --git a/src/lib/file-tree-keyboard.test.ts b/src/lib/file-tree-keyboard.test.ts new file mode 100644 index 000000000..ebfa34fba --- /dev/null +++ b/src/lib/file-tree-keyboard.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "vitest" + +import { + buildVisibleTreeRows, + resolveTreeKeyboardAction, + type VisibleTreeRow, +} from "@/lib/file-tree-keyboard" +import type { FileTreeNode } from "@/lib/types" + +const ROOT = "__root__" + +// src/ +// a.ts +// utils/ +// b.ts +// README.md +const NODES: FileTreeNode[] = [ + { + kind: "dir", + name: "src", + path: "src", + children: [ + { kind: "file", name: "a.ts", path: "src/a.ts" }, + { + kind: "dir", + name: "utils", + path: "src/utils", + children: [{ kind: "file", name: "b.ts", path: "src/utils/b.ts" }], + }, + ], + }, + { kind: "file", name: "README.md", path: "README.md" }, +] + +function rows(...expanded: string[]): VisibleTreeRow[] { + return buildVisibleTreeRows(NODES, new Set(expanded), ROOT) +} + +const paths = (r: VisibleTreeRow[]) => r.map((row) => row.path) + +describe("buildVisibleTreeRows", () => { + it("shows only the root row when the root is collapsed", () => { + const r = rows() + expect(paths(r)).toEqual([ROOT]) + expect(r[0]).toMatchObject({ parentPath: null, isExpanded: false }) + }) + + it("descends only into expanded directories, in render order", () => { + expect(paths(rows(ROOT))).toEqual([ROOT, "src", "README.md"]) + expect(paths(rows(ROOT, "src"))).toEqual([ + ROOT, + "src", + "src/a.ts", + "src/utils", + "README.md", + ]) + expect(paths(rows(ROOT, "src", "src/utils"))).toEqual([ + ROOT, + "src", + "src/a.ts", + "src/utils", + "src/utils/b.ts", + "README.md", + ]) + }) + + it("records parentPath and isExpanded per row", () => { + const byPath = new Map( + rows(ROOT, "src", "src/utils").map((row) => [row.path, row]) + ) + expect(byPath.get("src")).toMatchObject({ + parentPath: ROOT, + kind: "dir", + isExpanded: true, + }) + expect(byPath.get("src/a.ts")).toMatchObject({ + parentPath: "src", + kind: "file", + isExpanded: false, + }) + expect(byPath.get("src/utils/b.ts")).toMatchObject({ + parentPath: "src/utils", + kind: "file", + }) + expect(byPath.get("README.md")).toMatchObject({ parentPath: ROOT }) + }) +}) + +describe("resolveTreeKeyboardAction — vertical movement", () => { + const all = rows(ROOT, "src", "src/utils") + + it("selects the root when nothing is focused yet", () => { + expect(resolveTreeKeyboardAction("ArrowDown", all, undefined)).toEqual({ + kind: "focus", + path: ROOT, + }) + expect(resolveTreeKeyboardAction("ArrowUp", all, undefined)).toEqual({ + kind: "focus", + path: ROOT, + }) + }) + + it("moves to the next/previous visible row", () => { + expect(resolveTreeKeyboardAction("ArrowDown", all, ROOT)).toEqual({ + kind: "focus", + path: "src", + }) + expect(resolveTreeKeyboardAction("ArrowUp", all, "src")).toEqual({ + kind: "focus", + path: ROOT, + }) + }) + + it("clamps at the ends", () => { + expect(resolveTreeKeyboardAction("ArrowUp", all, ROOT)).toEqual({ + kind: "focus", + path: ROOT, + }) + expect(resolveTreeKeyboardAction("ArrowDown", all, "README.md")).toEqual({ + kind: "focus", + path: "README.md", + }) + }) + + it("supports Home and End", () => { + expect(resolveTreeKeyboardAction("Home", all, "README.md")).toEqual({ + kind: "focus", + path: ROOT, + }) + expect(resolveTreeKeyboardAction("End", all, ROOT)).toEqual({ + kind: "focus", + path: "README.md", + }) + }) + + it("falls back to the first row when the focused path is no longer visible", () => { + expect(resolveTreeKeyboardAction("ArrowDown", all, "ghost/path")).toEqual({ + kind: "focus", + path: ROOT, + }) + }) +}) + +describe("resolveTreeKeyboardAction — ArrowRight", () => { + it("expands a collapsed directory in place", () => { + const r = rows(ROOT) // src collapsed + expect(resolveTreeKeyboardAction("ArrowRight", r, "src")).toEqual({ + kind: "expand", + path: "src", + }) + }) + + it("steps into the first child of an expanded directory", () => { + const r = rows(ROOT, "src") + expect(resolveTreeKeyboardAction("ArrowRight", r, "src")).toEqual({ + kind: "focus", + path: "src/a.ts", + }) + }) + + it("is a no-op on a file", () => { + const r = rows(ROOT, "src") + expect(resolveTreeKeyboardAction("ArrowRight", r, "src/a.ts")).toEqual({ + kind: "noop", + path: "", + }) + }) + + it("is a no-op on an expanded directory whose children are not loaded yet", () => { + const emptyDir: FileTreeNode[] = [ + { kind: "dir", name: "lazy", path: "lazy", children: [] }, + ] + const r = buildVisibleTreeRows(emptyDir, new Set([ROOT, "lazy"]), ROOT) + expect(resolveTreeKeyboardAction("ArrowRight", r, "lazy")).toEqual({ + kind: "noop", + path: "", + }) + }) +}) + +describe("resolveTreeKeyboardAction — ArrowLeft", () => { + it("collapses an expanded directory in place", () => { + const r = rows(ROOT, "src") + expect(resolveTreeKeyboardAction("ArrowLeft", r, "src")).toEqual({ + kind: "collapse", + path: "src", + }) + }) + + it("jumps to the parent from a collapsed directory", () => { + const r = rows(ROOT) // src collapsed + expect(resolveTreeKeyboardAction("ArrowLeft", r, "src")).toEqual({ + kind: "focus", + path: ROOT, + }) + }) + + it("jumps to the parent from a file", () => { + const r = rows(ROOT, "src") + expect(resolveTreeKeyboardAction("ArrowLeft", r, "src/a.ts")).toEqual({ + kind: "focus", + path: "src", + }) + }) + + it("collapses the expanded root", () => { + const r = rows(ROOT) + expect(resolveTreeKeyboardAction("ArrowLeft", r, ROOT)).toEqual({ + kind: "collapse", + path: ROOT, + }) + }) + + it("is a no-op on the collapsed root (no parent to move to)", () => { + const r = rows() // root collapsed + expect(resolveTreeKeyboardAction("ArrowLeft", r, ROOT)).toEqual({ + kind: "noop", + path: "", + }) + }) +}) + +describe("resolveTreeKeyboardAction — activation & unhandled keys", () => { + const all = rows(ROOT, "src", "src/utils") + + it("opens a focused file on Enter and Space", () => { + expect(resolveTreeKeyboardAction("Enter", all, "src/a.ts")).toEqual({ + kind: "open", + path: "src/a.ts", + }) + expect(resolveTreeKeyboardAction(" ", all, "src/a.ts")).toEqual({ + kind: "open", + path: "src/a.ts", + }) + }) + + it("toggles a focused directory on Enter", () => { + expect(resolveTreeKeyboardAction("Enter", all, "src")).toEqual({ + kind: "toggle", + path: "src", + }) + }) + + it("returns null for keys the tree does not own", () => { + expect(resolveTreeKeyboardAction("a", all, "src")).toBeNull() + expect(resolveTreeKeyboardAction("Tab", all, "src")).toBeNull() + expect(resolveTreeKeyboardAction("Escape", all, "src")).toBeNull() + }) + + it("returns null when there are no rows", () => { + expect(resolveTreeKeyboardAction("ArrowDown", [], undefined)).toBeNull() + }) +}) diff --git a/src/lib/file-tree-keyboard.ts b/src/lib/file-tree-keyboard.ts new file mode 100644 index 000000000..cdd9fb1ef --- /dev/null +++ b/src/lib/file-tree-keyboard.ts @@ -0,0 +1,158 @@ +import type { FileTreeNode } from "@/lib/types" + +/** + * Keyboard navigation model for the workspace file tree. + * + * The tree is rendered by plain recursion (not virtualized), so a row exists in + * the DOM only when every ancestor is expanded. To drive Up/Down/Left/Right + * navigation we first flatten the visible rows into render order, then resolve a + * single intent per key press. Both steps are pure so they can be unit-tested in + * isolation from the (large, stateful) panel component that applies the intent. + */ + +export interface VisibleTreeRow { + path: string + kind: "file" | "dir" + /** The parent row's path, or `null` for the synthetic root row. */ + parentPath: string | null + /** Directories only: whether currently expanded. Always `false` for files. */ + isExpanded: boolean +} + +/** + * Flatten the tree into the exact order rows are rendered, including the + * synthetic root row (`rootPath`) that wraps the top-level nodes. Only expanded + * directories are descended into, mirroring what is actually mounted. + */ +export function buildVisibleTreeRows( + nodes: readonly FileTreeNode[], + expandedPaths: ReadonlySet, + rootPath: string +): VisibleTreeRow[] { + const rows: VisibleTreeRow[] = [] + const rootExpanded = expandedPaths.has(rootPath) + rows.push({ + path: rootPath, + kind: "dir", + parentPath: null, + isExpanded: rootExpanded, + }) + + const walk = (items: readonly FileTreeNode[], parentPath: string) => { + for (const item of items) { + if (item.kind === "file") { + rows.push({ + path: item.path, + kind: "file", + parentPath, + isExpanded: false, + }) + continue + } + const expanded = expandedPaths.has(item.path) + rows.push({ + path: item.path, + kind: "dir", + parentPath, + isExpanded: expanded, + }) + if (expanded) walk(item.children, item.path) + } + } + + // Top-level nodes are children of the root row, so they only show when the + // root itself is expanded. + if (rootExpanded) walk(nodes, rootPath) + return rows +} + +export type TreeKeyboardActionKind = + | "focus" + | "expand" + | "collapse" + | "toggle" + | "open" + | "noop" + +export interface TreeKeyboardAction { + kind: TreeKeyboardActionKind + /** Target path for the action; empty for `noop`. */ + path: string +} + +const NOOP: TreeKeyboardAction = { kind: "noop", path: "" } + +/** + * Resolve a key press against the visible rows into a single navigation intent + * (IntelliJ IDEA-style project tree behavior): + * + * - `ArrowDown`/`ArrowUp` — move focus to the next/previous visible row (clamped). + * - `ArrowRight` — on a collapsed directory: expand; on an expanded directory: + * move to its first visible child; on a file: no-op. + * - `ArrowLeft` — on an expanded directory: collapse; otherwise move to the + * parent directory. + * - `Home`/`End` — jump to the first/last visible row. + * - `Enter`/`Space` — open a file, or toggle a directory. + * + * Returns `null` for keys the tree does not own (so the caller does not + * `preventDefault`), or a `{ kind: "noop" }` action for owned keys that produce + * no change (so the caller can still swallow the key to stop the scroll area + * from scrolling). + */ +export function resolveTreeKeyboardAction( + key: string, + rows: readonly VisibleTreeRow[], + currentPath: string | undefined +): TreeKeyboardAction | null { + if (rows.length === 0) return null + + const currentIndex = + currentPath == null ? -1 : rows.findIndex((row) => row.path === currentPath) + const current = currentIndex >= 0 ? rows[currentIndex] : undefined + + switch (key) { + case "ArrowDown": { + const next = + currentIndex < 0 ? 0 : Math.min(rows.length - 1, currentIndex + 1) + return { kind: "focus", path: rows[next].path } + } + case "ArrowUp": { + const prev = currentIndex <= 0 ? 0 : currentIndex - 1 + return { kind: "focus", path: rows[prev].path } + } + case "ArrowRight": { + if (!current) return { kind: "focus", path: rows[0].path } + if (current.kind !== "dir") return NOOP + if (!current.isExpanded) return { kind: "expand", path: current.path } + // Already expanded: step into the first child if one is currently visible + // (it always follows immediately in render order). + const child = rows[currentIndex + 1] + if (child && child.parentPath === current.path) { + return { kind: "focus", path: child.path } + } + return NOOP + } + case "ArrowLeft": { + if (!current) return { kind: "focus", path: rows[0].path } + if (current.kind === "dir" && current.isExpanded) { + return { kind: "collapse", path: current.path } + } + if (current.parentPath != null) { + return { kind: "focus", path: current.parentPath } + } + return NOOP + } + case "Home": + return { kind: "focus", path: rows[0].path } + case "End": + return { kind: "focus", path: rows[rows.length - 1].path } + case "Enter": + case " ": { + if (!current) return NOOP + if (current.kind === "file") return { kind: "open", path: current.path } + return { kind: "toggle", path: current.path } + } + default: + return null + } +} diff --git a/src/lib/monaco-themes.test.ts b/src/lib/monaco-themes.test.ts index a53b4b0ee..8d6d6e7a4 100644 --- a/src/lib/monaco-themes.test.ts +++ b/src/lib/monaco-themes.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest" import { configureLanguageValidation, defineMonacoThemes, + defineWorkspaceBgTheme, EDITOR_CANVAS_BG, EDITOR_LINE_HIGHLIGHT, monacoThemeName, @@ -135,12 +136,52 @@ describe("configureLanguageValidation", () => { expect(call?.[1].colors["editorGutter.background"]).toBe( EDITOR_CANVAS_BG.blue.dark ) + // Sticky scroll (pinned parent-scope lines) tracks the canvas: in the opaque + // base theme it equals the canvas bg (unchanged). The workspace-bg variant + // makes it transparent and frosts the band in CSS instead (see the wsbg test + // below) — so it stops being a stark opaque slab over a background image. + expect(call?.[1].colors["editorStickyScroll.background"]).toBe( + EDITOR_CANVAS_BG.blue.dark + ) + expect(call?.[1].colors["editorStickyScrollGutter.background"]).toBe( + EDITOR_CANVAS_BG.blue.dark + ) // The current-line highlight follows the theme's tinted --muted, not a fixed // gray, so the focused line carries the accent hue. expect(call?.[1].colors["editor.lineHighlightBackground"]).toBe( EDITOR_LINE_HIGHLIGHT.blue.dark ) }) + + it("lets the sticky-scroll band go transparent with the canvas in the workspace-bg variant", () => { + // With a workspace background image the canvas is transparent (alpha 0) and + // the sticky band tracks it, rather than staying an opaque slab: a single + // full-width frosted surface is painted on `.sticky-widget` in CSS instead + // (covering the scrollbar strip Monaco's content-width inner layer leaves + // bare). So the sticky bg must carry the same transparent canvas value. + const { monaco } = makeMonaco() + const defineTheme = monaco.editor.defineTheme + const base = monacoThemeName("blue", true) + + const name = defineWorkspaceBgTheme( + monaco as unknown as Parameters[0], + base, + 0 + ) + + const call = defineTheme.mock.calls.find((c) => c[0] === name) + expect(call).toBeDefined() + // Fully transparent (`…dark` + "00"), matching `editor.background`, so the + // frosted CSS band shows through uniformly across content, gutter and strip. + const transparentCanvas = `${EDITOR_CANVAS_BG.blue.dark}00` + expect(call?.[1].colors["editor.background"]).toBe(transparentCanvas) + expect(call?.[1].colors["editorStickyScroll.background"]).toBe( + transparentCanvas + ) + expect(call?.[1].colors["editorStickyScrollGutter.background"]).toBe( + transparentCanvas + ) + }) }) // The focused line's highlight tracks each theme's `--muted` token so it follows diff --git a/src/lib/monaco-themes.ts b/src/lib/monaco-themes.ts index 9a0c9a64a..15d847f44 100644 --- a/src/lib/monaco-themes.ts +++ b/src/lib/monaco-themes.ts @@ -520,13 +520,29 @@ function withCanvasBackground( // the editor. Empty (default) keeps them fully opaque — unchanged behaviour. alphaHexSuffix = "" ): Record { - const bg = EDITOR_CANVAS_BG[color][dark ? "dark" : "light"] + alphaHexSuffix + const opaqueBg = EDITOR_CANVAS_BG[color][dark ? "dark" : "light"] + const bg = opaqueBg + alphaHexSuffix return { ...base, "editor.background": bg, "editorGutter.background": bg, "peekViewEditor.background": bg, "peekViewEditorGutter.background": bg, + // Sticky scroll (pinned parent-scope lines) defaults its background to + // `editor.background`; keep it tracking the canvas here (so it follows `bg`, + // not a fixed value). In the opaque base theme that's the solid canvas colour + // — unchanged. When a workspace background image makes the canvas translucent, + // a fully OPAQUE sticky band read as a stark dark slab floating over the image + // ("一坨黑色"), and Monaco's inner `.sticky-widget-lines-scrollable` only spans + // the content width — leaving the vertical-scrollbar strip bare so scrolled + // code bled through on the right. So we let the band go transparent with the + // canvas and instead paint ONE full-width frosted surface on `.sticky-widget` + // in CSS (globals.css `[data-workspace-bg="on"] .monaco-editor .sticky-widget`): + // a translucent tint + backdrop blur that covers the strip and blends the + // header into the frosted-panel aesthetic while keeping the pinned lines + // legible. `bg` = opaque canvas when off (zero regression), transparent when on. + "editorStickyScroll.background": bg, + "editorStickyScrollGutter.background": bg, "editor.lineHighlightBackground": EDITOR_LINE_HIGHLIGHT[color][dark ? "dark" : "light"], } @@ -678,8 +694,9 @@ const WSBG_CANVAS_ALPHA = 0 // Like `useMonacoThemeSync`, but when a workspace background image is enabled it // swaps in a fully transparent-canvas theme (see `WSBG_CANVAS_ALPHA`) so the code // area reads like the conversation canvas rather than a frosted panel. Disabled → -// the opaque base theme, byte-for-byte unchanged (zero regression). Shared by the -// file editor, diff viewer and merge editor. +// the opaque base theme, visually unchanged (zero regression: the sticky-scroll +// keys equal `editor.background` when opaque). Shared by the file editor, diff +// viewer and merge editor. // // The caller supplies the loaded monaco instance (from the editor's onMount, or // `useMonaco()` for conditionally-mounted editors) rather than this hook calling diff --git a/src/lib/types.ts b/src/lib/types.ts index 16da4ed4d..45c01845b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2245,6 +2245,14 @@ export type FileTreeNode = | { kind: "file"; name: string; path: string } | { kind: "dir"; name: string; path: string; children: FileTreeNode[] } +/** Flat gitignore-aware workspace entry returned by `list_workspace_files`. */ +export interface WorkspaceFileEntry { + name: string + /** Path relative to the workspace root, always forward-slashed. */ + path: string + kind: "file" | "dir" +} + export interface DirectoryEntry { name: string path: string diff --git a/src/lib/window-chrome.test.ts b/src/lib/window-chrome.test.ts new file mode 100644 index 000000000..cd1537557 --- /dev/null +++ b/src/lib/window-chrome.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" + +import { + LEFT_CHROME_CLUSTER, + MAC_TRAFFIC_LIGHT_INSET, + RIGHT_CHROME_CLUSTER, + WINDOW_CAPTION_WIDTH, + leftChromeReserve, + rightChromeClusterWidth, + rightChromeReserve, +} from "./window-chrome" + +// The app "zoom" scales the root font-size (rem), so the rem-sized chrome buttons +// grow with zoom. Their fixed-px containers must grow by the same factor or the +// buttons overflow/clip at high zoom (the 150% bug). These guard that only the +// DOM button CLUSTER scales, while the native insets (macOS traffic-light +// clearance, Windows/Linux caption strip) stay fixed. +describe("window-chrome zoom scaling", () => { + it("defaults to 100% (no scaling) and matches the pre-zoom baseline", () => { + // Baseline the aux-panel collapse test also hard-codes: 116 (mac/web) and + // 116 + 138 = 254 (win/linux caption reserved). + expect(rightChromeReserve(false)).toBe(RIGHT_CHROME_CLUSTER) + expect(rightChromeReserve(true)).toBe( + RIGHT_CHROME_CLUSTER + WINDOW_CAPTION_WIDTH + ) + expect(rightChromeClusterWidth()).toBe(RIGHT_CHROME_CLUSTER) + expect(leftChromeReserve(false)).toBe(LEFT_CHROME_CLUSTER) + expect(leftChromeReserve(true)).toBe( + MAC_TRAFFIC_LIGHT_INSET + LEFT_CHROME_CLUSTER + ) + }) + + it("scales only the button cluster at 150%, leaving native insets fixed", () => { + // 116 → 174, 80 → 120. + expect(rightChromeClusterWidth(150)).toBe(174) + expect(rightChromeReserve(false, 150)).toBe(174) + // Native caption strip stays 138. + expect(rightChromeReserve(true, 150)).toBe(174 + WINDOW_CAPTION_WIDTH) + // Native traffic-light inset stays 76; only the 80 cluster scales to 120. + expect(leftChromeReserve(false, 150)).toBe(120) + expect(leftChromeReserve(true, 150)).toBe(MAC_TRAFFIC_LIGHT_INSET + 120) + }) + + it("scales the cluster down below 100% too and rounds to whole pixels", () => { + // 116 * 0.9 = 104.4 → 104 (rounded). + expect(rightChromeClusterWidth(90)).toBe(104) + // 80 * 0.5 = 40, plus the fixed 76 inset. + expect(leftChromeReserve(true, 50)).toBe(MAC_TRAFFIC_LIGHT_INSET + 40) + }) +}) diff --git a/src/lib/window-chrome.ts b/src/lib/window-chrome.ts index cdf5141a5..79db57684 100644 --- a/src/lib/window-chrome.ts +++ b/src/lib/window-chrome.ts @@ -30,18 +30,56 @@ export const LEFT_CHROME_CLUSTER = 80 /** Right cluster: terminal + aux + settings (three icon buttons + padding). */ export const RIGHT_CHROME_CLUSTER = 116 +/** + * Scale a DOM button-cluster width by the app's rem-based zoom. + * + * The app "zoom" scales the root font-size (`documentElement.style.fontSize = + * 16 * zoom/100`, see AppearanceProvider), so the rem-sized chrome buttons + * (`h-6 w-6`, `gap-1`, `pl-3`/`pr-3`) grow with zoom. Their containers must grow + * by the same factor or the buttons overflow and get clipped at high zoom. The + * NATIVE insets do NOT scale this way — the macOS traffic lights keep a constant + * horizontal inset (only their Y shifts with zoom, see `traffic_light_position_at` + * in commands/windows.rs) and the Windows/Linux caption buttons are fixed 46px + * each — so callers add those separately, outside this helper. + */ +function scaleCluster(px: number, zoom: number): number { + return Math.round((px * zoom) / 100) +} + /** * Width the window's left-edge column reserves for the left overlay. - * `macInset` adds the traffic-light clearance (desktop macOS only). + * `macInset` adds the traffic-light clearance (desktop macOS only); `zoom` (a + * percent, default 100) scales the rem-sized button cluster to match the buttons + * inside it, while the native traffic-light inset stays fixed. */ -export function leftChromeReserve(macInset: boolean): number { - return (macInset ? MAC_TRAFFIC_LIGHT_INSET : 0) + LEFT_CHROME_CLUSTER +export function leftChromeReserve(macInset: boolean, zoom = 100): number { + return ( + (macInset ? MAC_TRAFFIC_LIGHT_INSET : 0) + + scaleCluster(LEFT_CHROME_CLUSTER, zoom) + ) } /** * Width the window's right-edge column reserves for the right overlay. - * `winLinuxCaption` adds the native caption-button strip (desktop Win/Linux). + * `winLinuxCaption` adds the native caption-button strip (desktop Win/Linux); + * `zoom` (a percent, default 100) scales the rem-sized button cluster, while the + * fixed native caption strip stays constant. + */ +export function rightChromeReserve( + winLinuxCaption: boolean, + zoom = 100 +): number { + return ( + scaleCluster(RIGHT_CHROME_CLUSTER, zoom) + + (winLinuxCaption ? WINDOW_CAPTION_WIDTH : 0) + ) +} + +/** + * The right-edge overlay's OWN width — just the (zoom-scaled) button cluster. + * The native caption strip isn't part of this box; it's cleared by the overlay's + * `right` offset (see `FolderLayoutShell`), so only the cluster is measured here. */ -export function rightChromeReserve(winLinuxCaption: boolean): number { - return RIGHT_CHROME_CLUSTER + (winLinuxCaption ? WINDOW_CAPTION_WIDTH : 0) +export function rightChromeClusterWidth(zoom = 100): number { + return scaleCluster(RIGHT_CHROME_CLUSTER, zoom) }