Fix Windows task path handling for worktree variables with spaces (#55727)

## Summary
Fixes task variable resolution and Windows shell argument handling when
`$ZED_WORKTREE_ROOT` contains spaces or when tasks are spawned while the
global `tasks.json` buffer is focused.

## Problem
`$ZED_WORKTREE_ROOT` could resolve from the active editor item even when
that item was outside every visible project worktree, such as the global
`tasks.json` file under the Zed config directory. On Windows, paths with
spaces also need to remain one logical argument when terminal shell
commands are constructed.

## Changes
- Only let the active editor item provide the active task context when
it belongs to a visible project worktree.
- Fall back to the selected or first visible project worktree when the
active editor item is external/global.
- Preserve project-file-specific task variables for normal project
buffers.
- Add regression coverage for `$ZED_WORKTREE_ROOT` in `args` and `cwd`
with synthetic Windows-style paths containing spaces.
- Add regression coverage for PowerShell and cmd shell command
construction so a spaced path remains quoted as one shell argument.

## Testing
Ran on Windows with Rust 1.95.0, Visual Studio Build Tools 2022, and
Windows SDK 10.0.26100.0:

- `cargo fmt --all --check`
- `cargo test -p util
shell_builder::test::windows_powershell_preserves_spaced_arg_as_single_shell_argument
-- --exact`
- `cargo test -p util
shell_builder::test::windows_cmd_preserves_spaced_arg_as_single_shell_argument
-- --exact`
- `cargo test -p task
task_template::tests::test_worktree_root_with_spaces_stays_atomic_in_args_and_cwd
-- --exact`
- `cargo test -p tasks_ui
tests::test_non_project_active_editor_uses_visible_worktree_context --
--exact`
- `cargo test -p task`
- `cargo test -p tasks_ui`
- `cargo test -p workspace`

Release Notes:

- Fix Windows task path handling for worktree variables with spaces
- [GPUI] N/A

Co-authored-by: Chris Biscardi <chris@christopherbiscardi.com>
This commit is contained in:
Indy FuandChris Biscardi authored and GitHub committed 2026-09-19 01:57:44 +00:00
1 parent 56ae3f4293
commit a0f008b755
3 files changed
+138 -12

No files matched your search

+40
View File
@@ -626,6 +626,46 @@ mod tests {
); );
} }
#[test]
fn test_worktree_root_with_spaces_stays_atomic_in_args_and_cwd() {
let worktree_root = r"C:\worktrees\Godot Projects\sample-game";
let task = TaskTemplate {
label: "Run Godot Game".to_string(),
command: "godot".to_string(),
args: vec![
"--path".to_string(),
VariableName::WorktreeRoot.template_value(),
"scenes/main_menu.tscn".to_string(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
..TaskTemplate::default()
};
let resolved = task
.resolve_task(
TEST_ID_BASE,
&TaskContext {
cwd: None,
task_variables: TaskVariables::from_iter([(
VariableName::WorktreeRoot,
worktree_root.to_string(),
)]),
project_env: HashMap::default(),
},
)
.expect("task should resolve with worktree root variable");
assert_eq!(
resolved.resolved.args,
vec![
"--path".to_string(),
worktree_root.to_string(),
"scenes/main_menu.tscn".to_string()
]
);
assert_eq!(resolved.resolved.cwd, Some(PathBuf::from(worktree_root)));
}
#[test] #[test]
fn test_template_variables_resolution() { fn test_template_variables_resolution() {
let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1")); let custom_variable_1 = VariableName::Custom(Cow::Borrowed("custom_variable_1"));
+64 -12
View File
@@ -361,7 +361,7 @@ pub fn task_contexts(
cx: &mut App, cx: &mut App,
) -> Task<TaskContexts> { ) -> Task<TaskContexts> {
let active_item = workspace.active_item(cx); let active_item = workspace.active_item(cx);
let active_worktree = active_item let active_item_worktree = active_item
.as_ref() .as_ref()
.and_then(|item| item.project_path(cx)) .and_then(|item| item.project_path(cx))
.map(|project_path| project_path.worktree_id) .map(|project_path| project_path.worktree_id)
@@ -371,21 +371,25 @@ pub fn task_contexts(
.read(cx) .read(cx)
.worktree_for_id(*worktree_id, cx) .worktree_for_id(*worktree_id, cx)
.is_some_and(|worktree| is_visible_directory(&worktree, cx)) .is_some_and(|worktree| is_visible_directory(&worktree, cx))
})
.or_else(|| {
workspace
.visible_worktrees(cx)
.next()
.map(|tree| tree.read(cx).id())
}); });
let active_worktree = active_item_worktree.or_else(|| {
workspace
.visible_worktrees(cx)
.next()
.map(|tree| tree.read(cx).id())
});
let active_editor = active_item.and_then(|item| item.act_as::<Editor>(cx)); let active_editor = active_item.and_then(|item| item.act_as::<Editor>(cx));
let active_project_editor = active_item_worktree
.is_some()
.then(|| active_editor.clone())
.flatten();
let editor_context_task = active_editor.as_ref().map(|active_editor| { let editor_context_task = active_project_editor.as_ref().map(|active_editor| {
active_editor.update(cx, |editor, cx| editor.task_context(window, cx)) active_editor.update(cx, |editor, cx| editor.task_context(window, cx))
}); });
let location = active_editor.as_ref().and_then(|editor| { let location = active_project_editor.as_ref().and_then(|editor| {
editor.update(cx, |editor, cx| { editor.update(cx, |editor, cx| {
let selection = editor.selections.newest_anchor(); let selection = editor.selections.newest_anchor();
let multi_buffer = editor.buffer().clone(); let multi_buffer = editor.buffer().clone();
@@ -401,14 +405,14 @@ pub fn task_contexts(
}) })
}); });
let lsp_task_sources = active_editor let lsp_task_sources = active_project_editor
.as_ref() .as_ref()
.map(|active_editor| { .map(|active_editor| {
active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx)) active_editor.update(cx, |editor, cx| editor.lsp_task_sources(false, false, cx))
}) })
.unwrap_or_default(); .unwrap_or_default();
let latest_selection = active_editor.as_ref().and_then(|active_editor| { let latest_selection = active_project_editor.as_ref().and_then(|active_editor| {
let snapshot = active_editor.read(cx).buffer().read(cx).snapshot(cx); let snapshot = active_editor.read(cx).buffer().read(cx).snapshot(cx);
snapshot snapshot
.anchor_to_buffer_anchor(active_editor.read(cx).selections.newest_anchor().head()) .anchor_to_buffer_anchor(active_editor.read(cx).selections.newest_anchor().head())
@@ -433,7 +437,8 @@ pub fn task_contexts(
if let Some(editor_context_task) = editor_context_task if let Some(editor_context_task) = editor_context_task
&& let Some(editor_context) = editor_context_task.await && let Some(editor_context) = editor_context_task.await
{ {
task_contexts.active_item_context = Some((active_worktree, location, editor_context)); task_contexts.active_item_context =
Some((active_item_worktree, location, editor_context));
} }
if let Some(active_worktree) = active_worktree { if let Some(active_worktree) = active_worktree {
@@ -491,6 +496,53 @@ mod tests {
use crate::task_contexts; use crate::task_contexts;
#[gpui::test]
async fn test_non_project_active_editor_uses_visible_worktree_context(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let worktree_root = path!("/worktrees/Godot Projects/sample-game");
fs.insert_tree(
worktree_root,
json!({
".zed": {
"tasks.json": "[]",
},
"scenes": {
"main_menu.tscn": "",
}
}),
)
.await;
let project = Project::test(fs, [worktree_root.as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let global_tasks_editor = cx.new_window_entity(|window, cx| Editor::multi_line(window, cx));
let contexts = workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_center(Box::new(global_tasks_editor), window, cx);
task_contexts(workspace, window, cx)
})
.await;
assert!(contexts.active_item_context.is_none());
assert_eq!(
contexts
.active_context()
.expect("visible worktree should provide an active task context"),
&TaskContext {
cwd: Some(worktree_root.into()),
task_variables: TaskVariables::from_iter([(
VariableName::WorktreeRoot,
worktree_root.into(),
)]),
project_env: HashMap::default(),
}
);
}
#[gpui::test] #[gpui::test]
async fn test_default_language_context(cx: &mut TestAppContext) { async fn test_default_language_context(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
+34
View File
@@ -350,4 +350,38 @@ mod test {
assert_eq!(program, "fish"); assert_eq!(program, "fish");
assert_eq!(args, vec!["-i", "-c", "echo oo"]); assert_eq!(args, vec!["-i", "-c", "echo oo"]);
} }
#[test]
fn windows_powershell_preserves_spaced_arg_as_single_shell_argument() {
let worktree_root = r"C:\worktrees\Godot Projects\sample-game";
let shell = Shell::Program("powershell".to_owned());
let (program, args) = ShellBuilder::new(&shell, true)
.build(Some("echo".into()), &[worktree_root.to_string()]);
assert_eq!(program, "powershell");
assert_eq!(
args,
vec!["-C".to_string(), format!("echo '{worktree_root}'")]
);
}
#[test]
fn windows_cmd_preserves_spaced_arg_as_single_shell_argument() {
let worktree_root = r"C:\worktrees\Godot Projects\sample-game";
let shell = Shell::Program("cmd".to_owned());
let (program, args) = ShellBuilder::new(&shell, true)
.build(Some("echo".into()), &[worktree_root.to_string()]);
assert_eq!(program, "cmd");
assert_eq!(
args,
vec![
"/S".to_string(),
"/C".to_string(),
format!("\"echo ^\"{worktree_root}^\"\""),
]
);
}
} }