fix: improve GPUI blur and WSL integration

This commit is contained in:
kuddev committed 2026-08-20 23:07:14 +08:00
1 parent cb2a8e0256
commit 10be219f3e
8 files changed
+841 -143

No files matched your search

+4 -1
View File
@@ -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/<drive>` and native
Linux repositories, and the blur change requires a live Windows DWM toggle
check in both directions.
+436 -42
View File
@@ -30,6 +30,9 @@ pub enum PanelView {
#[derive(Debug, Clone)]
pub struct FileRow {
pub path: PathBuf,
/// WSL 来宾中的真实绝对路径。`path` 仍作为现有选择/展开状态的稳定键,
/// 但绝不能把它交给宿主文件 API;来宾操作必须显式使用这一字段。
pub guest_path: Option<String>,
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<PathBuf>,
/// 聚焦终端是 WSL 且停在来宾目录时的位置。宿主未必看得见 WSL 文件系统
/// (9P 重定向不一定可用),所以这不是"另一种 cwd"——它是让 Git 视图
/// 改用「在来宾里跑 git」的开关,与 [`Self::followed_cwd`] 各管一段。
followed_wsl: Option<crate::shell_detect::WslCwd>,
/// Window-local override selected from the Files view.
custom_root: Option<PathBuf>,
/// WSL guest tree 的窗口内浏览根(例如点 `..` 后得到的 `/home`)。与
/// `custom_root` 互斥,且不改变终端实际 cwd / Git 跟随位置。
custom_wsl_root: Option<crate::shell_detect::WslCwd>,
/// Visible feedback for an invalid/disappeared custom root.
root_notice: Option<String>,
/// 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<crate::shell_detect::WslCwd>,
rows: Vec<FileRow>,
git: Option<GitInfo>,
}
@@ -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<PathBuf>) -> 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<PathBuf>,
wsl: Option<crate::shell_detect::WslCwd>,
) -> 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<PathBuf> {
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<String>) {
self.spawn_vcs("git", args.into_iter().map(OsString::from).collect());
if let Some(located) = self.followed_wsl.clone() {
let mut guest_args: Vec<OsString> = [
"-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<PathBuf>,
) -> Vec<FileRow> {
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<FileRow>,
expanded: &HashSet<PathBuf>,
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<FileRow>,
expanded: &HashSet<PathBuf>,
@@ -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<FileRow>, 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<FileRow>, 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<String> {
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<Item = OsString>) -> Option<Vec<u8>> {
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<FileRow>,
budget: &mut usize,
) {
let root = normalize_wsl_guest_path(&located.guest);
let mut args: Vec<OsString> =
[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<GitInfo> {
@@ -1567,27 +1888,63 @@ fn read_git(root: &Path) -> Option<GitInfo> {
// 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<String> {
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<GitInfo> {
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<String> {
// 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<String>) -> Option<GitInfo> {
// `-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,
@@ -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);
+95 -56
View File
@@ -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::<VisualEffects>().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::<VisualEffects>().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::<VisualEffects>() 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<bool>, cx: &mut App) {
/// 把窗口层效果应用到所有窗口。透明度完全由绘制像素 alpha 控制,因此模糊
/// 开关与 0%..100% 透明度互不绑死、无需跨帧时序补丁。
fn apply_window_effects(cx: &mut App) {
let blur = cx.try_global::<VisualEffects>().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::<VisualEffects>()
.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::<i32>() 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(
+76 -13
View File
@@ -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<std::path::PathBuf> {
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<crate::shell_detect::WslCwd> {
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<std::path::PathBuf>, Option<crate::shell_detect::WslCwd>) {
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<Self>) {
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`
@@ -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<String>,
is_dir: bool,
position: Point<Pixels>,
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<NebulaWorkspace>,
path: PathBuf,
guest_path: Option<String>,
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();
+187
View File
@@ -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<WslCwd> {
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<std::path::PathBuf> {
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://<host><path> 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<std::path::PathBuf> {
// `/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<DetectedShell> {
#[cfg(test)]
mod tests {
/// WSL 位置识别:发行版只认显式 `-d` / `--distribution`。裸 `wsl` 用的是
/// 系统默认发行版,名字无从得知——必须放弃而不是猜,否则会拼出一条指向
/// 错误发行版的路径。
#[test]
fn wsl_location_only_trusts_an_explicit_distribution() {
let owned =
|args: &[&str]| -> Vec<String> { 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(),
+12 -27
View File
@@ -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<std::path::PathBuf> {
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 <distro>` / `--distribution <distro>`; 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