Merge branch 'main' into keybindings-grind

This commit is contained in:
Mikayla Maki 2022-07-15 11:24:16 -07:00 committed by GitHub
commit f38206f819
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 882 additions and 468 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@
/crates/collab/static/styles.css
/vendor/bin
/assets/themes/*.json
dump.rdb

3
Cargo.lock generated
View file

@ -1611,6 +1611,7 @@ dependencies = [
"anyhow",
"clock",
"collections",
"context_menu",
"ctor",
"env_logger",
"futures",
@ -6990,7 +6991,7 @@ dependencies = [
[[package]]
name = "zed"
version = "0.46.0"
version = "0.47.1"
dependencies = [
"activity_indicator",
"anyhow",

View file

@ -1,2 +1,3 @@
web: cd ../zed.dev && PORT=3000 npx next dev
collab: cd crates/collab && cargo run
redis: redis-server

View file

@ -23,6 +23,12 @@ script/sqlx migrate run
script/seed-db
```
Install Redis:
```
brew install redis
```
Run the web frontend and the collaboration server.
```

View file

@ -1,29 +1,25 @@
{
// The name of the Zed theme to use for the UI
"theme": "cave-dark",
// The name of a font to use for rendering text in the editor
"buffer_font_family": "Zed Mono",
// The default font size for text in the editor
"buffer_font_size": 15,
// Whether to enable vim modes and key bindings
"vim_mode": false,
// Whether to show the informational hover box when moving the mouse
// over symbols in the editor.
"hover_popover_enabled": true,
// Whether to pop the completions menu while typing in an editor without
// explicitly requesting it.
"show_completions_on_input": true,
// Whether new projects should start out 'online'. Online projects
// appear in the contacts panel under your name, so that your contacts
// can see which projects you are working on. Regardless of this
// setting, projects keep their last online status when you reopen them.
"projects_online_by_default": true,
// Whether to use language servers to provide code intelligence.
"enable_language_server": true,
// When to automatically save edited buffers. This setting can
// take four values.
//
@ -36,7 +32,6 @@
// 4. Save when idle for a certain amount of time:
// "autosave": { "after_delay": {"milliseconds": 500} },
"autosave": "off",
// How to auto-format modified buffers when saving them. This
// setting can take three values:
//
@ -47,12 +42,11 @@
// 3. Format code using an external command:
// "format_on_save": {
// "external": {
// "command": "sed",
// "arguments": ["-e", "s/ *$//"]
// "command": "prettier",
// "arguments": ["--stdin-filepath", "{buffer_path}"]
// }
// }
// },
"format_on_save": "language_server",
// How to soft-wrap long lines of text. This setting can take
// three values:
//
@ -63,18 +57,14 @@
// 2. Soft wrap lines at the preferred line length
// "soft_wrap": "preferred_line_length",
"soft_wrap": "none",
// The column at which to soft-wrap lines, for buffers where soft-wrap
// is enabled.
"preferred_line_length": 80,
// Whether to indent lines using tab characters, as opposed to multiple
// spaces.
"hard_tabs": false,
// How many columns a tab should occupy.
"tab_size": 4,
// Different settings for specific languages.
"languages": {
"Plain Text": {

View file

@ -6,3 +6,6 @@
// To see all of Zed's default settings without changing your
// custom settings, run the `open default settings` command
// from the command palette or from `Zed` application menu.
{
"buffer_font_size": 15
}

View file

@ -124,6 +124,10 @@ impl ContextMenu {
}
}
pub fn visible(&self) -> bool {
self.visible
}
fn action_dispatched(&mut self, action_id: TypeId, cx: &mut ViewContext<Self>) {
if let Some(ix) = self
.items

View file

@ -501,7 +501,12 @@ impl ProjectDiagnosticsEditor {
}
impl workspace::Item for ProjectDiagnosticsEditor {
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
fn tab_content(
&self,
_detail: Option<usize>,
style: &theme::Tab,
cx: &AppContext,
) -> ElementBox {
render_summary(
&self.summary,
&style.label.text,

View file

@ -23,6 +23,7 @@ test-support = [
text = { path = "../text" }
clock = { path = "../clock" }
collections = { path = "../collections" }
context_menu = { path = "../context_menu" }
fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" }
language = { path = "../language" }

View file

@ -4,6 +4,7 @@ mod highlight_matching_bracket;
mod hover_popover;
pub mod items;
mod link_go_to_definition;
mod mouse_context_menu;
pub mod movement;
mod multi_buffer;
pub mod selections_collection;
@ -34,6 +35,7 @@ use gpui::{
};
use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState};
pub use items::MAX_TAB_TITLE_LEN;
pub use language::{char_kind, CharKind};
use language::{
BracketPair, Buffer, CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticSeverity,
@ -319,6 +321,7 @@ pub fn init(cx: &mut MutableAppContext) {
hover_popover::init(cx);
link_go_to_definition::init(cx);
mouse_context_menu::init(cx);
workspace::register_project_item::<Editor>(cx);
workspace::register_followable_item::<Editor>(cx);
@ -425,6 +428,7 @@ pub struct Editor {
background_highlights: BTreeMap<TypeId, (fn(&Theme) -> Color, Vec<Range<Anchor>>)>,
nav_history: Option<ItemNavHistory>,
context_menu: Option<ContextMenu>,
mouse_context_menu: ViewHandle<context_menu::ContextMenu>,
completion_tasks: Vec<(CompletionId, Task<Option<()>>)>,
next_completion_id: CompletionId,
available_code_actions: Option<(ModelHandle<Buffer>, Arc<[CodeAction]>)>,
@ -1010,11 +1014,11 @@ impl Editor {
background_highlights: Default::default(),
nav_history: None,
context_menu: None,
mouse_context_menu: cx.add_view(|cx| context_menu::ContextMenu::new(cx)),
completion_tasks: Default::default(),
next_completion_id: 0,
available_code_actions: Default::default(),
code_actions_task: Default::default(),
document_highlights_task: Default::default(),
pending_rename: Default::default(),
searchable: true,
@ -1070,7 +1074,7 @@ impl Editor {
&self.buffer
}
pub fn title(&self, cx: &AppContext) -> String {
pub fn title<'a>(&self, cx: &'a AppContext) -> Cow<'a, str> {
self.buffer().read(cx).title(cx)
}
@ -1596,7 +1600,7 @@ impl Editor {
s.delete(newest_selection.id)
}
s.set_pending_range(start..end, mode);
s.set_pending_anchor_range(start..end, mode);
});
}
@ -1937,6 +1941,10 @@ impl Editor {
}
fn trigger_completion_on_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
if !cx.global::<Settings>().show_completions_on_input {
return;
}
let selection = self.selections.newest_anchor();
if self
.buffer
@ -5780,7 +5788,12 @@ impl View for Editor {
});
}
EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed()
Stack::new()
.with_child(
EditorElement::new(self.handle.clone(), style.clone(), self.cursor_shape).boxed(),
)
.with_child(ChildView::new(&self.mouse_context_menu).boxed())
.boxed()
}
fn ui_name() -> &'static str {
@ -6225,7 +6238,8 @@ pub fn styled_runs_for_code_label<'a>(
#[cfg(test)]
mod tests {
use crate::test::{
assert_text_with_selections, build_editor, select_ranges, EditorTestContext,
assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext,
EditorTestContext,
};
use super::*;
@ -6236,7 +6250,6 @@ mod tests {
};
use indoc::indoc;
use language::{FakeLspAdapter, LanguageConfig};
use lsp::FakeLanguageServer;
use project::FakeFs;
use settings::EditorSettings;
use std::{cell::RefCell, rc::Rc, time::Instant};
@ -6244,7 +6257,9 @@ mod tests {
use unindent::Unindent;
use util::{
assert_set_eq,
test::{marked_text_by, marked_text_ranges, marked_text_ranges_by, sample_text},
test::{
marked_text_by, marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker,
},
};
use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane};
@ -9524,199 +9539,182 @@ mod tests {
#[gpui::test]
async fn test_completion(cx: &mut gpui::TestAppContext) {
let mut language = Language::new(
LanguageConfig {
name: "Rust".into(),
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
Some(tree_sitter_rust::language()),
);
let mut fake_servers = language
.set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
..Default::default()
}),
..Default::default()
},
..Default::default()
}))
.await;
let text = "
one
two
three
"
.unindent();
let fs = FakeFs::new(cx.background().clone());
fs.insert_file("/file.rs", text).await;
let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
project.update(cx, |project, _| project.languages().add(Arc::new(language)));
let buffer = project
.update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
.await
.unwrap();
let mut fake_server = fake_servers.next().await.unwrap();
let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
editor.update(cx, |editor, cx| {
editor.project = Some(project);
editor.change_selections(None, cx, |s| {
s.select_ranges([Point::new(0, 3)..Point::new(0, 3)])
});
editor.handle_input(&Input(".".to_string()), cx);
});
handle_completion_request(
&mut fake_server,
"/file.rs",
Point::new(0, 4),
vec![
(Point::new(0, 4)..Point::new(0, 4), "first_completion"),
(Point::new(0, 4)..Point::new(0, 4), "second_completion"),
],
cx,
)
.await;
editor
.condition(&cx, |editor, _| editor.context_menu_visible())
.await;
let apply_additional_edits = editor.update(cx, |editor, cx| {
editor.move_down(&MoveDown, cx);
let apply_additional_edits = editor
.confirm_completion(&ConfirmCompletion::default(), cx)
.unwrap();
assert_eq!(
editor.text(cx),
"
one.second_completion
cx.set_state(indoc! {"
one|
two
three
"
.unindent()
);
apply_additional_edits
three"});
cx.simulate_keystroke(".");
handle_completion_request(
&mut cx,
indoc! {"
one.|<>
two
three"},
vec!["first_completion", "second_completion"],
)
.await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
let apply_additional_edits = cx.update_editor(|editor, cx| {
editor.move_down(&MoveDown, cx);
editor
.confirm_completion(&ConfirmCompletion::default(), cx)
.unwrap()
});
cx.assert_editor_state(indoc! {"
one.second_completion|
two
three"});
handle_resolve_completion_request(
&mut fake_server,
Some((Point::new(2, 5)..Point::new(2, 5), "\nadditional edit")),
)
.await;
apply_additional_edits.await.unwrap();
assert_eq!(
editor.read_with(cx, |editor, cx| editor.text(cx)),
"
&mut cx,
Some((
indoc! {"
one.second_completion
two
three<>"},
"\nadditional edit",
)),
)
.await;
apply_additional_edits.await.unwrap();
cx.assert_editor_state(indoc! {"
one.second_completion|
two
three
additional edit
"
.unindent()
);
additional edit"});
editor.update(cx, |editor, cx| {
editor.change_selections(None, cx, |s| {
s.select_ranges([
Point::new(1, 3)..Point::new(1, 3),
Point::new(2, 5)..Point::new(2, 5),
])
});
editor.handle_input(&Input(" ".to_string()), cx);
assert!(editor.context_menu.is_none());
editor.handle_input(&Input("s".to_string()), cx);
assert!(editor.context_menu.is_none());
});
handle_completion_request(
&mut fake_server,
"/file.rs",
Point::new(2, 7),
vec![
(Point::new(2, 6)..Point::new(2, 7), "fourth_completion"),
(Point::new(2, 6)..Point::new(2, 7), "fifth_completion"),
(Point::new(2, 6)..Point::new(2, 7), "sixth_completion"),
],
)
.await;
editor
.condition(&cx, |editor, _| editor.context_menu_visible())
.await;
editor.update(cx, |editor, cx| {
editor.handle_input(&Input("i".to_string()), cx);
});
handle_completion_request(
&mut fake_server,
"/file.rs",
Point::new(2, 8),
vec![
(Point::new(2, 6)..Point::new(2, 8), "fourth_completion"),
(Point::new(2, 6)..Point::new(2, 8), "fifth_completion"),
(Point::new(2, 6)..Point::new(2, 8), "sixth_completion"),
],
)
.await;
editor
.condition(&cx, |editor, _| editor.context_menu_visible())
.await;
let apply_additional_edits = editor.update(cx, |editor, cx| {
let apply_additional_edits = editor
.confirm_completion(&ConfirmCompletion::default(), cx)
.unwrap();
assert_eq!(
editor.text(cx),
"
cx.set_state(indoc! {"
one.second_completion
two sixth_completion
three sixth_completion
additional edit
"
.unindent()
);
apply_additional_edits
two|
three|
additional edit"});
cx.simulate_keystroke(" ");
assert!(cx.editor(|e, _| e.context_menu.is_none()));
cx.simulate_keystroke("s");
assert!(cx.editor(|e, _| e.context_menu.is_none()));
cx.assert_editor_state(indoc! {"
one.second_completion
two s|
three s|
additional edit"});
handle_completion_request(
&mut cx,
indoc! {"
one.second_completion
two s
three <s|>
additional edit"},
vec!["fourth_completion", "fifth_completion", "sixth_completion"],
)
.await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
cx.simulate_keystroke("i");
handle_completion_request(
&mut cx,
indoc! {"
one.second_completion
two si
three <si|>
additional edit"},
vec!["fourth_completion", "fifth_completion", "sixth_completion"],
)
.await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
let apply_additional_edits = cx.update_editor(|editor, cx| {
editor
.confirm_completion(&ConfirmCompletion::default(), cx)
.unwrap()
});
handle_resolve_completion_request(&mut fake_server, None).await;
cx.assert_editor_state(indoc! {"
one.second_completion
two sixth_completion|
three sixth_completion|
additional edit"});
handle_resolve_completion_request(&mut cx, None).await;
apply_additional_edits.await.unwrap();
async fn handle_completion_request(
fake: &mut FakeLanguageServer,
path: &'static str,
position: Point,
completions: Vec<(Range<Point>, &'static str)>,
cx.update(|cx| {
cx.update_global::<Settings, _, _>(|settings, _| {
settings.show_completions_on_input = false;
})
});
cx.set_state("editor|");
cx.simulate_keystroke(".");
assert!(cx.editor(|e, _| e.context_menu.is_none()));
cx.simulate_keystrokes(["c", "l", "o"]);
cx.assert_editor_state("editor.clo|");
assert!(cx.editor(|e, _| e.context_menu.is_none()));
cx.update_editor(|editor, cx| {
editor.show_completions(&ShowCompletions, cx);
});
handle_completion_request(&mut cx, "editor.<clo|>", vec!["close", "clobber"]).await;
cx.condition(|editor, _| editor.context_menu_visible())
.await;
let apply_additional_edits = cx.update_editor(|editor, cx| {
editor
.confirm_completion(&ConfirmCompletion::default(), cx)
.unwrap()
});
cx.assert_editor_state("editor.close|");
handle_resolve_completion_request(&mut cx, None).await;
apply_additional_edits.await.unwrap();
// Handle completion request passing a marked string specifying where the completion
// should be triggered from using '|' character, what range should be replaced, and what completions
// should be returned using '<' and '>' to delimit the range
async fn handle_completion_request<'a>(
cx: &mut EditorLspTestContext<'a>,
marked_string: &str,
completions: Vec<&'static str>,
) {
fake.handle_request::<lsp::request::Completion, _, _>(move |params, _| {
let complete_from_marker: TextRangeMarker = '|'.into();
let replace_range_marker: TextRangeMarker = ('<', '>').into();
let (_, mut marked_ranges) = marked_text_ranges_by(
marked_string,
vec![complete_from_marker.clone(), replace_range_marker.clone()],
);
let complete_from_position =
cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start);
let replace_range =
cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
cx.handle_request::<lsp::request::Completion, _, _>(move |url, params, _| {
let completions = completions.clone();
async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Url::from_file_path(path).unwrap()
);
assert_eq!(params.text_document_position.text_document.uri, url.clone());
assert_eq!(
params.text_document_position.position,
lsp::Position::new(position.row, position.column)
complete_from_position
);
Ok(Some(lsp::CompletionResponse::Array(
completions
.iter()
.map(|(range, new_text)| lsp::CompletionItem {
label: new_text.to_string(),
.map(|completion_text| lsp::CompletionItem {
label: completion_text.to_string(),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
range: lsp::Range::new(
lsp::Position::new(range.start.row, range.start.column),
lsp::Position::new(range.start.row, range.start.column),
),
new_text: new_text.to_string(),
range: replace_range.clone(),
new_text: completion_text.to_string(),
})),
..Default::default()
})
@ -9728,23 +9726,26 @@ mod tests {
.await;
}
async fn handle_resolve_completion_request(
fake: &mut FakeLanguageServer,
edit: Option<(Range<Point>, &'static str)>,
async fn handle_resolve_completion_request<'a>(
cx: &mut EditorLspTestContext<'a>,
edit: Option<(&'static str, &'static str)>,
) {
fake.handle_request::<lsp::request::ResolveCompletionItem, _, _>(move |_, _| {
let edit = edit.map(|(marked_string, new_text)| {
let replace_range_marker: TextRangeMarker = ('<', '>').into();
let (_, mut marked_ranges) =
marked_text_ranges_by(marked_string, vec![replace_range_marker.clone()]);
let replace_range = cx
.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
vec![lsp::TextEdit::new(replace_range, new_text.to_string())]
});
cx.handle_request::<lsp::request::ResolveCompletionItem, _, _>(move |_, _, _| {
let edit = edit.clone();
async move {
Ok(lsp::CompletionItem {
additional_text_edits: edit.map(|(range, new_text)| {
vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(range.start.row, range.start.column),
lsp::Position::new(range.end.row, range.end.column),
),
new_text.to_string(),
)]
}),
additional_text_edits: edit,
..Default::default()
})
}

View file

@ -7,6 +7,7 @@ use crate::{
display_map::{BlockStyle, DisplaySnapshot, TransformBlock},
hover_popover::HoverAt,
link_go_to_definition::{CmdChanged, GoToFetchedDefinition, UpdateGoToDefinitionLink},
mouse_context_menu::DeployMouseContextMenu,
EditorStyle,
};
use clock::ReplicaId;
@ -152,6 +153,24 @@ impl EditorElement {
true
}
fn mouse_right_down(
&self,
position: Vector2F,
layout: &mut LayoutState,
paint: &mut PaintState,
cx: &mut EventContext,
) -> bool {
if !paint.text_bounds.contains_point(position) {
return false;
}
let snapshot = self.snapshot(cx.app);
let (point, _) = paint.point_for_position(&snapshot, layout, position);
cx.dispatch_action(DeployMouseContextMenu { position, point });
true
}
fn mouse_up(&self, _position: Vector2F, cx: &mut EventContext) -> bool {
if self.view(cx.app.as_ref()).is_selecting() {
cx.dispatch_action(Select(SelectPhase::End));
@ -1482,6 +1501,11 @@ impl Element for EditorElement {
paint,
cx,
),
Event::MouseDown(MouseEvent {
button: MouseButton::Right,
position,
..
}) => self.mouse_right_down(*position, layout, paint, cx),
Event::MouseUp(MouseEvent {
button: MouseButton::Left,
position,

View file

@ -1,4 +1,6 @@
use crate::{Anchor, Autoscroll, Editor, Event, ExcerptId, NavigationData, ToPoint as _};
use crate::{
Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer, NavigationData, ToPoint as _,
};
use anyhow::{anyhow, Result};
use futures::FutureExt;
use gpui::{
@ -10,12 +12,18 @@ use project::{File, Project, ProjectEntryId, ProjectPath};
use rpc::proto::{self, update_view};
use settings::Settings;
use smallvec::SmallVec;
use std::{fmt::Write, path::PathBuf, time::Duration};
use std::{
borrow::Cow,
fmt::Write,
path::{Path, PathBuf},
time::Duration,
};
use text::{Point, Selection};
use util::TryFutureExt;
use workspace::{FollowableItem, Item, ItemHandle, ItemNavHistory, ProjectItem, StatusItemView};
pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
pub const MAX_TAB_TITLE_LEN: usize = 24;
impl FollowableItem for Editor {
fn from_state_proto(
@ -292,9 +300,44 @@ impl Item for Editor {
}
}
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
let title = self.title(cx);
Label::new(title, style.label.clone()).boxed()
fn tab_description<'a>(&'a self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
match path_for_buffer(&self.buffer, detail, true, cx)? {
Cow::Borrowed(path) => Some(path.to_string_lossy()),
Cow::Owned(path) => Some(path.to_string_lossy().to_string().into()),
}
}
fn tab_content(
&self,
detail: Option<usize>,
style: &theme::Tab,
cx: &AppContext,
) -> ElementBox {
Flex::row()
.with_child(
Label::new(self.title(cx).into(), style.label.clone())
.aligned()
.boxed(),
)
.with_children(detail.and_then(|detail| {
let path = path_for_buffer(&self.buffer, detail, false, cx)?;
let description = path.to_string_lossy();
Some(
Label::new(
if description.len() > MAX_TAB_TITLE_LEN {
description[..MAX_TAB_TITLE_LEN].to_string() + ""
} else {
description.into()
},
style.description.text.clone(),
)
.contained()
.with_style(style.description.container)
.aligned()
.boxed(),
)
}))
.boxed()
}
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
@ -534,3 +577,42 @@ impl StatusItemView for CursorPosition {
cx.notify();
}
}
fn path_for_buffer<'a>(
buffer: &ModelHandle<MultiBuffer>,
mut height: usize,
include_filename: bool,
cx: &'a AppContext,
) -> Option<Cow<'a, Path>> {
let file = buffer.read(cx).as_singleton()?.read(cx).file()?;
// Ensure we always render at least the filename.
height += 1;
let mut prefix = file.path().as_ref();
while height > 0 {
if let Some(parent) = prefix.parent() {
prefix = parent;
height -= 1;
} else {
break;
}
}
// Here we could have just always used `full_path`, but that is very
// allocation-heavy and so we try to use a `Cow<Path>` if we haven't
// traversed all the way up to the worktree's root.
if height > 0 {
let full_path = file.full_path(cx);
if include_filename {
Some(full_path.into())
} else {
Some(full_path.parent().unwrap().to_path_buf().into())
}
} else {
let mut path = file.path().strip_prefix(prefix).unwrap();
if !include_filename {
path = path.parent().unwrap();
}
Some(path.into())
}
}

View file

@ -342,12 +342,11 @@ mod tests {
test();"});
let mut requests =
cx.lsp
.handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(symbol_range),
target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
target_uri: url.clone(),
target_range,
target_selection_range: target_range,
},
@ -387,13 +386,12 @@ mod tests {
// Response without source range still highlights word
cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_mouse_location = None);
let mut requests =
cx.lsp
.handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
// No origin range
origin_selection_range: None,
target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
target_uri: url.clone(),
target_range,
target_selection_range: target_range,
},
@ -495,12 +493,11 @@ mod tests {
test();"});
let mut requests =
cx.lsp
.handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(symbol_range),
target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
target_uri: url,
target_range,
target_selection_range: target_range,
},
@ -584,12 +581,11 @@ mod tests {
test();"});
let mut requests =
cx.lsp
.handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: None,
target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
target_uri: url,
target_range,
target_selection_range: target_range,
},

View file

@ -0,0 +1,103 @@
use context_menu::ContextMenuItem;
use gpui::{geometry::vector::Vector2F, impl_internal_actions, MutableAppContext, ViewContext};
use crate::{
DisplayPoint, Editor, EditorMode, FindAllReferences, GoToDefinition, Rename, SelectMode,
ToggleCodeActions,
};
#[derive(Clone, PartialEq)]
pub struct DeployMouseContextMenu {
pub position: Vector2F,
pub point: DisplayPoint,
}
impl_internal_actions!(editor, [DeployMouseContextMenu]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(deploy_context_menu);
}
pub fn deploy_context_menu(
editor: &mut Editor,
&DeployMouseContextMenu { position, point }: &DeployMouseContextMenu,
cx: &mut ViewContext<Editor>,
) {
// Don't show context menu for inline editors
if editor.mode() != EditorMode::Full {
return;
}
// Don't show the context menu if there isn't a project associated with this editor
if editor.project.is_none() {
return;
}
// Move the cursor to the clicked location so that dispatched actions make sense
editor.change_selections(None, cx, |s| {
s.clear_disjoint();
s.set_pending_display_range(point..point, SelectMode::Character);
});
editor.mouse_context_menu.update(cx, |menu, cx| {
menu.show(
position,
vec![
ContextMenuItem::item("Rename Symbol", Rename),
ContextMenuItem::item("Go To Definition", GoToDefinition),
ContextMenuItem::item("Find All References", FindAllReferences),
ContextMenuItem::item(
"Code Actions",
ToggleCodeActions {
deployed_from_indicator: false,
},
),
],
cx,
);
});
cx.notify();
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use crate::test::EditorLspTestContext;
use super::*;
#[gpui::test]
async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
..Default::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
fn te|st()
do_work();"});
let point = cx.display_point(indoc! {"
fn test()
do_w|ork();"});
cx.update_editor(|editor, cx| {
deploy_context_menu(
editor,
&DeployMouseContextMenu {
position: Default::default(),
point,
},
cx,
)
});
cx.assert_editor_state(indoc! {"
fn test()
do_w|ork();"});
cx.editor(|editor, app| assert!(editor.mouse_context_menu.read(app).visible()));
}
}

View file

@ -14,6 +14,7 @@ use language::{
use settings::Settings;
use smallvec::SmallVec;
use std::{
borrow::Cow,
cell::{Ref, RefCell},
cmp, fmt, io,
iter::{self, FromIterator},
@ -1194,14 +1195,14 @@ impl MultiBuffer {
.collect()
}
pub fn title(&self, cx: &AppContext) -> String {
if let Some(title) = self.title.clone() {
return title;
pub fn title<'a>(&'a self, cx: &'a AppContext) -> Cow<'a, str> {
if let Some(title) = self.title.as_ref() {
return title.into();
}
if let Some(buffer) = self.as_singleton() {
if let Some(file) = buffer.read(cx).file() {
return file.file_name(cx).to_string_lossy().into();
return file.file_name(cx).to_string_lossy();
}
}

View file

@ -384,7 +384,7 @@ impl<'a> MutableSelectionsCollection<'a> {
}
}
pub fn set_pending_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
pub fn set_pending_anchor_range(&mut self, range: Range<Anchor>, mode: SelectMode) {
self.collection.pending = Some(PendingSelection {
selection: Selection {
id: post_inc(&mut self.collection.next_selection_id),
@ -398,6 +398,42 @@ impl<'a> MutableSelectionsCollection<'a> {
self.selections_changed = true;
}
pub fn set_pending_display_range(&mut self, range: Range<DisplayPoint>, mode: SelectMode) {
let (start, end, reversed) = {
let display_map = self.display_map();
let buffer = self.buffer();
let mut start = range.start;
let mut end = range.end;
let reversed = if start > end {
mem::swap(&mut start, &mut end);
true
} else {
false
};
let end_bias = if end > start { Bias::Left } else { Bias::Right };
(
buffer.anchor_before(start.to_point(&display_map)),
buffer.anchor_at(end.to_point(&display_map), end_bias),
reversed,
)
};
let new_pending = PendingSelection {
selection: Selection {
id: post_inc(&mut self.collection.next_selection_id),
start,
end,
reversed,
goal: SelectionGoal::None,
},
mode,
};
self.collection.pending = Some(new_pending);
self.selections_changed = true;
}
pub fn set_pending(&mut self, selection: Selection<Anchor>, mode: SelectMode) {
self.collection.pending = Some(PendingSelection { selection, mode });
self.selections_changed = true;

View file

@ -4,12 +4,14 @@ use std::{
sync::Arc,
};
use futures::StreamExt;
use anyhow::Result;
use futures::{Future, StreamExt};
use indoc::indoc;
use collections::BTreeMap;
use gpui::{json, keymap::Keystroke, AppContext, ModelHandle, ViewContext, ViewHandle};
use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig, Selection};
use lsp::request;
use project::Project;
use settings::Settings;
use util::{
@ -110,6 +112,13 @@ impl<'a> EditorTestContext<'a> {
}
}
pub fn condition(
&self,
predicate: impl FnMut(&Editor, &AppContext) -> bool,
) -> impl Future<Output = ()> {
self.editor.condition(self.cx, predicate)
}
pub fn editor<F, T>(&mut self, read: F) -> T
where
F: FnOnce(&Editor, &AppContext) -> T,
@ -424,6 +433,7 @@ pub struct EditorLspTestContext<'a> {
pub cx: EditorTestContext<'a>,
pub lsp: lsp::FakeLanguageServer,
pub workspace: ViewHandle<Workspace>,
pub editor_lsp_url: lsp::Url,
}
impl<'a> EditorLspTestContext<'a> {
@ -497,6 +507,7 @@ impl<'a> EditorLspTestContext<'a> {
},
lsp,
workspace,
editor_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
}
}
@ -520,11 +531,15 @@ impl<'a> EditorLspTestContext<'a> {
pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
let (unmarked, mut ranges) = marked_text_ranges_by(marked_text, vec![('[', ']').into()]);
assert_eq!(unmarked, self.cx.buffer_text());
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
let offset_range = ranges.remove(&('[', ']').into()).unwrap()[0].clone();
let start_point = offset_range.start.to_point(&snapshot.buffer_snapshot);
let end_point = offset_range.end.to_point(&snapshot.buffer_snapshot);
self.to_lsp_range(offset_range)
}
pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
let start_point = range.start.to_point(&snapshot.buffer_snapshot);
let end_point = range.end.to_point(&snapshot.buffer_snapshot);
self.editor(|editor, cx| {
let buffer = editor.buffer().read(cx);
let start = point_to_lsp(
@ -546,12 +561,45 @@ impl<'a> EditorLspTestContext<'a> {
})
}
pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
let point = offset.to_point(&snapshot.buffer_snapshot);
self.editor(|editor, cx| {
let buffer = editor.buffer().read(cx);
point_to_lsp(
buffer
.point_to_buffer_offset(point, cx)
.unwrap()
.1
.to_point_utf16(&buffer.read(cx)),
)
})
}
pub fn update_workspace<F, T>(&mut self, update: F) -> T
where
F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
{
self.workspace.update(self.cx.cx, update)
}
pub fn handle_request<T, F, Fut>(
&self,
mut handler: F,
) -> futures::channel::mpsc::UnboundedReceiver<()>
where
T: 'static + request::Request,
T::Params: 'static + Send,
F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
Fut: 'static + Send + Future<Output = Result<T::Result>>,
{
let url = self.editor_lsp_url.clone();
self.lsp.handle_request::<T, _, _>(move |params, cx| {
let url = url.clone();
handler(url, params, cx)
})
}
}
impl<'a> Deref for EditorLspTestContext<'a> {

View file

@ -20,7 +20,7 @@ use std::{
any::Any,
cmp::{self, Ordering},
collections::{BTreeMap, HashMap},
ffi::OsString,
ffi::OsStr,
future::Future,
iter::{self, Iterator, Peekable},
mem,
@ -185,7 +185,7 @@ pub trait File: Send + Sync {
/// Returns the last component of this handle's absolute path. If this handle refers to the root
/// of its worktree, then this method will return the name of the worktree itself.
fn file_name(&self, cx: &AppContext) -> OsString;
fn file_name<'a>(&'a self, cx: &'a AppContext) -> &'a OsStr;
fn is_deleted(&self) -> bool;

View file

@ -15,4 +15,4 @@ pollster = "0.2.5"
smol = "1.2.5"
[build-dependencies]
wasmtime = "0.38"
wasmtime = { version = "0.38", features = ["all-arch"] }

View file

@ -152,7 +152,7 @@ Plugins in the `plugins` directory are automatically recompiled and serialized t
- `plugin.wasm` is the plugin compiled to Wasm. As a baseline, this should be about 4MB for debug builds and 2MB for release builds, but it depends on the specific plugin being built.
- `plugin.wasm.pre` is the plugin compiled to Wasm *and additionally* precompiled to host-platform-agnostic cranelift-specific IR. This should be about 700KB for debug builds and 500KB in release builds. Each plugin takes about 1 or 2 seconds to compile to native code using cranelift, so precompiling plugins drastically reduces the startup time required to begin to run a plugin.
- `plugin.wasm.pre` is the plugin compiled to Wasm *and additionally* precompiled to host-platform-specific native code, determined by the `TARGET` cargo exposes at compile-time. This should be about 700KB for debug builds and 500KB in release builds. Each plugin takes about 1 or 2 seconds to compile to native code using cranelift, so precompiling plugins drastically reduces the startup time required to begin to run a plugin.
For all intents and purposes, it is *highly recommended* that you use precompiled plugins where possible, as they are much more lightweight and take much less time to instantiate.
@ -246,18 +246,17 @@ Once all imports are marked, we can instantiate the plugin. To instantiate the p
```rust
let plugin = builder
.init(
true,
include_bytes!("../../../plugins/bin/cool_plugin.wasm.pre"),
PluginBinary::Precompiled(bytes),
)
.await
.unwrap();
```
The `.init` method currently takes two arguments:
The `.init` method takes a single argument containing the plugin binary.
1. First, the 'precompiled' flag, indicating whether the plugin is *normal* (`.wasm`) or precompiled (`.wasm.pre`). When using a precompiled plugin, set this flag to `true`.
1. If not precompiled, use `PluginBinary::Wasm(bytes)`. This supports both the WebAssembly Textual format (`.wat`) and the WebAssembly Binary format (`.wasm`).
2. Second, the raw plugin Wasm itself, as an array of bytes. When not precompiled, this can be either the Wasm binary format (`.wasm`) or the Wasm textual format (`.wat`). When precompiled, this must be the precompiled plugin (`.wasm.pre`).
2. If precompiled, use `PluginBinary::Precompiled(bytes)`. This supports precompiled plugins ending in `.wasm.pre`. You need to be extra-careful when using precompiled plugins to ensure that the plugin target matches the target of the binary you are compiling.
The `.init` method is asynchronous, and must be `.await`ed upon. If the plugin is malformed or doesn't import the right functions, an error will be raised.

View file

@ -26,7 +26,6 @@ fn main() {
"release" => (&["--release"][..], "release"),
unknown => panic!("unknown profile `{}`", unknown),
};
// Invoke cargo to build the plugins
let build_successful = std::process::Command::new("cargo")
.args([
@ -42,8 +41,13 @@ fn main() {
.success();
assert!(build_successful);
// Get the target architecture for pre-cross-compilation of plugins
// and create and engine with the appropriate config
let target_triple = std::env::var("TARGET").unwrap().to_string();
println!("cargo:rerun-if-env-changed=TARGET");
let engine = create_default_engine(&target_triple);
// Find all compiled binaries
let engine = create_default_engine();
let binaries = std::fs::read_dir(base.join("target/wasm32-wasi").join(profile_target))
.expect("Could not find compiled plugins in target");
@ -66,11 +70,17 @@ fn main() {
}
}
/// Creates a default engine for compiling Wasm.
fn create_default_engine() -> Engine {
/// Creates an engine with the default configuration.
/// N.B. This must create an engine with the same config as the one
/// in `plugin_runtime/src/plugin.rs`.
fn create_default_engine(target_triple: &str) -> Engine {
let mut config = Config::default();
config
.target(target_triple)
.expect(&format!("Could not set target to `{}`", target_triple));
config.async_support(true);
Engine::new(&config).expect("Could not create engine")
config.consume_fuel(true);
Engine::new(&config).expect("Could not create precompilation engine")
}
fn precompile(path: &Path, engine: &Engine) {
@ -80,7 +90,7 @@ fn precompile(path: &Path, engine: &Engine) {
.expect("Could not precompile module");
let out_path = path.parent().unwrap().join(&format!(
"{}.pre",
path.file_name().unwrap().to_string_lossy()
path.file_name().unwrap().to_string_lossy(),
));
let mut out_file = std::fs::File::create(out_path)
.expect("Could not create output file for precompiled module");

View file

@ -23,7 +23,7 @@ mod tests {
}
async {
let mut runtime = PluginBuilder::new_fuel_with_default_ctx(PluginYield::default_fuel())
let mut runtime = PluginBuilder::new_default()
.unwrap()
.host_function("mystery_number", |input: u32| input + 7)
.unwrap()

View file

@ -1,6 +1,5 @@
use std::future::Future;
use std::time::Duration;
use std::{fs::File, marker::PhantomData, path::Path};
use anyhow::{anyhow, Error};
@ -55,34 +54,14 @@ impl<A: Serialize, R: DeserializeOwned> Clone for WasiFn<A, R> {
}
}
pub struct PluginYieldEpoch {
delta: u64,
epoch: std::time::Duration,
}
pub struct PluginYieldFuel {
pub struct Metering {
initial: u64,
refill: u64,
}
pub enum PluginYield {
Epoch {
yield_epoch: PluginYieldEpoch,
initialize_incrementer: Box<dyn FnOnce(Engine) -> () + Send>,
},
Fuel(PluginYieldFuel),
}
impl PluginYield {
pub fn default_epoch() -> PluginYieldEpoch {
PluginYieldEpoch {
delta: 1,
epoch: Duration::from_millis(1),
}
}
pub fn default_fuel() -> PluginYieldFuel {
PluginYieldFuel {
impl Default for Metering {
fn default() -> Self {
Metering {
initial: 1000,
refill: 1000,
}
@ -97,110 +76,44 @@ pub struct PluginBuilder {
wasi_ctx: WasiCtx,
engine: Engine,
linker: Linker<WasiCtxAlloc>,
yield_when: PluginYield,
metering: Metering,
}
/// Creates an engine with the default configuration.
/// N.B. This must create an engine with the same config as the one
/// in `plugin_runtime/build.rs`.
fn create_default_engine() -> Result<Engine, Error> {
let mut config = Config::default();
config.async_support(true);
config.consume_fuel(true);
Engine::new(&config)
}
impl PluginBuilder {
/// Creates an engine with the proper configuration given the yield mechanism in use
fn create_engine(yield_when: &PluginYield) -> Result<(Engine, Linker<WasiCtxAlloc>), Error> {
let mut config = Config::default();
config.async_support(true);
match yield_when {
PluginYield::Epoch { .. } => {
config.epoch_interruption(true);
}
PluginYield::Fuel(_) => {
config.consume_fuel(true);
}
}
let engine = Engine::new(&config)?;
let linker = Linker::new(&engine);
Ok((engine, linker))
}
/// Create a new [`PluginBuilder`] with the given WASI context.
/// Using the default context is a safe bet, see [`new_with_default_context`].
/// This plugin will yield after each fixed configurable epoch.
pub fn new_epoch<C>(
wasi_ctx: WasiCtx,
yield_epoch: PluginYieldEpoch,
spawn_detached_future: C,
) -> Result<Self, Error>
where
C: FnOnce(std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>>) -> ()
+ Send
+ 'static,
{
// we can't create the future until after initializing
// because we need the engine to load the plugin
let epoch = yield_epoch.epoch;
let initialize_incrementer = Box::new(move |engine: Engine| {
spawn_detached_future(Box::pin(async move {
loop {
smol::Timer::after(epoch).await;
engine.increment_epoch();
}
}))
});
let yield_when = PluginYield::Epoch {
yield_epoch,
initialize_incrementer,
};
let (engine, linker) = Self::create_engine(&yield_when)?;
Ok(PluginBuilder {
wasi_ctx,
engine,
linker,
yield_when,
})
}
/// Create a new [`PluginBuilder`] with the given WASI context.
/// Using the default context is a safe bet, see [`new_with_default_context`].
/// This plugin will yield after a configurable amount of fuel is consumed.
pub fn new_fuel(wasi_ctx: WasiCtx, yield_fuel: PluginYieldFuel) -> Result<Self, Error> {
let yield_when = PluginYield::Fuel(yield_fuel);
let (engine, linker) = Self::create_engine(&yield_when)?;
pub fn new(wasi_ctx: WasiCtx, metering: Metering) -> Result<Self, Error> {
let engine = create_default_engine()?;
let linker = Linker::new(&engine);
Ok(PluginBuilder {
wasi_ctx,
engine,
linker,
yield_when,
metering,
})
}
/// Create a new `WasiCtx` that inherits the
/// host processes' access to `stdout` and `stderr`.
fn default_ctx() -> WasiCtx {
WasiCtxBuilder::new()
/// Create a new `PluginBuilder` with the default `WasiCtx` (see [`default_ctx`]).
/// This plugin will yield after a configurable amount of fuel is consumed.
pub fn new_default() -> Result<Self, Error> {
let default_ctx = WasiCtxBuilder::new()
.inherit_stdout()
.inherit_stderr()
.build()
}
/// Create a new `PluginBuilder` with the default `WasiCtx` (see [`default_ctx`]).
/// This plugin will yield after each fixed configurable epoch.
pub fn new_epoch_with_default_ctx<C>(
yield_epoch: PluginYieldEpoch,
spawn_detached_future: C,
) -> Result<Self, Error>
where
C: FnOnce(std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'static>>) -> ()
+ Send
+ 'static,
{
Self::new_epoch(Self::default_ctx(), yield_epoch, spawn_detached_future)
}
/// Create a new `PluginBuilder` with the default `WasiCtx` (see [`default_ctx`]).
/// This plugin will yield after a configurable amount of fuel is consumed.
pub fn new_fuel_with_default_ctx(yield_fuel: PluginYieldFuel) -> Result<Self, Error> {
Self::new_fuel(Self::default_ctx(), yield_fuel)
.build();
let metering = Metering::default();
Self::new(default_ctx, metering)
}
/// Add an `async` host function. See [`host_function`] for details.
@ -433,19 +346,8 @@ impl Plugin {
};
// set up automatic yielding based on configuration
match plugin.yield_when {
PluginYield::Epoch {
yield_epoch: PluginYieldEpoch { delta, .. },
initialize_incrementer,
} => {
store.epoch_deadline_async_yield_and_update(delta);
initialize_incrementer(engine);
}
PluginYield::Fuel(PluginYieldFuel { initial, refill }) => {
store.add_fuel(initial).unwrap();
store.out_of_fuel_async_yield(u64::MAX, refill);
}
}
store.add_fuel(plugin.metering.initial).unwrap();
store.out_of_fuel_async_yield(u64::MAX, plugin.metering.refill);
// load the provided module into the asynchronous runtime
linker.module_async(&mut store, "", &module).await?;

View file

@ -1646,11 +1646,10 @@ impl language::File for File {
/// Returns the last component of this handle's absolute path. If this handle refers to the root
/// of its worktree, then this method will return the name of the worktree itself.
fn file_name(&self, cx: &AppContext) -> OsString {
fn file_name<'a>(&'a self, cx: &'a AppContext) -> &'a OsStr {
self.path
.file_name()
.map(|name| name.into())
.unwrap_or_else(|| OsString::from(&self.worktree.read(cx).root_name))
.unwrap_or_else(|| OsStr::new(&self.worktree.read(cx).root_name))
}
fn is_deleted(&self) -> bool {

View file

@ -4,7 +4,7 @@ use crate::{
ToggleWholeWord,
};
use collections::HashMap;
use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll};
use editor::{Anchor, Autoscroll, Editor, MultiBuffer, SelectAll, MAX_TAB_TITLE_LEN};
use gpui::{
actions, elements::*, platform::CursorStyle, Action, AppContext, ElementBox, Entity,
ModelContext, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View,
@ -26,8 +26,6 @@ use workspace::{
actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);
const MAX_TAB_TITLE_LEN: usize = 24;
#[derive(Default)]
struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
@ -220,7 +218,12 @@ impl Item for ProjectSearchView {
.update(cx, |editor, cx| editor.deactivated(cx));
}
fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
fn tab_content(
&self,
_detail: Option<usize>,
tab_theme: &theme::Tab,
cx: &gpui::AppContext,
) -> ElementBox {
let settings = cx.global::<Settings>();
let search_theme = &settings.theme.search;
Flex::row()

View file

@ -25,6 +25,7 @@ pub struct Settings {
pub buffer_font_size: f32,
pub default_buffer_font_size: f32,
pub hover_popover_enabled: bool,
pub show_completions_on_input: bool,
pub vim_mode: bool,
pub autosave: Autosave,
pub editor_defaults: EditorSettings,
@ -83,6 +84,8 @@ pub struct SettingsFileContent {
#[serde(default)]
pub hover_popover_enabled: Option<bool>,
#[serde(default)]
pub show_completions_on_input: Option<bool>,
#[serde(default)]
pub vim_mode: Option<bool>,
#[serde(default)]
pub autosave: Option<Autosave>,
@ -118,6 +121,7 @@ impl Settings {
buffer_font_size: defaults.buffer_font_size.unwrap(),
default_buffer_font_size: defaults.buffer_font_size.unwrap(),
hover_popover_enabled: defaults.hover_popover_enabled.unwrap(),
show_completions_on_input: defaults.show_completions_on_input.unwrap(),
projects_online_by_default: defaults.projects_online_by_default.unwrap(),
vim_mode: defaults.vim_mode.unwrap(),
autosave: defaults.autosave.unwrap(),
@ -160,6 +164,10 @@ impl Settings {
merge(&mut self.buffer_font_size, data.buffer_font_size);
merge(&mut self.default_buffer_font_size, data.buffer_font_size);
merge(&mut self.hover_popover_enabled, data.hover_popover_enabled);
merge(
&mut self.show_completions_on_input,
data.show_completions_on_input,
);
merge(&mut self.vim_mode, data.vim_mode);
merge(&mut self.autosave, data.autosave);
@ -219,6 +227,7 @@ impl Settings {
buffer_font_size: 14.,
default_buffer_font_size: 14.,
hover_popover_enabled: true,
show_completions_on_input: true,
vim_mode: false,
autosave: Autosave::Off,
editor_defaults: EditorSettings {
@ -248,7 +257,7 @@ impl Settings {
pub fn settings_file_json_schema(
theme_names: Vec<String>,
language_names: Vec<String>,
language_names: &[String],
) -> serde_json::Value {
let settings = SchemaSettings::draft07().with(|settings| {
settings.option_add_null_type = false;
@ -275,8 +284,13 @@ pub fn settings_file_json_schema(
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))),
object: Some(Box::new(ObjectValidation {
properties: language_names
.into_iter()
.map(|name| (name, Schema::new_ref("#/definitions/EditorSettings".into())))
.iter()
.map(|name| {
(
name.clone(),
Schema::new_ref("#/definitions/EditorSettings".into()),
)
})
.collect(),
..Default::default()
})),

View file

@ -16,8 +16,11 @@ pub fn deploy_modal(workspace: &mut Workspace, _: &DeployModal, cx: &mut ViewCon
if let Some(StoredConnection(stored_connection)) = possible_connection {
// Create a view from the stored connection
workspace.toggle_modal(cx, |_, cx| {
cx.add_view(|cx| Terminal::from_connection(stored_connection, true, cx))
cx.add_view(|cx| Terminal::from_connection(stored_connection.clone(), true, cx))
});
cx.set_global::<Option<StoredConnection>>(Some(StoredConnection(
stored_connection.clone(),
)));
} else {
// No connection was stored, create a new terminal
if let Some(closed_terminal_handle) = workspace.toggle_modal(cx, |workspace, cx| {

View file

@ -261,7 +261,12 @@ impl View for Terminal {
}
impl Item for Terminal {
fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
fn tab_content(
&self,
_detail: Option<usize>,
tab_theme: &theme::Tab,
cx: &gpui::AppContext,
) -> ElementBox {
let settings = cx.global::<Settings>();
let search_theme = &settings.theme.search; //TODO properly integrate themes
@ -405,7 +410,7 @@ mod tests {
///Basic integration test, can we get the terminal to show up, execute a command,
//and produce noticable output?
#[gpui::test]
#[gpui::test(retries = 5)]
async fn test_terminal(cx: &mut TestAppContext) {
let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
@ -416,7 +421,7 @@ mod tests {
terminal.enter(&Enter, cx);
});
cx.set_condition_duration(Some(Duration::from_secs(2)));
cx.set_condition_duration(Some(Duration::from_secs(5)));
terminal
.condition(cx, |terminal, cx| {
let term = terminal.connection.read(cx).term.clone();

View file

@ -93,6 +93,7 @@ pub struct Tab {
pub container: ContainerStyle,
#[serde(flatten)]
pub label: LabelStyle,
pub description: ContainedText,
pub spacing: f32,
pub icon_width: f32,
pub icon_close: Color,

View file

@ -24,7 +24,7 @@ pub fn marked_text(marked_text: &str) -> (String, Vec<usize>) {
(unmarked_text, markers.remove(&'|').unwrap_or_default())
}
#[derive(Eq, PartialEq, Hash)]
#[derive(Clone, Eq, PartialEq, Hash)]
pub enum TextRangeMarker {
Empty(char),
Range(char, char),

View file

@ -71,10 +71,10 @@ const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(|pane: &mut Pane, action: &ActivateItem, cx| {
pane.activate_item(action.0, true, true, cx);
pane.activate_item(action.0, true, true, false, cx);
});
cx.add_action(|pane: &mut Pane, _: &ActivateLastItem, cx| {
pane.activate_item(pane.items.len() - 1, true, true, cx);
pane.activate_item(pane.items.len() - 1, true, true, false, cx);
});
cx.add_action(|pane: &mut Pane, _: &ActivatePrevItem, cx| {
pane.activate_prev_item(cx);
@ -288,7 +288,7 @@ impl Pane {
{
let prev_active_item_index = pane.active_item_index;
pane.nav_history.borrow_mut().set_mode(mode);
pane.activate_item(index, true, true, cx);
pane.activate_item(index, true, true, false, cx);
pane.nav_history
.borrow_mut()
.set_mode(NavigationMode::Normal);
@ -380,7 +380,7 @@ impl Pane {
&& item.project_entry_ids(cx).as_slice() == &[project_entry_id]
{
let item = item.boxed_clone();
pane.activate_item(ix, true, focus_item, cx);
pane.activate_item(ix, true, focus_item, true, cx);
return Some(item);
}
}
@ -404,9 +404,11 @@ impl Pane {
cx: &mut ViewContext<Workspace>,
) {
// Prevent adding the same item to the pane more than once.
// If there is already an active item, reorder the desired item to be after it
// and activate it.
if let Some(item_ix) = pane.read(cx).items.iter().position(|i| i.id() == item.id()) {
pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, activate_pane, focus_item, cx)
pane.activate_item(item_ix, activate_pane, focus_item, true, cx)
});
return;
}
@ -426,7 +428,7 @@ impl Pane {
};
pane.items.insert(item_ix, item);
pane.activate_item(item_ix, activate_pane, focus_item, cx);
pane.activate_item(item_ix, activate_pane, focus_item, false, cx);
cx.notify();
});
}
@ -465,13 +467,31 @@ impl Pane {
pub fn activate_item(
&mut self,
index: usize,
mut index: usize,
activate_pane: bool,
focus_item: bool,
move_after_current_active: bool,
cx: &mut ViewContext<Self>,
) {
use NavigationMode::{GoingBack, GoingForward};
if index < self.items.len() {
if move_after_current_active {
// If there is already an active item, reorder the desired item to be after it
// and activate it.
if self.active_item_index != index && self.active_item_index < self.items.len() {
let pane_to_activate = self.items.remove(index);
if self.active_item_index < index {
index = self.active_item_index + 1;
} else if self.active_item_index < self.items.len() + 1 {
index = self.active_item_index;
// Index is less than active_item_index. Reordering will decrement the
// active_item_index, so adjust it accordingly
self.active_item_index = index - 1;
}
self.items.insert(index, pane_to_activate);
}
}
let prev_active_item_ix = mem::replace(&mut self.active_item_index, index);
if prev_active_item_ix != self.active_item_index
|| matches!(self.nav_history.borrow().mode, GoingBack | GoingForward)
@ -502,7 +522,7 @@ impl Pane {
} else if self.items.len() > 0 {
index = self.items.len() - 1;
}
self.activate_item(index, true, true, cx);
self.activate_item(index, true, true, false, cx);
}
pub fn activate_next_item(&mut self, cx: &mut ViewContext<Self>) {
@ -512,7 +532,7 @@ impl Pane {
} else {
index = 0;
}
self.activate_item(index, true, true, cx);
self.activate_item(index, true, true, false, cx);
}
pub fn close_active_item(
@ -641,10 +661,13 @@ impl Pane {
pane.update(&mut cx, |pane, cx| {
if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) {
if item_ix == pane.active_item_index {
if item_ix + 1 < pane.items.len() {
pane.activate_next_item(cx);
} else if item_ix > 0 {
// Activate the previous item if possible.
// This returns the user to the previously opened tab if they closed
// a ne item they just navigated to.
if item_ix > 0 {
pane.activate_prev_item(cx);
} else if item_ix + 1 < pane.items.len() {
pane.activate_next_item(cx);
}
}
@ -712,7 +735,7 @@ impl Pane {
if has_conflict && can_save {
let mut answer = pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, true, true, cx);
pane.activate_item(item_ix, true, true, false, cx);
cx.prompt(
PromptLevel::Warning,
CONFLICT_MESSAGE,
@ -733,7 +756,7 @@ impl Pane {
});
let should_save = if should_prompt_for_save && !will_autosave {
let mut answer = pane.update(cx, |pane, cx| {
pane.activate_item(item_ix, true, true, cx);
pane.activate_item(item_ix, true, true, false, cx);
cx.prompt(
PromptLevel::Warning,
DIRTY_MESSAGE,
@ -840,8 +863,10 @@ impl Pane {
} else {
None
};
let mut row = Flex::row().scrollable::<Tabs, _>(1, autoscroll, cx);
for (ix, item) in self.items.iter().enumerate() {
for (ix, (item, detail)) in self.items.iter().zip(self.tab_details(cx)).enumerate() {
let detail = if detail == 0 { None } else { Some(detail) };
let is_active = ix == self.active_item_index;
row.add_child({
@ -850,7 +875,7 @@ impl Pane {
} else {
theme.workspace.tab.clone()
};
let title = item.tab_content(&tab_style, cx);
let title = item.tab_content(detail, &tab_style, cx);
let mut style = if is_active {
theme.workspace.active_tab.clone()
@ -971,6 +996,43 @@ impl Pane {
row.boxed()
})
}
fn tab_details(&self, cx: &AppContext) -> Vec<usize> {
let mut tab_details = (0..self.items.len()).map(|_| 0).collect::<Vec<_>>();
let mut tab_descriptions = HashMap::default();
let mut done = false;
while !done {
done = true;
// Store item indices by their tab description.
for (ix, (item, detail)) in self.items.iter().zip(&tab_details).enumerate() {
if let Some(description) = item.tab_description(*detail, cx) {
if *detail == 0
|| Some(&description) != item.tab_description(detail - 1, cx).as_ref()
{
tab_descriptions
.entry(description)
.or_insert(Vec::new())
.push(ix);
}
}
}
// If two or more items have the same tab description, increase their level
// of detail and try again.
for (_, item_ixs) in tab_descriptions.drain() {
if item_ixs.len() > 1 {
done = false;
for ix in item_ixs {
tab_details[ix] += 1;
}
}
}
}
tab_details
}
}
impl Entity for Pane {

View file

@ -256,7 +256,11 @@ pub trait Item: View {
fn navigate(&mut self, _: Box<dyn Any>, _: &mut ViewContext<Self>) -> bool {
false
}
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
fn tab_description<'a>(&'a self, _: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
None
}
fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
-> ElementBox;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
fn is_singleton(&self, cx: &AppContext) -> bool;
@ -409,7 +413,9 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
}
pub trait ItemHandle: 'static + fmt::Debug {
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox;
fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>>;
fn tab_content(&self, detail: Option<usize>, style: &theme::Tab, cx: &AppContext)
-> ElementBox;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
fn is_singleton(&self, cx: &AppContext) -> bool;
@ -463,8 +469,17 @@ impl dyn ItemHandle {
}
impl<T: Item> ItemHandle for ViewHandle<T> {
fn tab_content(&self, style: &theme::Tab, cx: &AppContext) -> ElementBox {
self.read(cx).tab_content(style, cx)
fn tab_description<'a>(&self, detail: usize, cx: &'a AppContext) -> Option<Cow<'a, str>> {
self.read(cx).tab_description(detail, cx)
}
fn tab_content(
&self,
detail: Option<usize>,
style: &theme::Tab,
cx: &AppContext,
) -> ElementBox {
self.read(cx).tab_content(detail, style, cx)
}
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
@ -562,7 +577,7 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
if T::should_activate_item_on_event(event) {
pane.update(cx, |pane, cx| {
if let Some(ix) = pane.index_for_item(&item) {
pane.activate_item(ix, true, true, cx);
pane.activate_item(ix, true, true, false, cx);
pane.activate(cx);
}
});
@ -1507,7 +1522,7 @@ impl Workspace {
});
if let Some((pane, ix)) = result {
self.activate_pane(pane.clone(), cx);
pane.update(cx, |pane, cx| pane.activate_item(ix, true, true, cx));
pane.update(cx, |pane, cx| pane.activate_item(ix, true, true, false, cx));
true
} else {
false
@ -2686,11 +2701,62 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext};
use project::{FakeFs, Project, ProjectEntryId};
use serde_json::json;
#[gpui::test]
async fn test_tab_disambiguation(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
Settings::test_async(cx);
let fs = FakeFs::new(cx.background());
let project = Project::test(fs, [], cx).await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
// Adding an item with no ambiguity renders the tab without detail.
let item1 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.tab_descriptions = Some(vec!["c", "b1/c", "a/b1/c"]);
item
});
workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item1.clone()), cx);
});
item1.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), None));
// Adding an item that creates ambiguity increases the level of detail on
// both tabs.
let item2 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.tab_descriptions = Some(vec!["c", "b2/c", "a/b2/c"]);
item
});
workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item2.clone()), cx);
});
item1.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), Some(1)));
item2.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), Some(1)));
// Adding an item that creates ambiguity increases the level of detail only
// on the ambiguous tabs. In this case, the ambiguity can't be resolved so
// we stop at the highest detail available.
let item3 = cx.add_view(window_id, |_| {
let mut item = TestItem::new();
item.tab_descriptions = Some(vec!["c", "b2/c", "a/b2/c"]);
item
});
workspace.update(cx, |workspace, cx| {
workspace.add_item(Box::new(item3.clone()), cx);
});
item1.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), Some(1)));
item2.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), Some(3)));
item3.read_with(cx, |item, _| assert_eq!(item.tab_detail.get(), Some(3)));
}
#[gpui::test]
async fn test_tracking_active_path(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
@ -2880,7 +2946,7 @@ mod tests {
let close_items = workspace.update(cx, |workspace, cx| {
pane.update(cx, |pane, cx| {
pane.activate_item(1, true, true, cx);
pane.activate_item(1, true, true, false, cx);
assert_eq!(pane.active_item().unwrap().id(), item2.id());
});
@ -3211,6 +3277,8 @@ mod tests {
project_entry_ids: Vec<ProjectEntryId>,
project_path: Option<ProjectPath>,
nav_history: Option<ItemNavHistory>,
tab_descriptions: Option<Vec<&'static str>>,
tab_detail: Cell<Option<usize>>,
}
enum TestItemEvent {
@ -3230,6 +3298,8 @@ mod tests {
project_entry_ids: self.project_entry_ids.clone(),
project_path: self.project_path.clone(),
nav_history: None,
tab_descriptions: None,
tab_detail: Default::default(),
}
}
}
@ -3247,6 +3317,8 @@ mod tests {
project_path: None,
is_singleton: true,
nav_history: None,
tab_descriptions: None,
tab_detail: Default::default(),
}
}
@ -3277,7 +3349,15 @@ mod tests {
}
impl Item for TestItem {
fn tab_content(&self, _: &theme::Tab, _: &AppContext) -> ElementBox {
fn tab_description<'a>(&'a self, detail: usize, _: &'a AppContext) -> Option<Cow<'a, str>> {
self.tab_descriptions.as_ref().and_then(|descriptions| {
let description = *descriptions.get(detail).or(descriptions.last())?;
Some(description.into())
})
}
fn tab_content(&self, detail: Option<usize>, _: &theme::Tab, _: &AppContext) -> ElementBox {
self.tab_detail.set(detail);
Empty::new().boxed()
}

View file

@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
version = "0.46.0"
version = "0.47.1"
[lib]
name = "zed"

View file

@ -1,5 +1,6 @@
use gpui::executor::Background;
pub use language::*;
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use std::{borrow::Cow, str, sync::Arc};
use util::ResultExt;
@ -17,6 +18,21 @@ mod typescript;
#[exclude = "*.rs"]
struct LanguageDir;
// TODO - Remove this once the `init` function is synchronous again.
lazy_static! {
pub static ref LANGUAGE_NAMES: Vec<String> = LanguageDir::iter()
.filter_map(|path| {
if path.ends_with("config.toml") {
let config = LanguageDir::get(&path)?;
let config = toml::from_slice::<LanguageConfig>(&config.data).ok()?;
Some(config.name.to_string())
} else {
None
}
})
.collect();
}
pub async fn init(languages: Arc<LanguageRegistry>, executor: Arc<Background>) {
for (name, grammar, lsp_adapter) in [
(

View file

@ -5,16 +5,12 @@ use collections::HashMap;
use futures::lock::Mutex;
use gpui::executor::Background;
use language::{LanguageServerName, LspAdapter};
use plugin_runtime::{Plugin, PluginBinary, PluginBuilder, PluginYield, WasiFn};
use plugin_runtime::{Plugin, PluginBinary, PluginBuilder, WasiFn};
use std::{any::Any, path::PathBuf, sync::Arc};
use util::ResultExt;
pub async fn new_json(executor: Arc<Background>) -> Result<PluginLspAdapter> {
let executor_ref = executor.clone();
let plugin =
PluginBuilder::new_epoch_with_default_ctx(PluginYield::default_epoch(), move |future| {
executor_ref.spawn(future).detach()
})?
let plugin = PluginBuilder::new_default()?
.host_function_async("command", |command: String| async move {
let mut args = command.split(' ');
let command = args.next().unwrap();
@ -26,7 +22,7 @@ pub async fn new_json(executor: Arc<Background>) -> Result<PluginLspAdapter> {
.map(|output| output.stdout)
})?
.init(PluginBinary::Precompiled(include_bytes!(
"../../../../plugins/bin/json_language.wasm.pre"
"../../../../plugins/bin/json_language.wasm.pre",
)))
.await?;
@ -46,6 +42,7 @@ pub struct PluginLspAdapter {
}
impl PluginLspAdapter {
#[allow(unused)]
pub async fn new(mut plugin: Plugin, executor: Arc<Background>) -> Result<Self> {
Ok(Self {
name: plugin.function("name")?,

View file

@ -102,14 +102,14 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
let app_state = app_state.clone();
move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
open_config_file(&SETTINGS_PATH, app_state.clone(), cx, || {
let header = Assets.load("settings/header-comments.json").unwrap();
let json = Assets.load("settings/default.json").unwrap();
let header = str::from_utf8(header.as_ref()).unwrap();
let json = str::from_utf8(json.as_ref()).unwrap();
let mut content = Rope::new();
content.push(header);
content.push(json);
content
str::from_utf8(
Assets
.load("settings/initial_user_settings.json")
.unwrap()
.as_ref(),
)
.unwrap()
.into()
});
}
});
@ -209,7 +209,7 @@ pub fn initialize_workspace(
cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone()));
let theme_names = app_state.themes.list().collect();
let language_names = app_state.languages.language_names();
let language_names = &languages::LANGUAGE_NAMES;
workspace.project().update(cx, |project, cx| {
let action_names = cx.all_action_names().collect::<Vec<_>>();

View file

@ -1,7 +1,14 @@
import Theme from "../themes/common/theme";
import { border, modalShadow } from "./components";
import { border, modalShadow, player } from "./components";
export default function terminal(theme: Theme) {
/**
* Colors are controlled per-cell in the terminal grid.
* Cells can be set to any of these more 'theme-capable' colors
* or can be set directly with RGB values.
* Here are the common interpretations of these names:
* https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
*/
let colors = {
black: theme.ramps.neutral(0).hex(),
red: theme.ramps.red(0.5).hex(),
@ -11,7 +18,7 @@ export default function terminal(theme: Theme) {
magenta: theme.ramps.magenta(0.5).hex(),
cyan: theme.ramps.cyan(0.5).hex(),
white: theme.ramps.neutral(7).hex(),
brightBlack: theme.ramps.neutral(2).hex(),
brightBlack: theme.ramps.neutral(4).hex(),
brightRed: theme.ramps.red(0.25).hex(),
brightGreen: theme.ramps.green(0.25).hex(),
brightYellow: theme.ramps.yellow(0.25).hex(),
@ -19,10 +26,19 @@ export default function terminal(theme: Theme) {
brightMagenta: theme.ramps.magenta(0.25).hex(),
brightCyan: theme.ramps.cyan(0.25).hex(),
brightWhite: theme.ramps.neutral(7).hex(),
/**
* Default color for characters
*/
foreground: theme.ramps.neutral(7).hex(),
/**
* Default color for the rectangle background of a cell
*/
background: theme.ramps.neutral(0).hex(),
modalBackground: theme.ramps.neutral(1).hex(),
cursor: theme.ramps.neutral(7).hex(),
/**
* Default color for the cursor
*/
cursor: player(theme, 1).selection.cursor,
dimBlack: theme.ramps.neutral(7).hex(),
dimRed: theme.ramps.red(0.75).hex(),
dimGreen: theme.ramps.green(0.75).hex(),

View file

@ -27,6 +27,10 @@ export default function workspace(theme: Theme) {
left: 8,
right: 8,
},
description: {
margin: { left: 6, top: 1 },
...text(theme, "sans", "muted", { size: "2xs" })
}
};
const activeTab = {