task: Add task contexts (#8675)

This PR supplements tasks with additional environment variables; ideally
we'll be able to write a task like:
`cargo test -p $ZED_CURRENT_PACKAGE -- $ZED_CURRENT_FUNCTION`
- [x] Flesh out multibuffer interactions
- [x] Add ZED_SYMBOL detection based on tree-sitter queries
- [ ] Add release note and demo
- [x] Figure out a solution for rerun dilemma - should `task: rerun`
reevaluate contexts for tasks?

This PR introduced the following variables:
- ZED_COLUMN - current line column
- ZED_ROW - current line row
and the following, which are available for buffers with associated
files:
- ZED_WORKTREE_ROOT - absolute path to the root of the current worktree.
- ZED_FILE - absolute path to the file
- ZED_SYMBOL - currently selected symbol; should match the last symbol
shown in a symbol breadcrumb (e.g. `mod tests > fn test_task_contexts`
should be equal to ZED_SYMBOL of `test_task_contexts`). Note that this
isn't necessarily a test function or a function at all.

Also, you can use them in `cwd` field of definitions (note though that
we're using https://docs.rs/subst/latest/subst/#features for that, so
don't expect a full shell functionality to work); the syntax should
match up with your typical Unix shell.


Release Notes:

- Added task contexts, which are additional environment variables set by
Zed for task execution; task content is dependent on the state of the
editor at the time the task is spawned.

---------

Co-authored-by: Anthony <anthonyeid7@protonmail.com>
This commit is contained in:
Piotr Osiewicz 2024-03-04 21:04:53 +01:00 committed by GitHub
parent b2f18cfe71
commit 2201b9b116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 623 additions and 190 deletions

View file

@ -7,6 +7,7 @@ license = "GPL-3.0-or-later"
[dependencies]
anyhow.workspace = true
editor.workspace = true
fuzzy.workspace = true
gpui.workspace = true
menu.workspace = true
@ -17,10 +18,14 @@ serde.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
language.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
serde_json.workspace = true
tree-sitter-rust.workspace = true
tree-sitter-typescript.workspace = true
workspace = { workspace = true, features = ["test-support"] }

View file

@ -1,8 +1,11 @@
use std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf};
use editor::Editor;
use gpui::{AppContext, ViewContext, WindowContext};
use language::Point;
use modal::TasksModal;
use task::Task;
use project::{Location, WorktreeId};
use task::{Task, TaskContext};
use util::ResultExt;
use workspace::Workspace;
@ -15,16 +18,28 @@ pub fn init(cx: &mut AppContext) {
.register_action(|workspace, _: &modal::Spawn, cx| {
let inventory = workspace.project().read(cx).task_inventory().clone();
let workspace_handle = workspace.weak_handle();
workspace
.toggle_modal(cx, |cx| TasksModal::new(inventory, workspace_handle, cx))
let cwd = task_cwd(workspace, cx).log_err().flatten();
let task_context = task_context(workspace, cwd, cx);
workspace.toggle_modal(cx, |cx| {
TasksModal::new(inventory, task_context, workspace_handle, cx)
})
})
.register_action(move |workspace, _: &modal::Rerun, cx| {
if let Some(task) = workspace.project().update(cx, |project, cx| {
project
.task_inventory()
.update(cx, |inventory, cx| inventory.last_scheduled_task(cx))
}) {
schedule_task(workspace, task.as_ref(), cx)
.register_action(move |workspace, action: &modal::Rerun, cx| {
if let Some((task, old_context)) =
workspace.project().update(cx, |project, cx| {
project
.task_inventory()
.update(cx, |inventory, cx| inventory.last_scheduled_task(cx))
})
{
let task_context = if action.reevaluate_context {
let cwd = task_cwd(workspace, cx).log_err().flatten();
task_context(workspace, cwd, cx)
} else {
old_context
};
schedule_task(workspace, task.as_ref(), task_context, cx)
};
});
},
@ -32,16 +47,117 @@ pub fn init(cx: &mut AppContext) {
.detach();
}
fn schedule_task(workspace: &Workspace, task: &dyn Task, cx: &mut ViewContext<'_, Workspace>) {
let cwd = match task.cwd() {
Some(cwd) => Some(cwd.to_path_buf()),
None => task_cwd(workspace, cx).log_err().flatten(),
};
let spawn_in_terminal = task.exec(cwd);
fn task_context(
workspace: &Workspace,
cwd: Option<PathBuf>,
cx: &mut WindowContext<'_>,
) -> TaskContext {
let current_editor = workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
.clone();
if let Some(current_editor) = current_editor {
(|| {
let editor = current_editor.read(cx);
let selection = editor.selections.newest::<usize>(cx);
let (buffer, _, _) = editor
.buffer()
.read(cx)
.point_to_buffer_offset(selection.start, cx)?;
current_editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let selection_range = selection.range();
let start = snapshot
.display_snapshot
.buffer_snapshot
.anchor_after(selection_range.start)
.text_anchor;
let end = snapshot
.display_snapshot
.buffer_snapshot
.anchor_after(selection_range.end)
.text_anchor;
let Point { row, column } = snapshot
.display_snapshot
.buffer_snapshot
.offset_to_point(selection_range.start);
let row = row + 1;
let column = column + 1;
let location = Location {
buffer: buffer.clone(),
range: start..end,
};
let current_file = location
.buffer
.read(cx)
.file()
.map(|file| file.path().to_string_lossy().to_string());
let worktree_id = location
.buffer
.read(cx)
.file()
.map(|file| WorktreeId::from_usize(file.worktree_id()));
let context = buffer
.read(cx)
.language()
.and_then(|language| language.context_provider())
.and_then(|provider| provider.build_context(location, cx).ok());
let worktree_path = worktree_id.and_then(|worktree_id| {
workspace
.project()
.read(cx)
.worktree_for_id(worktree_id, cx)
.map(|worktree| worktree.read(cx).abs_path().to_string_lossy().to_string())
});
let mut env = HashMap::from_iter([
("ZED_ROW".into(), row.to_string()),
("ZED_COLUMN".into(), column.to_string()),
]);
if let Some(path) = current_file {
env.insert("ZED_FILE".into(), path);
}
if let Some(worktree_path) = worktree_path {
env.insert("ZED_WORKTREE_ROOT".into(), worktree_path);
}
if let Some(language_context) = context {
if let Some(symbol) = language_context.symbol {
env.insert("ZED_SYMBOL".into(), symbol);
}
}
Some(TaskContext {
cwd: cwd.clone(),
env,
})
})
})()
.unwrap_or_else(|| TaskContext {
cwd,
env: Default::default(),
})
} else {
TaskContext {
cwd,
env: Default::default(),
}
}
}
fn schedule_task(
workspace: &Workspace,
task: &dyn Task,
task_cx: TaskContext,
cx: &mut ViewContext<'_, Workspace>,
) {
let spawn_in_terminal = task.exec(task_cx.clone());
if let Some(spawn_in_terminal) = spawn_in_terminal {
workspace.project().update(cx, |project, cx| {
project.task_inventory().update(cx, |inventory, _| {
inventory.task_scheduled(task.id().clone());
inventory.task_scheduled(task.id().clone(), task_cx);
})
});
cx.emit(workspace::Event::SpawnTask(spawn_in_terminal));
@ -82,3 +198,176 @@ fn task_cwd(workspace: &Workspace, cx: &mut WindowContext) -> anyhow::Result<Opt
};
Ok(cwd.map(|path| path.to_path_buf()))
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, sync::Arc};
use editor::Editor;
use gpui::{Entity, TestAppContext};
use language::{DefaultContextProvider, Language, LanguageConfig};
use project::{FakeFs, Project, TaskSourceKind};
use serde_json::json;
use task::{oneshot_source::OneshotSource, TaskContext};
use ui::VisualContext;
use workspace::{AppState, Workspace};
use crate::{task_context, task_cwd};
#[gpui::test]
async fn test_default_language_context(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/dir",
json!({
".zed": {
"tasks.json": r#"[
{
"label": "example task",
"command": "echo",
"args": ["4"]
},
{
"label": "another one",
"command": "echo",
"args": ["55"]
},
]"#,
},
"a.ts": "function this_is_a_test() { }",
"rust": {
"b.rs": "use std; fn this_is_a_rust_file() { }",
}
}),
)
.await;
let rust_language = Arc::new(
Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::language()),
)
.with_outline_query(
r#"(function_item
"fn" @context
name: (_) @name) @item"#,
)
.unwrap()
.with_context_provider(Some(Arc::new(DefaultContextProvider))),
);
let typescript_language = Arc::new(
Language::new(
LanguageConfig::default(),
Some(tree_sitter_typescript::language_typescript()),
)
.with_outline_query(
r#"(function_declaration
"async"? @context
"function" @context
name: (_) @name
parameters: (formal_parameters
"(" @context
")" @context)) @item"#,
)
.unwrap()
.with_context_provider(Some(Arc::new(DefaultContextProvider))),
);
let project = Project::test(fs, ["/dir".as_ref()], cx).await;
project.update(cx, |project, cx| {
project.task_inventory().update(cx, |inventory, cx| {
inventory.add_source(TaskSourceKind::UserInput, |cx| OneshotSource::new(cx), cx)
})
});
let worktree_id = project.update(cx, |project, cx| {
project.worktrees().next().unwrap().read(cx).id()
});
let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
let buffer1 = workspace
.update(cx, |this, cx| {
this.project()
.update(cx, |this, cx| this.open_buffer((worktree_id, "a.ts"), cx))
})
.await
.unwrap();
buffer1.update(cx, |this, cx| {
this.set_language(Some(typescript_language), cx)
});
let editor1 = cx.new_view(|cx| Editor::for_buffer(buffer1, Some(project.clone()), cx));
let buffer2 = workspace
.update(cx, |this, cx| {
this.project().update(cx, |this, cx| {
this.open_buffer((worktree_id, "rust/b.rs"), cx)
})
})
.await
.unwrap();
buffer2.update(cx, |this, cx| this.set_language(Some(rust_language), cx));
let editor2 = cx.new_view(|cx| Editor::for_buffer(buffer2, Some(project), cx));
workspace.update(cx, |this, cx| {
this.add_item_to_center(Box::new(editor1.clone()), cx);
this.add_item_to_center(Box::new(editor2.clone()), cx);
assert_eq!(this.active_item(cx).unwrap().item_id(), editor2.entity_id());
assert_eq!(
task_context(this, task_cwd(this, cx).unwrap(), cx),
TaskContext {
cwd: Some("/dir".into()),
env: HashMap::from_iter([
("ZED_FILE".into(), "rust/b.rs".into()),
("ZED_WORKTREE_ROOT".into(), "/dir".into()),
("ZED_ROW".into(), "1".into()),
("ZED_COLUMN".into(), "1".into()),
])
}
);
// And now, let's select an identifier.
editor2.update(cx, |this, cx| {
this.change_selections(None, cx, |selections| selections.select_ranges([14..18]))
});
assert_eq!(
task_context(this, task_cwd(this, cx).unwrap(), cx),
TaskContext {
cwd: Some("/dir".into()),
env: HashMap::from_iter([
("ZED_FILE".into(), "rust/b.rs".into()),
("ZED_WORKTREE_ROOT".into(), "/dir".into()),
("ZED_SYMBOL".into(), "this_is_a_rust_file".into()),
("ZED_ROW".into(), "1".into()),
("ZED_COLUMN".into(), "15".into()),
])
}
);
// Now, let's switch the active item to .ts file.
this.activate_item(&editor1, cx);
assert_eq!(
task_context(this, task_cwd(this, cx).unwrap(), cx),
TaskContext {
cwd: Some("/dir".into()),
env: HashMap::from_iter([
("ZED_FILE".into(), "a.ts".into()),
("ZED_WORKTREE_ROOT".into(), "/dir".into()),
("ZED_SYMBOL".into(), "this_is_a_test".into()),
("ZED_ROW".into(), "1".into()),
("ZED_COLUMN".into(), "1".into()),
])
}
);
});
}
pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
language::init(cx);
crate::init(cx);
editor::init(cx);
workspace::init_settings(cx);
Project::init_settings(cx);
state
})
}
}

