Add preview tabs (#9125)

This PR implements the preview tabs feature from VSCode.
More details and thanks for the head start of the implementation here
#6782.

Here is what I have observed from using the vscode implementation ([x]
-> already implemented):
- [x] Single click on project file opens tab as preview
- [x] Double click on item in project panel opens tab as permanent
- [x] Double click on the tab makes it permanent
- [x] Navigating away from the tab makes the tab permanent and the new
tab is shown as preview (e.g. GoToReference)
- [x] Existing preview tab is reused when opening a new tab
- [x] Dragging tab to the same/another panel makes the tab permanent
- [x] Opening a tab from the file finder makes the tab permanent
- [x] Editing a preview tab will make the tab permanent
- [x] Using the space key in the project panel opens the tab as preview
- [x] Handle navigation history correctly (restore a preview tab as
preview as well)
- [x] Restore preview tabs after restarting
- [x] Support opening files from file finder in preview mode (vscode:
"Enable Preview From Quick Open")
 
I need to do some more testing of the vscode implementation, there might
be other behaviors/workflows which im not aware of that open an item as
preview/make them permanent.

Showcase:


https://github.com/zed-industries/zed/assets/53836821/9be16515-c740-4905-bea1-88871112ef86


TODOs
- [x] Provide `enable_preview_tabs` setting
- [x] Write some tests
- [x] How should we handle this in collaboration mode (have not tested
the behavior so far)
- [x] Keyboard driven usage (probably need workspace commands)
- [x] Register `TogglePreviewTab` only when setting enabled?
- [x] Render preview tabs in tab switcher as italic
- [x] Render preview tabs in image viewer as italic
- [x] Should this be enabled by default (it is the default behavior in
VSCode)?
- [x] Docs

Future improvements (out of scope for now):
- Support preview mode for find all references and possibly other
multibuffers (VSCode: "Enable Preview From Code Navigation")


Release Notes:

- Added preview tabs
([#4922](https://github.com/zed-industries/zed/issues/4922)).

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Bennet Bo Fenner 2024-04-11 23:09:12 +02:00 committed by GitHub
parent edb1ea2433
commit ea4419076e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 783 additions and 152 deletions

1
Cargo.lock generated
View File

@ -3772,6 +3772,7 @@ dependencies = [
"picker",
"project",
"serde_json",
"settings",
"text",
"theme",
"ui",

View File

@ -298,6 +298,16 @@
// Position of the close button on the editor tabs.
"close_position": "right"
},
// Settings related to preview tabs.
"preview_tabs": {
// Whether preview tabs should be enabled.
// Preview tabs allow you to open files in preview mode, where they close automatically
// when you switch to another file unless you explicitly pin them.
// This is useful for quickly viewing files without cluttering your workspace.
"enabled": true,
// Whether to open files in preview mode when selected from the file finder.
"enable_preview_from_file_finder": false
},
// Whether or not to remove any trailing whitespace from lines of a buffer
// before saving it.
"remove_trailing_whitespace_on_save": true,

View File

@ -42,6 +42,7 @@ use std::{
time::Duration,
};
use unindent::Unindent as _;
use workspace::Pane;
#[ctor::ctor]
fn init_logger() {
@ -6127,3 +6128,269 @@ async fn test_join_after_restart(cx1: &mut TestAppContext, cx2: &mut TestAppCont
let client2 = server.create_client(cx2, "user_a").await;
join_channel(channel2, &client2, cx2).await.unwrap();
}
#[gpui::test]
async fn test_preview_tabs(cx: &mut TestAppContext) {
let (_server, client) = TestServer::start1(cx).await;
let (workspace, cx) = client.build_test_workspace(cx).await;
let project = workspace.update(cx, |workspace, _| workspace.project().clone());
let worktree_id = project.update(cx, |project, cx| {
project.worktrees().next().unwrap().read(cx).id()
});
let path_1 = ProjectPath {
worktree_id,
path: Path::new("1.txt").into(),
};
let path_2 = ProjectPath {
worktree_id,
path: Path::new("2.js").into(),
};
let path_3 = ProjectPath {
worktree_id,
path: Path::new("3.rs").into(),
};
let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
let get_path = |pane: &Pane, idx: usize, cx: &AppContext| {
pane.item_for_index(idx).unwrap().project_path(cx).unwrap()
};
// Opening item 3 as a "permanent" tab
workspace
.update(cx, |workspace, cx| {
workspace.open_path(path_3.clone(), None, false, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(pane.preview_item_id(), None);
assert!(!pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Open item 1 as preview
workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path_1.clone(), None, true, true, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(get_path(pane, 1, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Open item 2 as preview
workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(get_path(pane, 1, cx), path_2.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Going back should show item 1 as preview
workspace
.update(cx, |workspace, cx| workspace.go_back(pane.downgrade(), cx))
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(get_path(pane, 1, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(pane.can_navigate_forward());
});
// Closing item 1
pane.update(cx, |pane, cx| {
pane.close_item_by_id(
pane.active_item().unwrap().item_id(),
workspace::SaveIntent::Skip,
cx,
)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(pane.preview_item_id(), None);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Going back should show item 1 as preview
workspace
.update(cx, |workspace, cx| workspace.go_back(pane.downgrade(), cx))
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_3.clone());
assert_eq!(get_path(pane, 1, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(pane.can_navigate_forward());
});
// Close permanent tab
pane.update(cx, |pane, cx| {
let id = pane.items().nth(0).unwrap().item_id();
pane.close_item_by_id(id, workspace::SaveIntent::Skip, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(0).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(pane.can_navigate_forward());
});
// Split pane to the right
pane.update(cx, |pane, cx| {
pane.split(workspace::SplitDirection::Right, cx);
});
let right_pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(0).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(pane.can_navigate_forward());
});
right_pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(pane.preview_item_id(), None);
assert!(!pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Open item 2 as preview in right pane
workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(0).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(pane.can_navigate_forward());
});
right_pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(get_path(pane, 1, cx), path_2.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
// Focus left pane
workspace.update(cx, |workspace, cx| {
workspace.activate_pane_in_direction(workspace::SplitDirection::Left, cx)
});
// Open item 2 as preview in left pane
workspace
.update(cx, |workspace, cx| {
workspace.open_path_preview(path_2.clone(), None, true, true, cx)
})
.await
.unwrap();
pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 1);
assert_eq!(get_path(pane, 0, cx), path_2.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(0).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
right_pane.update(cx, |pane, cx| {
assert_eq!(pane.items_len(), 2);
assert_eq!(get_path(pane, 0, cx), path_1.clone());
assert_eq!(get_path(pane, 1, cx), path_2.clone());
assert_eq!(
pane.preview_item_id(),
Some(pane.items().nth(1).unwrap().item_id())
);
assert!(pane.can_navigate_backward());
assert!(!pane.can_navigate_forward());
});
}

View File

@ -24,7 +24,7 @@ use ui::{prelude::*, Label};
use util::ResultExt;
use workspace::notifications::NotificationId;
use workspace::{
item::{FollowableItem, Item, ItemEvent, ItemHandle},
item::{FollowableItem, Item, ItemEvent, ItemHandle, TabContentParams},
register_followable_item,
searchable::SearchableItemHandle,
ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId,
@ -374,7 +374,7 @@ impl Item for ChannelView {
}
}
fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
let label = if let Some(channel) = self.channel(cx) {
match (
self.channel_buffer.read(cx).buffer().read(cx).read_only(),
@ -388,7 +388,7 @@ impl Item for ChannelView {
"channel notes (disconnected)".to_string()
};
Label::new(label)
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -38,7 +38,7 @@ pub use toolbar_controls::ToolbarControls;
use ui::{h_flex, prelude::*, Icon, IconName, Label};
use util::TryFutureExt;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
};
@ -645,10 +645,10 @@ impl Item for ProjectDiagnosticsEditor {
Some("Project Diagnostics".into())
}
fn tab_content(&self, _detail: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
if self.summary.error_count == 0 && self.summary.warning_count == 0 {
Label::new("No problems")
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted
@ -663,7 +663,7 @@ impl Item for ProjectDiagnosticsEditor {
.gap_1()
.child(Icon::new(IconName::XCircle).color(Color::Error))
.child(Label::new(self.summary.error_count.to_string()).color(
if selected {
if params.selected {
Color::Default
} else {
Color::Muted
@ -677,7 +677,7 @@ impl Item for ProjectDiagnosticsEditor {
.gap_1()
.child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning))
.child(Label::new(self.summary.warning_count.to_string()).color(
if selected {
if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -19,7 +19,7 @@ use project::repository::GitFileStatus;
use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
use rpc::proto::{self, update_view, PeerId};
use settings::Settings;
use workspace::item::ItemSettings;
use workspace::item::{ItemSettings, TabContentParams};
use std::{
borrow::Cow,
@ -594,7 +594,7 @@ impl Item for Editor {
Some(path.to_string_lossy().to_string().into())
}
fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
let label_color = if ItemSettings::get_global(cx).git_status {
self.buffer()
.read(cx)
@ -602,14 +602,14 @@ impl Item for Editor {
.and_then(|buffer| buffer.read(cx).project_path(cx))
.and_then(|path| self.project.as_ref()?.read(cx).entry_for_path(&path, cx))
.map(|entry| {
entry_git_aware_label_color(entry.git_status, entry.is_ignored, selected)
entry_git_aware_label_color(entry.git_status, entry.is_ignored, params.selected)
})
.unwrap_or_else(|| entry_label_color(selected))
.unwrap_or_else(|| entry_label_color(params.selected))
} else {
entry_label_color(selected)
entry_label_color(params.selected)
};
let description = detail.and_then(|detail| {
let description = params.detail.and_then(|detail| {
let path = path_for_buffer(&self.buffer, detail, false, cx)?;
let description = path.to_string_lossy();
let description = description.trim();
@ -623,7 +623,11 @@ impl Item for Editor {
h_flex()
.gap_2()
.child(Label::new(self.title(cx).to_string()).color(label_color))
.child(
Label::new(self.title(cx).to_string())
.color(label_color)
.italic(params.preview),
)
.when_some(description, |this, description| {
this.child(
Label::new(description)

View File

@ -23,6 +23,7 @@ use std::{ops::Range, sync::Arc};
use theme::ThemeSettings;
use ui::{popover_menu, prelude::*, ContextMenu, ToggleButton, Tooltip};
use util::ResultExt as _;
use workspace::item::TabContentParams;
use workspace::{
item::{Item, ItemEvent},
Workspace, WorkspaceId,
@ -925,9 +926,9 @@ impl FocusableView for ExtensionsPage {
impl Item for ExtensionsPage {
type Event = ItemEvent;
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
Label::new("Extensions")
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -22,6 +22,7 @@ itertools = "0.11"
menu.workspace = true
picker.workspace = true
project.workspace = true
settings.workspace = true
text.workspace = true
theme.workspace = true
ui.workspace = true

View File

@ -12,6 +12,7 @@ use gpui::{
use itertools::Itertools;
use picker::{Picker, PickerDelegate};
use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
use settings::Settings;
use std::{
cmp,
path::{Path, PathBuf},
@ -23,7 +24,7 @@ use std::{
use text::Point;
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
use workspace::{ModalView, Workspace};
use workspace::{item::PreviewTabsSettings, ModalView, Workspace};
actions!(file_finder, [Toggle, SelectPrev]);
@ -782,11 +783,22 @@ impl PickerDelegate for FileFinderDelegate {
if let Some(m) = self.matches.get(self.selected_index()) {
if let Some(workspace) = self.workspace.upgrade() {
let open_task = workspace.update(cx, move |workspace, cx| {
let split_or_open = |workspace: &mut Workspace, project_path, cx| {
let split_or_open =
|workspace: &mut Workspace,
project_path,
cx: &mut ViewContext<Workspace>| {
let allow_preview =
PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder;
if secondary {
workspace.split_path(project_path, cx)
workspace.split_path_preview(project_path, allow_preview, cx)
} else {
workspace.open_path(project_path, None, true, cx)
workspace.open_path_preview(
project_path,
None,
true,
allow_preview,
cx,
)
}
};
match m {

View File

@ -1,7 +1,7 @@
use crate::{
self as gpui, hsla, point, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle,
DefiniteLength, Fill, FlexDirection, FlexWrap, FontWeight, Hsla, JustifyContent, Length,
Position, SharedString, StyleRefinement, Visibility, WhiteSpace,
DefiniteLength, Fill, FlexDirection, FlexWrap, FontStyle, FontWeight, Hsla, JustifyContent,
Length, Position, SharedString, StyleRefinement, Visibility, WhiteSpace,
};
use crate::{BoxShadow, TextStyleRefinement};
use smallvec::{smallvec, SmallVec};
@ -681,6 +681,24 @@ pub trait Styled: Sized {
self
}
/// Set the font style to 'non-italic',
/// see the [Tailwind Docs](https://tailwindcss.com/docs/font-style#italicizing-text)
fn non_italic(mut self) -> Self {
self.text_style()
.get_or_insert_with(Default::default)
.font_style = Some(FontStyle::Normal);
self
}
/// Set the font style to 'italic',
/// see the [Tailwind Docs](https://tailwindcss.com/docs/font-style#italicizing-text)
fn italic(mut self) -> Self {
self.text_style()
.get_or_insert_with(Default::default)
.font_style = Some(FontStyle::Italic);
self
}
/// Remove the text decoration on this element, this value cascades to its child elements.
fn text_decoration_none(mut self) -> Self {
self.text_style()

View File

@ -11,7 +11,7 @@ use project::{Project, ProjectEntryId, ProjectPath};
use std::{ffi::OsStr, path::PathBuf};
use util::ResultExt;
use workspace::{
item::{Item, ProjectItem},
item::{Item, ProjectItem, TabContentParams},
ItemId, Pane, Workspace, WorkspaceId,
};
@ -72,12 +72,7 @@ pub struct ImageView {
impl Item for ImageView {
type Event = ();
fn tab_content(
&self,
_detail: Option<usize>,
selected: bool,
_cx: &WindowContext,
) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _cx: &WindowContext) -> AnyElement {
let title = self
.path
.file_name()
@ -86,11 +81,12 @@ impl Item for ImageView {
.to_string();
Label::new(title)
.single_line()
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.italic(params.preview)
.into_any_element()
}

View File

@ -13,7 +13,7 @@ use std::{borrow::Cow, sync::Arc};
use ui::{popover_menu, prelude::*, Button, Checkbox, ContextMenu, Label, Selection};
use util::maybe;
use workspace::{
item::{Item, ItemHandle},
item::{Item, ItemHandle, TabContentParams},
searchable::{SearchEvent, SearchableItem, SearchableItemHandle},
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
};
@ -628,9 +628,9 @@ impl Item for LspLogView {
Editor::to_item_events(event, f)
}
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext<'_>) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext<'_>) -> AnyElement {
Label::new("LSP Logs")
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -11,7 +11,7 @@ use theme::ActiveTheme;
use tree_sitter::{Node, TreeCursor};
use ui::{h_flex, popover_menu, ButtonLike, Color, ContextMenu, Label, LabelCommon, PopoverMenu};
use workspace::{
item::{Item, ItemHandle},
item::{Item, ItemHandle, TabContentParams},
SplitDirection, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
};
@ -391,9 +391,9 @@ impl Item for SyntaxTreeView {
fn to_item_events(_: &Self::Event, _: impl FnMut(workspace::item::ItemEvent)) {}
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext<'_>) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext<'_>) -> AnyElement {
Label::new("Syntax Tree")
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -12,7 +12,7 @@ use gpui::{
};
use language::LanguageRegistry;
use ui::prelude::*;
use workspace::item::{Item, ItemHandle};
use workspace::item::{Item, ItemHandle, TabContentParams};
use workspace::{Pane, Workspace};
use crate::OpenPreviewToTheSide;
@ -439,15 +439,10 @@ impl EventEmitter<PreviewEvent> for MarkdownPreviewView {}
impl Item for MarkdownPreviewView {
type Event = PreviewEvent;
fn tab_content(
&self,
_detail: Option<usize>,
selected: bool,
_cx: &WindowContext,
) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _cx: &WindowContext) -> AnyElement {
h_flex()
.gap_2()
.child(Icon::new(IconName::FileDoc).color(if selected {
.child(Icon::new(IconName::FileDoc).color(if params.selected {
Color::Default
} else {
Color::Muted
@ -458,7 +453,7 @@ impl Item for MarkdownPreviewView {
} else {
self.fallback_tab_description.clone()
})
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -130,6 +130,7 @@ actions!(
Paste,
Rename,
Open,
OpenPermanent,
ToggleFocus,
NewSearchInDirectory,
]
@ -156,6 +157,7 @@ pub enum Event {
OpenedEntry {
entry_id: ProjectEntryId,
focus_opened_item: bool,
allow_preview: bool,
},
SplitEntry {
entry_id: ProjectEntryId,
@ -262,6 +264,7 @@ impl ProjectPanel {
&Event::OpenedEntry {
entry_id,
focus_opened_item,
allow_preview,
} => {
if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
@ -270,13 +273,14 @@ impl ProjectPanel {
let entry_id = entry.id;
workspace
.open_path(
.open_path_preview(
ProjectPath {
worktree_id,
path: file_path.clone(),
},
None,
focus_opened_item,
allow_preview,
cx,
)
.detach_and_prompt_err("Failed to open file", cx, move |e, _| {
@ -592,9 +596,22 @@ impl ProjectPanel {
}
fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
self.open_internal(true, false, cx);
}
fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
self.open_internal(false, true, cx);
}
fn open_internal(
&mut self,
allow_preview: bool,
focus_opened_item: bool,
cx: &mut ViewContext<Self>,
) {
if let Some((_, entry)) = self.selected_entry(cx) {
if entry.is_file() {
self.open_entry(entry.id, true, cx);
self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
} else {
self.toggle_expanded(entry.id, cx);
}
@ -666,7 +683,7 @@ impl ProjectPanel {
}
this.update_visible_entries(None, cx);
if is_new_entry && !is_dir {
this.open_entry(new_entry.id, true, cx);
this.open_entry(new_entry.id, true, false, cx);
}
cx.notify();
})?;
@ -686,11 +703,13 @@ impl ProjectPanel {
&mut self,
entry_id: ProjectEntryId,
focus_opened_item: bool,
allow_preview: bool,
cx: &mut ViewContext<Self>,
) {
cx.emit(Event::OpenedEntry {
entry_id,
focus_opened_item,
allow_preview,
});
}
@ -1461,7 +1480,13 @@ impl ProjectPanel {
if event.down.modifiers.secondary() {
this.split_entry(entry_id, cx);
} else {
this.open_entry(entry_id, event.up.click_count > 1, cx);
let click_count = event.up.click_count;
this.open_entry(
entry_id,
click_count > 1,
click_count == 1,
cx,
);
}
}
}
@ -1535,6 +1560,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::collapse_all_entries))
.on_action(cx.listener(Self::open))
.on_action(cx.listener(Self::open_permanent))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::copy_path))

View File

@ -36,12 +36,11 @@ use ui::{
};
use util::paths::PathMatcher;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
searchable::{Direction, SearchableItem, SearchableItemHandle},
ItemNavHistory, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
WorkspaceId,
DeploySearch, ItemNavHistory, NewSearch, Pane, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace, WorkspaceId,
};
use workspace::{DeploySearch, NewSearch};
const MIN_INPUT_WIDTH_REMS: f32 = 15.;
const MAX_INPUT_WIDTH_REMS: f32 = 30.;
@ -379,7 +378,7 @@ impl Item for ProjectSearchView {
.update(cx, |editor, cx| editor.deactivated(cx));
}
fn tab_content(&self, _: Option<usize>, selected: bool, cx: &WindowContext<'_>) -> AnyElement {
fn tab_content(&self, params: TabContentParams, cx: &WindowContext<'_>) -> AnyElement {
let last_query: Option<SharedString> = self
.model
.read(cx)
@ -395,12 +394,14 @@ impl Item for ProjectSearchView {
.unwrap_or_else(|| "Project Search".into());
h_flex()
.gap_2()
.child(Icon::new(IconName::MagnifyingGlass).color(if selected {
.child(
Icon::new(IconName::MagnifyingGlass).color(if params.selected {
Color::Default
} else {
Color::Muted
}))
.child(Label::new(tab_name).color(if selected {
}),
)
.child(Label::new(tab_name).color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -13,7 +13,7 @@ use std::sync::Arc;
use ui::{prelude::*, ListItem, ListItemSpacing, Tooltip};
use util::ResultExt;
use workspace::{
item::ItemHandle,
item::{ItemHandle, TabContentParams},
pane::{render_item_indicator, tab_details, Event as PaneEvent},
ModalView, Pane, SaveIntent, Workspace,
};
@ -130,6 +130,7 @@ struct TabMatch {
item_index: usize,
item: Box<dyn ItemHandle>,
detail: usize,
preview: bool,
}
pub struct TabSwitcherDelegate {
@ -202,6 +203,7 @@ impl TabSwitcherDelegate {
item_index,
item: item.boxed_clone(),
detail,
preview: pane.is_active_preview_item(item.item_id()),
})
.for_each(|tab_match| self.matches.push(tab_match));
@ -324,7 +326,12 @@ impl PickerDelegate for TabSwitcherDelegate {
.get(ix)
.expect("Invalid matches state: no element for index {ix}");
let label = tab_match.item.tab_content(Some(tab_match.detail), true, cx);
let params = TabContentParams {
detail: Some(tab_match.detail),
selected: true,
preview: tab_match.preview,
};
let label = tab_match.item.tab_content(params, cx);
let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
let indicator_color = if let Some(ref indicator) = indicator {
indicator.color

View File

@ -26,7 +26,7 @@ use terminal_element::TerminalElement;
use ui::{h_flex, prelude::*, ContextMenu, Icon, IconName, Label};
use util::{paths::PathLikeWithPosition, ResultExt};
use workspace::{
item::{BreadcrumbText, Item, ItemEvent},
item::{BreadcrumbText, Item, ItemEvent, TabContentParams},
notifications::NotifyResultExt,
register_deserializable_item,
searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
@ -783,12 +783,7 @@ impl Item for TerminalView {
Some(self.terminal().read(cx).title(false).into())
}
fn tab_content(
&self,
_detail: Option<usize>,
selected: bool,
cx: &WindowContext,
) -> AnyElement {
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
let terminal = self.terminal().read(cx);
let title = terminal.title(true);
let icon = match terminal.task() {
@ -808,7 +803,7 @@ impl Item for TerminalView {
h_flex()
.gap_2()
.child(Icon::new(icon))
.child(Label::new(title).color(if selected {
.child(Label::new(title).color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -43,6 +43,11 @@ impl LabelCommon for HighlightedLabel {
self.base = self.base.strikethrough(strikethrough);
self
}
fn italic(mut self, italic: bool) -> Self {
self.base = self.base.italic(italic);
self
}
}
impl RenderOnce for HighlightedLabel {

View File

@ -126,6 +126,20 @@ impl LabelCommon for Label {
self.base = self.base.strikethrough(strikethrough);
self
}
/// Sets the italic property of the label.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").italic(true);
/// ```
fn italic(mut self, italic: bool) -> Self {
self.base = self.base.italic(italic);
self
}
}
impl RenderOnce for Label {

View File

@ -33,6 +33,9 @@ pub trait LabelCommon {
/// Sets the strikethrough property of the label.
fn strikethrough(self, strikethrough: bool) -> Self;
/// Sets the italic property of the label.
fn italic(self, italic: bool) -> Self;
}
#[derive(IntoElement)]
@ -41,6 +44,7 @@ pub struct LabelLike {
line_height_style: LineHeightStyle,
pub(crate) color: Color,
strikethrough: bool,
italic: bool,
children: SmallVec<[AnyElement; 2]>,
}
@ -51,6 +55,7 @@ impl LabelLike {
line_height_style: LineHeightStyle::default(),
color: Color::Default,
strikethrough: false,
italic: false,
children: SmallVec::new(),
}
}
@ -76,6 +81,11 @@ impl LabelCommon for LabelLike {
self.strikethrough = strikethrough;
self
}
fn italic(mut self, italic: bool) -> Self {
self.italic = italic;
self
}
}
impl ParentElement for LabelLike {
@ -106,6 +116,7 @@ impl RenderOnce for LabelLike {
.when(self.line_height_style == LineHeightStyle::UiLabel, |this| {
this.line_height(relative(1.))
})
.when(self.italic, |this| this.italic())
.text_color(self.color.color(cx))
.children(self.children)
}

View File

@ -15,7 +15,7 @@ use ui::{prelude::*, CheckboxWithLabel};
use vim::VimModeSetting;
use workspace::{
dock::DockPosition,
item::{Item, ItemEvent},
item::{Item, ItemEvent, TabContentParams},
open_new, AppState, Welcome, Workspace, WorkspaceId,
};
@ -284,9 +284,9 @@ impl FocusableView for WelcomePage {
impl Item for WelcomePage {
type Event = ItemEvent;
fn tab_content(&self, _: Option<usize>, selected: bool, _: &WindowContext) -> AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
Label::new("Welcome to Zed!")
.color(if selected {
.color(if params.selected {
Color::Default
} else {
Color::Muted

View File

@ -42,6 +42,12 @@ pub struct ItemSettings {
pub close_position: ClosePosition,
}
#[derive(Deserialize)]
pub struct PreviewTabsSettings {
pub enabled: bool,
pub enable_preview_from_file_finder: bool,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ClosePosition {
@ -71,6 +77,19 @@ pub struct ItemSettingsContent {
close_position: Option<ClosePosition>,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct PreviewTabsSettingsContent {
/// Whether to show opened editors as preview editors.
/// Preview editors do not stay open, are reused until explicitly set to be kept open opened (via double-click or editing) and show file names in italic.
///
/// Default: true
enabled: Option<bool>,
/// Whether to open a preview editor when opening a file using the file finder.
///
/// Default: false
enable_preview_from_file_finder: Option<bool>,
}
impl Settings for ItemSettings {
const KEY: Option<&'static str> = Some("tabs");
@ -81,6 +100,16 @@ impl Settings for ItemSettings {
}
}
impl Settings for PreviewTabsSettings {
const KEY: Option<&'static str> = Some("preview_tabs");
type FileContent = PreviewTabsSettingsContent;
fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
sources.json_merge()
}
}
#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)]
pub enum ItemEvent {
CloseItem,
@ -95,14 +124,16 @@ pub struct BreadcrumbText {
pub highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
}
#[derive(Debug, Clone, Copy)]
pub struct TabContentParams {
pub detail: Option<usize>,
pub selected: bool,
pub preview: bool,
}
pub trait Item: FocusableView + EventEmitter<Self::Event> {
type Event;
fn tab_content(
&self,
_detail: Option<usize>,
_selected: bool,
_cx: &WindowContext,
) -> AnyElement {
fn tab_content(&self, _params: TabContentParams, _cx: &WindowContext) -> AnyElement {
gpui::Empty.into_any()
}
fn to_item_events(_event: &Self::Event, _f: impl FnMut(ItemEvent)) {}
@ -236,9 +267,9 @@ pub trait ItemHandle: 'static + Send {
fn focus_handle(&self, cx: &WindowContext) -> FocusHandle;
fn tab_tooltip_text(&self, cx: &AppContext) -> Option<SharedString>;
fn tab_description(&self, detail: usize, cx: &AppContext) -> Option<SharedString>;
fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement;
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement;
fn telemetry_event_text(&self, cx: &WindowContext) -> Option<&'static str>;
fn dragged_tab_content(&self, detail: Option<usize>, cx: &WindowContext) -> AnyElement;
fn dragged_tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement;
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath>;
fn project_entry_ids(&self, cx: &AppContext) -> SmallVec<[ProjectEntryId; 3]>;
fn project_item_model_ids(&self, cx: &AppContext) -> SmallVec<[EntityId; 3]>;
@ -339,12 +370,18 @@ impl<T: Item> ItemHandle for View<T> {
self.read(cx).tab_description(detail, cx)
}
fn tab_content(&self, detail: Option<usize>, selected: bool, cx: &WindowContext) -> AnyElement {
self.read(cx).tab_content(detail, selected, cx)
fn tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
self.read(cx).tab_content(params, cx)
}
fn dragged_tab_content(&self, detail: Option<usize>, cx: &WindowContext) -> AnyElement {
self.read(cx).tab_content(detail, true, cx)
fn dragged_tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement {
self.read(cx).tab_content(
TabContentParams {
selected: true,
..params
},
cx,
)
}
fn project_path(&self, cx: &AppContext) -> Option<ProjectPath> {
@ -532,6 +569,7 @@ impl<T: Item> ItemHandle for View<T> {
Pane::autosave_item(&item, workspace.project().clone(), cx)
});
}
pane.update(cx, |pane, cx| pane.handle_item_edit(item.item_id(), cx));
}
_ => {}
@ -817,7 +855,7 @@ impl<T: FollowableItem> WeakFollowableItemHandle for WeakView<T> {
#[cfg(any(test, feature = "test-support"))]
pub mod test {
use super::{Item, ItemEvent};
use super::{Item, ItemEvent, TabContentParams};
use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId};
use gpui::{
AnyElement, AppContext, Context as _, EntityId, EventEmitter, FocusableView,
@ -990,11 +1028,10 @@ pub mod test {
fn tab_content(
&self,
detail: Option<usize>,
_selected: bool,
params: TabContentParams,
_cx: &ui::prelude::WindowContext,
) -> AnyElement {
self.tab_detail.set(detail);
self.tab_detail.set(params.detail);
gpui::div().into_any_element()
}

View File

@ -1,5 +1,8 @@
use crate::{
item::{ClosePosition, Item, ItemHandle, ItemSettings, WeakItemHandle},
item::{
ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings, TabContentParams,
WeakItemHandle,
},
toolbar::Toolbar,
workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
NewCenterTerminal, NewFile, NewSearch, OpenVisible, SplitDirection, ToggleZoom, Workspace,
@ -11,8 +14,8 @@ use gpui::{
actions, anchored, deferred, impl_actions, prelude::*, Action, AnchorCorner, AnyElement,
AppContext, AsyncWindowContext, ClickEvent, DismissEvent, Div, DragMoveEvent, EntityId,
EventEmitter, ExternalPaths, FocusHandle, FocusableView, KeyContext, Model, MouseButton,
NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle, Subscription, Task,
View, ViewContext, VisualContext, WeakFocusHandle, WeakView, WindowContext,
MouseDownEvent, NavigationDirection, Pixels, Point, PromptLevel, Render, ScrollHandle,
Subscription, Task, View, ViewContext, VisualContext, WeakFocusHandle, WeakView, WindowContext,
};
use parking_lot::Mutex;
use project::{Project, ProjectEntryId, ProjectPath};
@ -120,6 +123,7 @@ actions!(
SplitUp,
SplitRight,
SplitDown,
TogglePreviewTab,
]
);
@ -184,6 +188,7 @@ pub struct Pane {
zoomed: bool,
was_focused: bool,
active_item_index: usize,
preview_item_id: Option<EntityId>,
last_focus_handle_by_item: HashMap<EntityId, WeakFocusHandle>,
nav_history: NavHistory,
toolbar: View<Toolbar>,
@ -207,6 +212,7 @@ pub struct Pane {
pub struct ItemNavHistory {
history: NavHistory,
item: Arc<dyn WeakItemHandle>,
is_preview: bool,
}
#[derive(Clone)]
@ -242,6 +248,7 @@ pub struct NavigationEntry {
pub item: Arc<dyn WeakItemHandle>,
pub data: Option<Box<dyn Any + Send>>,
pub timestamp: usize,
pub is_preview: bool,
}
#[derive(Clone)]
@ -281,6 +288,7 @@ impl Pane {
was_focused: false,
zoomed: false,
active_item_index: 0,
preview_item_id: None,
last_focus_handle_by_item: Default::default(),
nav_history: NavHistory(Arc::new(Mutex::new(NavHistoryState {
mode: NavigationMode::Normal,
@ -435,6 +443,10 @@ impl Pane {
fn settings_changed(&mut self, cx: &mut ViewContext<Self>) {
self.display_nav_history_buttons = TabBarSettings::get_global(cx).show_nav_history_buttons;
if !PreviewTabsSettings::get_global(cx).enabled {
self.preview_item_id = None;
}
cx.notify();
}
@ -478,6 +490,7 @@ impl Pane {
ItemNavHistory {
history: self.nav_history.clone(),
item: Arc::new(item.downgrade()),
is_preview: self.preview_item_id == Some(item.item_id()),
}
}
@ -531,10 +544,45 @@ impl Pane {
self.toolbar.update(cx, |_, cx| cx.notify());
}
pub fn preview_item_id(&self) -> Option<EntityId> {
self.preview_item_id
}
fn preview_item_idx(&self) -> Option<usize> {
if let Some(preview_item_id) = self.preview_item_id {
self.items
.iter()
.position(|item| item.item_id() == preview_item_id)
} else {
None
}
}
pub fn is_active_preview_item(&self, item_id: EntityId) -> bool {
self.preview_item_id == Some(item_id)
}
/// Marks the item with the given ID as the preview item.
/// This will be ignored if the global setting `preview_tabs` is disabled.
pub fn set_preview_item_id(&mut self, item_id: Option<EntityId>, cx: &AppContext) {
if PreviewTabsSettings::get_global(cx).enabled {
self.preview_item_id = item_id;
}
}
pub fn handle_item_edit(&mut self, item_id: EntityId, cx: &AppContext) {
if let Some(preview_item_id) = self.preview_item_id {
if preview_item_id == item_id {
self.set_preview_item_id(None, cx)
}
}
}
pub(crate) fn open_item(
&mut self,
project_entry_id: Option<ProjectEntryId>,
focus_item: bool,
allow_preview: bool,
cx: &mut ViewContext<Self>,
build_item: impl FnOnce(&mut ViewContext<Pane>) -> Box<dyn ItemHandle>,
) -> Box<dyn ItemHandle> {
@ -552,11 +600,43 @@ impl Pane {
}
if let Some((index, existing_item)) = existing_item {
// If the item is already open, and the item is a preview item
// and we are not allowing items to open as preview, mark the item as persistent.
if let Some(preview_item_id) = self.preview_item_id {
if let Some(tab) = self.items.get(index) {
if tab.item_id() == preview_item_id && !allow_preview {
self.set_preview_item_id(None, cx);
}
}
}
self.activate_item(index, focus_item, focus_item, cx);
existing_item
} else {
let mut destination_index = None;
if allow_preview {
// If we are opening a new item as preview and we have an existing preview tab, remove it.
if let Some(item_idx) = self.preview_item_idx() {
let prev_active_item_index = self.active_item_index;
self.remove_item(item_idx, false, false, cx);
self.active_item_index = prev_active_item_index;
// If the item is being opened as preview and we have an existing preview tab,
// open the new item in the position of the existing preview tab.
if item_idx < self.items.len() {
destination_index = Some(item_idx);
}
}
}
let new_item = build_item(cx);
self.add_item(new_item.clone(), true, focus_item, None, cx);
if allow_preview {
self.set_preview_item_id(Some(new_item.item_id()), cx);
}
self.add_item(new_item.clone(), true, focus_item, destination_index, cx);
new_item
}
}
@ -648,7 +728,10 @@ impl Pane {
self.activate_item(insertion_index, activate_pane, focus_item, cx);
} else {
self.items.insert(insertion_index, item.clone());
if insertion_index <= self.active_item_index {
if insertion_index <= self.active_item_index
&& self.preview_item_idx() != Some(self.active_item_index)
{
self.active_item_index += 1;
}
@ -1043,7 +1126,7 @@ impl Pane {
.iter()
.position(|i| i.item_id() == item.item_id())
{
pane.remove_item(item_ix, false, cx);
pane.remove_item(item_ix, false, true, cx);
}
})
.ok();
@ -1058,6 +1141,7 @@ impl Pane {
&mut self,
item_index: usize,
activate_pane: bool,
close_pane_if_empty: bool,
cx: &mut ViewContext<Self>,
) {
self.activation_history
@ -1091,17 +1175,24 @@ impl Pane {
});
if self.items.is_empty() {
item.deactivated(cx);
if close_pane_if_empty {
self.update_toolbar(cx);
cx.emit(Event::Remove);
}
}
if item_index < self.active_item_index {
self.active_item_index -= 1;
}
let mode = self.nav_history.mode();
self.nav_history.set_mode(NavigationMode::ClosingItem);
item.deactivated(cx);
self.nav_history.set_mode(NavigationMode::Normal);
self.nav_history.set_mode(mode);
if self.is_active_preview_item(item.item_id()) {
self.set_preview_item_id(None, cx);
}
if let Some(path) = item.project_path(cx) {
let abs_path = self
@ -1125,7 +1216,7 @@ impl Pane {
.remove(&item.item_id());
}
if self.items.is_empty() && self.zoomed {
if self.items.is_empty() && close_pane_if_empty && self.zoomed {
cx.emit(Event::ZoomOut);
}
@ -1290,7 +1381,7 @@ impl Pane {
}
})?;
self.remove_item(item_index_to_delete, false, cx);
self.remove_item(item_index_to_delete, false, true, cx);
self.nav_history.remove_item(item_id);
Some(())
@ -1330,8 +1421,19 @@ impl Pane {
cx: &mut ViewContext<'_, Pane>,
) -> impl IntoElement {
let is_active = ix == self.active_item_index;
let is_preview = self
.preview_item_id
.map(|id| id == item.item_id())
.unwrap_or(false);
let label = item.tab_content(Some(detail), is_active, cx);
let label = item.tab_content(
TabContentParams {
detail: Some(detail),
selected: is_active,
preview: is_preview,
},
cx,
);
let close_side = &ItemSettings::get_global(cx).close_position;
let indicator = render_item_indicator(item.boxed_clone(), cx);
let item_id = item.item_id();
@ -1363,6 +1465,16 @@ impl Pane {
.detach_and_log_err(cx);
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |pane, event: &MouseDownEvent, cx| {
if let Some(id) = pane.preview_item_id {
if id == item_id && event.click_count > 1 {
pane.set_preview_item_id(None, cx);
}
}
}),
)
.on_drag(
DraggedTab {
item: item.boxed_clone(),
@ -1639,6 +1751,12 @@ impl Pane {
let mut to_pane = cx.view().clone();
let split_direction = self.drag_split_direction;
let item_id = dragged_tab.item.item_id();
if let Some(preview_item_id) = self.preview_item_id {
if item_id == preview_item_id {
self.set_preview_item_id(None, cx);
}
}
let from_pane = dragged_tab.pane.clone();
self.workspace
.update(cx, |_, cx| {
@ -1786,6 +1904,17 @@ impl Render for Pane {
.on_action(cx.listener(|pane: &mut Pane, _: &ActivateNextItem, cx| {
pane.activate_next_item(true, cx);
}))
.when(PreviewTabsSettings::get_global(cx).enabled, |this| {
this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, cx| {
if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) {
if pane.is_active_preview_item(active_item_id) {
pane.set_preview_item_id(None, cx);
} else {
pane.set_preview_item_id(Some(active_item_id), cx);
}
}
}))
})
.on_action(
cx.listener(|pane: &mut Self, action: &CloseActiveItem, cx| {
if let Some(task) = pane.close_active_item(action, cx) {
@ -1946,7 +2075,8 @@ impl Render for Pane {
impl ItemNavHistory {
pub fn push<D: 'static + Send + Any>(&mut self, data: Option<D>, cx: &mut WindowContext) {
self.history.push(data, self.item.clone(), cx);
self.history
.push(data, self.item.clone(), self.is_preview, cx);
}
pub fn pop_backward(&mut self, cx: &mut WindowContext) -> Option<NavigationEntry> {
@ -2020,6 +2150,7 @@ impl NavHistory {
&mut self,
data: Option<D>,
item: Arc<dyn WeakItemHandle>,
is_preview: bool,
cx: &mut WindowContext,
) {
let state = &mut *self.0.lock();
@ -2033,6 +2164,7 @@ impl NavHistory {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
state.forward_stack.clear();
}
@ -2044,6 +2176,7 @@ impl NavHistory {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
NavigationMode::GoingForward => {
@ -2054,6 +2187,7 @@ impl NavHistory {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
NavigationMode::ClosingItem => {
@ -2064,6 +2198,7 @@ impl NavHistory {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
}
@ -2706,7 +2841,14 @@ mod tests {
impl Render for DraggedTab {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
let label = self.item.tab_content(Some(self.detail), false, cx);
let label = self.item.tab_content(
TabContentParams {
detail: Some(self.detail),
selected: false,
preview: false,
},
cx,
);
Tab::new("")
.selected(self.is_active)
.child(label)

View File

@ -168,6 +168,7 @@ define_connection! {
// kind: String, // Indicates which view this connects to. This is the key in the item_deserializers global
// position: usize, // Position of the item in the parent pane. This is equivalent to panes' position column
// active: bool, // Indicates if this item is the active one in the pane
// preview: bool // Indicates if this item is a preview item
// )
pub static ref DB: WorkspaceDb<()> =
&[sql!(
@ -279,6 +280,10 @@ define_connection! {
sql!(
ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
),
// Add preview field to items
sql!(
ALTER TABLE items ADD COLUMN preview INTEGER; //bool
),
];
}
@ -623,7 +628,7 @@ impl WorkspaceDb {
fn get_items(&self, pane_id: PaneId) -> Result<Vec<SerializedItem>> {
self.select_bound(sql!(
SELECT kind, item_id, active FROM items
SELECT kind, item_id, active, preview FROM items
WHERE pane_id = ?
ORDER BY position
))?(pane_id)
@ -636,7 +641,7 @@ impl WorkspaceDb {
items: &[SerializedItem],
) -> Result<()> {
let mut insert = conn.exec_bound(sql!(
INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active) VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO items(workspace_id, pane_id, position, kind, item_id, active, preview) VALUES (?, ?, ?, ?, ?, ?, ?)
)).context("Preparing insertion")?;
for (position, item) in items.iter().enumerate() {
insert((workspace_id, pane_id, position, item))?;
@ -836,15 +841,15 @@ mod tests {
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, false),
SerializedItem::new("Terminal", 6, true),
SerializedItem::new("Terminal", 5, false, false),
SerializedItem::new("Terminal", 6, true, false),
],
false,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 7, true),
SerializedItem::new("Terminal", 8, false),
SerializedItem::new("Terminal", 7, true, false),
SerializedItem::new("Terminal", 8, false, false),
],
false,
)),
@ -852,8 +857,8 @@ mod tests {
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 9, false),
SerializedItem::new("Terminal", 10, true),
SerializedItem::new("Terminal", 9, false, false),
SerializedItem::new("Terminal", 10, true, false),
],
false,
)),
@ -1000,15 +1005,15 @@ mod tests {
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false),
SerializedItem::new("Terminal", 2, true),
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, false),
SerializedItem::new("Terminal", 3, true),
SerializedItem::new("Terminal", 4, false, false),
SerializedItem::new("Terminal", 3, true, false),
],
true,
)),
@ -1016,8 +1021,8 @@ mod tests {
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, true),
SerializedItem::new("Terminal", 6, false),
SerializedItem::new("Terminal", 5, true, false),
SerializedItem::new("Terminal", 6, false, false),
],
false,
)),
@ -1047,15 +1052,15 @@ mod tests {
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false),
SerializedItem::new("Terminal", 2, true),
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, false),
SerializedItem::new("Terminal", 3, true),
SerializedItem::new("Terminal", 4, false, false),
SerializedItem::new("Terminal", 3, true, false),
],
true,
)),
@ -1063,8 +1068,8 @@ mod tests {
),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 5, false),
SerializedItem::new("Terminal", 6, true),
SerializedItem::new("Terminal", 5, false, false),
SerializedItem::new("Terminal", 6, true, false),
],
false,
)),
@ -1082,15 +1087,15 @@ mod tests {
vec![
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 1, false),
SerializedItem::new("Terminal", 2, true),
SerializedItem::new("Terminal", 1, false, false),
SerializedItem::new("Terminal", 2, true, false),
],
false,
)),
SerializedPaneGroup::Pane(SerializedPane::new(
vec![
SerializedItem::new("Terminal", 4, true),
SerializedItem::new("Terminal", 3, false),
SerializedItem::new("Terminal", 4, true, false),
SerializedItem::new("Terminal", 3, false, false),
],
true,
)),

View File

@ -246,6 +246,7 @@ impl SerializedPane {
) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
let mut item_tasks = Vec::new();
let mut active_item_index = None;
let mut preview_item_index = None;
for (index, item) in self.children.iter().enumerate() {
let project = project.clone();
item_tasks.push(pane.update(cx, |_, cx| {
@ -261,6 +262,9 @@ impl SerializedPane {
if item.active {
active_item_index = Some(index);
}
if item.preview {
preview_item_index = Some(index);
}
}
let mut items = Vec::new();
@ -281,6 +285,14 @@ impl SerializedPane {
})?;
}
if let Some(preview_item_index) = preview_item_index {
pane.update(cx, |pane, cx| {
if let Some(item) = pane.item_for_index(preview_item_index) {
pane.set_preview_item_id(Some(item.item_id()), cx);
}
})?;
}
anyhow::Ok(items)
}
}
@ -294,14 +306,16 @@ pub struct SerializedItem {
pub kind: Arc<str>,
pub item_id: ItemId,
pub active: bool,
pub preview: bool,
}
impl SerializedItem {
pub fn new(kind: impl AsRef<str>, item_id: ItemId, active: bool) -> Self {
pub fn new(kind: impl AsRef<str>, item_id: ItemId, active: bool, preview: bool) -> Self {
Self {
kind: Arc::from(kind.as_ref()),
item_id,
active,
preview,
}
}
}
@ -313,20 +327,22 @@ impl Default for SerializedItem {
kind: Arc::from("Terminal"),
item_id: 100000,
active: false,
preview: false,
}
}
}
impl StaticColumnCount for SerializedItem {
fn column_count() -> usize {
3
4
}
}
impl Bind for &SerializedItem {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
let next_index = statement.bind(&self.kind, start_index)?;
let next_index = statement.bind(&self.item_id, next_index)?;
statement.bind(&self.active, next_index)
let next_index = statement.bind(&self.active, next_index)?;
statement.bind(&self.preview, next_index)
}
}
@ -335,11 +351,13 @@ impl Column for SerializedItem {
let (kind, next_index) = Arc::<str>::column(statement, start_index)?;
let (item_id, next_index) = ItemId::column(statement, next_index)?;
let (active, next_index) = bool::column(statement, next_index)?;
let (preview, next_index) = bool::column(statement, next_index)?;
Ok((
SerializedItem {
kind,
item_id,
active,
preview,
},
next_index,
))

View File

@ -1,5 +1,5 @@
use crate::{
item::{Item, ItemEvent},
item::{Item, ItemEvent, TabContentParams},
ItemNavHistory, WorkspaceId,
};
use anyhow::Result;
@ -93,21 +93,18 @@ impl Item for SharedScreen {
}
}
fn tab_content(
&self,
_: Option<usize>,
selected: bool,
_: &WindowContext<'_>,
) -> gpui::AnyElement {
fn tab_content(&self, params: TabContentParams, _: &WindowContext<'_>) -> gpui::AnyElement {
h_flex()
.gap_1()
.child(Icon::new(IconName::Screen))
.child(
Label::new(format!("{}'s screen", self.user.github_login)).color(if selected {
Label::new(format!("{}'s screen", self.user.github_login)).color(
if params.selected {
Color::Default
} else {
Color::Muted
}),
},
),
)
.into_any()
}

View File

@ -32,7 +32,10 @@ use gpui::{
LayoutId, ManagedView, Model, ModelContext, PathPromptOptions, Point, PromptLevel, Render,
Size, Subscription, Task, View, WeakView, WindowHandle, WindowOptions,
};
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, ProjectItem};
use item::{
FollowableItem, FollowableItemHandle, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
ProjectItem,
};
use itertools::Itertools;
use language::{LanguageRegistry, Rope};
use lazy_static::lazy_static;
@ -261,6 +264,7 @@ impl Column for WorkspaceId {
pub fn init_settings(cx: &mut AppContext) {
WorkspaceSettings::register(cx);
ItemSettings::register(cx);
PreviewTabsSettings::register(cx);
TabBarSettings::register(cx);
}
@ -1142,7 +1146,13 @@ impl Workspace {
})?;
pane.update(&mut cx, |pane, cx| {
let item = pane.open_item(project_entry_id, true, cx, build_item);
let item = pane.open_item(
project_entry_id,
true,
entry.is_preview,
cx,
build_item,
);
navigated |= Some(item.item_id()) != prev_active_item_id;
pane.nav_history_mut().set_mode(NavigationMode::Normal);
if let Some(data) = entry.data {
@ -2066,6 +2076,17 @@ impl Workspace {
pane: Option<WeakView<Pane>>,
focus_item: bool,
cx: &mut WindowContext,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
self.open_path_preview(path, pane, focus_item, false, cx)
}
pub fn open_path_preview(
&mut self,
path: impl Into<ProjectPath>,
pane: Option<WeakView<Pane>>,
focus_item: bool,
allow_preview: bool,
cx: &mut WindowContext,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
let pane = pane.unwrap_or_else(|| {
self.last_active_center_pane.clone().unwrap_or_else(|| {
@ -2080,7 +2101,7 @@ impl Workspace {
cx.spawn(move |mut cx| async move {
let (project_entry_id, build_item) = task.await?;
pane.update(&mut cx, |pane, cx| {
pane.open_item(project_entry_id, focus_item, cx, build_item)
pane.open_item(project_entry_id, focus_item, allow_preview, cx, build_item)
})
})
}
@ -2089,6 +2110,15 @@ impl Workspace {
&mut self,
path: impl Into<ProjectPath>,
cx: &mut ViewContext<Self>,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
self.split_path_preview(path, false, cx)
}
pub fn split_path_preview(
&mut self,
path: impl Into<ProjectPath>,
allow_preview: bool,
cx: &mut ViewContext<Self>,
) -> Task<Result<Box<dyn ItemHandle>, anyhow::Error>> {
let pane = self.last_active_center_pane.clone().unwrap_or_else(|| {
self.panes
@ -2110,7 +2140,7 @@ impl Workspace {
let pane = pane.upgrade()?;
let new_pane = this.split_pane(pane, SplitDirection::Right, cx);
new_pane.update(cx, |new_pane, cx| {
Some(new_pane.open_item(project_entry_id, true, cx, build_item))
Some(new_pane.open_item(project_entry_id, true, allow_preview, cx, build_item))
})
})
.map(|option| option.ok_or_else(|| anyhow!("pane was dropped")))?
@ -2155,6 +2185,9 @@ impl Workspace {
}
let item = cx.new_view(|cx| T::for_project_item(self.project().clone(), project_item, cx));
pane.update(cx, |pane, cx| {
pane.set_preview_item_id(Some(item.item_id()), cx)
});
self.add_item(pane, Box::new(item.clone()), cx);
item
}
@ -2536,7 +2569,7 @@ impl Workspace {
if source != destination {
// Close item from previous pane
source.update(cx, |source, cx| {
source.remove_item(item_ix, false, cx);
source.remove_item(item_ix, false, true, cx);
});
}
@ -3408,6 +3441,7 @@ impl Workspace {
kind: Arc::from(item_handle.serialized_item_kind()?),
item_id: item_handle.item_id().as_u64(),
active: Some(item_handle.item_id()) == active_item_id,
preview: pane.is_active_preview_item(item_handle.item_id()),
})
})
.collect::<Vec<_>>(),

View File

@ -613,6 +613,40 @@ The following settings can be overridden for each specific language:
These values take in the same options as the root-level settings with the same name.
## Preview tabs
- Description:
Preview tabs allow you to open files in preview mode, where they close automatically when you switch to another file unless you explicitly pin them. This is useful for quickly viewing files without cluttering your workspace. Preview tabs display their file names in italics. \
There are several ways to convert a preview tab into a regular tab:
- Double-clicking on the file
- Double-clicking on the tab header
- Using the 'project_panel::OpenPermanent' action
- Editing the file
- Dragging the file to a different pane
- Setting: `preview_tabs`
- Default:
```json
"preview_tabs": {
"enabled": true,
"enable_preview_from_file_finder": false
}
```
**Options**
### Enable preview from file finder
- Description: Determines whether to open files in preview mode when selected from the file finder.
- Setting: `enable_preview_from_file_finder`
- Default: `false`
**Options**
`boolean` values
## Preferred Line Length
- Description: The column at which to soft-wrap lines, for buffers where soft-wrap is enabled.