From ea4419076ece52500c131617613ca2eeaee56532 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Date: Thu, 11 Apr 2024 23:09:12 +0200 Subject: [PATCH] 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 --- Cargo.lock | 1 + assets/settings/default.json | 10 + crates/collab/src/tests/integration_tests.rs | 267 ++++++++++++++++++ crates/collab_ui/src/channel_view.rs | 6 +- crates/diagnostics/src/diagnostics.rs | 10 +- crates/editor/src/items.rs | 18 +- crates/extensions_ui/src/extensions_ui.rs | 5 +- crates/file_finder/Cargo.toml | 1 + crates/file_finder/src/file_finder.rs | 28 +- crates/gpui/src/styled.rs | 22 +- crates/image_viewer/src/image_viewer.rs | 12 +- crates/language_tools/src/lsp_log.rs | 6 +- crates/language_tools/src/syntax_tree_view.rs | 6 +- .../src/markdown_preview_view.rs | 13 +- crates/project_panel/src/project_panel.rs | 34 ++- crates/search/src/project_search.rs | 23 +- crates/tab_switcher/src/tab_switcher.rs | 11 +- crates/terminal_view/src/terminal_view.rs | 11 +- .../src/components/label/highlighted_label.rs | 5 + crates/ui/src/components/label/label.rs | 14 + crates/ui/src/components/label/label_like.rs | 11 + crates/welcome/src/welcome.rs | 6 +- crates/workspace/src/item.rs | 69 +++-- crates/workspace/src/pane.rs | 170 ++++++++++- crates/workspace/src/persistence.rs | 53 ++-- crates/workspace/src/persistence/model.rs | 24 +- crates/workspace/src/shared_screen.rs | 21 +- crates/workspace/src/workspace.rs | 44 ++- docs/src/configuring_zed.md | 34 +++ 29 files changed, 783 insertions(+), 152 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f0c154378..da08ad6363 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3772,6 +3772,7 @@ dependencies = [ "picker", "project", "serde_json", + "settings", "text", "theme", "ui", diff --git a/assets/settings/default.json b/assets/settings/default.json index 53948023ed..6bf04d3229 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 81cde74cbb..87eb5d51ba 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -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()); + }); +} diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index bae647b826..59099dd486 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -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, 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 diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 7dec4be5cd..e764a9e8ef 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -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, 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 diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index e3e596a90a..042661dff4 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -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, 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) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 13209272c0..b9b7817309 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -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, 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 diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 283b7c5495..03411c130a 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -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 diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index d7c7dfd4b3..8447dc8a55 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -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,13 +783,24 @@ 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| { - if secondary { - workspace.split_path(project_path, cx) - } else { - workspace.open_path(project_path, None, true, cx) - } - }; + let split_or_open = + |workspace: &mut Workspace, + project_path, + cx: &mut ViewContext| { + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; + if secondary { + workspace.split_path_preview(project_path, allow_preview, cx) + } else { + workspace.open_path_preview( + project_path, + None, + true, + allow_preview, + cx, + ) + } + }; match m { Match::History(history_match, _) => { let worktree_id = history_match.project.worktree_id; diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 854559c102..54adbb3891 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -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() diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 22dc3fb5d6..62ed376463 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -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, - 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() } diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 0a106a3f80..429e0c278c 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -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, 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 diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 1dd647fc82..a8a3906b53 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -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, 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 diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 7c9ecbfd26..4f8bb78475 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -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 for MarkdownPreviewView {} impl Item for MarkdownPreviewView { type Event = PreviewEvent; - fn tab_content( - &self, - _detail: Option, - 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 diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4e8df2d93e..f939111e5d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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.open_internal(true, false, cx); + } + + fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext) { + self.open_internal(false, true, cx); + } + + fn open_internal( + &mut self, + allow_preview: bool, + focus_opened_item: bool, + cx: &mut ViewContext, + ) { 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, ) { 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)) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 4b8ad21daa..ebbef2fdda 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -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, selected: bool, cx: &WindowContext<'_>) -> AnyElement { + fn tab_content(&self, params: TabContentParams, cx: &WindowContext<'_>) -> AnyElement { let last_query: Option = 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 { - Color::Default - } else { - Color::Muted - })) - .child(Label::new(tab_name).color(if selected { + .child( + Icon::new(IconName::MagnifyingGlass).color(if params.selected { + Color::Default + } else { + Color::Muted + }), + ) + .child(Label::new(tab_name).color(if params.selected { Color::Default } else { Color::Muted diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 913877af10..651d2ba284 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -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, 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 diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 2c62e40a3c..12a67ab537 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -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, - 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 diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 88174f4895..876f584672 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -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 { diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 09ded5db63..5560e9fea8 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -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 { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 04fb193660..2d4577f05c 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -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) } diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index d36fcf53a8..62a82b2b54 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -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, 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 diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 26b46ea3d3..8ee69ced3f 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -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, } +#[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, + /// Whether to open a preview editor when opening a file using the file finder. + /// + /// Default: false + enable_preview_from_file_finder: Option, +} + 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, _: &mut AppContext) -> Result { + sources.json_merge() + } +} + #[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)] pub enum ItemEvent { CloseItem, @@ -95,14 +124,16 @@ pub struct BreadcrumbText { pub highlights: Option, HighlightStyle)>>, } +#[derive(Debug, Clone, Copy)] +pub struct TabContentParams { + pub detail: Option, + pub selected: bool, + pub preview: bool, +} + pub trait Item: FocusableView + EventEmitter { type Event; - fn tab_content( - &self, - _detail: Option, - _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; fn tab_description(&self, detail: usize, cx: &AppContext) -> Option; - fn tab_content(&self, detail: Option, 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, cx: &WindowContext) -> AnyElement; + fn dragged_tab_content(&self, params: TabContentParams, cx: &WindowContext) -> AnyElement; fn project_path(&self, cx: &AppContext) -> Option; 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 ItemHandle for View { self.read(cx).tab_description(detail, cx) } - fn tab_content(&self, detail: Option, 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, 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 { @@ -532,6 +569,7 @@ impl ItemHandle for View { 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 WeakFollowableItemHandle for WeakView { #[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, - _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() } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 57320256e6..449dae95d7 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -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, last_focus_handle_by_item: HashMap, nav_history: NavHistory, toolbar: View, @@ -207,6 +212,7 @@ pub struct Pane { pub struct ItemNavHistory { history: NavHistory, item: Arc, + is_preview: bool, } #[derive(Clone)] @@ -242,6 +248,7 @@ pub struct NavigationEntry { pub item: Arc, pub data: Option>, 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.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 { + self.preview_item_id + } + + fn preview_item_idx(&self) -> Option { + 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, 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, focus_item: bool, + allow_preview: bool, cx: &mut ViewContext, build_item: impl FnOnce(&mut ViewContext) -> Box, ) -> Box { @@ -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.activation_history @@ -1091,17 +1175,24 @@ impl Pane { }); if self.items.is_empty() { item.deactivated(cx); - self.update_toolbar(cx); - cx.emit(Event::Remove); + 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(&mut self, data: Option, 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 { @@ -2020,6 +2150,7 @@ impl NavHistory { &mut self, data: Option, item: Arc, + 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), 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), 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), 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), 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) -> 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) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index b719b1f703..77cadc25c5 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -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> { 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, )), diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index ac8fa76377..9dffa3c27e 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -246,6 +246,7 @@ impl SerializedPane { ) -> Result>>> { 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, pub item_id: ItemId, pub active: bool, + pub preview: bool, } impl SerializedItem { - pub fn new(kind: impl AsRef, item_id: ItemId, active: bool) -> Self { + pub fn new(kind: impl AsRef, 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 { 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::::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, )) diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index b49da4febe..a1b8c2eee3 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -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, - 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 { - Color::Default - } else { - Color::Muted - }), + Label::new(format!("{}'s screen", self.user.github_login)).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + ), ) .into_any() } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b72ca6af18..d38f252a68 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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>, focus_item: bool, cx: &mut WindowContext, + ) -> Task, anyhow::Error>> { + self.open_path_preview(path, pane, focus_item, false, cx) + } + + pub fn open_path_preview( + &mut self, + path: impl Into, + pane: Option>, + focus_item: bool, + allow_preview: bool, + cx: &mut WindowContext, ) -> Task, 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, cx: &mut ViewContext, + ) -> Task, anyhow::Error>> { + self.split_path_preview(path, false, cx) + } + + pub fn split_path_preview( + &mut self, + path: impl Into, + allow_preview: bool, + cx: &mut ViewContext, ) -> Task, 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::>(), diff --git a/docs/src/configuring_zed.md b/docs/src/configuring_zed.md index e62ac95eec..bbaca79953 100644 --- a/docs/src/configuring_zed.md +++ b/docs/src/configuring_zed.md @@ -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.