View file

@ -2,23 +2,36 @@ use std::{path::PathBuf, sync::Arc};
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{
actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView, InteractiveElement,
Model, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
VisualContext, WeakView,
actions, impl_actions, rems, AppContext, DismissEvent, EventEmitter, FocusableView,
InteractiveElement, Model, ParentElement, Render, SharedString, Styled, Subscription, View,
ViewContext, VisualContext, WeakView,
};
use picker::{
highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText},
Picker, PickerDelegate,
};
use project::{Inventory, ProjectPath, TaskSourceKind};
use task::{oneshot_source::OneshotSource, Task};
use task::{oneshot_source::OneshotSource, Task, TaskContext};
use ui::{v_flex, ListItem, ListItemSpacing, RenderOnce, Selectable, WindowContext};
use util::{paths::PathExt, ResultExt};
use workspace::{ModalView, Workspace};
use crate::schedule_task;
use serde::Deserialize;
actions!(task, [Spawn]);
actions!(task, [Spawn, Rerun]);
/// Rerun last task
#[derive(PartialEq, Clone, Deserialize, Default)]
pub struct Rerun {
#[serde(default)]
/// Controls whether the task context is reevaluated prior to execution of a task.
/// If it is not, environment variables such as ZED_COLUMN, ZED_FILE are gonna be the same as in the last execution of a task
/// If it is, these variables will be updated to reflect current state of editor at the time task::Rerun is executed.
/// default: false
pub reevaluate_context: bool,
}
impl_actions!(task, [Rerun]);
/// A modal used to spawn new tasks.
pub(crate) struct TasksModalDelegate {
@ -28,10 +41,15 @@ pub(crate) struct TasksModalDelegate {
selected_index: usize,
workspace: WeakView<Workspace>,
prompt: String,
task_context: TaskContext,
}
impl TasksModalDelegate {
fn new(inventory: Model<Inventory>, workspace: WeakView<Workspace>) -> Self {
fn new(
inventory: Model<Inventory>,
task_context: TaskContext,
workspace: WeakView<Workspace>,
) -> Self {
Self {
inventory,
workspace,
@ -39,6 +57,7 @@ impl TasksModalDelegate {
matches: Vec::new(),
selected_index: 0,
prompt: String::default(),
task_context,
}
}
@ -79,11 +98,16 @@ pub(crate) struct TasksModal {
impl TasksModal {
pub(crate) fn new(
inventory: Model<Inventory>,
task_context: TaskContext,
workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let picker = cx
.new_view(|cx| Picker::uniform_list(TasksModalDelegate::new(inventory, workspace), cx));
let picker = cx.new_view(|cx| {
Picker::uniform_list(
TasksModalDelegate::new(inventory, task_context, workspace),
cx,
)
});
let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
cx.emit(DismissEvent);
});
@ -223,7 +247,7 @@ impl PickerDelegate for TasksModalDelegate {
self.workspace
.update(cx, |workspace, cx| {
schedule_task(workspace, task.as_ref(), cx);
schedule_task(workspace, task.as_ref(), self.task_context.clone(), cx);
})
.ok();
cx.emit(DismissEvent);
@ -279,13 +303,12 @@ mod tests {
use gpui::{TestAppContext, VisualTestContext};
use project::{FakeFs, Project};
use serde_json::json;
use workspace::AppState;
use super::*;
#[gpui::test]
async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) {
init_test(cx);
crate::tests::init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/dir",
@ -431,16 +454,4 @@ mod tests {
.collect::<Vec<_>>()
})
}
fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let state = AppState::test(cx);
language::init(cx);
crate::init(cx);
editor::init(cx);
workspace::init_settings(cx);
Project::init_settings(cx);
state
})
}
}