feat: 编辑器实时探测并显示已关联虚拟环境

fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
2026-10-24 22:30:10 +08:00
parent 23565b40a4
commit dd7612bc74
20 changed files with 1508 additions and 48 deletions
+374 -16
View File
@@ -6,7 +6,9 @@ use std::{
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use utoipa::ToSchema;
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
@@ -42,23 +44,8 @@ pub fn prepare_script_environment(
});
}
let working_directory = absolute_path(Path::new(working_dir))?;
let interpreter_path = resolve_interpreter(working_dir, interpreter);
let explicit_venv = configured_venv
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_against(&working_directory, value))
.transpose()?;
let detected_venv = explicit_venv
.as_deref()
.map(validate_venv)
.transpose()?
.flatten()
.or_else(|| detect_venv_from_interpreter(&interpreter_path))
.or_else(|| {
is_generic_python(interpreter)
.then(|| detect_venv_from_ancestors(&working_directory))
.flatten()
});
let detected_venv = resolve_venv(working_dir, interpreter, configured_venv)?.venv;
let mut environment = BTreeMap::new();
for (key, value) in user_env {
@@ -115,6 +102,265 @@ pub fn prepare_script_environment(
})
}
/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum VenvSource {
/// 手动填写的 `action.venv`
Configured,
/// 由解释器路径(`...\.venv\Scripts\python.exe`)识别
Interpreter,
/// 从工作目录或上级目录探测到的 `.venv`、`venv` 或 `env`
WorkingDirectory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedVenv {
pub venv: Option<PathBuf>,
pub source: Option<VenvSource>,
}
/// 解析任务实际会使用的虚拟环境,顺序与执行期保持一致:
/// 手动指定 → 解释器路径推断 → 工作目录向上探测。
pub fn resolve_venv(
working_dir: &str,
interpreter: &str,
configured_venv: Option<&str>,
) -> io::Result<ResolvedVenv> {
if !is_python_interpreter(interpreter) {
return Ok(ResolvedVenv {
venv: None,
source: None,
});
}
let working_directory = absolute_path(Path::new(working_dir))?;
let interpreter_path = resolve_interpreter(working_dir, interpreter);
let explicit_venv = configured_venv
.filter(|value| !value.trim().is_empty())
.map(|value| resolve_against(&working_directory, value))
.transpose()?;
if let Some(venv) = explicit_venv
.as_deref()
.map(validate_venv)
.transpose()?
.flatten()
{
return Ok(ResolvedVenv {
venv: Some(venv),
source: Some(VenvSource::Configured),
});
}
if let Some(venv) = detect_venv_from_interpreter(&interpreter_path) {
return Ok(ResolvedVenv {
venv: Some(venv),
source: Some(VenvSource::Interpreter),
});
}
if is_generic_python(interpreter)
&& let Some(venv) = detect_venv_from_ancestors(&working_directory)
{
return Ok(ResolvedVenv {
venv: Some(venv),
source: Some(VenvSource::WorkingDirectory),
});
}
Ok(ResolvedVenv {
venv: None,
source: None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum InterpreterKind {
Python,
Node,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum VenvProbeStatus {
/// 已关联到有效的虚拟环境
Linked,
/// 手动指定的虚拟环境无效
Invalid,
/// 没有虚拟环境,将直接使用系统解释器
Missing,
/// 解释器不是 Python,虚拟环境不适用
NotApplicable,
/// 输入不完整,暂时无法探测
Incomplete,
}
/// 编辑器在用户填写工作目录时发起的探测请求。
#[derive(Debug, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
pub struct VenvProbeRequest {
/// 任务工作目录,对应 `action.working_dir`
pub working_dir: String,
/// 解释器或可执行程序,对应 `action.interpreter`
pub interpreter: String,
/// 手动填写的虚拟环境目录,对应 `action.venv`
#[serde(default)]
pub venv: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct VenvProbeResponse {
pub status: VenvProbeStatus,
pub interpreter_kind: InterpreterKind,
/// 虚拟环境根目录的绝对路径
pub venv_path: Option<String>,
/// 供界面展示的解释器路径;位于工作目录内时使用相对路径
pub display_path: Option<String>,
pub source: Option<VenvSource>,
pub message: String,
}
pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse {
let working_dir = request.working_dir.trim();
let interpreter = request.interpreter.trim();
let configured_venv = request
.venv
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let interpreter_kind = classify_interpreter(interpreter);
if working_dir.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写工作目录");
}
if interpreter.is_empty() {
return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序");
}
if !matches!(interpreter_kind, InterpreterKind::Python) {
return VenvProbeResponse {
status: VenvProbeStatus::NotApplicable,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "当前解释器不是 Python,不需要虚拟环境".to_owned(),
};
}
let working_directory = match absolute_path(Path::new(working_dir)) {
Ok(path) => path,
Err(error) => {
return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}"));
}
};
if !working_directory.is_dir() {
return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境");
}
match resolve_venv(working_dir, interpreter, configured_venv) {
Ok(ResolvedVenv {
venv: Some(venv),
source,
}) => VenvProbeResponse {
status: VenvProbeStatus::Linked,
interpreter_kind,
display_path: Some(python_display_path(&venv, working_dir)),
venv_path: Some(venv.to_string_lossy().into_owned()),
source,
message: venv_source_message(source),
},
Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse {
status: VenvProbeStatus::Missing,
interpreter_kind,
venv_path: None,
display_path: Some(interpreter.to_owned()),
source: None,
message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(),
},
Err(error) => VenvProbeResponse {
status: VenvProbeStatus::Invalid,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: error.to_string(),
},
}
}
fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse {
VenvProbeResponse {
status: VenvProbeStatus::Incomplete,
interpreter_kind,
venv_path: None,
display_path: None,
source: None,
message: message.to_owned(),
}
}
fn venv_source_message(source: Option<VenvSource>) -> String {
match source {
Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(),
Some(VenvSource::Interpreter) => "已从解释器路径识别虚拟环境".to_owned(),
Some(VenvSource::WorkingDirectory) => "已自动关联工作目录中的虚拟环境".to_owned(),
None => "已关联虚拟环境".to_owned(),
}
}
fn python_display_path(venv: &Path, working_dir: &str) -> String {
let python = venv.join("Scripts").join("python.exe");
absolute_path(Path::new(working_dir))
.ok()
.and_then(|base| relative_display(&base, &python))
.unwrap_or_else(|| python.to_string_lossy().into_owned())
}
fn relative_display(base: &Path, target: &Path) -> Option<String> {
let base_components = base.components().collect::<Vec<_>>();
let target_components = target.components().collect::<Vec<_>>();
if target_components.len() <= base_components.len() {
return None;
}
let shares_prefix = base_components
.iter()
.zip(&target_components)
.all(|(base, target)| {
base.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case(&target.as_os_str().to_string_lossy())
});
if !shares_prefix {
return None;
}
let relative = target_components[base_components.len()..]
.iter()
.map(|component| component.as_os_str())
.collect::<PathBuf>();
Some(relative.to_string_lossy().into_owned())
}
fn classify_interpreter(interpreter: &str) -> InterpreterKind {
if is_python_interpreter(interpreter) {
return InterpreterKind::Python;
}
let name = Path::new(interpreter)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(interpreter)
.to_ascii_lowercase();
let stem = name.strip_suffix(".exe").unwrap_or(&name);
if matches!(stem, "node" | "nodejs") {
InterpreterKind::Node
} else {
InterpreterKind::Other
}
}
pub fn validate_configured_venv(working_dir: &str, configured_venv: &str) -> io::Result<PathBuf> {
let working_directory = absolute_path(Path::new(working_dir))?;
let venv = resolve_against(&working_directory, configured_venv)?;
@@ -383,4 +629,116 @@ mod tests {
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV"));
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
}
fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse {
probe_venv(&VenvProbeRequest {
working_dir: working_dir.to_owned(),
interpreter: interpreter.to_owned(),
venv: venv.map(str::to_owned),
})
}
#[test]
fn probe_links_venv_detected_in_working_directory() {
let temp = TempDir::new().unwrap();
create_fake_venv(temp.path());
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
assert_eq!(response.status, VenvProbeStatus::Linked);
assert_eq!(response.source, Some(VenvSource::WorkingDirectory));
assert_eq!(response.interpreter_kind, InterpreterKind::Python);
let expected = Path::new(".venv")
.join("Scripts")
.join("python.exe")
.to_string_lossy()
.into_owned();
assert_eq!(response.display_path, Some(expected));
}
#[test]
fn probe_prefers_configured_venv_over_detection() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
fs::create_dir_all(&project).unwrap();
let venv = create_fake_venv(&project);
let response = probe(
project.to_str().unwrap(),
"python.exe",
Some(venv.to_str().unwrap()),
);
assert_eq!(response.status, VenvProbeStatus::Linked);
assert_eq!(response.source, Some(VenvSource::Configured));
}
#[test]
fn probe_reports_system_interpreter_when_no_venv_exists() {
let temp = TempDir::new().unwrap();
let response = probe(temp.path().to_str().unwrap(), "python.exe", None);
assert_eq!(response.status, VenvProbeStatus::Missing);
assert_eq!(response.display_path.as_deref(), Some("python.exe"));
assert!(response.venv_path.is_none());
}
#[test]
fn probe_rejects_invalid_configured_venv() {
let temp = TempDir::new().unwrap();
let missing = temp.path().join("missing-venv");
let response = probe(
temp.path().to_str().unwrap(),
"python.exe",
Some(missing.to_str().unwrap()),
);
assert_eq!(response.status, VenvProbeStatus::Invalid);
assert!(response.message.contains("pyvenv.cfg"));
}
#[test]
fn probe_marks_non_python_interpreters_as_not_applicable() {
let temp = TempDir::new().unwrap();
let working_dir = temp.path().to_str().unwrap();
let node = probe(working_dir, "node.exe", None);
assert_eq!(node.status, VenvProbeStatus::NotApplicable);
assert_eq!(node.interpreter_kind, InterpreterKind::Node);
let shell = probe(working_dir, "cmd.exe", None);
assert_eq!(shell.status, VenvProbeStatus::NotApplicable);
assert_eq!(shell.interpreter_kind, InterpreterKind::Other);
}
#[test]
fn probe_requires_existing_working_directory() {
let temp = TempDir::new().unwrap();
let empty = probe(" ", "python.exe", None);
assert_eq!(empty.status, VenvProbeStatus::Incomplete);
assert!(empty.message.contains("工作目录"));
let missing = temp.path().join("absent");
let response = probe(missing.to_str().unwrap(), "python.exe", None);
assert_eq!(response.status, VenvProbeStatus::Incomplete);
assert!(response.message.contains("工作目录"));
}
#[test]
fn probe_matches_execution_environment_for_same_input() {
let temp = TempDir::new().unwrap();
let venv = create_fake_venv(temp.path());
let working_dir = temp.path().to_str().unwrap();
let prepared =
prepare_script_environment(working_dir, "python.exe", None, &BTreeMap::new()).unwrap();
let response = probe(working_dir, "python.exe", None);
assert_eq!(prepared.virtual_env.as_deref(), Some(venv.as_path()));
assert_eq!(response.venv_path.as_deref(), Some(venv.to_str().unwrap()));
assert_eq!(response.status, VenvProbeStatus::Linked);
}
}