From 10be219f3e97b4e416dd7b071b837ea3eb2be23d Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 20 Aug 2026 23:07:14 +0800 Subject: [PATCH] fix: improve GPUI blur and WSL integration --- docs/release-notes/unreleased.md | 5 +- nebula_app/src/display/side_panel.rs | 478 ++++++++++++++++-- nebula_app/src/gpui_shell/terminal/session.rs | 8 + nebula_app/src/gpui_shell/wallpaper.rs | 151 ++++-- nebula_app/src/gpui_shell/workspace.rs | 89 +++- .../src/gpui_shell/workspace/file_tree.rs | 27 +- nebula_app/src/shell_detect.rs | 187 +++++++ nebula_app/src/window_context.rs | 39 +- 8 files changed, 841 insertions(+), 143 deletions(-) diff --git a/docs/release-notes/unreleased.md b/docs/release-notes/unreleased.md index f343f60..f96d8e4 100644 --- a/docs/release-notes/unreleased.md +++ b/docs/release-notes/unreleased.md @@ -72,4 +72,7 @@ This file contains changes that are not part of a numbered release yet. The PowerShell/OSC changes need first-prompt checks for all profile creation routes. The mixed-DPI change still requires a real Windows two-monitor matrix, including Windows 11 150% -> 125% and the reverse direction. A compile check -alone is not sufficient to claim that the physical monitor seam is fixed. +alone is not sufficient to claim that the physical monitor seam is fixed. The +WSL changes still require guest-path checks for both `/mnt/` and native +Linux repositories, and the blur change requires a live Windows DWM toggle +check in both directions. diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index cd44fc0..ca60575 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -30,6 +30,9 @@ pub enum PanelView { #[derive(Debug, Clone)] pub struct FileRow { pub path: PathBuf, + /// WSL 来宾中的真实绝对路径。`path` 仍作为现有选择/展开状态的稳定键, + /// 但绝不能把它交给宿主文件 API;来宾操作必须显式使用这一字段。 + pub guest_path: Option, pub name: String, pub depth: usize, pub is_dir: bool, @@ -211,8 +214,15 @@ pub struct SidePanel { /// Latest focused pane cwd, retained while a custom root is active so the /// panel can resume following immediately without persisting any setting. followed_cwd: Option, + /// 聚焦终端是 WSL 且停在来宾目录时的位置。宿主未必看得见 WSL 文件系统 + /// (9P 重定向不一定可用),所以这不是"另一种 cwd"——它是让 Git 视图 + /// 改用「在来宾里跑 git」的开关,与 [`Self::followed_cwd`] 各管一段。 + followed_wsl: Option, /// Window-local override selected from the Files view. custom_root: Option, + /// WSL guest tree 的窗口内浏览根(例如点 `..` 后得到的 `/home`)。与 + /// `custom_root` 互斥,且不改变终端实际 cwd / Git 跟随位置。 + custom_wsl_root: Option, /// Visible feedback for an invalid/disappeared custom root. root_notice: Option, /// Flattened visible tree rows for the Files view. @@ -276,6 +286,8 @@ struct PanelSnapshot { /// Root the snapshot was built from — stale snapshots (root changed while /// the worker ran) are dropped on harvest. root: PathBuf, + /// Files 快照所属的来宾位置;避免两个发行版恰好同为 `/home` 时串结果。 + files_wsl: Option, rows: Vec, git: Option, } @@ -483,7 +495,9 @@ impl SidePanel { view: PanelView::Files, root: None, followed_cwd: None, + followed_wsl: None, custom_root: None, + custom_wsl_root: None, root_notice: None, rows: Vec::new(), expanded: HashSet::new(), @@ -530,25 +544,63 @@ impl SidePanel { /// refresh was requested (toggle), or the throttle window has elapsed. /// Called once per drawn frame from the window context; cheap when nothing /// changed. Returns whether the snapshot was rebuilt (i.e. needs redraw). + /// + /// 抽屉内部的刷新(文件操作后、根切换后)走这一层就够;跟随聚焦终端的 + /// 调用方请用 [`Self::sync_at`] 一并交出 WSL 位置。 pub fn sync(&mut self, cwd: Option) -> bool { + self.sync_at(cwd, None) + } + + /// [`Self::sync`] 加上 WSL 位置。`wsl` = 聚焦终端所在的 WSL 发行版与来宾 + /// 目录([`crate::shell_detect::wsl_cwd`] 解析),`None` = 聚焦的是宿主 + /// 终端或压根认不出发行版。 + pub fn sync_at( + &mut self, + cwd: Option, + wsl: Option, + ) -> bool { if !self.open { return false; } // 先收割落地的后台快照——旧内容在工人跑动期间一直显示,这里一次 // 性换成新内容(先显示旧的、再更新,VSCode 的树刷新模式)。 - let mut changed = self.harvest_snapshot(); + let changed = self.harvest_snapshot(); // 聚焦 pane 报不出本地有效目录时(SSH 远端路径、未映射的 WSL 路 // 径、shell 还没发 OSC)保持现状:跟随的语义是"最后一个已知有效 // 目录",清空只会让切到远程 tab 的瞬间目录树闪成空白。 - if cwd.is_some() { + // 先记下这次到底给出了哪些位置——两个字段各自会 move 掉入参。 + let had_cwd = cwd.is_some(); + let has_guest_cwd = !had_cwd && wsl.is_some(); + if had_cwd { self.followed_cwd = cwd; + } else if has_guest_cwd { + // `/mnt/d` 能映射为 D:\,而 `/home` 只能由来宾读取。从前这里保留 + // 旧 D:\,导致新 WSL 位置永远被宿主根盖住。 + self.followed_cwd = None; } + // WSL 位置沿用同一套"最后一个已知有效位置"语义,但多一条清除规则: + // 切回一个宿主终端(cwd 有值而 wsl 没有)必须把它清掉,否则 Git 面板 + // 会继续显示上一个 WSL 仓库的状态。两者都拿不到(SSH tab)才保持现状。 + let wsl_before = self.followed_wsl.clone(); + if wsl.is_some() { + self.followed_wsl = wsl; + } else if had_cwd { + self.followed_wsl = None; + } + // 换了 WSL 仓库、或从 WSL 切回宿主,都要立刻重算 Git 快照:这类切换 + // 不改 `root`(宿主看不见来宾目录,树停在原处),光靠 `root_changed` + // 抓不到,只等节流窗口的话面板会挂着上一个仓库的分支名不动。 + let wsl_changed = wsl_before != self.followed_wsl; let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir()); if custom_invalidated { self.custom_root = None; self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned()); } - let next_root = self.custom_root.clone().or_else(|| self.followed_cwd.clone()); + let next_root = self + .custom_root + .clone() + .or_else(|| self.custom_wsl_root.as_ref().map(wsl_root_key)) + .or_else(|| self.followed_tree_root()); let root_changed = next_root != self.root; // While a filter query is live, skip the periodic re-snapshot: it // would drop and rebuild the search index under the user's fingers. @@ -559,7 +611,7 @@ impl SidePanel { if self.op_done.swap(false, std::sync::atomic::Ordering::Relaxed) { self.needs_refresh = true; } - if !(root_changed || custom_invalidated || stale || self.needs_refresh) { + if !(root_changed || custom_invalidated || stale || wsl_changed || self.needs_refresh) { return changed; } if root_changed { @@ -569,7 +621,7 @@ impl SidePanel { self.selected = None; } self.refresh(); - changed || root_changed || custom_invalidated + changed || root_changed || custom_invalidated || wsl_changed } /// 测试辅助:等后台快照工人落地并收割。生产路径永不阻塞。 @@ -598,7 +650,9 @@ impl SidePanel { }; let Some(snapshot) = snapshot else { return false }; // 工人跑动期间根又变了:这份快照已经过期,丢弃。新刷新在路上。 - if self.root.as_ref() != Some(&snapshot.root) { + if self.root.as_ref() != Some(&snapshot.root) + || self.file_wsl_root().cloned() != snapshot.files_wsl + { return false; } self.rows = snapshot.rows; @@ -617,6 +671,7 @@ impl SidePanel { } let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root); self.custom_root = Some(root.clone()); + self.custom_wsl_root = None; self.root_notice = None; if self.root.as_ref() != Some(&root) { self.root = Some(root); @@ -630,11 +685,13 @@ impl SidePanel { /// Resume following the most recently observed focused pane cwd. pub fn clear_custom_root(&mut self) -> bool { - if self.custom_root.take().is_none() { + let cleared_host = self.custom_root.take().is_some(); + let cleared_wsl = self.custom_wsl_root.take().is_some(); + if !cleared_host && !cleared_wsl { return false; } self.root_notice = None; - let next_root = self.followed_cwd.clone(); + let next_root = self.followed_tree_root(); if self.root != next_root { self.root = next_root; self.expanded.clear(); @@ -646,7 +703,38 @@ impl SidePanel { } pub fn custom_root_active(&self) -> bool { - self.custom_root.is_some() + self.custom_root.is_some() || self.custom_wsl_root.is_some() + } + + /// Files 视图当前是否由 WSL guest 提供。宿主可达的 `/mnt/<盘>` 与 UNC + /// 不会进入这里,仍保留完整的宿主打开、回收站和 Explorer 操作。 + pub fn file_wsl_root(&self) -> Option<&crate::shell_detect::WslCwd> { + self.custom_wsl_root.as_ref().or_else(|| { + (self.custom_root.is_none() && self.followed_cwd.is_none()) + .then_some(self.followed_wsl.as_ref()) + .flatten() + }) + } + + fn followed_tree_root(&self) -> Option { + self.followed_cwd.clone().or_else(|| self.followed_wsl.as_ref().map(wsl_root_key)) + } + + fn set_custom_wsl_root(&mut self, located: crate::shell_detect::WslCwd) -> bool { + let root = wsl_root_key(&located); + let changed = + self.custom_wsl_root.as_ref() != Some(&located) || self.root.as_ref() != Some(&root); + self.custom_root = None; + self.custom_wsl_root = Some(located); + self.root_notice = None; + if self.root.as_ref() != Some(&root) { + self.root = Some(root); + self.expanded.clear(); + self.scroll = 0; + self.selected = None; + self.refresh(); + } + changed } /// 目录:Git/SVN 状态所属的那一个。始终是终端当前目录,与目录树的浏览 @@ -706,34 +794,54 @@ impl SidePanel { return; } let expanded = self.expanded.clone(); + let files_wsl = self.file_wsl_root().cloned(); // VCS 状态始终读**终端当前目录**,不跟着目录树的浏览位置走。在树里点 // `..` 往上翻是纯浏览动作(`custom_root`,窗口内覆盖),把仓库状态一起 // 带走的后果是:翻到仓库外面之后 Git 视图只剩「当前目录不在 Git/SVN // 仓库中」,而回头的入口偏偏只画在 Files 视图里。行与状态在这里解耦: // 行看 `root`,git 看 `followed_cwd`。 let git_root = self.followed_cwd.clone().unwrap_or_else(|| root.clone()); + let wsl = self.followed_wsl.clone(); let running = std::sync::Arc::clone(&self.snapshot_running); let slot = std::sync::Arc::clone(&self.snapshot_slot); std::thread::spawn(move || { - let rows = SidePanel::tree_rows(&root, &expanded); + let rows = match &files_wsl { + Some(located) => SidePanel::tree_rows_wsl(located, &expanded), + None => SidePanel::tree_rows(&root, &expanded), + }; // 设置可强制只认 Git / SVN(混合仓库场景);Auto 保持既有探测: // a checkout nested inside a Git tree must remain visible as SVN. // Prefer SVN only when its metadata is in the current path's // ancestor chain; ordinary Git directories keep the cheaper Git // first path and only probe SVN as a fallback. - let git = match nebula_settings::RuntimeSettings::load().vcs_display { - nebula_settings::VcsDisplayName::Git => read_git(&git_root), - nebula_settings::VcsDisplayName::Svn => read_svn(&git_root), - nebula_settings::VcsDisplayName::Auto => { - if svn_dir_hint(&git_root) { - read_svn(&git_root).or_else(|| read_git(&git_root)) - } else { - read_git(&git_root).or_else(|| read_svn(&git_root)) - } + let git = match &wsl { + // 聚焦的是 WSL 终端:宿主的 `git_root` 与来宾目录毫无关系 + // (UNC 不可达时它还停在上一个宿主目录),必须在来宾里跑 + // git。SVN 不走这条——WSL 里用 svn 没有实据,不加没验证过 + // 的分支;`vcs_display` 强制 Svn 时同理保持"无仓库"。 + Some(located) + if !matches!( + nebula_settings::RuntimeSettings::load().vcs_display, + nebula_settings::VcsDisplayName::Svn + ) => + { + read_git_wsl(located) + }, + Some(_) => None, + None => match nebula_settings::RuntimeSettings::load().vcs_display { + nebula_settings::VcsDisplayName::Git => read_git(&git_root), + nebula_settings::VcsDisplayName::Svn => read_svn(&git_root), + nebula_settings::VcsDisplayName::Auto => { + if svn_dir_hint(&git_root) { + read_svn(&git_root).or_else(|| read_git(&git_root)) + } else { + read_git(&git_root).or_else(|| read_svn(&git_root)) + } + }, }, }; if let Ok(mut slot) = slot.lock() { - *slot = Some(PanelSnapshot { root, rows, git }); + *slot = Some(PanelSnapshot { root, files_wsl, rows, git }); } running.store(false, std::sync::atomic::Ordering::Release); }); @@ -746,7 +854,10 @@ impl SidePanel { let Some(root) = self.root.clone() else { return }; let needle = self.search.trim().to_lowercase(); if needle.is_empty() { - self.rows = Self::tree_rows(&root, &self.expanded); + self.rows = match self.file_wsl_root() { + Some(located) => Self::tree_rows_wsl(located, &self.expanded), + None => Self::tree_rows(&root, &self.expanded), + }; return; } // Filter mode: string-match against the cached flat index. The index @@ -756,8 +867,12 @@ impl SidePanel { if self.search_index.is_none() { let mut index = Vec::new(); let mut budget = SEARCH_VISIT_BUDGET; - build_search_index(&root, 0, &mut index, &mut budget); - Self::mark_ignored(&root, &mut index); + if let Some(located) = self.file_wsl_root() { + build_wsl_search_index(located, &mut index, &mut budget); + } else { + build_search_index(&root, 0, &mut index, &mut budget); + Self::mark_ignored(&root, &mut index); + } self.search_index = Some(index); } let index = self.search_index.as_ref().unwrap(); @@ -879,7 +994,25 @@ impl SidePanel { } fn spawn_git(&mut self, args: Vec) { - self.spawn_vcs("git", args.into_iter().map(OsString::from).collect()); + if let Some(located) = self.followed_wsl.clone() { + let mut guest_args: Vec = [ + "-d", + located.distro.as_str(), + "--", + "git", + "-C", + located.guest.as_str(), + "--no-optional-locks", + ] + .into_iter() + .map(OsString::from) + .collect(); + guest_args.extend(args.into_iter().map(OsString::from)); + let host_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + self.spawn_vcs_at(PathBuf::from("wsl.exe"), guest_args, host_cwd); + } else { + self.spawn_vcs("git", args.into_iter().map(OsString::from).collect()); + } } fn spawn_svn_mutation(&mut self, operation: SvnMutation) { @@ -1148,6 +1281,7 @@ impl SidePanel { if let Some(parent) = root.parent() { rows.push(FileRow { path: parent.to_path_buf(), + guest_path: None, name: "..".to_owned(), depth: 0, is_dir: true, @@ -1161,6 +1295,68 @@ impl SidePanel { rows } + /// UNC 不可达时直接从 WSL guest 枚举 Files 树。命令参数逐项传给 + /// `CreateProcessW`,不经过 shell,因此发行版名、路径和文件名都不会参与 + /// 命令拼接。`find -printf` 用 NUL 分隔,换行文件名也不会破坏记录边界。 + fn tree_rows_wsl( + located: &crate::shell_detect::WslCwd, + expanded: &HashSet, + ) -> Vec { + let root = normalize_wsl_guest_path(&located.guest); + let mut rows = Vec::new(); + if let Some(parent) = wsl_guest_parent(&root) { + rows.push(FileRow { + path: PathBuf::from(&parent), + guest_path: Some(parent), + name: "..".to_owned(), + depth: 0, + is_dir: true, + expanded: false, + is_parent: true, + ignored: false, + }); + } + Self::flatten_wsl_dir_into(&mut rows, expanded, &located.distro, &root, 0); + rows + } + + fn flatten_wsl_dir_into( + rows: &mut Vec, + expanded: &HashSet, + distro: &str, + guest_dir: &str, + depth: usize, + ) { + if rows.len() >= MAX_ROWS { + return; + } + let mut entries = wsl_read_dir(distro, guest_dir); + entries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); + entries.truncate(MAX_PER_DIR); + for (is_dir, name, guest_path) in entries { + if rows.len() >= MAX_ROWS { + return; + } + // 该 PathBuf 只作为展开/选中的稳定键;真实来宾路径始终取 + // `guest_path`,绝不把这个键交给宿主文件系统。 + let path_key = PathBuf::from(&guest_path); + let is_expanded = is_dir && expanded.contains(&path_key); + rows.push(FileRow { + path: path_key.clone(), + guest_path: Some(guest_path.clone()), + name, + depth, + is_dir, + expanded: is_expanded, + is_parent: false, + ignored: false, + }); + if is_expanded { + Self::flatten_wsl_dir_into(rows, expanded, distro, &guest_path, depth + 1); + } + } + } + fn flatten_dir_into( rows: &mut Vec, expanded: &HashSet, @@ -1190,6 +1386,7 @@ impl SidePanel { let is_expanded = is_dir && expanded.contains(&path); rows.push(FileRow { path: path.clone(), + guest_path: None, name, depth, is_dir, @@ -1283,9 +1480,16 @@ impl SidePanel { return false; } let path = row.path.clone(); + let guest_path = row.guest_path.clone(); if row.is_parent { // 返回上级只改变窗口内的目录树根节点,不能连带修改终端 cwd; // 用户仍可通过现有“跟随”操作回到当前终端目录。 + if let (Some(current), Some(guest)) = (self.file_wsl_root(), guest_path) { + return self.set_custom_wsl_root(crate::shell_detect::WslCwd { + distro: current.distro.clone(), + guest, + }); + } return self.set_custom_root(path); } if !self.expanded.remove(&path) { @@ -1542,6 +1746,7 @@ fn build_search_index(dir: &Path, depth: usize, index: &mut Vec, budget let path = entry.path(); index.push(FileRow { path: path.clone(), + guest_path: None, name, depth: 0, is_dir, @@ -1555,6 +1760,122 @@ fn build_search_index(dir: &Path, depth: usize, index: &mut Vec, budget } } +fn wsl_root_key(located: &crate::shell_detect::WslCwd) -> PathBuf { + PathBuf::from(normalize_wsl_guest_path(&located.guest)) +} + +fn normalize_wsl_guest_path(path: &str) -> String { + let normalized = path.trim().replace('\\', "/"); + if normalized == "/" { + return normalized; + } + normalized.trim_end_matches('/').to_owned() +} + +fn wsl_guest_parent(path: &str) -> Option { + let normalized = normalize_wsl_guest_path(path); + if normalized == "/" { + return None; + } + let parent = normalized + .rsplit_once('/') + .map_or("/", |(parent, _)| if parent.is_empty() { "/" } else { parent }); + Some(parent.to_owned()) +} + +fn wsl_guest_join(parent: &str, name: &str) -> String { + if parent == "/" { + format!("/{name}") + } else { + format!("{}/{name}", parent.trim_end_matches('/')) + } +} + +/// `find` 输出为重复的 `type NUL value NUL`。只接受完整记录;来宾进程被 +/// 中断时尾部半条记录会被自然丢弃,不会生成指向错误位置的行。 +fn parse_wsl_find_pairs(output: &[u8]) -> Vec<(u8, String)> { + let fields: Vec<&[u8]> = output + .split_inclusive(|byte| *byte == 0) + .filter_map(|field| field.strip_suffix(&[0])) + .collect(); + + fields + .chunks_exact(2) + .filter_map(|pair| { + let kind = pair[0].first().copied()?; + Some((kind, String::from_utf8_lossy(pair[1]).into_owned())) + }) + .collect() +} + +fn run_wsl_find(distro: &str, args: impl IntoIterator) -> Option> { + let mut command = std::process::Command::new("wsl.exe"); + command.args(["-d", distro, "--", "find"]).args(args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let output = command.output().ok()?; + if !output.status.success() { + let reason = String::from_utf8_lossy(&output.stderr); + log::debug!("WSL file tree find failed in {distro}: {}", reason.trim()); + return None; + } + Some(output.stdout) +} + +fn wsl_read_dir(distro: &str, guest_dir: &str) -> Vec<(bool, String, String)> { + let args = [guest_dir, "-mindepth", "1", "-maxdepth", "1", "-printf", r"%y\0%f\0"] + .into_iter() + .map(OsString::from); + let Some(output) = run_wsl_find(distro, args) else { return Vec::new() }; + parse_wsl_find_pairs(&output) + .into_iter() + .filter_map(|(kind, name)| { + (name != ".git").then(|| { + let guest_path = wsl_guest_join(guest_dir, &name); + (kind == b'd', name, guest_path) + }) + }) + .collect() +} + +fn build_wsl_search_index( + located: &crate::shell_detect::WslCwd, + index: &mut Vec, + budget: &mut usize, +) { + let root = normalize_wsl_guest_path(&located.guest); + let mut args: Vec = + [root.as_str(), "-mindepth", "1", "-maxdepth", "8", "(", "-type", "d", "(", "-name", ".*"] + .into_iter() + .map(OsString::from) + .collect(); + for skipped in SEARCH_SKIP_DIRS.iter().filter(|name| !name.starts_with('.')) { + args.extend([OsString::from("-o"), OsString::from("-name"), OsString::from(skipped)]); + } + args.extend([")", "-prune", ")", "-o", "-printf", r"%y\0%p\0"].into_iter().map(OsString::from)); + let Some(output) = run_wsl_find(&located.distro, args) else { return }; + for (kind, guest_path) in parse_wsl_find_pairs(&output) { + if *budget == 0 || index.len() >= SEARCH_INDEX_CAP { + break; + } + *budget -= 1; + let name = guest_path.rsplit('/').next().unwrap_or(&guest_path).to_owned(); + index.push(FileRow { + path: PathBuf::from(&guest_path), + guest_path: Some(guest_path), + name, + depth: 0, + is_dir: kind == b'd', + expanded: false, + is_parent: false, + ignored: false, + }); + } +} + /// Snapshot git state for `root`: branch, ±line counts, changed files./// `None` when git is missing or `root` isn't inside a work tree. Runs /// synchronously — callers throttle (see [`SidePanel::sync`]). fn read_git(root: &Path) -> Option { @@ -1567,27 +1888,63 @@ fn read_git(root: &Path) -> Option { // the user is already browsing carries none of the write risks the global // opt-in guards against. let safe_directory = format!("safe.directory={}", root.display()); - let run = |args: &[&str]| -> Option { + let location = root.display().to_string(); + collect_git_info(|args| { let mut cmd = Command::new("git"); cmd.args(["-c", &safe_directory, "--no-optional-locks"]).args(args).current_dir(root); - // Suppress the console window that `Command` flashes on Windows GUI apps. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } - let out = cmd.output().ok()?; - if !out.status.success() { - // Leave a trace instead of a silent blank panel: the first stderr - // line names the actual refusal (ownership, not-a-repo, …). - let stderr = String::from_utf8_lossy(&out.stderr); - let reason = stderr.lines().next().unwrap_or("unknown error"); - log::debug!("git {:?} failed in {}: {reason}", args.first(), root.display()); - return None; - } - Some(String::from_utf8_lossy(&out.stdout).into_owned()) - }; + run_git(cmd, args, &location) + }) +} +/// 在 WSL 来宾里读 git 状态:`wsl.exe -d <发行版> -- git -C <来宾目录> …`。 +/// +/// # 为什么不走宿主 git +/// +/// 宿主 git 要读 WSL 仓库,只能经 `\\wsl.localhost\…` UNC,而那条路依赖 9P +/// 文件重定向——实测在 WSL 2.7.8 + Windows 22631 上完全不可达(见 +/// [`crate::shell_detect::wsl_unc_cwd`])。来宾里的 git 反而什么都不缺: +/// 输出格式与宿主 `git status --porcelain -b` 逐字相同,所以解析与宿主 +/// 路径完全共用 [`collect_git_info`],没有第二套解析要维护。 +/// +/// 也不需要 `safe.directory`:来宾里的仓库属于来宾用户自己,不会触发 +/// dubious ownership——那本来就是跨 UNC 读别人的文件才有的问题。 +/// +/// 代价是每次快照多一次 `wsl.exe` 进程往返(发行版没运行时还会把它拉起 +/// 来)。快照本来就在后台线程上、且有节流,不进渲染路径。 +fn read_git_wsl(located: &crate::shell_detect::WslCwd) -> Option { + use std::process::Command; + let location = format!("{}:{}", located.distro, located.guest); + collect_git_info(|args| { + let mut cmd = Command::new("wsl.exe"); + cmd.args(["-d", &located.distro, "--", "git", "-C", &located.guest, "--no-optional-locks"]) + .args(args); + run_git(cmd, args, &location) + }) +} + +/// 跑一条已配好的 git 命令,取 stdout。`location` 只用于失败日志。 +fn run_git(mut cmd: std::process::Command, args: &[&str], location: &str) -> Option { + // Suppress the console window that `Command` flashes on Windows GUI apps. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + let out = cmd.output().ok()?; + if !out.status.success() { + // Leave a trace instead of a silent blank panel: the first stderr + // line names the actual refusal (ownership, not-a-repo, …). + let stderr = String::from_utf8_lossy(&out.stderr); + let reason = stderr.lines().next().unwrap_or("unknown error"); + log::debug!("git {:?} failed in {location}: {reason}", args.first()); + return None; + } + Some(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// 宿主与 WSL 来宾两条运行路径共用的 porcelain 解析。`run` 收一组 git 参数、 +/// 回 stdout(失败即 `None`)。 +fn collect_git_info(run: impl Fn(&[&str]) -> Option) -> Option { // `-b --porcelain` yields `## branch...upstream [ahead N]` + one `XY path` // per change, X = index (staged) status, Y = worktree status. let status = run(&["status", "--porcelain", "-b"])?; @@ -2737,6 +3094,41 @@ mod tests { assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); } + #[test] + fn native_wsl_cwd_replaces_the_stale_host_root() { + let host = std::env::temp_dir(); + let mut panel = SidePanel::new(); + panel.toggle(PanelView::Files); + // 这里只验证状态切换,不让单测启动真实 WSL/文件快照工人。 + panel.snapshot_running.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(panel.sync(Some(host))); + + let located = crate::shell_detect::WslCwd { + distro: "Debian".to_owned(), + guest: "/home/hello".to_owned(), + }; + assert!(panel.sync_at(None, Some(located.clone()))); + assert_eq!(panel.root(), Some(Path::new("/home/hello"))); + assert_eq!(panel.file_wsl_root(), Some(&located)); + assert!(panel.followed_cwd.is_none(), "旧宿主根不能盖住来宾 cwd"); + } + + #[test] + fn wsl_find_records_preserve_newlines_and_drop_partial_tails() { + let parsed = parse_wsl_find_pairs(b"d\0hello\0f\0line\nname.txt\0f\0partial"); + assert_eq!(parsed, vec![(b'd', "hello".to_owned()), (b'f', "line\nname.txt".to_owned())]); + } + + #[test] + fn wsl_guest_path_helpers_keep_root_and_parent_boundaries() { + assert_eq!(normalize_wsl_guest_path("/home/hello/"), "/home/hello"); + assert_eq!(wsl_guest_parent("/home/hello"), Some("/home".to_owned())); + assert_eq!(wsl_guest_parent("/home"), Some("/".to_owned())); + assert_eq!(wsl_guest_parent("/"), None); + assert_eq!(wsl_guest_join("/", "home"), "/home"); + assert_eq!(wsl_guest_join("/home", "hello"), "/home/hello"); + } + /// The per-directory cap must be applied *after* ordering. Capping the /// `read_dir` iterator instead samples filesystem order, which in a /// dot-heavy repo root pushes the real source directories past the cap and @@ -2795,6 +3187,7 @@ mod tests { let mut rows = vec![ FileRow { path: PathBuf::from("src"), + guest_path: None, name: "src".into(), depth: 0, is_dir: true, @@ -2804,6 +3197,7 @@ mod tests { }, FileRow { path: PathBuf::from("target"), + guest_path: None, name: "target".into(), depth: 0, is_dir: true, diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 92cdb5e..1c51a2f 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -100,6 +100,14 @@ pub fn spawn( options.shell = shell; options.working_directory = cwd; options.env.insert(crate::ai_hook::PANE_ENV.to_owned(), pane_id.to_string()); + // WSL tab 的 cwd 上报(OSC 7):来宾登录 shell 默认不发,目录树与 Git 视图 + // 因此永远停在"等待终端上报工作目录"。只对 `wsl.exe` 启动生效,见 + // [`crate::shell_detect::wsl_cwd_report_env`]。 + if let Some(launch) = &options.shell { + let program = launch.program().to_owned(); + let args = launch.args().to_vec(); + options.env.extend(crate::shell_detect::wsl_cwd_report_env(&program, &args)); + } tty::setup_env(); let pty = tty::new(&options, window_size, 0)?; let shell_pid = pty.child_pid().unwrap_or(0); diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index 157448b..6e4d5df 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -1,9 +1,8 @@ //! GPUI 壳的窗口视效:背景模糊 / 窗口透明度 / 壁纸。 //! -//! 语义全部对齐旧壳: -//! - 模糊 = DWM 系统 backdrop(`DWMSBT_TRANSIENTWINDOW`,旧壳 -//! `display::window::apply_windows_backdrop` 同款调用;< 22621 的系统该 -//! 调用无效,自动回落纯 alpha 透明)。 +//! 语义全部对齐旧壳,但**模糊的实现手段不能照抄旧壳**(见 +//! [`background_appearance`]):一律走 GPUI 自己的 +//! [`WindowBackgroundAppearance`],由平台层落到各自的原生 API。 //! - 透明度 = 壳底色与终端默认背景的 alpha(文字与彩色单元背景保持不 //! 透明,对比度不塌——旧壳 `draw_window_backdrop` 裁定)。 //! - 壁纸 = 底色之上、单元格之下的一层图(旧壳 `renderer::image` 的 @@ -74,7 +73,6 @@ struct CardWallpaper { /// 随后把窗口层效果(背景外观 + DWM backdrop)应用到所有已开窗口。 pub fn refresh(cx: &mut App) { let rt = nebula_settings::RuntimeSettings::load(); - let previous_blur = cx.try_global::().map(|effects| effects.blur); let wallpaper = rt.background_image.as_ref().and_then(|raw_path| { let path = PathBuf::from(raw_path); @@ -132,7 +130,7 @@ pub fn refresh(cx: &mut App) { }); cx.set_global(VisualEffects { opacity: rt.opacity, blur: rt.blur, wallpaper }); - apply_window_effects(previous_blur, cx); + apply_window_effects(cx); } /// 当前窗口透明度(无全局时视为不透明)。 @@ -141,89 +139,130 @@ pub fn window_opacity(cx: &App) -> f32 { cx.try_global::().map(|v| v.opacity).unwrap_or(1.0) } -/// 壳/卡要透出亚克力或铺满壁纸时,不能停在用户滑块的 1.0: -/// 旧壳注释写的是「半透明壳浮在壁纸上」,但滑块默认 100%,实心壳会把 -/// 效果全部盖掉,开关看起来像坏了。模糊开着时顶到 0.88,铺满 chrome -/// 再顶到 0.78,文字仍可读。 +/// 壳/卡透明度严格跟随用户滑块。模糊是独立的窗口背景外观属性,不能反向 +/// 篡改透明度;否则打开模糊会把用户设置的 100% 偷改成 88%,与旧壳语义 +/// 不一致。铺满 chrome 的壁纸仍沿用自己的可见性上限。 +/// +/// 推论:不透明度 100% 时开模糊在画面上看不出变化——模糊的是窗口**后方** +/// 的内容,被完全不透明的像素挡住了。这是两个开关正交的必然结果,不是 bug。 pub fn chrome_surface_opacity(cx: &App) -> f32 { let Some(effects) = cx.try_global::() else { return 1.0; }; let mut alpha = effects.opacity.clamp(0.0, 1.0); - if effects.blur { - alpha = alpha.min(0.88); - } if effects.wallpaper.as_ref().is_some_and(|wp| wp.cover_chrome) { alpha = alpha.min(0.78); } alpha } -/// 开窗参数用。Windows 上必须一开始就声明最终外观:GPUI 创建后再改 -/// appearance,合成器往往不补 alpha 通道,后开的模糊/铺满壁纸会失效。 +/// 开窗参数用。GPUI 通用层在窗口创建时就会把这个值下发到平台层 +/// (`gpui::Window::new` → `platform_window.set_background_appearance`), +/// 所以启动即模糊不需要等 [`refresh`] 补第二次。 pub fn initial_background_appearance() -> WindowBackgroundAppearance { background_appearance(nebula_settings::RuntimeSettings::load().blur) } -/// `blur` 开关 → GPUI 窗口外观。 +/// 模糊开关 → 窗口背景外观。**唯一落笔点**,启动与热应用共用。 /// -/// **这里是模糊此前完全无效的根因。** GPUI 的 Windows 后端不走 DWM 系统 -/// backdrop,而是未文档化的 `SetWindowCompositionAttribute` ACCENT_POLICY -/// (fork 内 `platform/windows/window.rs::set_background_appearance`): +/// # 为什么不能照抄旧壳的 DWM system backdrop /// -/// | appearance | accent state | 实际效果 | -/// | ------------- | --------------------------------- | ---------------- | -/// | `Opaque` | ACCENT_DISABLED | 不透明 | -/// | `Transparent` | ACCENT_ENABLE_TRANSPARENTGRADIENT | **只透明,不模糊** | -/// | `Blurred` | ACCENT_ENABLE_ACRYLICBLURBEHIND | 真正的亚克力模糊 | +/// 旧壳(winit)用 `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE, +/// DWMSBT_TRANSIENTWINDOW)` 三行就拿到 Acrylic。同一段代码搬到 GPUI 壳上 +/// 调用成功、画面零变化——**因为窗口的合成结构不同**:GPUI 只要没禁用 +/// DirectComposition 就给窗口加 `WS_EX_NOREDIRECTIONBITMAP` +/// (`gpui/src/platform/windows/window.rs`),DWM 因此不为它创建重定向 +/// 表面,system backdrop 材质没有挂载点,`DwmSetWindowAttribute` 返回 +/// `S_OK` 却什么都不画。旧壳是普通重定向窗口,才没这个问题。 /// -/// accent policy 一旦生效就接管窗口合成,旧壳那三行 -/// `DWMWA_SYSTEMBACKDROP_TYPE` 会被它压掉。此前本模块无论开关都恒设 -/// `Transparent`,于是用户拉低透明度只看到「透出了桌面但没有一点模糊」 -/// —— 正是 TRANSPARENTGRADIENT 的行为,不是设置没生效。 +/// 上游 Zed 走过同一条弯路:PR #41842 第一版把 `Blurred` 改成 +/// `DWMSBT_TRANSIENTWINDOW`,第二版又改回 `SetWindowCompositionAttribute`, +/// 只把 Mica / MicaAlt 留给 DWM backdrop。所以这里直接用 GPUI 的 +/// [`WindowBackgroundAppearance::Blurred`]——Windows 平台层落到 +/// `ACCENT_ENABLE_ACRYLICBLURBEHIND`(且已处理 tint alpha 为 0 时 DWM 跳过 +/// 模糊的坑),macOS / Linux 各自落到原生毛玻璃。一处开关,三平台生效, +/// 不必碰 fork 也不必自写 FFI。 +/// +/// # 关闭时为什么是 `Opaque` 而不是 `Transparent` +/// +/// Windows 上窗口透明度完全由我们绘制的像素 alpha 决定(DirectComposition +/// swapchain 固定预乘 alpha),`ACCENT_DISABLED` 下 0% 不透明度实测就能透 +/// 出后方内容。`Transparent` 会额外挂 `ACCENT_ENABLE_TRANSPARENTGRADIENT` +/// 的纯色渐变层,对我们没有收益。其他平台维持原有 `Transparent` 语义。 fn background_appearance(blur: bool) -> WindowBackgroundAppearance { if blur { - WindowBackgroundAppearance::Blurred - } else { + return WindowBackgroundAppearance::Blurred; + } + #[cfg(windows)] + { + WindowBackgroundAppearance::Opaque + } + #[cfg(not(windows))] + { WindowBackgroundAppearance::Transparent } } -/// 把窗口层效果应用到所有窗口。 -/// -/// 模糊只走 accent policy 一条路。**不再叠加 DWM `DWMWA_SYSTEMBACKDROP_TYPE`**: -/// 旧壳靠它拿 acrylic,但 GPUI 的 accent policy 会接管合成,两套同时写只会 -/// 让 DWM 的内部状态含混——实测表现是模糊打开正常、关闭却关不掉。 -/// -/// 关闭时不能在同一个 DWM 合成帧里连续写 `Opaque` 和 `Transparent`:Win32 -/// 调用虽然都已同步执行,合成器仍可能只呈现最后一个状态,让旧 acrylic 层 -/// 留到下一次窗口变化。先让 `ACCENT_DISABLED` 真正呈现一帧,再恢复纯透明。 -fn apply_window_effects(previous_blur: Option, cx: &mut App) { +/// 把窗口层效果应用到所有窗口。透明度完全由绘制像素 alpha 控制,因此模糊 +/// 开关与 0%..100% 透明度互不绑死、无需跨帧时序补丁。 +fn apply_window_effects(cx: &mut App) { let blur = cx.try_global::().map(|v| v.blur).unwrap_or(false); let appearance = background_appearance(blur); for handle in cx.windows() { let _ = handle.update(cx, |_, window, _| { - if previous_blur == Some(true) && !blur { - window.set_background_appearance(WindowBackgroundAppearance::Opaque); - window.refresh(); - window.on_next_frame(|window, cx| { - // 回调执行前用户可能又打开了模糊;以最新全局值收敛,避免 - // 旧的关闭回调在快速连点后反向覆盖新设置。 - let latest_blur = cx - .try_global::() - .map(|effects| effects.blur) - .unwrap_or(false); - window.set_background_appearance(background_appearance(latest_blur)); - window.refresh(); - }); - } else { - window.set_background_appearance(appearance); - window.refresh(); + window.set_background_appearance(appearance); + #[cfg(windows)] + if !blur { + clear_windows_system_backdrop(window); } + window.refresh(); }); } } +/// 清掉 Windows system-backdrop 层可能残留的材质。 +/// +/// GPUI 的 [`WindowBackgroundAppearance::Opaque`] 已经通过 AccentPolicy 写入 +/// `ACCENT_DISABLED`,它仍是主关闭入口。旧壳还会用官方 +/// `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE, DWMSBT_NONE)` 清 system +/// backdrop;实机证明只发 `SWP_FRAMECHANGED` 不会清属性,所以 GPUI 壳在关闭 +/// 时也补同一条清理。开启仍只走 GPUI `Blurred`,不叠加第二套材质。 +/// +/// 微软文档对 `DWMSBT_NONE` 的合同是 “Don't draw any system backdrop”。失败 +/// 必须留日志,否则视觉残留会再次被误判成“DWM 没重绘”。 +#[cfg(windows)] +fn clear_windows_system_backdrop(window: &Window) { + use windows_sys::Win32::Graphics::Dwm::{ + DWMSBT_NONE, DWMWA_SYSTEMBACKDROP_TYPE, DwmSetWindowAttribute, + }; + use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::Win32(handle) = handle.as_raw() else { + return; + }; + let hwnd = handle.hwnd.get() as *mut core::ffi::c_void; + let backdrop: i32 = DWMSBT_NONE; + // SAFETY: hwnd 来自当前存活的 GPUI 窗口;值与长度严格匹配 DWM 的 + // `DWM_SYSTEMBACKDROP_TYPE`(i32)合同。无效句柄由 HRESULT 安全报告。 + let result = unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_SYSTEMBACKDROP_TYPE as u32, + &backdrop as *const _ as *const core::ffi::c_void, + std::mem::size_of::() as u32, + ) + }; + if result < 0 { + log::warn!( + "DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE=NONE) failed: HRESULT=0x{:08X}", + result as u32 + ); + } +} + /// 解码壁纸:RGBA8 → 把图片透明度烘进 alpha → BGRA(gpui 图像帧的 /// 通道序,zed 的图片资产装载器同款处理)。 fn decode( diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index e35851e..7b75396 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -2027,11 +2027,51 @@ impl NebulaWorkspace { window.defer(cx, move |window, _| window.focus(&focus)); } + /// 聚焦 tab 的**宿主可见** cwd。WSL tab 报的是来宾路径(`/home/x`、 + /// `/mnt/d/x`),在宿主侧不是有效目录,此时交给 + /// [`crate::shell_detect::wsl_host_cwd`] 翻译:`/mnt/<盘>` 直接落到宿主盘 + /// (永远可达),来宾自有路径退一步试 `\\wsl.localhost\…`(9P 不可用的机器 + /// 上会失败,抽屉按既有语义保持上一个已知目录)。旧壳 + /// `focused_cwd().or_else(focused_wsl_cwd)` 同合同,判定共用一份。 + /// + /// Git 视图不受这里成败影响——它另有来宾直读路径,见 [`Self::active_wsl_cwd`]。 fn active_local_cwd(&self, cx: &App) -> Option { - self.tabs - .get(self.active) - .and_then(WorkspaceTab::focused_view) - .and_then(|view| view.read(cx).local_cwd()) + let view = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view)?; + if let Some(cwd) = view.read(cx).local_cwd() { + return Some(cwd); + } + let located = self.active_wsl_cwd(cx)?; + crate::shell_detect::wsl_host_cwd(&located) + } + + /// 聚焦 tab 所在的 WSL 发行版 + 来宾目录;不是 WSL、或发行版无从确定 + /// (裸 `wsl` 启动)时为 `None`。Git 视图拿它在来宾里直接跑 git,不经 + /// 任何 UNC 映射,所以宿主看不见 WSL 文件系统时依然有效。 + fn active_wsl_cwd(&self, cx: &App) -> Option { + let view = self.tabs.get(self.active).and_then(WorkspaceTab::focused_view)?; + let raw = view.read(cx).cwd.clone(); + let Some(crate::session::LaunchSession::Shell { program, args, .. }) = + self.meta(self.active).launch + else { + return None; + }; + crate::shell_detect::wsl_cwd(&raw, &program, &args) + } + + /// 抽屉这一帧该跟随的位置:宿主 cwd,加上**仅当**宿主侧拿不到等价路径时 + /// 才交出的 WSL 位置。 + /// + /// 为什么要这个门:`/mnt/<盘>/…` 已经被 [`Self::active_local_cwd`] 落回宿 + /// 主盘,此时宿主 git 与来宾 git 读的是同一个工作树,而宿主 git 快得多 + /// (来宾路径每次快照都要起一个 `wsl.exe`,`/mnt` 上的 git 本身也慢)。 + /// 只有 `/home/…` 这类来宾自有路径宿主根本读不到,才值得走来宾。 + fn side_panel_follow( + &self, + cx: &App, + ) -> (Option, Option) { + let cwd = self.active_local_cwd(cx); + let wsl = if cwd.is_some() { None } else { self.active_wsl_cwd(cx) }; + (cwd, wsl) } fn toggle_side_panel( @@ -2047,8 +2087,8 @@ impl NebulaWorkspace { return; } - let cwd = self.active_local_cwd(cx); - self.side_panel.sync(cwd); + let (cwd, wsl) = self.side_panel_follow(cx); + self.side_panel.sync_at(cwd, wsl); // The shared model builds snapshots on a worker and exposes a cheap, // throttled `sync`. GPUI polls only while the drawer is open; it does @@ -2065,8 +2105,8 @@ impl NebulaWorkspace { workspace.side_panel_polling = false; return false; } - let cwd = workspace.active_local_cwd(cx); - if workspace.side_panel.sync(cwd) { + let (cwd, wsl) = workspace.side_panel_follow(cx); + if workspace.side_panel.sync_at(cwd, wsl) { cx.notify(); } true @@ -2719,8 +2759,8 @@ impl NebulaWorkspace { } self.file_tree_menu = None; self.side_panel.toggle(view); - let cwd = self.active_local_cwd(cx); - self.side_panel.sync(cwd); + let (cwd, wsl) = self.side_panel_follow(cx); + self.side_panel.sync_at(cwd, wsl); cx.notify(); } @@ -3328,8 +3368,8 @@ impl NebulaWorkspace { .tooltip(format!("刷新 {vcs_label} 状态")) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.request_refresh(); - let cwd = this.active_local_cwd(cx); - this.side_panel.sync(cwd); + let (cwd, wsl) = this.side_panel_follow(cx); + this.side_panel.sync_at(cwd, wsl); cx.notify(); })), ) @@ -3374,6 +3414,16 @@ impl NebulaWorkspace { .into_any_element() } + /// 复制标签页:另开一个**同身份**的 Tab,不克隆活动 PTY 也不克隆分屏树 + /// (旧壳 `window_context::duplicate_tab` 同合同——副本是新进程/新会话, + /// 与 Windows Terminal 一致,也避开 PTY 共享所有权)。 + /// + /// 关键是继承 launch 身份。WSL / Git Bash / Nushell 这类 Tab 的 + /// program+args 记在 [`TabMeta::launch`],而此前这里一律走 + /// `add_terminal_at`——那是"现取设置里的默认 shell",于是复制一个 + /// WSL Tab 会得到 pwsh。`Default`(含旧快照没记身份的 `None`)仍按 + /// "复制这一刻"的默认 shell 解析,与旧壳 `spawn_tab_at` 一致;用户 + /// 元数据(自定义名字 / 色标)照旧壳一并带到副本上。 fn duplicate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(tab) = self.tabs.get(ix) else { return }; let Some(view) = tab.focused_view() else { return }; @@ -3381,11 +3431,24 @@ impl NebulaWorkspace { let view = view.read(cx); (view.ssh_destination.clone(), view.local_cwd()) }; + let meta = self.meta(ix); if let Some(destination) = ssh { self.add_ssh_terminal(destination, window, cx); } else { - self.add_terminal_at(cwd, None, window, cx); + let launch = match meta.launch { + None | Some(crate::session::LaunchSession::Default) => { + Self::configured_local_launch(cx) + }, + Some(launch) => launch, + }; + self.add_terminal_with(launch, cwd, None, window, cx); } + // 两个 add_* 入口都已把副本设为 active,名字与色标在这里覆写。 + if let Some(target) = self.tab_meta.get_mut(self.active) { + target.custom_name = meta.custom_name; + target.color = meta.color; + } + cx.notify(); } /// 进入行内重命名:对照旧壳 `TabRequest::BeginRename` diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 22908a3..eb6b145 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -51,6 +51,8 @@ impl NebulaWorkspace { let path = row.path.clone(); let open_path = path.clone(); let menu_path = path.clone(); + let guest_path = row.guest_path.clone(); + let menu_guest_path = guest_path.clone(); let is_dir = row.is_dir; let is_parent = row.is_parent; let selected = selected_path.as_ref() == Some(&path); @@ -130,7 +132,7 @@ impl NebulaWorkspace { cx.notify(); } })) - .when(!is_dir && !is_parent, |item| { + .when(!is_dir && !is_parent && guest_path.is_none(), |item| { item.on_double_click(cx.listener(move |this, _, window, cx| { // 与旧壳 chrome 同合同:应用内能读的(图片/可读文本) // 开查看 tab,其余交系统处理器。 @@ -147,6 +149,7 @@ impl NebulaWorkspace { cx.stop_propagation(); this.open_file_tree_context_menu( menu_path.clone(), + menu_guest_path.clone(), is_dir, event.position, window, @@ -199,8 +202,8 @@ impl NebulaWorkspace { .tooltip("刷新目录树") .on_click(cx.listener(|this, _, _, cx| { this.side_panel.request_refresh(); - let cwd = this.active_local_cwd(cx); - this.side_panel.sync(cwd); + let (cwd, wsl) = this.side_panel_follow(cx); + this.side_panel.sync_at(cwd, wsl); cx.notify(); })), ) @@ -231,6 +234,7 @@ impl NebulaWorkspace { fn open_file_tree_context_menu( &mut self, path: PathBuf, + guest_path: Option, is_dir: bool, position: Point, window: &mut Window, @@ -239,7 +243,14 @@ impl NebulaWorkspace { self.side_panel.selected = Some(path.clone()); let workspace = cx.entity().downgrade(); let menu = PopupMenu::build(window, cx, move |menu, window, _cx| { - file_tree_popup_menu(menu.external_link_icon(false), workspace, path, is_dir, window) + file_tree_popup_menu( + menu.external_link_icon(false), + workspace, + path, + guest_path, + is_dir, + window, + ) }); menu.focus_handle(cx).focus(window); let subscription = cx.subscribe_in(&menu, window, |this, _, _: &DismissEvent, _, cx| { @@ -325,9 +336,17 @@ fn file_tree_popup_menu( menu: PopupMenu, workspace: gpui::WeakEntity, path: PathBuf, + guest_path: Option, is_dir: bool, _window: &mut Window, ) -> PopupMenu { + if let Some(guest_path) = guest_path { + return menu.item(PopupMenuItem::new("复制 Linux 路径").icon(IconName::Copy).on_click( + move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(guest_path.clone())); + }, + )); + } let first = if is_dir { let open_here = workspace.clone(); let dir = path.clone(); diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 34043e9..b397d7b 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -426,6 +426,145 @@ pub fn shell_short_tag(name_or_id: &str) -> String { lower.split_whitespace().next().unwrap_or("").chars().take(10).collect() } +/// 一次 WSL 启动用的发行版名:只认 `-d` / `--distribution` 显式给出的那个。 +/// +/// 裸 `wsl` 启动跑的是系统默认发行版,名字我们无从得知——宁可返回 `None` +/// 让调用方保持现状,也不猜一个可能错的发行版去拼路径。非 WSL 程序同样 +/// 返回 `None`(按 `file_stem` 判断,`wsl.exe` / 全路径都认)。 +/// +/// 旧壳 `window_context::focused_wsl_cwd` 的判定原样固化在这里,两壳共用。 +pub fn wsl_launch_distro<'a>(program: &str, args: &'a [String]) -> Option<&'a str> { + let stem = std::path::Path::new(program) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if stem != "wsl" { + return None; + } + let index = args.iter().position(|arg| arg == "-d" || arg == "--distribution")?; + args.get(index + 1).map(String::as_str).filter(|distro| !distro.is_empty()) +} + +/// 终端报的 cwd 是来宾侧的绝对路径吗(`/home/x`)。宿主路径(`D:\…`)不需要 +/// 映射,交给普通路径分支即可。 +pub fn wsl_guest_cwd(cwd: &str) -> Option<&str> { + let trimmed = cwd.trim(); + trimmed.starts_with('/').then_some(trimmed) +} + +/// 来宾绝对路径 → `\\wsl.localhost\<发行版>\…` 形式的宿主 UNC 路径(纯拼接, +/// 不碰文件系统,便于单测)。 +/// +/// 用 `\\wsl.localhost\` 而非旧壳的 `\\wsl$\`:后者是 WSL 早期形式,新版 +/// Windows 只保留 `wsl.localhost` 作为正式名(`wsl_distro_names` 的注释里 +/// 早就写明目录选择器钉的是 `\\wsl.localhost\<名>`)。两种形式在不支持 +/// 9P 重定向的机器上都不可达,见 [`wsl_unc_cwd`]。 +pub fn wsl_unc_path(distro: &str, guest_path: &str) -> std::path::PathBuf { + std::path::PathBuf::from(format!( + "\\\\wsl.localhost\\{distro}{}", + guest_path.replace('/', "\\") + )) +} + +/// 一个 WSL 终端的位置:发行版名 + 来宾绝对路径。 +/// +/// 即使宿主看不见来宾文件系统(9P 重定向不可用,见 [`wsl_unc_cwd`])这个位置 +/// 依然成立——拿着它可以用 `wsl.exe -d <发行版> -- <命令>` 直接在来宾里干活。 +/// Git 视图识别 WSL 仓库靠的就是它,不依赖任何 UNC 映射。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WslCwd { + pub distro: String, + pub guest: String, +} + +/// 认出「聚焦终端属于某个 WSL 发行版、且正停在来宾的某个目录」。 +/// +/// 刻意**不**验证目录存在:宿主无法可靠地看到来宾文件系统,能验证的地方只有 +/// 来宾里——而那正是调用方紧接着要跑的那条命令本身。 +pub fn wsl_cwd(cwd: &str, program: &str, args: &[String]) -> Option { + let guest = wsl_guest_cwd(cwd)?; + let distro = wsl_launch_distro(program, args)?; + Some(WslCwd { distro: distro.to_owned(), guest: guest.to_owned() }) +} + +/// [`WslCwd`] → 宿主可见的 UNC 路径,**且确认它真的可达**。两壳的抽屉都经这 +/// 一个入口,`is_dir` 把关只写一遍。 +/// +/// # 为什么必须 `is_dir()` 把关 +/// +/// `\\wsl.localhost\…` 依赖 WSL 的 9P 文件重定向(P9NP 网络提供程序)。 +/// 发行版没启动、或这台机器的 9P 重定向根本不工作时,UNC 路径不可达—— +/// 2026-08-20 实测:WSL 2.7.8 + Windows 22631 上 `\\wsl$\` 与 +/// `\\wsl.localhost\` 两种形式经 cmd / PowerShell / .NET 三条路都是"系统 +/// 找不到指定的路径",而发行版 `wsl -l -v` 明确 Running、P9NP 也在 +/// `ProviderOrder` 里、进程未提升(排除 UAC 会话隔离)。这种环境下本函数 +/// 一律返回 `None`,调用方必须能接受「拿不到宿主路径」并另寻出路(拿 +/// [`WslCwd`] 在来宾里直接跑命令),不能假定映射一定成功。 +pub fn wsl_unc_cwd(located: &WslCwd) -> Option { + let path = wsl_unc_path(&located.distro, &located.guest); + path.is_dir().then_some(path) +} + +/// 让 WSL 来宾在每个提示符前上报 cwd(OSC 7)所需的环境变量;非 WSL 启动返回空。 +/// +/// # 为什么只能走环境变量 +/// +/// `wsl.exe` 必须原样启动来宾的登录 shell(`chsh` 语义,见 +/// [`ShellIntegration::WslDefault`]——追加 `--exec bash` 是 1.1.0 的实际回归), +/// 所以既不能改启动参数、也没有宿主侧的 rc 文件可注入。可行的只剩环境变量: +/// bash 每画一次提示符都会执行 `$PROMPT_COMMAND`,而 `WSLENV` 会把点名的宿主 +/// 变量原样送进来宾。 +/// +/// 少了这条,WSL tab 永远停在"等待终端上报工作目录"——目录树与 Git 视图都是 +/// 靠 shell 上报的 cwd 驱动的(`TermEvent::CwdReport`),而 Debian/Ubuntu 的 +/// 默认 `.bashrc` 只设 OSC 0 窗口标题,不发 OSC 7(实测 `PROMPT_COMMAND` 为空)。 +/// +/// 尽力而为、失败无害:来宾默认 shell 是 zsh/fish(不认 `PROMPT_COMMAND`), +/// 或用户自己在 rc 里直接赋值把它覆盖掉,都只是回到"不上报"的现状,不会影响 +/// shell 正常启动。 +pub fn wsl_cwd_report_env(program: &str, args: &[String]) -> Vec<(String, String)> { + if wsl_launch_distro(program, args).is_none() { + return Vec::new(); + } + // OSC 7 = `ESC ] 7 ; file:// BEL`。host 会被解析端丢掉(取第一个 + // `/` 之后的部分),给个兜底值就够。用 `$PWD` 而不是 `$(pwd)`:每个提示符 + // 都要跑一次,不能带 fork 开销。 + const REPORT: &str = r#"printf '\033]7;file://%s%s\007' "${HOSTNAME:-wsl}" "$PWD""#; + // 宿主侧可能已经有 WSLENV(别的工具设的),必须追加而不是覆盖。 + let mut wslenv = std::env::var("WSLENV").unwrap_or_default(); + if !wslenv.is_empty() && !wslenv.split(':').any(|entry| entry == "PROMPT_COMMAND") { + wslenv.push(':'); + } + if !wslenv.split(':').any(|entry| entry == "PROMPT_COMMAND") { + wslenv.push_str("PROMPT_COMMAND"); + } + vec![("PROMPT_COMMAND".to_owned(), REPORT.to_owned()), ("WSLENV".to_owned(), wslenv)] +} + +/// [`WslCwd`] → 宿主可见且**已确认可达**的路径,按两条路依次尝试。 +/// +/// 1. `/mnt/<盘>/…`(automount——WSL 里访问宿主盘的常规形态,也是绝大多数人 +/// 实际工作的位置)本来就是宿主路径 `<盘>:\…`。这条不经任何网络重定向, +/// 永远可达,所以目录树在 `/mnt/d/...` 下能真正跟随 WSL 终端。 +/// 2. 来宾自有路径(`/home/…`)只能走 `\\wsl.localhost\…`,见 [`wsl_unc_cwd`] +/// ——9P 重定向不可用的机器上会失败并返回 `None`,调用方按既有语义保持 +/// 上一个已知目录。 +/// +/// 两条都失败也不影响 Git 视图:那条路走 [`WslCwd`] 在来宾里直接跑 git。 +pub fn wsl_host_cwd(located: &WslCwd) -> Option { + // `/mnt/d/x` → `file:///mnt/d/x` → `D:\x`,复用 OSC 8 链接那套 automount / + // MSYS / cygwin 路径翻译(含"盘符真的存在"的门控),不另写一份。 + #[cfg(windows)] + if let Some(path) = + crate::file_uri::file_uri_to_local_path(&format!("file://{}", located.guest)) + && path.is_dir() + { + return Some(path); + } + wsl_unc_cwd(located) +} + /// 已注册 WSL 发行版的名字(注册表 `Lxss` 各子键的 `DistributionName`), /// 字母序。目录选择器用它把 `\\wsl.localhost\<名>` 钉进侧栏——系统的 /// 「Linux」导航节点不是每台 Win11 都有(issue #12 的现场就没有),钉进 @@ -506,6 +645,54 @@ fn find_wsl_distros() -> Vec { #[cfg(test)] mod tests { + /// WSL 位置识别:发行版只认显式 `-d` / `--distribution`。裸 `wsl` 用的是 + /// 系统默认发行版,名字无从得知——必须放弃而不是猜,否则会拼出一条指向 + /// 错误发行版的路径。 + #[test] + fn wsl_location_only_trusts_an_explicit_distribution() { + let owned = + |args: &[&str]| -> Vec { args.iter().map(|arg| (*arg).to_owned()).collect() }; + + for flag in ["-d", "--distribution"] { + let args = owned(&[flag, "Debian"]); + assert_eq!(super::wsl_launch_distro("wsl.exe", &args), Some("Debian")); + let located = super::wsl_cwd("/home/hello", r"C:\Windows\System32\wsl.exe", &args) + .expect("WSL 位置"); + assert_eq!(located.distro, "Debian"); + assert_eq!(located.guest, "/home/hello"); + } + + // 裸 `wsl`、缺发行版名、以及非 WSL 程序都不识别。 + assert_eq!(super::wsl_launch_distro("wsl.exe", &[]), None); + assert_eq!(super::wsl_launch_distro("wsl.exe", &owned(&["-d"])), None); + assert_eq!(super::wsl_launch_distro("wsl.exe", &owned(&["-d", ""])), None); + assert_eq!(super::wsl_launch_distro("pwsh.exe", &owned(&["-d", "Debian"])), None); + } + + /// 只有来宾绝对路径需要映射;宿主路径与空 cwd 走普通分支。 + #[test] + fn wsl_guest_cwd_accepts_only_absolute_guest_paths() { + assert_eq!(super::wsl_guest_cwd(" /home/hello "), Some("/home/hello")); + assert_eq!(super::wsl_guest_cwd("/"), Some("/")); + assert_eq!(super::wsl_guest_cwd(r"D:\temp_build"), None); + assert_eq!(super::wsl_guest_cwd(""), None); + assert_eq!(super::wsl_guest_cwd("home/hello"), None); + } + + /// UNC 形式必须是 `\\wsl.localhost\<发行版>\…`:旧壳原来拼的 `\\wsl$\` 是 + /// WSL 早期形式,新版 Windows 只保证 `wsl.localhost` 这个名字。 + #[test] + fn wsl_unc_path_uses_the_localhost_form_with_backslashes() { + assert_eq!( + super::wsl_unc_path("Debian", "/home/hello/src"), + std::path::PathBuf::from(r"\\wsl.localhost\Debian\home\hello\src") + ); + assert_eq!( + super::wsl_unc_path("Ubuntu-24.04", "/"), + std::path::PathBuf::from(r"\\wsl.localhost\Ubuntu-24.04\") + ); + } + fn detected(id: &str, program: &str, args: &[&str]) -> super::DetectedShell { super::DetectedShell { name: id.to_owned(), diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index e643aeb..c4fcf23 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2065,41 +2065,26 @@ impl WindowContext { session::valid_dir(&cwd) } - /// The focused pane's cwd mapped through `\\wsl$` when the pane belongs - /// to a WSL tab reporting a Linux path — so the directory tree can follow - /// a WSL shell. Only the drawer uses this: spawning terminals in a UNC - /// directory has its own semantics and is deliberately not affected. + /// The focused pane's cwd mapped through `\\wsl.localhost` when the pane + /// belongs to a WSL tab reporting a Linux path — so the directory tree can + /// follow a WSL shell. Only the drawer uses this: spawning terminals in a + /// UNC directory has its own semantics and is deliberately not affected. + /// + /// 判定与拼接走 [`crate::shell_detect`] 的公共入口,与 GPUI 壳同一份 + /// 规则。注意它在 9P 重定向不可用的机器上一律返回 `None`(见 + /// [`crate::shell_detect::wsl_unc_cwd`]),届时目录树保持上一个已知根, + /// 不会跳到坏路径。GPUI 壳另有「在来宾里直接跑 git」的 Git 视图路径; + /// 旧壳刻意不接那条链——新功能只在主壳(GPUI)上长。 fn focused_wsl_cwd(&self) -> Option { let raw = self.pane(self.focused_pane_id()).map(|p| p.nebula_state.cwd.clone())?; - let raw = raw.trim(); - if !raw.starts_with('/') { - return None; - } let tab = self.tabs.iter().find(|tab| { let mut ids = Vec::new(); tab.layout.leaves(&mut ids); ids.contains(&self.focused_pane_id()) })?; let TabLaunch::Shell { shell, .. } = &tab.launch else { return None }; - let program = std::path::Path::new(shell.program()) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - if program != "wsl" { - return None; - } - // `wsl -d ` / `--distribution `; a bare `wsl` launch - // uses the default distro whose name we cannot know — skip mapping - // and let the tree keep its last known root. - let args = shell.args(); - let distro = args - .iter() - .position(|arg| arg == "-d" || arg == "--distribution") - .and_then(|index| args.get(index + 1))?; - let unc = format!("\\\\wsl$\\{distro}{}", raw.replace('/', "\\")); - let path = std::path::PathBuf::from(unc); - path.is_dir().then_some(path) + let located = crate::shell_detect::wsl_cwd(&raw, shell.program(), shell.args())?; + crate::shell_detect::wsl_unc_cwd(&located) } /// Name of the first busy program under any of `pane_ids`, for the close