first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,26 +1,17 @@
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::document::ai_document_model::AIDocumentId,
|
||||
cloud_object::{
|
||||
breadcrumbs::ContainingObject,
|
||||
model::{
|
||||
persistence::{CloudModel, CloudModelEvent},
|
||||
view::{CloudViewModel, Editor, EditorState},
|
||||
},
|
||||
CloudObject, Owner, Space,
|
||||
},
|
||||
drive::sharing::{ContentEditability, SharingAccessLevel},
|
||||
notebooks::CloudNotebook,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{CloudNotebookModel, NotebookId};
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::cloud_object::breadcrumbs::ContainingObject;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
|
||||
use crate::cloud_object::{CloudObject, Owner, Space};
|
||||
use crate::drive::sharing::{ContentEditability, SharingAccessLevel};
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub enum ActiveNotebook {
|
||||
@@ -68,12 +59,12 @@ impl ActiveNotebookData {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
|
||||
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
|
||||
me.handle_update_manager_event(event, ctx);
|
||||
});
|
||||
|
||||
let cloud_model = CloudModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
|
||||
me.handle_cloud_model_event(event, ctx);
|
||||
});
|
||||
|
||||
@@ -226,7 +217,7 @@ impl ActiveNotebookData {
|
||||
// create a new client id
|
||||
let new_id = ClientId::default();
|
||||
|
||||
// Set the active notebook to be an uncommited notebook
|
||||
// Set the active notebook to be an uncommitted notebook
|
||||
self.active_notebook = ActiveNotebook::NewNotebook(Box::new(CloudNotebook::new_local(
|
||||
CloudNotebookModel::default(),
|
||||
owner,
|
||||
@@ -311,7 +302,7 @@ impl ActiveNotebookData {
|
||||
|
||||
/// Checks whether or not this notebook has edit conflicts that would
|
||||
/// results in the conflict resolution banner being shown. We check both
|
||||
/// if a conflicting object has been recieved from the server, and that there
|
||||
/// if a conflicting object has been received from the server, and that there
|
||||
/// are no pending content changes on the notebook.
|
||||
///
|
||||
/// We need to check the pending content changes because of a race condition where
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
//! Shared context menu implementation for notebooks.
|
||||
|
||||
use galaxy_core::context_flag::ContextFlag;
|
||||
use galaxyui::{
|
||||
elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack},
|
||||
keymap::Trigger,
|
||||
presenter::ChildView,
|
||||
Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxy_core::context_flag::ContextFlag;
|
||||
use galaxyui::elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack};
|
||||
use galaxyui::keymap::Trigger;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
editor::EditorView,
|
||||
menu::{self, Menu, MenuItem, MenuItemFields},
|
||||
pane_group::{focus_state::PaneFocusHandle, PaneEvent, SplitPaneState},
|
||||
util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke, CustomAction},
|
||||
};
|
||||
|
||||
use super::{
|
||||
editor::{keys::custom_action_to_display, view::RichTextEditorView},
|
||||
telemetry::ActionEntrypoint,
|
||||
use super::editor::keys::custom_action_to_display;
|
||||
use super::editor::view::RichTextEditorView;
|
||||
use super::telemetry::ActionEntrypoint;
|
||||
use crate::editor::EditorView;
|
||||
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::{PaneEvent, SplitPaneState};
|
||||
use crate::util::bindings::{
|
||||
keybinding_name_to_display_string, trigger_to_keystroke, CustomAction,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,31 +3,31 @@ use galaxy_editor::model::CoreEditorModel;
|
||||
use galaxyui::{platform::WindowStyle, App};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::App;
|
||||
|
||||
use super::MenuSource;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::notebook::NotebookView;
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusState};
|
||||
use crate::pane_group::{BackingView as _, PaneId};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::{
|
||||
cloud_object::model::{persistence::CloudModel, view::CloudViewModel},
|
||||
editor::InteractionState,
|
||||
network::NetworkStatus,
|
||||
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_editor::content::text::BufferBlockItem;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonTooltipPosition,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewContext, ViewHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, ObjectIdType, Space},
|
||||
drive::CloudObjectTypeAndId,
|
||||
menu::{self, Menu, MenuItemFields},
|
||||
notebooks::telemetry::EmbeddedObjectInfo,
|
||||
search::notebook_embedding::{
|
||||
searcher::EmbeddingSearchItemAction,
|
||||
view::{EmbeddingSearchEvent, EmbeddingSearchMenu},
|
||||
},
|
||||
server::ids::SyncId,
|
||||
themes::theme::Fill,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
use galaxy_editor::content::text::BufferBlockItem;
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning, OffsetType,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor,
|
||||
YAxisAnchor,
|
||||
};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::button::ButtonTooltipPosition;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
view::{EditorViewAction, EditorViewEvent, RichTextEditorView},
|
||||
BlockType,
|
||||
};
|
||||
use super::embedded_item::EmbeddedWorkflow;
|
||||
use super::view::{EditorViewAction, EditorViewEvent, RichTextEditorView};
|
||||
use super::BlockType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{ObjectIdType, Space};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::menu::{self, Menu, MenuItemFields};
|
||||
use crate::notebooks::telemetry::EmbeddedObjectInfo;
|
||||
use crate::search::notebook_embedding::searcher::EmbeddingSearchItemAction;
|
||||
use crate::search::notebook_embedding::view::{EmbeddingSearchEvent, EmbeddingSearchMenu};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
/// The saved position ID for the block insertion button.
|
||||
const BLOCK_INSERT_BUTTON_ID: &str = "notebook_block_insertion_button";
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
use std::{collections::HashMap, ops::Range, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::{
|
||||
content::{markdown::MarkdownStyle, text::TextStylesWithMetadata},
|
||||
editor::EmbeddedItemModel,
|
||||
extract_block,
|
||||
render::{
|
||||
element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock},
|
||||
layout::TextLayout,
|
||||
model::{
|
||||
viewport::ViewportItem, BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem,
|
||||
EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat, LaidOutEmbeddedItem,
|
||||
ParagraphStyles, RenderState, EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
|
||||
},
|
||||
BLOCK_FOOTER_HEIGHT,
|
||||
},
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{Border, Empty},
|
||||
SingletonEntity,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{ConstrainedBox, CornerRadius, Margin, Padding, Radius},
|
||||
text_layout::TextFrame,
|
||||
units::{IntoPixels, Pixels},
|
||||
AppContext, Element, LayoutContext, SizeConstraint,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::html_parser::WARP_EMBED_ATTRIBUTE_NAME;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use serde_yaml::Mapping;
|
||||
use string_offset::ByteOffset;
|
||||
|
||||
use crate::{
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
ui_components::icons::Icon,
|
||||
workflows::{workflow::Workflow, CloudWorkflow, WorkflowId},
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::content::markdown::MarkdownStyle;
|
||||
use galaxy_editor::content::text::TextStylesWithMetadata;
|
||||
use galaxy_editor::editor::EmbeddedItemModel;
|
||||
use galaxy_editor::extract_block;
|
||||
use galaxy_editor::render::element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock};
|
||||
use galaxy_editor::render::layout::TextLayout;
|
||||
use galaxy_editor::render::model::viewport::ViewportItem;
|
||||
use galaxy_editor::render::model::{
|
||||
BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem, EmbeddedItemHTMLRepresentation,
|
||||
EmbeddedItemRichFormat, LaidOutEmbeddedItem, ParagraphStyles, RenderState,
|
||||
EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
|
||||
};
|
||||
use galaxy_editor::render::BLOCK_FOOTER_HEIGHT;
|
||||
use galaxyui::elements::{Border, ConstrainedBox, CornerRadius, Empty, Margin, Padding, Radius};
|
||||
use galaxyui::text_layout::TextFrame;
|
||||
use galaxyui::units::{IntoPixels, Pixels};
|
||||
use galaxyui::{AppContext, Element, LayoutContext, SingletonEntity, SizeConstraint};
|
||||
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::drive::cloud_object_styling::warp_drive_icon_color;
|
||||
use crate::drive::DriveObjectType;
|
||||
use crate::server::ids::{HashableId, ToServerId};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::{CloudWorkflow, WorkflowId};
|
||||
|
||||
// Spacing for the embedded workflow card.
|
||||
const EMBED_WORKFLOW_SPACING: BlockSpacing = BlockSpacing {
|
||||
|
||||
@@ -1,48 +1,47 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
use std::borrow::Cow;
|
||||
use std::mem;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_completer::signatures::CommandRegistry;
|
||||
use galaxy_editor::{
|
||||
content::{anchor::Anchor, buffer::Buffer, selection_model::BufferSelectionModel},
|
||||
editor::EmbeddedItemModel,
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::{button::ButtonVariant, components::UiComponent},
|
||||
AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{model::persistence::CloudModel, CloudObject},
|
||||
completer::SessionAgnosticContext,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
server::ids::{HashableId, ToServerId},
|
||||
settings::FontSettings,
|
||||
terminal::input::decorations::{parse_current_commands_and_tokens, ParsedTokensSnapshot},
|
||||
themes::theme::AnsiColorIdentifier,
|
||||
ui_components::icons::Icon,
|
||||
util::bindings::CustomAction,
|
||||
workflows::{CloudWorkflow, WorkflowId},
|
||||
use galaxy_completer::signatures::CommandRegistry;
|
||||
use galaxy_editor::content::anchor::Anchor;
|
||||
use galaxy_editor::content::buffer::Buffer;
|
||||
use galaxy_editor::content::selection_model::BufferSelectionModel;
|
||||
use galaxy_editor::editor::EmbeddedItemModel;
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::elements::{
|
||||
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement, Shrinkable,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
embedded_item::EmbeddedWorkflow,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
notebook_command::{parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color},
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
use super::embedded_item::EmbeddedWorkflow;
|
||||
use super::keys::{custom_action_to_display, NotebookKeybindings};
|
||||
use super::model::ChildModelHandle;
|
||||
use super::notebook_command::{
|
||||
parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color,
|
||||
};
|
||||
use super::view::EditorViewAction;
|
||||
use super::{rich_text_styles, NotebookWorkflow};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::completer::SessionAgnosticContext;
|
||||
use crate::notebooks::styles::block_footer_action_button;
|
||||
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo};
|
||||
use crate::server::ids::{HashableId, ToServerId};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::terminal::input::decorations::{
|
||||
parse_current_commands_and_tokens, ParsedTokensSnapshot,
|
||||
};
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::bindings::CustomAction;
|
||||
use crate::workflows::{CloudWorkflow, WorkflowId};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
use std::{fmt::Write, time::Duration};
|
||||
use std::fmt::Write;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::Sender;
|
||||
use galaxy_editor::{
|
||||
render::model::{AutoScrollMode, Decoration},
|
||||
search::{SearchEvent, Searcher},
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::r#async::debounce;
|
||||
use galaxy_editor::render::model::{AutoScrollMode, Decoration};
|
||||
use galaxy_editor::search::{SearchEvent, Searcher};
|
||||
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::toggle_button::ToggleButton;
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
|
||||
elements::{
|
||||
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
|
||||
},
|
||||
platform::Cursor,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
toggle_button::ToggleButton,
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
debounce::debounce,
|
||||
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
|
||||
ui_components::icons::Icon,
|
||||
view_components::find::{
|
||||
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::NotebooksEditorModel,
|
||||
view::{EditorViewEvent, RichTextEditorView},
|
||||
use super::model::NotebooksEditorModel;
|
||||
use super::view::{EditorViewEvent, RichTextEditorView};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::find::{
|
||||
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
|
||||
REGEX_TOGGLE_TOOLTIP,
|
||||
};
|
||||
|
||||
/// View for the find bar within a notebook.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Utilities for notebook keybindings.
|
||||
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
|
||||
util::bindings::{custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction},
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use crate::util::bindings::{
|
||||
custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction,
|
||||
};
|
||||
|
||||
pub const RUN_COMMANDS_KEYBINDING_NAME: &str = "editor_view:run_commands";
|
||||
@@ -40,6 +40,7 @@ impl NotebookKeybindings {
|
||||
|
||||
fn handle_keybinding_change(
|
||||
&mut self,
|
||||
_: ModelHandle<KeybindingChangedNotifier>,
|
||||
event: &KeybindingChangedEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
use galaxy_editor::{
|
||||
editor::NavigationKey, model::RichTextEditorModel, render::model::RenderState,
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxy_editor::model::RichTextEditorModel;
|
||||
use galaxy_editor::render::model::RenderState;
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement,
|
||||
PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType,
|
||||
ParentElement, PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
fonts::Weight,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
},
|
||||
};
|
||||
|
||||
use super::model::NotebooksEditorModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
|
||||
TextOptions,
|
||||
};
|
||||
|
||||
const EDITOR_WIDTH: f32 = 368.;
|
||||
const EDITOR_VERTICAL_PADDING: f32 = 12.;
|
||||
|
||||
@@ -2,32 +2,29 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::ui::{builder::CHECK_SVG_PATH, theme::color::internal_colors};
|
||||
use galaxy_editor::{
|
||||
content::text::{
|
||||
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
|
||||
},
|
||||
render::model::{
|
||||
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
|
||||
ParagraphStyles, RichTextStyles, TableStyle, PARAGRAPH_MIN_HEIGHT,
|
||||
},
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::{
|
||||
elements::Border, fonts::FamilyId, ui_components::checkbox::HOVER_BACKGROUND_COLOR,
|
||||
};
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
notebooks::editor::embedded_item::EmbeddedWorkflow,
|
||||
settings::{derived_notebook_font_size, FontSettings},
|
||||
themes::theme::Fill,
|
||||
ui_components::icons::Icon,
|
||||
util::color::{ContrastingColor, MinimumAllowedContrast},
|
||||
workflows::{CloudWorkflow, WorkflowSource, WorkflowType},
|
||||
use galaxy_core::ui::builder::CHECK_SVG_PATH;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_editor::content::text::{
|
||||
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
|
||||
};
|
||||
use galaxy_editor::render::model::{
|
||||
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
|
||||
ParagraphStyles, RichTextStyles, TableStyle,
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::elements::{Border, ListIndentLevel};
|
||||
use galaxyui::fonts::FamilyId;
|
||||
use galaxyui::ui_components::checkbox::HOVER_BACKGROUND_COLOR;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::notebooks::editor::embedded_item::EmbeddedWorkflow;
|
||||
use crate::settings::{derived_notebook_font_size, FontSettings};
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use crate::workflows::{CloudWorkflow, WorkflowSource, WorkflowType};
|
||||
|
||||
mod block_insertion_menu;
|
||||
mod embedded_item;
|
||||
@@ -42,9 +39,7 @@ mod omnibar;
|
||||
pub mod view;
|
||||
|
||||
pub use block_insertion_menu::BlockInsertionSource;
|
||||
use galaxyui::elements::ListIndentLevel;
|
||||
|
||||
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.6;
|
||||
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.5;
|
||||
const NOTEBOOK_BASELINE_RATIO: f32 = 0.7;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -204,26 +199,31 @@ pub(crate) fn markdown_table_style(
|
||||
|
||||
/// Build [`RichTextStyles`] based on the current [`Appearance`].
|
||||
pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -> RichTextStyles {
|
||||
let line_height_ratio = NOTEBOOK_LINE_HEIGHT_RATIO;
|
||||
let baseline_ratio = NOTEBOOK_BASELINE_RATIO;
|
||||
let theme = appearance.theme();
|
||||
let inline_font_color: ColorU = theme.terminal_colors().normal.red.into();
|
||||
let font_size = derived_notebook_font_size(font_settings);
|
||||
|
||||
let base_text = ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio,
|
||||
font_family: appearance.ui_font_family(),
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio,
|
||||
fixed_width_tab_size: None,
|
||||
};
|
||||
|
||||
RichTextStyles {
|
||||
base_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
font_family: appearance.ui_font_family(),
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
fixed_width_tab_size: None,
|
||||
},
|
||||
base_text,
|
||||
code_text: ParagraphStyles {
|
||||
font_family: appearance.monospace_font_family(),
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
line_height_ratio,
|
||||
text_color: theme.main_text_color(theme.background()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
baseline_ratio,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_background: theme.background().into(),
|
||||
@@ -231,10 +231,10 @@ pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -
|
||||
embedding_text: ParagraphStyles {
|
||||
font_size,
|
||||
font_weight: Default::default(),
|
||||
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
|
||||
line_height_ratio,
|
||||
font_family: appearance.monospace_font_family(),
|
||||
text_color: theme.main_text_color(theme.surface_2()).into_solid(),
|
||||
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
|
||||
baseline_ratio,
|
||||
fixed_width_tab_size: Some(4),
|
||||
},
|
||||
code_border: Border::all(1.).with_border_fill(theme.surface_3()),
|
||||
@@ -270,8 +270,8 @@ pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -
|
||||
},
|
||||
block_spacings: Default::default(),
|
||||
show_placeholder_text_on_empty_block: true,
|
||||
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
|
||||
cursor_width: 1.,
|
||||
minimum_paragraph_height: Some(base_text.line_height()),
|
||||
cursor_width: 3.,
|
||||
highlight_urls: true,
|
||||
table_style: markdown_table_style(appearance, appearance.ui_font_family(), font_size),
|
||||
}
|
||||
|
||||
@@ -1,62 +1,57 @@
|
||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||
use std::{any::Any, borrow::Cow, collections::HashMap, ops::Range, time::Duration};
|
||||
use std::any::Any;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
|
||||
clipboard::ClipboardContent,
|
||||
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
||||
};
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use base64::Engine as _;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_parser::FormattedText;
|
||||
use mermaid_to_svg::MermaidTheme;
|
||||
use num_traits::SaturatingSub;
|
||||
use regex::Regex;
|
||||
use string_offset::CharOffset;
|
||||
use url::Url;
|
||||
use vec1::{vec1, Vec1};
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::{CloudModel, CloudModelEvent},
|
||||
debounce::debounce,
|
||||
editor::InteractionState,
|
||||
notebooks::telemetry::BlockInfo,
|
||||
};
|
||||
use crate::{
|
||||
notebooks::editor::interaction_state_model::InteractionStateModelEvent,
|
||||
terminal::ShellLaunchData,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::r#async::debounce;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_editor::{
|
||||
content::{buffer::ShouldAutoscroll, selection_model::BufferSelectionModel},
|
||||
model::BufferUpdateWrapper,
|
||||
render::model::{BlockItem, StyleUpdateAction},
|
||||
use galaxy_editor::content::buffer::{
|
||||
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin,
|
||||
SelectionOffsets, ShouldAutoscroll,
|
||||
};
|
||||
use galaxy_editor::{
|
||||
content::{
|
||||
buffer::{
|
||||
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction,
|
||||
EditOrigin, SelectionOffsets,
|
||||
},
|
||||
text::{
|
||||
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, BufferTextStyle,
|
||||
CodeBlockType, IndentBehavior, IndentUnit, TextStyles, TextStylesWithMetadata,
|
||||
},
|
||||
},
|
||||
model::{CoreEditorModel, RichTextEditorModel},
|
||||
render::model::{AutoScrollMode, RenderEvent, RenderState, RichTextStyles},
|
||||
search::Searcher,
|
||||
selection::{SelectionMode, SelectionModel, TextDirection, TextUnit},
|
||||
use galaxy_editor::content::selection_model::BufferSelectionModel;
|
||||
use galaxy_editor::content::text::{
|
||||
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, BufferTextStyle, CodeBlockType,
|
||||
IndentBehavior, IndentUnit, TextStyles, TextStylesWithMetadata,
|
||||
};
|
||||
use galaxy_editor::model::{BufferUpdateWrapper, CoreEditorModel, RichTextEditorModel};
|
||||
use galaxy_editor::render::model::{
|
||||
AutoScrollMode, BlockItem, RenderEvent, RenderState, RichTextStyles, StyleUpdateAction,
|
||||
};
|
||||
use galaxy_editor::search::Searcher;
|
||||
use galaxy_editor::selection::{SelectionMode, SelectionModel, TextDirection, TextUnit};
|
||||
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::ListIndentLevel;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use super::{
|
||||
super::telemetry::SelectionMode as TelemetrySelectionMode, embedding_model::NotebookEmbed,
|
||||
interaction_state_model::InteractionStateModel, notebook_command::NotebookCommand,
|
||||
NotebookWorkflow,
|
||||
use galaxyui::{
|
||||
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, WindowId,
|
||||
};
|
||||
|
||||
use super::super::telemetry::SelectionMode as TelemetrySelectionMode;
|
||||
use super::embedding_model::NotebookEmbed;
|
||||
use super::interaction_state_model::InteractionStateModel;
|
||||
use super::notebook_command::NotebookCommand;
|
||||
use super::NotebookWorkflow;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::interaction_state_model::InteractionStateModelEvent;
|
||||
use crate::notebooks::file::MarkdownDisplayMode;
|
||||
use crate::notebooks::telemetry::BlockInfo;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
|
||||
const DEBOUNCED_RESIZE_PERIOD: Duration = Duration::from_millis(5);
|
||||
|
||||
lazy_static! {
|
||||
@@ -111,6 +106,7 @@ pub struct NotebooksEditorModel {
|
||||
resize_tx: async_channel::Sender<()>,
|
||||
/// Context used to generate clickable file path links for notebooks.
|
||||
file_link_resolution_context: Option<FileLinkResolutionContext>,
|
||||
default_mermaid_display_mode: MarkdownDisplayMode,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -158,15 +154,6 @@ impl NotebooksEditorModel {
|
||||
&& FeatureFlag::EditableMarkdownMermaid.is_enabled()
|
||||
}
|
||||
|
||||
fn render_mermaid_diagrams_in_state(state: &InteractionState) -> bool {
|
||||
FeatureFlag::MarkdownMermaid.is_enabled()
|
||||
&& (matches!(state, InteractionState::Selectable)
|
||||
|| (Self::editable_markdown_mermaid_enabled()
|
||||
&& matches!(
|
||||
state,
|
||||
InteractionState::Editable | InteractionState::EditableWithInvalidSelection
|
||||
)))
|
||||
}
|
||||
pub fn new(
|
||||
text_styles: RichTextStyles,
|
||||
rte_window_id: WindowId,
|
||||
@@ -189,7 +176,7 @@ impl NotebooksEditorModel {
|
||||
Buffer::new(Box::new(notebook_tab_indentation))
|
||||
.with_embedded_item_conversion(super::notebook_embedded_item_conversion)
|
||||
});
|
||||
ctx.subscribe_to_model(&content, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&content, |me, _, event, ctx| {
|
||||
me.handle_content_model_event(event, ctx);
|
||||
});
|
||||
|
||||
@@ -216,7 +203,7 @@ impl NotebooksEditorModel {
|
||||
);
|
||||
|
||||
let cloud_model = CloudModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
|
||||
me.handle_cloud_model_event(event, ctx)
|
||||
});
|
||||
|
||||
@@ -239,6 +226,7 @@ impl NotebooksEditorModel {
|
||||
rte_window_id,
|
||||
resize_tx,
|
||||
file_link_resolution_context: None,
|
||||
default_mermaid_display_mode: MarkdownDisplayMode::Raw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +273,33 @@ impl NotebooksEditorModel {
|
||||
self.file_link_resolution_context = file_link_resolution_context;
|
||||
}
|
||||
|
||||
pub fn set_default_mermaid_display_mode(
|
||||
&mut self,
|
||||
mode: MarkdownDisplayMode,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.default_mermaid_display_mode == mode {
|
||||
return;
|
||||
}
|
||||
self.default_mermaid_display_mode = mode;
|
||||
|
||||
for command in self
|
||||
.child_models
|
||||
.model_handles::<NotebookCommand>()
|
||||
.collect_vec()
|
||||
{
|
||||
command.update(ctx, |command, _| {
|
||||
command.mermaid_display_mode = mode;
|
||||
});
|
||||
}
|
||||
|
||||
if self.sync_mermaid_render_offsets(ctx) {
|
||||
self.rebuild_layout(ctx);
|
||||
} else {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new model for searching the editor.
|
||||
pub fn new_search(&self, ctx: &mut ModelContext<Self>) -> ModelHandle<Searcher> {
|
||||
let buffer = self.content.clone();
|
||||
@@ -299,7 +314,12 @@ impl NotebooksEditorModel {
|
||||
<Self as RichTextEditorModel>::update_to_new_markdown(self, markdown, ctx);
|
||||
}
|
||||
|
||||
fn handle_render_model_event(&mut self, event: &RenderEvent, ctx: &mut ModelContext<Self>) {
|
||||
fn handle_render_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<RenderState>,
|
||||
event: &RenderEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Ignore render events until bound to a real window, and when the window is closed.
|
||||
let Some(window_id) = self.rte_window_id else {
|
||||
return;
|
||||
@@ -320,8 +340,12 @@ impl NotebooksEditorModel {
|
||||
self.content.clone(),
|
||||
self.selection_model.clone(),
|
||||
window_id,
|
||||
self.default_mermaid_display_mode,
|
||||
ctx,
|
||||
);
|
||||
if self.sync_mermaid_render_offsets(ctx) {
|
||||
self.rebuild_layout(ctx);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
@@ -329,6 +353,7 @@ impl NotebooksEditorModel {
|
||||
|
||||
fn handle_interaction_state_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<InteractionStateModel>,
|
||||
event: &InteractionStateModelEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
@@ -342,15 +367,51 @@ impl NotebooksEditorModel {
|
||||
show_final_trailing_newline_when_non_empty,
|
||||
);
|
||||
});
|
||||
let render_mermaid_diagrams = Self::render_mermaid_diagrams_in_state(new_state);
|
||||
let relayout_needed = self.render_state.update(ctx, |render_state, _| {
|
||||
render_state.set_render_mermaid_diagrams(render_mermaid_diagrams)
|
||||
});
|
||||
if relayout_needed {
|
||||
}
|
||||
|
||||
/// Set the Mermaid display mode (Raw or Rendered) for the block at the given offset.
|
||||
/// `block_offset` is the buffer's 0-indexed block marker position (i.e. `outline.start`).
|
||||
/// Updates `mermaid_render_offsets` in the render state and triggers relayout if changed.
|
||||
pub fn set_mermaid_render_mode(
|
||||
&mut self,
|
||||
block_offset: CharOffset,
|
||||
mode: MarkdownDisplayMode,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(command) = self.child_models.model_at::<NotebookCommand>(block_offset) {
|
||||
command.update(ctx, |cmd, _| {
|
||||
cmd.mermaid_display_mode = mode;
|
||||
});
|
||||
}
|
||||
|
||||
if self.sync_mermaid_render_offsets(ctx) {
|
||||
self.rebuild_layout(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_mermaid_render_offsets(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
let new_offsets: HashSet<CharOffset> = self
|
||||
.child_models
|
||||
.model_handles::<NotebookCommand>()
|
||||
.filter_map(|handle| {
|
||||
if matches!(
|
||||
handle.as_ref(ctx).mermaid_display_mode,
|
||||
MarkdownDisplayMode::Rendered
|
||||
) {
|
||||
handle
|
||||
.as_ref(ctx)
|
||||
.start_offset(ctx)
|
||||
.map(|o| o + CharOffset::from(1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.render_state
|
||||
.update(ctx, |rs, _| rs.set_mermaid_render_offsets(new_offsets))
|
||||
}
|
||||
|
||||
fn handle_content_model_event(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(window_id) = self.rte_window_id {
|
||||
if !ctx.is_window_open(window_id) {
|
||||
@@ -1187,7 +1248,16 @@ impl NotebooksEditorModel {
|
||||
BlockType::Text(BufferBlockStyle::CodeBlock {
|
||||
code_block_type: CodeBlockType::Mermaid,
|
||||
})
|
||||
) && Self::editable_markdown_mermaid_enabled()
|
||||
) && self
|
||||
.child_models
|
||||
.model_at::<NotebookCommand>(block_start)
|
||||
.is_some_and(|command| {
|
||||
matches!(
|
||||
command.as_ref(ctx).mermaid_display_mode,
|
||||
MarkdownDisplayMode::Rendered
|
||||
)
|
||||
})
|
||||
&& Self::editable_markdown_mermaid_enabled()
|
||||
&& matches!(
|
||||
self.interaction_state(ctx),
|
||||
InteractionState::Selectable
|
||||
@@ -1257,6 +1327,53 @@ impl NotebooksEditorModel {
|
||||
self.content.as_ref(app).link_url_at_offset(offset)
|
||||
}
|
||||
|
||||
pub fn scroll_to_matching_header(
|
||||
&mut self,
|
||||
fragment: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(range) = self.find_matching_header(fragment, ctx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.render_state.update(ctx, |render_state, _| {
|
||||
render_state
|
||||
.request_autoscroll_to(AutoScrollMode::PositionOffsetInViewportCenter(range.start));
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn find_matching_header(&self, fragment: &str, ctx: &AppContext) -> Option<Range<CharOffset>> {
|
||||
let target = fragment.strip_prefix('#')?;
|
||||
if target.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = urlencoding::decode(target).ok()?;
|
||||
let target = target.trim().to_lowercase();
|
||||
if target.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = self.content.as_ref(ctx);
|
||||
for outline in content.outline_blocks() {
|
||||
if !matches!(
|
||||
&outline.block_type,
|
||||
BlockType::Text(BufferBlockStyle::Header { .. })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let heading = content
|
||||
.text_in_range(outline.start + 1..outline.end)
|
||||
.into_string();
|
||||
if heading.trim().to_lowercase() == target {
|
||||
return Some(outline.start..outline.end);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether or not there's an active command block selection.
|
||||
pub fn has_command_selection(&self, ctx: &AppContext) -> bool {
|
||||
self.child_models
|
||||
@@ -2041,6 +2158,7 @@ impl ChildModels {
|
||||
content: ModelHandle<Buffer>,
|
||||
selection_model: ModelHandle<BufferSelectionModel>,
|
||||
rte_window_id: WindowId,
|
||||
default_mermaid_display_mode: MarkdownDisplayMode,
|
||||
ctx: &mut ModelContext<T>,
|
||||
) {
|
||||
// Resolve each existing model to its current offsets in the buffer, filtering out models
|
||||
@@ -2124,7 +2242,7 @@ impl ChildModels {
|
||||
outline.end
|
||||
);
|
||||
let new_model = ctx.add_model(|ctx| {
|
||||
NotebookCommand::new(
|
||||
let mut command = NotebookCommand::new(
|
||||
outline.start,
|
||||
outline.end,
|
||||
interaction_state.clone(),
|
||||
@@ -2132,7 +2250,9 @@ impl ChildModels {
|
||||
selection_model.clone(),
|
||||
rte_window_id,
|
||||
ctx,
|
||||
)
|
||||
);
|
||||
command.mermaid_display_mode = default_mermaid_display_mode;
|
||||
command
|
||||
});
|
||||
|
||||
self.models.insert(outline.start, Box::new(new_model));
|
||||
|
||||
@@ -2,31 +2,6 @@ use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::rich_text_styles;
|
||||
use super::NotebooksEditorModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::model::DEBOUNCED_RESIZE_PERIOD;
|
||||
use crate::notebooks::editor::notebook_command::NotebookCommand;
|
||||
use crate::notebooks::editor::view::{RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings::FontSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
use chrono::Utc;
|
||||
use futures::prelude::*;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
@@ -52,6 +27,38 @@ use markdown_parser::{
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use string_offset::CharOffset;
|
||||
use vec1::vec1;
|
||||
use galaxy_editor::render::model::{BlockItem, RenderEvent};
|
||||
use galaxyui::r#async::{block_on, FutureId, Timer};
|
||||
use galaxyui::{
|
||||
AddSingletonModel, App, AppContext, Element, Entity, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewHandle,
|
||||
};
|
||||
|
||||
use super::super::rich_text_styles;
|
||||
use super::NotebooksEditorModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::model::DEBOUNCED_RESIZE_PERIOD;
|
||||
use crate::notebooks::editor::notebook_command::NotebookCommand;
|
||||
use crate::notebooks::editor::view::{RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::notebooks::file::MarkdownDisplayMode;
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::settings::FontSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces};
|
||||
|
||||
/// Container for a [`RichTextEditorView`] in unit tests.
|
||||
struct TestView {
|
||||
@@ -498,6 +505,86 @@ fn test_inline_markdown_double_leading_underscore_not_italic() {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_header_simple() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let editor = model_from_markdown("- [Goal](#goal)\n\n## Goal\nBody", &mut app, true);
|
||||
|
||||
editor.read(&app, |editor, ctx| {
|
||||
let range = editor
|
||||
.find_matching_header("#goal", ctx)
|
||||
.expect("Fragment should match heading");
|
||||
let heading = editor
|
||||
.content
|
||||
.as_ref(ctx)
|
||||
.text_in_range(range.start + 1..range.end)
|
||||
.into_string();
|
||||
|
||||
assert_eq!(heading, "Goal");
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_header_case_insensitive() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let editor = model_from_markdown("## My Bold Goal\nBody", &mut app, true);
|
||||
|
||||
editor.read(&app, |editor, ctx| {
|
||||
assert!(editor.find_matching_header("#my bold goal", ctx).is_some());
|
||||
assert!(editor.find_matching_header("#MY BOLD GOAL", ctx).is_some());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_header_percent_decoded() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let editor = model_from_markdown("## Hello World\nBody", &mut app, true);
|
||||
|
||||
editor.read(&app, |editor, ctx| {
|
||||
assert!(editor.find_matching_header("#Hello%20World", ctx).is_some());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_header_returns_first_match() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let editor = model_from_markdown("## Goal\nFirst\n\n## Goal\nSecond", &mut app, true);
|
||||
|
||||
editor.read(&app, |editor, ctx| {
|
||||
let range = editor
|
||||
.find_matching_header("#goal", ctx)
|
||||
.expect("Should match first heading");
|
||||
let heading_text = editor
|
||||
.content
|
||||
.as_ref(ctx)
|
||||
.text_in_range(range.start + 1..range.end)
|
||||
.into_string();
|
||||
assert_eq!(heading_text, "Goal");
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_matching_header_missing_returns_none() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let editor = model_from_markdown("## Goal\nBody", &mut app, true);
|
||||
|
||||
editor.read(&app, |editor, ctx| {
|
||||
assert!(editor.find_matching_header("#nonexistent", ctx).is_none());
|
||||
assert!(editor.find_matching_header("#", ctx).is_none());
|
||||
assert!(editor.find_matching_header("", ctx).is_none());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_bias_editing() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -606,6 +693,101 @@ fn test_plain_text_pasting() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_inside_raw_mermaid_block_edits_text_without_removing_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let markdown = "Text
|
||||
```mermaid
|
||||
graph TD
|
||||
A --> B
|
||||
```
|
||||
More text";
|
||||
let original_char_count = markdown.chars().count();
|
||||
|
||||
let model_handle = model_from_markdown(markdown, &mut app, true);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_interaction_state(InteractionState::Editable, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
let cursor_offset = CharOffset::from(
|
||||
markdown
|
||||
.find("graph TD")
|
||||
.expect("Mermaid source should exist")
|
||||
+ 3,
|
||||
);
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.cursor_at(cursor_offset, ctx);
|
||||
assert!(!model.has_command_selection(ctx));
|
||||
model.backspace(ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
model_handle.read(&app, |model, ctx| {
|
||||
let updated_markdown = model.markdown(ctx);
|
||||
let updated_mermaid_command = model
|
||||
.child_models
|
||||
.model_handles::<NotebookCommand>()
|
||||
.exactly_one()
|
||||
.ok()
|
||||
.expect("Mermaid command should still exist after backspace");
|
||||
let updated_mermaid_command = updated_mermaid_command.as_ref(ctx);
|
||||
let updated_mermaid_range = updated_mermaid_command
|
||||
.start_offset(ctx)
|
||||
.expect("Mermaid command should still have a start offset")
|
||||
..updated_mermaid_command
|
||||
.end_offset(ctx)
|
||||
.expect("Mermaid command should still have an end offset");
|
||||
let cursor = model.selection.as_ref(ctx).cursors(ctx)[0];
|
||||
|
||||
assert!(updated_markdown.contains("```mermaid"));
|
||||
assert!(updated_markdown.contains("More text"));
|
||||
assert_eq!(updated_markdown.chars().count(), original_char_count - 1);
|
||||
assert!(model.selection_is_single_cursor(ctx));
|
||||
assert!(cursor > updated_mermaid_range.start && cursor < updated_mermaid_range.end);
|
||||
assert!(!model.has_command_selection(ctx));
|
||||
});
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.cursor_at(cursor_offset, ctx);
|
||||
assert!(!model.has_command_selection(ctx));
|
||||
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
model_handle.read(&app, |model, ctx| {
|
||||
let updated_markdown = model.markdown(ctx);
|
||||
let updated_mermaid_command = model
|
||||
.child_models
|
||||
.model_handles::<NotebookCommand>()
|
||||
.exactly_one()
|
||||
.ok()
|
||||
.expect("Mermaid command should still exist after delete");
|
||||
let updated_mermaid_command = updated_mermaid_command.as_ref(ctx);
|
||||
let updated_mermaid_range = updated_mermaid_command
|
||||
.start_offset(ctx)
|
||||
.expect("Mermaid command should still have a start offset")
|
||||
..updated_mermaid_command
|
||||
.end_offset(ctx)
|
||||
.expect("Mermaid command should still have an end offset");
|
||||
let cursor = model.selection.as_ref(ctx).cursors(ctx)[0];
|
||||
|
||||
assert!(updated_markdown.contains("```mermaid"));
|
||||
assert!(updated_markdown.contains("More text"));
|
||||
assert_eq!(updated_markdown.chars().count(), original_char_count - 1);
|
||||
assert!(model.selection_is_single_cursor(ctx));
|
||||
assert!(cursor > updated_mermaid_range.start && cursor < updated_mermaid_range.end);
|
||||
assert!(!model.has_command_selection(ctx));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pasting_link_on_selected_text() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -1277,7 +1459,7 @@ fn test_debounced_resizes() {
|
||||
let render_state = app.read(|ctx| model_handle.as_ref(ctx).render_state().clone());
|
||||
let model2 = render_state.clone();
|
||||
let _observer = app.add_model::<Observer, _>(move |ctx| {
|
||||
ctx.subscribe_to_model(&model2, move |_, event, _| {
|
||||
ctx.subscribe_to_model(&model2, move |_, _, event, _| {
|
||||
block_on(events_tx.send(*event)).unwrap();
|
||||
});
|
||||
Observer {}
|
||||
@@ -1673,17 +1855,17 @@ fn mock_server_workflow(id: i64, app: &mut App) {
|
||||
current_editor_uid: None,
|
||||
};
|
||||
|
||||
let workflow = ServerWorkflow {
|
||||
id: SyncId::ServerId(workflow_id.into()),
|
||||
metadata: server_metadata,
|
||||
permissions: ServerPermissions {
|
||||
let workflow = ServerWorkflow::new(
|
||||
SyncId::ServerId(workflow_id.into()),
|
||||
CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
|
||||
server_metadata,
|
||||
ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
permissions_last_updated_ts: ts.into(),
|
||||
anyone_link_sharing: None,
|
||||
},
|
||||
model: CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
|
||||
};
|
||||
);
|
||||
|
||||
CloudModel::handle(app).update(app, |cloud_model, _| {
|
||||
cloud_model.add_object(sync_id, CloudWorkflow::new_from_server(workflow));
|
||||
@@ -2099,7 +2281,12 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
|
||||
initialize_deps(&mut app);
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let markdown = "Text\n```mermaid\ngraph TD\nA --> B\n```\nMore text";
|
||||
let markdown = "Text
|
||||
```mermaid
|
||||
graph TD
|
||||
A --> B
|
||||
```
|
||||
More text";
|
||||
|
||||
let model_handle = model_from_markdown(markdown, &mut app, true);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
@@ -2107,6 +2294,11 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
@@ -2133,6 +2325,16 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| model.undo(ctx));
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
.expect("Mermaid command should exist after undo");
|
||||
let mermaid_range = command_range(&mermaid_command, &mut app);
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.cursor_at(mermaid_range.start, ctx);
|
||||
@@ -2159,7 +2361,12 @@ fn test_backspace_with_cursor_inside_rendered_mermaid_block_is_atomic() {
|
||||
initialize_deps(&mut app);
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let markdown = "Text\n```mermaid\ngraph TD\nA --> B\n```\nMore text";
|
||||
let markdown = "Text
|
||||
```mermaid
|
||||
graph TD
|
||||
A --> B
|
||||
```
|
||||
More text";
|
||||
|
||||
let model_handle = model_from_markdown(markdown, &mut app, true);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
@@ -2167,6 +2374,11 @@ fn test_backspace_with_cursor_inside_rendered_mermaid_block_is_atomic() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
@@ -2212,6 +2424,13 @@ fn test_move_up_from_below_rendered_mermaid_block_lands_on_block_start() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode so the navigation
|
||||
// code treats the block as an atomic rendered unit.
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
@@ -2248,6 +2467,13 @@ fn test_shift_select_across_rendered_mermaid_block_is_reversible_from_below() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode so selection
|
||||
// normalization treats the block as an atomic rendered unit.
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
@@ -2297,6 +2523,13 @@ fn test_move_down_from_rendered_mermaid_block_start_returns_below_block() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode so navigation
|
||||
// treats the block as an atomic rendered unit.
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let mermaid_command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
@@ -2482,7 +2715,10 @@ fn test_copy_selection_with_markdown_image_omits_image_clipboard_data() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
|
||||
fn test_mermaid_feature_flag_disables_rendering_and_toggle() {
|
||||
// With the flag disabled, Mermaid blocks never render as diagrams.
|
||||
// With the flag enabled, blocks default to Raw mode — no auto-rendering.
|
||||
// Diagram rendering only happens when the user explicitly selects Rendered.
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let markdown = "```mermaid\ngraph TD\nA --> B\n```";
|
||||
@@ -2516,7 +2752,46 @@ fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let enabled_is_mermaid_diagram = model_handle.read(&app, |model, ctx| {
|
||||
// Even with the flag enabled, blocks default to Raw mode — not auto-rendered.
|
||||
let enabled_defaults_to_raw = model_handle.read(&app, |model, ctx| {
|
||||
!matches!(
|
||||
model
|
||||
.render_state
|
||||
.as_ref(ctx)
|
||||
.content()
|
||||
.block_at_height(0.)
|
||||
.map(|item| item.item),
|
||||
Some(BlockItem::MermaidDiagram { .. })
|
||||
)
|
||||
});
|
||||
assert!(enabled_defaults_to_raw);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_mermaid_display_mode_renders_initial_mermaid_blocks() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_deps(&mut app);
|
||||
let _enabled = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let markdown = "```mermaid\ngraph TD\nA --> B\n```";
|
||||
|
||||
let model_handle = model_from_markdown(markdown, &mut app, true);
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.set_default_mermaid_display_mode(MarkdownDisplayMode::Rendered, ctx);
|
||||
model.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
});
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
layout_model(&mut app, &model_handle).await;
|
||||
|
||||
let command = command_models(&model_handle, &mut app)
|
||||
.into_iter()
|
||||
.exactly_one()
|
||||
.expect("Mermaid command should exist");
|
||||
command.read(&app, |command, _| {
|
||||
assert_eq!(command.mermaid_display_mode, MarkdownDisplayMode::Rendered);
|
||||
});
|
||||
|
||||
let is_mermaid_diagram = model_handle.read(&app, |model, ctx| {
|
||||
matches!(
|
||||
model
|
||||
.render_state
|
||||
@@ -2527,10 +2802,9 @@ fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
|
||||
Some(BlockItem::MermaidDiagram { .. })
|
||||
)
|
||||
});
|
||||
assert!(enabled_is_mermaid_diagram);
|
||||
assert!(is_mermaid_diagram);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dont_invalidate_command_selection() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -1,78 +1,75 @@
|
||||
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
|
||||
use std::borrow::Cow;
|
||||
use std::mem;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_channel::Sender;
|
||||
use galaxy_completer::signatures::CommandRegistry;
|
||||
use galaxy_editor::{
|
||||
content::{
|
||||
anchor::Anchor,
|
||||
buffer::{Buffer, BufferEvent, EditOrigin},
|
||||
selection_model::BufferSelectionModel,
|
||||
text::{
|
||||
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
},
|
||||
},
|
||||
editor::RunnableCommandModel,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
use pathfinder_color::ColorU;
|
||||
use string_offset::{ByteOffset, CharOffset};
|
||||
use syntect::{
|
||||
easy::HighlightLines,
|
||||
highlighting::{self, Theme, ThemeSet},
|
||||
parsing::SyntaxSet,
|
||||
util::LinesWithEndings,
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{self, Theme, ThemeSet};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
|
||||
use galaxy_completer::signatures::CommandRegistry;
|
||||
use galaxy_core::r#async::debounce;
|
||||
use galaxy_editor::content::anchor::Anchor;
|
||||
use galaxy_editor::content::buffer::{Buffer, BufferEvent, EditOrigin};
|
||||
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
|
||||
use galaxy_editor::content::selection_model::BufferSelectionModel;
|
||||
use galaxy_editor::content::text::{
|
||||
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
|
||||
CODE_BLOCK_SHELL_DISPLAY_LANG,
|
||||
};
|
||||
|
||||
use galaxy_editor::editor::RunnableCommandModel;
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::{elements::Align, r#async::SpawnedFutureHandle, AppContext};
|
||||
use galaxyui::elements::{
|
||||
Align, Border, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
|
||||
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::Properties,
|
||||
presenter::ChildView,
|
||||
Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
|
||||
WeakModelHandle, WindowId,
|
||||
};
|
||||
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
completer::SessionAgnosticContext,
|
||||
debounce::debounce,
|
||||
drive::workflows::arguments::ArgumentsState,
|
||||
editor::InteractionState,
|
||||
notebooks::{
|
||||
styles::block_footer_action_button,
|
||||
telemetry::{ActionEntrypoint, BlockInfo},
|
||||
},
|
||||
settings::FontSettings,
|
||||
terminal::input::{
|
||||
decorations::{parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot},
|
||||
DEBOUNCE_INPUT_DECORATION_PERIOD,
|
||||
},
|
||||
themes::theme::{AnsiColorIdentifier, AnsiColors},
|
||||
ui_components::icons::Icon,
|
||||
util::{
|
||||
bindings::CustomAction,
|
||||
color::{ContrastingColor, MinimumAllowedContrast},
|
||||
},
|
||||
view_components::{Dropdown, DropdownItem},
|
||||
workflows::{workflow::Workflow, WorkflowType},
|
||||
Assets,
|
||||
AppContext, AssetProvider as _, Element, Entity, ModelAsRef, ModelContext, ModelHandle,
|
||||
SingletonEntity, ViewHandle, WeakModelHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
interaction_state_model::InteractionStateModel,
|
||||
keys::{custom_action_to_display, NotebookKeybindings},
|
||||
model::ChildModelHandle,
|
||||
rich_text_styles,
|
||||
view::EditorViewAction,
|
||||
NotebookWorkflow,
|
||||
use super::interaction_state_model::InteractionStateModel;
|
||||
use super::keys::{custom_action_to_display, NotebookKeybindings};
|
||||
use super::model::ChildModelHandle;
|
||||
use super::view::EditorViewAction;
|
||||
use super::{rich_text_styles, NotebookWorkflow};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::completer::SessionAgnosticContext;
|
||||
use crate::drive::workflows::arguments::ArgumentsState;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::menu::MenuItemFields;
|
||||
use crate::notebooks::file::MarkdownDisplayMode;
|
||||
use crate::notebooks::styles::block_footer_action_button;
|
||||
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::terminal::input::decorations::{
|
||||
parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot,
|
||||
};
|
||||
use crate::terminal::input::DEBOUNCE_INPUT_DECORATION_PERIOD;
|
||||
use crate::themes::theme::{AnsiColorIdentifier, AnsiColors};
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::bindings::CustomAction;
|
||||
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
|
||||
use crate::view_components::dropdown::DropdownAction;
|
||||
use crate::view_components::Dropdown;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::WorkflowType;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::ASSETS;
|
||||
|
||||
lazy_static! {
|
||||
static ref SUPPORTED_LANGUAGES: &'static [&'static str] = &[
|
||||
@@ -95,6 +92,9 @@ lazy_static! {
|
||||
struct MouseStateHandles {
|
||||
insert_button_state: MouseStateHandle,
|
||||
copy_button_state: MouseStateHandle,
|
||||
mermaid_raw_button_state: MouseStateHandle,
|
||||
mermaid_rendered_button_state: MouseStateHandle,
|
||||
mermaid_fullscreen_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
struct CachedHighlightKey {
|
||||
@@ -118,6 +118,31 @@ struct CodeHighlightResult {
|
||||
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
|
||||
}
|
||||
|
||||
enum CodeBlockDropdownIcon {
|
||||
Image(Icon),
|
||||
Tinted(Icon),
|
||||
}
|
||||
|
||||
fn code_block_dropdown_icon(code_block_type: &CodeBlockType) -> CodeBlockDropdownIcon {
|
||||
match code_block_type {
|
||||
CodeBlockType::Mermaid => CodeBlockDropdownIcon::Image(Icon::MermaidLang),
|
||||
CodeBlockType::Code { lang } => match lang.as_str() {
|
||||
"Go" => CodeBlockDropdownIcon::Image(Icon::GoLang),
|
||||
"C++" => CodeBlockDropdownIcon::Image(Icon::CppLang),
|
||||
"JavaScript" => CodeBlockDropdownIcon::Image(Icon::JavaScriptLang),
|
||||
"Python" => CodeBlockDropdownIcon::Image(Icon::PythonLang),
|
||||
"Rust" => CodeBlockDropdownIcon::Image(Icon::RustLang),
|
||||
"SQL" => CodeBlockDropdownIcon::Image(Icon::SqlLang),
|
||||
"JSON" => CodeBlockDropdownIcon::Image(Icon::JsonLang),
|
||||
"PHP" => CodeBlockDropdownIcon::Image(Icon::PhpLang),
|
||||
"Kotlin" => CodeBlockDropdownIcon::Image(Icon::KotlinLang),
|
||||
"PowerShell" => CodeBlockDropdownIcon::Tinted(Icon::Powershell),
|
||||
_ => CodeBlockDropdownIcon::Tinted(Icon::Code1),
|
||||
},
|
||||
CodeBlockType::Shell => CodeBlockDropdownIcon::Tinted(Icon::Terminal),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runnable command behavior for notebooks.
|
||||
pub struct NotebookCommand {
|
||||
start: Anchor,
|
||||
@@ -128,6 +153,8 @@ pub struct NotebookCommand {
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
is_selected: bool,
|
||||
block_type_dropdown: ViewHandle<Dropdown<EditorViewAction>>,
|
||||
/// Display mode for this Mermaid block (Raw or Rendered).
|
||||
pub mermaid_display_mode: MarkdownDisplayMode,
|
||||
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
debounce_highlighting_tx: Sender<()>,
|
||||
@@ -162,21 +189,24 @@ impl NotebookCommand {
|
||||
let block_type_dropdown = ctx.add_typed_action_view(rte_window_id, |ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
|
||||
dropdown.set_top_bar_max_width(68.);
|
||||
dropdown.set_menu_width(68., ctx);
|
||||
dropdown.set_top_bar_max_width(96.);
|
||||
dropdown.set_menu_width(160., ctx);
|
||||
|
||||
dropdown.add_items(
|
||||
CodeBlockType::all()
|
||||
.map(|code_block_type| {
|
||||
DropdownItem::new(
|
||||
code_block_type.to_string().as_str(),
|
||||
dropdown.set_rich_items(
|
||||
CodeBlockType::all().map(|code_block_type| {
|
||||
let mut item = MenuItemFields::new(code_block_type.to_string())
|
||||
.with_on_select_action(DropdownAction::select_action_and_close(
|
||||
EditorViewAction::CodeBlockTypeSelectedAtOffset {
|
||||
code_block_type,
|
||||
code_block_type: code_block_type.clone(),
|
||||
start_anchor: start.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
item = match code_block_dropdown_icon(&code_block_type) {
|
||||
CodeBlockDropdownIcon::Image(icon) => item.with_image_icon(icon.into()),
|
||||
CodeBlockDropdownIcon::Tinted(icon) => item.with_icon(icon),
|
||||
};
|
||||
item.into_item()
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
@@ -193,9 +223,8 @@ impl NotebookCommand {
|
||||
|
||||
let syntax_config = {
|
||||
let ps = SyntaxSet::load_defaults_newlines();
|
||||
if let Some(asset) = Assets::get("bundled/syntax_theme/base16.tmTheme") {
|
||||
let binary = asset.data;
|
||||
let mut cursor = std::io::Cursor::new(binary);
|
||||
if let Ok(asset) = ASSETS.get("bundled/syntax_theme/base16.tmTheme") {
|
||||
let mut cursor = std::io::Cursor::new(asset);
|
||||
match ThemeSet::load_from_reader(&mut cursor) {
|
||||
Ok(theme) => Some((ps, theme)),
|
||||
Err(e) => {
|
||||
@@ -226,6 +255,7 @@ impl NotebookCommand {
|
||||
mouse_state_handles: Default::default(),
|
||||
is_selected: false,
|
||||
block_type_dropdown,
|
||||
mermaid_display_mode: MarkdownDisplayMode::Raw,
|
||||
syntax_highlighting_handle: None,
|
||||
cached_highlight_delta: None,
|
||||
debounce_highlighting_tx,
|
||||
@@ -437,7 +467,12 @@ impl NotebookCommand {
|
||||
});
|
||||
}
|
||||
|
||||
fn on_buffer_content_updated(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
|
||||
fn on_buffer_content_updated(
|
||||
&mut self,
|
||||
_: ModelHandle<Buffer>,
|
||||
event: &BufferEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// If the buffer changes, check to see if we should update the dropdown
|
||||
match event {
|
||||
BufferEvent::ContentChanged { origin, delta, .. }
|
||||
@@ -618,6 +653,122 @@ impl RunnableCommandModel for NotebookCommand {
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
if matches!(block_style, CodeBlockType::Mermaid)
|
||||
&& FeatureFlag::MarkdownMermaid.is_enabled()
|
||||
{
|
||||
let is_raw = matches!(self.mermaid_display_mode, MarkdownDisplayMode::Raw);
|
||||
let start_anchor_raw = self.start.clone();
|
||||
let start_anchor_rendered = self.start.clone();
|
||||
let tooltip_builder_raw = appearance.ui_builder().clone();
|
||||
let tooltip_builder_rendered = appearance.ui_builder().clone();
|
||||
|
||||
let active_highlight = UiComponentStyles {
|
||||
background: Some(appearance.theme().surface_3().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let raw_button = icon_button(
|
||||
appearance,
|
||||
Icon::Code1,
|
||||
is_raw,
|
||||
self.mouse_state_handles.mermaid_raw_button_state.clone(),
|
||||
)
|
||||
.with_active_styles(active_highlight)
|
||||
.with_tooltip(move || {
|
||||
tooltip_builder_raw
|
||||
.tool_tip("Raw".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::Arrow)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::MermaidDisplayModeSelected {
|
||||
start_anchor: start_anchor_raw.clone(),
|
||||
mode: MarkdownDisplayMode::Raw,
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
let rendered_button = icon_button(
|
||||
appearance,
|
||||
Icon::Dataflow04,
|
||||
!is_raw,
|
||||
self.mouse_state_handles
|
||||
.mermaid_rendered_button_state
|
||||
.clone(),
|
||||
)
|
||||
.with_active_styles(active_highlight)
|
||||
.with_tooltip(move || {
|
||||
tooltip_builder_rendered
|
||||
.tool_tip("Rendered".to_string())
|
||||
.build()
|
||||
.finish()
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::Arrow)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EditorViewAction::MermaidDisplayModeSelected {
|
||||
start_anchor: start_anchor_rendered.clone(),
|
||||
mode: MarkdownDisplayMode::Rendered,
|
||||
});
|
||||
})
|
||||
.finish();
|
||||
|
||||
footer.add_child(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(2.)
|
||||
.with_child(raw_button)
|
||||
.with_child(rendered_button)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_margin_left(8.)
|
||||
.with_background(appearance.theme().background())
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().surface_3()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if !is_raw {
|
||||
let fullscreen_model = self.handle.clone();
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
block_footer_action_button(
|
||||
appearance,
|
||||
Icon::Maximize,
|
||||
self.mouse_state_handles
|
||||
.mermaid_fullscreen_button_state
|
||||
.clone(),
|
||||
"Open full screen",
|
||||
None,
|
||||
)
|
||||
.on_click(move |ctx, app, _| {
|
||||
if let Some(command_model) = fullscreen_model.upgrade(app) {
|
||||
if let Some(source) = command_model.as_ref(app).command(app) {
|
||||
if !source.trim().is_empty() {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::OpenLightbox {
|
||||
images: vec![LightboxImage {
|
||||
source: LightboxImageSource::Resolved {
|
||||
asset_source: mermaid_asset_source(&source),
|
||||
},
|
||||
description: None,
|
||||
}],
|
||||
initial_index: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
|
||||
footer.add_child(
|
||||
Align::new(
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
//! Implementation for the omnibar - a floating menu for editor interactions
|
||||
//! like formatting and changing block types.
|
||||
|
||||
use galaxy_editor::{
|
||||
content::text::{
|
||||
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
|
||||
TextStylesWithMetadata,
|
||||
},
|
||||
model::RichTextEditorModel,
|
||||
render::model::RenderState,
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxy_editor::content::text::{
|
||||
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
|
||||
TextStylesWithMetadata,
|
||||
};
|
||||
use galaxy_editor::model::RichTextEditorModel;
|
||||
use galaxy_editor::render::model::RenderState;
|
||||
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
|
||||
elements::{
|
||||
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, SizeConstraint, TypedActionView,
|
||||
View, ViewContext, ViewHandle,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
menu::MenuVariant,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem},
|
||||
};
|
||||
|
||||
use super::{
|
||||
model::{NotebooksEditorModel, RichTextEditorModelEvent},
|
||||
view::EditorViewAction,
|
||||
BlockType,
|
||||
};
|
||||
use super::model::{NotebooksEditorModel, RichTextEditorModelEvent};
|
||||
use super::view::EditorViewAction;
|
||||
use super::BlockType;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::menu::MenuVariant;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem};
|
||||
|
||||
const OMNIBAR_HEIGHT: f32 = 32.;
|
||||
const OMNIBAR_PADDING: f32 = 4.;
|
||||
@@ -377,7 +373,7 @@ impl View for Omnibar {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OmnibarAction {
|
||||
/// Toggle bold styling on the selected text.
|
||||
BoldSelection,
|
||||
|
||||
+229
-107
@@ -1,96 +1,81 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ops::Range,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use galaxy_editor::{
|
||||
content::{
|
||||
anchor::Anchor,
|
||||
text::{BufferTextStyle, CodeBlockType, TextStyles},
|
||||
version::BufferVersion,
|
||||
},
|
||||
editor::{EmbeddedItemModel, NavigationKey, RunnableCommandModel, TextDecoration},
|
||||
model::{CoreEditorModel, RichTextEditorModel},
|
||||
render::{
|
||||
element::{
|
||||
DisplayOptions, DisplayStateHandle, RichTextAction, RichTextElement,
|
||||
VerticalExpansionBehavior,
|
||||
},
|
||||
model::{BlockItem, HitTestBlockType, Location, RenderState},
|
||||
},
|
||||
selection::{TextDirection, TextUnit},
|
||||
};
|
||||
use markdown_parser::{parse_html, parse_markdown, FormattedText};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use galaxy_util::{path::LineAndColumnArg, user_input::UserInput};
|
||||
use galaxy_editor::content::anchor::Anchor;
|
||||
use galaxy_editor::content::text::{BufferTextStyle, CodeBlockType, TextStyles};
|
||||
use galaxy_editor::content::version::BufferVersion;
|
||||
use galaxy_editor::editor::{EmbeddedItemModel, NavigationKey, RunnableCommandModel, TextDecoration};
|
||||
use galaxy_editor::model::{CoreEditorModel, RichTextEditorModel};
|
||||
use galaxy_editor::render::element::{
|
||||
DisplayOptions, DisplayStateHandle, RichTextAction, RichTextElement, VerticalExpansionBehavior,
|
||||
};
|
||||
use galaxy_editor::render::model::{BlockItem, HitTestBlockType, Location, RenderState};
|
||||
use galaxy_editor::selection::{TextDirection, TextUnit};
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
|
||||
use galaxyui::actions::StandardAction;
|
||||
use galaxyui::assets::asset_cache::{AssetCache, AssetHandle, AssetState};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
AnchorPair, Axis, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
Dismiss, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning, OffsetType,
|
||||
ParentAnchor, ParentElement, PositionedElementOffsetBounds, PositioningAxis, Radius,
|
||||
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Stack, XAxisAnchor,
|
||||
YAxisAnchor,
|
||||
};
|
||||
use galaxyui::event::ModifiersState;
|
||||
use galaxyui::fonts::{FallbackFontEvent, FallbackFontModel};
|
||||
use galaxyui::image_cache::ImageType;
|
||||
use galaxyui::keymap::{EditableBinding, FixedBinding, PerPlatformKeystroke};
|
||||
use galaxyui::platform::{Cursor, OperatingSystem};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::text::word_boundaries::WordBoundariesPolicy;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::units::Pixels;
|
||||
use galaxyui::windowing::WindowManager;
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
|
||||
assets::asset_cache::{AssetCache, AssetHandle, AssetState},
|
||||
clipboard::ClipboardContent,
|
||||
elements::{
|
||||
AnchorPair, Axis, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
Dismiss, Fill, Flex, Icon, MouseStateHandle, OffsetPositioning, OffsetType, ParentAnchor,
|
||||
ParentElement, PositionedElementOffsetBounds, PositioningAxis, Radius, ScrollStateHandle,
|
||||
Scrollable, ScrollableElement, ScrollbarWidth, Stack, XAxisAnchor, YAxisAnchor,
|
||||
},
|
||||
event::ModifiersState,
|
||||
fonts::{FallbackFontEvent, FallbackFontModel},
|
||||
image_cache::ImageType,
|
||||
keymap::{EditableBinding, FixedBinding},
|
||||
platform::{Cursor, OperatingSystem},
|
||||
presenter::ChildView,
|
||||
r#async::SpawnedFutureHandle,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
units::Pixels,
|
||||
windowing, AppContext, BlurContext, CursorInfo, Element, Entity, FocusContext, ModelHandle,
|
||||
SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use galaxyui::{actions::StandardAction, elements::Hoverable};
|
||||
use galaxyui::{keymap::PerPlatformKeystroke, windowing::WindowManager};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cmd_or_ctrl_shift,
|
||||
editor::InteractionState,
|
||||
features::FeatureFlag,
|
||||
notebooks::{
|
||||
editor::{find_bar::FindBarAction, model::word_unit},
|
||||
link::{LinkTarget, NotebookLinks, ResolveError},
|
||||
telemetry::{ActionEntrypoint, BlockInfo, EmbeddedObjectInfo, SelectionMode},
|
||||
},
|
||||
server::ids::SyncId,
|
||||
settings::{AppEditorSettings, FontSettings, SelectionSettings},
|
||||
terminal::{grid_renderer::URL_COLOR, links::directly_open_link_keybinding_string},
|
||||
ui_components::icons::ICON_DIMENSIONS,
|
||||
util::{
|
||||
bindings::CustomAction,
|
||||
tooltips::{render_tooltip, should_show_open_in_warp_link, TooltipLink, TooltipRedaction},
|
||||
},
|
||||
view_components::DismissibleToast,
|
||||
};
|
||||
|
||||
use super::block_insertion_menu::{BlockInsertionMenuState, BlockInsertionSource};
|
||||
use super::find_bar::{FindBar, FindBarEvent, FindBarState};
|
||||
use super::keys::NotebookKeybindings;
|
||||
use super::link_editor::{LinkEditor, LinkEditorEvent};
|
||||
use super::model::{NotebooksEditorModel, RichTextEditorModelEvent};
|
||||
use super::omnibar::{Omnibar, OmnibarEvent};
|
||||
use super::{rich_text_styles, BlockType, NotebookWorkflow};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cmd_or_ctrl_shift;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::notebooks::editor::find_bar::FindBarAction;
|
||||
use crate::notebooks::editor::model::word_unit;
|
||||
use crate::notebooks::file::MarkdownDisplayMode;
|
||||
use crate::notebooks::link::{LinkTarget, NotebookLinks, ResolveError};
|
||||
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo, EmbeddedObjectInfo, SelectionMode};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::settings::{AppEditorSettings, FontSettings, SelectionSettings};
|
||||
use crate::terminal::grid_renderer::URL_COLOR;
|
||||
use crate::terminal::links::directly_open_link_keybinding_string;
|
||||
use crate::ui_components::icons::ICON_DIMENSIONS;
|
||||
use crate::util::bindings::CustomAction;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::link_detection::{detect_file_paths, get_word_range_at_offset, DetectedLinkType};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::text::word_boundaries::WordBoundariesPolicy;
|
||||
|
||||
use super::{
|
||||
block_insertion_menu::{BlockInsertionMenuState, BlockInsertionSource},
|
||||
find_bar::{FindBar, FindBarEvent, FindBarState},
|
||||
keys::NotebookKeybindings,
|
||||
link_editor::{LinkEditor, LinkEditorEvent},
|
||||
model::{NotebooksEditorModel, RichTextEditorModelEvent},
|
||||
omnibar::{Omnibar, OmnibarEvent},
|
||||
rich_text_styles, BlockType, NotebookWorkflow,
|
||||
use crate::util::tooltips::{
|
||||
render_tooltip, should_show_open_in_warp_link, TooltipLink, TooltipRedaction,
|
||||
};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "view_tests.rs"]
|
||||
@@ -465,7 +450,7 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_key_binding("ctrl-f"),
|
||||
EditableBinding::new(
|
||||
// This doesn't reuse the move_to_line_start naming from the terminal input editor to
|
||||
// distinguish between soft-wrappped line and hard-wrapped line (paragraph) movement.
|
||||
// distinguish between soft-wrapped line and hard-wrapped line (paragraph) movement.
|
||||
"editor_view:move_to_paragraph_start",
|
||||
"Move to start of paragraph",
|
||||
EditorViewAction::MoveToParagraphStart,
|
||||
@@ -884,6 +869,14 @@ pub enum EditorViewAction {
|
||||
line_and_column_num: Option<LineAndColumnArg>,
|
||||
force_open_in_warp: bool,
|
||||
},
|
||||
/// Signal from a Mermaid toggle view that the user changed the display mode for a block.
|
||||
MermaidDisplayModeSelected {
|
||||
start_anchor: Anchor,
|
||||
mode: MarkdownDisplayMode,
|
||||
},
|
||||
OpenMermaidDiagramLightbox {
|
||||
block_start: CharOffset,
|
||||
},
|
||||
}
|
||||
|
||||
impl EditorViewAction {
|
||||
@@ -911,6 +904,34 @@ impl EditorViewAction {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LayoutAffectingAssetLoads {
|
||||
loading: HashSet<AssetHandle>,
|
||||
loaded_needs_relayout: bool,
|
||||
}
|
||||
|
||||
enum LayoutAffectingAssetLoad {
|
||||
Loading(AssetHandle),
|
||||
LoadedNeedsRelayout,
|
||||
}
|
||||
|
||||
fn mermaid_diagram_needs_loaded_layout(
|
||||
config: &warp_editor::render::model::ImageBlockConfig,
|
||||
image: &ImageType,
|
||||
) -> bool {
|
||||
let ImageType::Svg { svg } = image else {
|
||||
return false;
|
||||
};
|
||||
let intrinsic_size = svg.size();
|
||||
let intrinsic_width = intrinsic_size.width();
|
||||
let intrinsic_height = intrinsic_size.height();
|
||||
if intrinsic_width <= 0. || intrinsic_height <= 0. {
|
||||
return false;
|
||||
}
|
||||
let expected_height = config.width.as_f32() * intrinsic_height / intrinsic_width;
|
||||
(config.height.as_f32() - expected_height).abs() > 0.5
|
||||
}
|
||||
|
||||
pub enum EditorViewEvent {
|
||||
Edited,
|
||||
Focused,
|
||||
@@ -962,7 +983,7 @@ struct MouseStateHandles {
|
||||
secondary_link_mouse_handle: MouseStateHandle,
|
||||
}
|
||||
|
||||
// Represents the states of an ongoing mouse event. Note that these states are mutally exclusive:
|
||||
// Represents the states of an ongoing mouse event. Note that these states are mutually exclusive:
|
||||
// If one is selecting, they couldn't be initiating task list toggling at the same time.
|
||||
enum OngoingMouseEvent {
|
||||
Selecting,
|
||||
@@ -1110,7 +1131,7 @@ impl RichTextEditorView {
|
||||
|
||||
// Ensure that we re-render when the rendering model changes.
|
||||
ctx.observe(&model.as_ref(ctx).render_state().clone(), |me, _, ctx| {
|
||||
me.watch_visible_layout_affecting_asset_loads(ctx);
|
||||
me.watch_layout_affecting_asset_loads(ctx);
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
@@ -1298,37 +1319,69 @@ impl RichTextEditorView {
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_layout_affecting_asset_loads(
|
||||
&self,
|
||||
ctx: &ViewContext<Self>,
|
||||
) -> HashSet<AssetHandle> {
|
||||
fn layout_affecting_asset_loads(&self, ctx: &ViewContext<Self>) -> LayoutAffectingAssetLoads {
|
||||
let render_state = self.model.as_ref(ctx).render_state().clone();
|
||||
let render_state = render_state.as_ref(ctx);
|
||||
let viewport = render_state.viewport();
|
||||
let asset_cache = AssetCache::as_ref(ctx);
|
||||
let mut loads = LayoutAffectingAssetLoads::default();
|
||||
|
||||
render_state
|
||||
.content()
|
||||
.viewport_items(viewport.height(), viewport.width(), viewport.scroll_top())
|
||||
.filter_map(|(_, block)| Self::layout_affecting_asset_load(block, asset_cache))
|
||||
.collect()
|
||||
.block_items()
|
||||
.filter_map(|block| Self::layout_affecting_asset_load(block, asset_cache))
|
||||
.for_each(|load| match load {
|
||||
LayoutAffectingAssetLoad::Loading(handle) => {
|
||||
loads.loading.insert(handle);
|
||||
}
|
||||
LayoutAffectingAssetLoad::LoadedNeedsRelayout => {
|
||||
loads.loaded_needs_relayout = true;
|
||||
}
|
||||
});
|
||||
loads
|
||||
}
|
||||
|
||||
fn layout_affecting_asset_load(
|
||||
block: &BlockItem,
|
||||
asset_cache: &AssetCache,
|
||||
) -> Option<AssetHandle> {
|
||||
let BlockItem::MermaidDiagram { asset_source, .. } = block else {
|
||||
return None;
|
||||
};
|
||||
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
|
||||
AssetState::Loading { handle } => Some(handle),
|
||||
AssetState::Loaded { .. } | AssetState::Evicted | AssetState::FailedToLoad(_) => None,
|
||||
) -> Option<LayoutAffectingAssetLoad> {
|
||||
match block {
|
||||
BlockItem::MermaidDiagram {
|
||||
asset_source,
|
||||
config,
|
||||
..
|
||||
} => match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
|
||||
AssetState::Loading { handle } => Some(LayoutAffectingAssetLoad::Loading(handle)),
|
||||
AssetState::Loaded { data } => {
|
||||
mermaid_diagram_needs_loaded_layout(config, data.as_ref())
|
||||
.then_some(LayoutAffectingAssetLoad::LoadedNeedsRelayout)
|
||||
}
|
||||
AssetState::Evicted | AssetState::FailedToLoad(_) => None,
|
||||
},
|
||||
// Mermaid-labeled code blocks that are currently rendered as code (because the
|
||||
// Mermaid source has not yet been verified as parseable) also need to be watched
|
||||
// so we can re-run layout when the asset load resolves.
|
||||
BlockItem::RunnableCodeBlock {
|
||||
pending_mermaid_asset: Some(asset_source),
|
||||
..
|
||||
} => match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
|
||||
AssetState::Loading { handle } => Some(LayoutAffectingAssetLoad::Loading(handle)),
|
||||
AssetState::Loaded { .. } | AssetState::Evicted | AssetState::FailedToLoad(_) => {
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_visible_layout_affecting_asset_loads(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
for handle in self.visible_layout_affecting_asset_loads(ctx) {
|
||||
fn watch_layout_affecting_asset_loads(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let loads = self.layout_affecting_asset_loads(ctx);
|
||||
if loads.loaded_needs_relayout {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.rebuild_layout(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
for handle in loads.loading {
|
||||
if self
|
||||
.pending_layout_affecting_asset_loads
|
||||
.insert(handle.clone())
|
||||
@@ -1338,8 +1391,9 @@ impl RichTextEditorView {
|
||||
ctx.spawn(future, move |me, (), ctx| {
|
||||
me.pending_layout_affecting_asset_loads.remove(&handle);
|
||||
me.model.update(ctx, |model, ctx| {
|
||||
if matches!(model.interaction_state(ctx), InteractionState::Selectable)
|
||||
{
|
||||
if Self::should_rebuild_layout_after_layout_affecting_asset_load(
|
||||
model.interaction_state(ctx),
|
||||
) {
|
||||
model.rebuild_layout(ctx);
|
||||
}
|
||||
});
|
||||
@@ -1352,6 +1406,15 @@ impl RichTextEditorView {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_rebuild_layout_after_layout_affecting_asset_load(state: InteractionState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
InteractionState::Selectable
|
||||
| InteractionState::Editable
|
||||
| InteractionState::EditableWithInvalidSelection
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles changes to the lifecycle state.
|
||||
fn handle_windowing_state_event(
|
||||
&mut self,
|
||||
@@ -1815,6 +1878,26 @@ impl RichTextEditorView {
|
||||
self.model
|
||||
.update(ctx, |model, ctx| model.select_command_at(block_start, ctx))
|
||||
}
|
||||
fn open_mermaid_lightbox(&self, block_start: CharOffset, ctx: &mut ViewContext<Self>) {
|
||||
let asset_source = {
|
||||
let render_state = self.model.as_ref(ctx).render_state().clone();
|
||||
let content = render_state.as_ref(ctx).content();
|
||||
let block = content.block_at_offset(block_start);
|
||||
block.and_then(|b| match b.item {
|
||||
BlockItem::MermaidDiagram { asset_source, .. } => Some(asset_source.clone()),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
if let Some(asset_source) = asset_source {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox {
|
||||
images: vec![ui_components::lightbox::LightboxImage {
|
||||
source: ui_components::lightbox::LightboxImageSource::Resolved { asset_source },
|
||||
description: None,
|
||||
}],
|
||||
initial_index: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_open_file_or_url(
|
||||
&mut self,
|
||||
@@ -1880,6 +1963,18 @@ impl RichTextEditorView {
|
||||
};
|
||||
|
||||
if let Some(url) = url {
|
||||
if url.starts_with('#')
|
||||
&& (cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable))
|
||||
{
|
||||
let scrolled = self
|
||||
.model
|
||||
.update(ctx, |model, ctx| model.scroll_to_matching_header(&url, ctx));
|
||||
if scrolled {
|
||||
self.open_link = None;
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// In read-only comment chips (Selectable), open the link directly on
|
||||
// click instead of showing a tooltip.
|
||||
if cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable) {
|
||||
@@ -2960,6 +3055,20 @@ impl TypedActionView for RichTextEditorView {
|
||||
} => self.model.update(ctx, |model, ctx| {
|
||||
model.update_code_block_type_at_offset(code_block_type, start_anchor.clone(), ctx)
|
||||
}),
|
||||
MermaidDisplayModeSelected { start_anchor, mode } => {
|
||||
let offset = self
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.buffer_selection_model()
|
||||
.as_ref(ctx)
|
||||
.resolve_anchor(start_anchor);
|
||||
if let Some(offset) = offset {
|
||||
let mode = *mode;
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.set_mermaid_render_mode(offset, mode, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
CopyTextToClipboard {
|
||||
text,
|
||||
block,
|
||||
@@ -2980,6 +3089,9 @@ impl TypedActionView for RichTextEditorView {
|
||||
.model
|
||||
.update(ctx, |model, ctx| model.remove_embedding_at(*offset, ctx)),
|
||||
MiddleClickPaste => self.middle_click_paste(ctx),
|
||||
OpenMermaidDiagramLightbox { block_start } => {
|
||||
self.open_mermaid_lightbox(*block_start, ctx);
|
||||
}
|
||||
OpenFile {
|
||||
path,
|
||||
line_and_column_num,
|
||||
@@ -3226,6 +3338,8 @@ impl TypedActionView for RichTextEditorView {
|
||||
| EditorViewAction::RunWorkflow(_)
|
||||
| EditorViewAction::RemoveEmbeddingAt(_)
|
||||
| EditorViewAction::OpenFile { .. }
|
||||
| EditorViewAction::MermaidDisplayModeSelected { .. }
|
||||
| EditorViewAction::OpenMermaidDiagramLightbox { .. }
|
||||
| EditorViewAction::VimUserTyped(_) => ActionAccessibilityContent::Empty,
|
||||
}
|
||||
}
|
||||
@@ -3362,10 +3476,7 @@ impl RichTextAction<RichTextEditorView> for EditorViewAction {
|
||||
Location::Block {
|
||||
start_offset,
|
||||
end_offset,
|
||||
block_type:
|
||||
HitTestBlockType::Code
|
||||
| HitTestBlockType::MermaidDiagram
|
||||
| HitTestBlockType::Embedding,
|
||||
block_type: HitTestBlockType::MermaidDiagram,
|
||||
..
|
||||
} => match click_count {
|
||||
1 if modifiers.shift => view
|
||||
@@ -3381,6 +3492,17 @@ impl RichTextAction<RichTextEditorView> for EditorViewAction {
|
||||
.or(Some(EditorViewAction::SelectBlock {
|
||||
block_start: start_offset,
|
||||
})),
|
||||
1 => Some(EditorViewAction::OpenMermaidDiagramLightbox {
|
||||
block_start: start_offset,
|
||||
}),
|
||||
2 => Some(EditorViewAction::ExitCommandSelection),
|
||||
_ => None,
|
||||
},
|
||||
Location::Block {
|
||||
start_offset,
|
||||
block_type: HitTestBlockType::Code | HitTestBlockType::Embedding,
|
||||
..
|
||||
} => match click_count {
|
||||
1 => Some(EditorViewAction::SelectBlock {
|
||||
block_start: start_offset,
|
||||
}),
|
||||
|
||||
@@ -1,44 +1,51 @@
|
||||
use crate::features::FeatureFlag;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_channel::TryRecvError;
|
||||
use galaxy_editor::render::{
|
||||
element::RichTextAction,
|
||||
model::{HitTestBlockType, Location, RenderEvent},
|
||||
use parking_lot::Mutex;
|
||||
use string_offset::CharOffset;
|
||||
use tempfile::tempdir;
|
||||
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
|
||||
use galaxy_editor::render::element::RichTextAction;
|
||||
use galaxy_editor::render::model::{
|
||||
BlockItem, BlockSpacing, HitTestBlockType, ImageBlockConfig, Location, RenderEvent,
|
||||
};
|
||||
use galaxy_util::user_input::UserInput;
|
||||
use std::sync::Arc;
|
||||
use string_offset::CharOffset;
|
||||
|
||||
use galaxyui::assets::asset_cache::{AssetCache, AssetState};
|
||||
use galaxyui::event::ModifiersState;
|
||||
use galaxyui::image_cache::ImageType;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::block_on;
|
||||
use galaxyui::units::Pixels;
|
||||
use galaxyui::windowing::WindowManager;
|
||||
use galaxyui::{
|
||||
platform::WindowStyle, presenter::ChildView, App, Element, Entity, View, ViewHandle,
|
||||
};
|
||||
use galaxyui::{SingletonEntity, TypedActionView, WindowId};
|
||||
use galaxyui::{App, Element, Entity, SingletonEntity, TypedActionView, View, ViewHandle, WindowId};
|
||||
|
||||
use super::{EditorViewAction, RichTextEditorConfig, RichTextEditorView};
|
||||
use super::{EditorViewAction, LayoutAffectingAssetLoad, RichTextEditorConfig, RichTextEditorView};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::link_editor::LinkEditorAction;
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::notebooks::file::MarkdownDisplayMode;
|
||||
use crate::notebooks::link::{LinkEvent, NotebookLinks, SessionSource};
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
use crate::settings::FontSettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::test_util::assert_eventually;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::UserWorkspaces;
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel, search::files::model::FileSearchModel,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces};
|
||||
|
||||
/// Container for a [`RichTextEditorView`] in unit tests.
|
||||
struct TestView {
|
||||
@@ -136,6 +143,25 @@ async fn reset_editor_with_markdown(
|
||||
.await;
|
||||
}
|
||||
|
||||
fn link_offset(
|
||||
editor: &RichTextEditorView,
|
||||
link_url: &str,
|
||||
ctx: &warpui::AppContext,
|
||||
) -> CharOffset {
|
||||
let max_offset = editor.markdown(ctx).chars().count();
|
||||
(0..=max_offset)
|
||||
.map(CharOffset::from)
|
||||
.find(|offset| {
|
||||
editor
|
||||
.model
|
||||
.as_ref(ctx)
|
||||
.link_url_at(*offset, ctx)
|
||||
.as_deref()
|
||||
== Some(link_url)
|
||||
})
|
||||
.expect("Expected link URL to exist in editor")
|
||||
}
|
||||
|
||||
fn rendered_mermaid_block_range(
|
||||
editor: &RichTextEditorView,
|
||||
ctx: &galaxyui::AppContext,
|
||||
@@ -159,6 +185,68 @@ fn rendered_mermaid_block_range(
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loaded_mermaid_diagram_with_placeholder_height_needs_relayout() {
|
||||
App::test((), |app| async move {
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let contents = "graph TD\nA[Start] --> B[Finish]\n";
|
||||
let asset_source = mermaid_asset_source(contents);
|
||||
|
||||
let pending = app.read(|ctx| {
|
||||
let asset_cache = AssetCache::as_ref(ctx);
|
||||
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
|
||||
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
|
||||
AssetState::Loaded { .. } => None,
|
||||
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
|
||||
AssetState::FailedToLoad(err) => {
|
||||
panic!("Mermaid asset should load successfully: {err}")
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(future) = pending {
|
||||
future.await;
|
||||
}
|
||||
|
||||
app.read(|ctx| {
|
||||
let config = ImageBlockConfig {
|
||||
width: Pixels::new(640.),
|
||||
height: Pixels::new(120.),
|
||||
spacing: BlockSpacing::default(),
|
||||
};
|
||||
let block = BlockItem::MermaidDiagram {
|
||||
content_length: CharOffset::from(contents.chars().count()),
|
||||
asset_source,
|
||||
config,
|
||||
};
|
||||
let asset_cache = AssetCache::as_ref(ctx);
|
||||
|
||||
assert!(matches!(
|
||||
RichTextEditorView::layout_affecting_asset_load(&block, asset_cache),
|
||||
Some(LayoutAffectingAssetLoad::LoadedNeedsRelayout)
|
||||
));
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_affecting_asset_loads_rebuild_selectable_and_editable_layouts() {
|
||||
assert!(
|
||||
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
|
||||
InteractionState::Selectable,
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
|
||||
InteractionState::Editable,
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
|
||||
InteractionState::EditableWithInvalidSelection,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -277,6 +365,22 @@ fn test_omnibar_is_hidden_for_rendered_mermaid_selection() {
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
|
||||
let render_state = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.set_mermaid_render_mode(
|
||||
CharOffset::from(7),
|
||||
MarkdownDisplayMode::Rendered,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
let mermaid_block_range =
|
||||
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
|
||||
@@ -300,6 +404,22 @@ fn test_shift_click_on_rendered_mermaid_dispatches_selection_update_to_block_bou
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
|
||||
let render_state = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.set_mermaid_render_mode(
|
||||
CharOffset::from(7),
|
||||
MarkdownDisplayMode::Rendered,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
editor.selection_end(ctx);
|
||||
@@ -380,6 +500,22 @@ fn test_drag_on_rendered_mermaid_dispatches_selection_update_to_block_boundary()
|
||||
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
|
||||
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
|
||||
|
||||
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
|
||||
let render_state = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model.as_ref(ctx).render_state().clone()
|
||||
});
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.model.update(ctx, |model, ctx| {
|
||||
model.set_mermaid_render_mode(
|
||||
CharOffset::from(7),
|
||||
MarkdownDisplayMode::Rendered,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.selection_start(CharOffset::from(2), false, ctx);
|
||||
});
|
||||
@@ -524,6 +660,128 @@ fn test_link_editing() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editable_markdown_anchor_click_opens_link_tooltip() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
reset_editor_with_markdown(&mut app, &editor_view, "- [Goal](#goal)\n\n## Goal").await;
|
||||
|
||||
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(
|
||||
&EditorViewAction::MaybeOpenFileOrUrl {
|
||||
offset,
|
||||
link_in_text: None,
|
||||
cmd: false,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, _ctx| {
|
||||
let open_link = editor
|
||||
.open_link
|
||||
.as_ref()
|
||||
.expect("Editable anchor click should show the link tooltip");
|
||||
assert_eq!(open_link.url, "#goal");
|
||||
assert!(open_link.editable);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_click_markdown_anchor_navigates_without_link_tooltip() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_, editor_view, _) = initialize_editor(&mut app);
|
||||
reset_editor_with_markdown(&mut app, &editor_view, "- [Goal](#goal)\n\n## Goal").await;
|
||||
|
||||
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(
|
||||
&EditorViewAction::MaybeOpenFileOrUrl {
|
||||
offset,
|
||||
link_in_text: None,
|
||||
cmd: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
editor_view.read(&app, |editor, _ctx| {
|
||||
assert!(
|
||||
editor.open_link.is_none(),
|
||||
"Cmd-click anchor navigation should not show the link tooltip"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cmd_click_missing_markdown_anchor_falls_back_to_link_resolution() {
|
||||
App::test((), |mut app| async move {
|
||||
let (window_id, editor_view, _) = initialize_editor(&mut app);
|
||||
let base = tempdir().expect("Expected temp dir");
|
||||
let fallback_path = base.path().join("#missing.png");
|
||||
std::fs::File::create(&fallback_path).expect("Expected fallback file");
|
||||
let session = Arc::new(Session::test().with_shell_launch_data(
|
||||
ShellLaunchData::Executable {
|
||||
executable_path: PathBuf::from("/bin/bash"),
|
||||
shell_type: ShellType::Bash,
|
||||
},
|
||||
));
|
||||
|
||||
ActiveSession::handle(&app).update(&mut app, |active_session, ctx| {
|
||||
active_session.set_session_for_test(
|
||||
window_id,
|
||||
session.clone(),
|
||||
Some(base.path()),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
reset_editor_with_markdown(
|
||||
&mut app,
|
||||
&editor_view,
|
||||
"- [Missing](#missing.png)\n\n## Goal",
|
||||
)
|
||||
.await;
|
||||
|
||||
let events = Arc::new(Mutex::new(Vec::<LinkEvent>::new()));
|
||||
let links = editor_view.read(&app, |editor, _ctx| editor.links.clone());
|
||||
{
|
||||
let events = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&links, move |_, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#missing.png", ctx));
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.handle_action(
|
||||
&EditorViewAction::MaybeOpenFileOrUrl {
|
||||
offset,
|
||||
link_in_text: None,
|
||||
cmd: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
assert_eventually!(
|
||||
events.lock().iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
LinkEvent::OpenFileWithTarget { path, .. } if path == &fallback_path
|
||||
)
|
||||
}),
|
||||
"Missing anchor click should fall back to link resolution: {:?}",
|
||||
events.lock().clone()
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn test_run_command_from_text_selection() {
|
||||
// This tests that, starting from a text selection, we can still run a command.
|
||||
|
||||
+323
-120
@@ -1,77 +1,66 @@
|
||||
use std::{
|
||||
mem,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::mem;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_util::path::user_friendly_path;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, GalaxyA11yRole},
|
||||
elements::{
|
||||
Align, Container, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
|
||||
Stack, Text,
|
||||
},
|
||||
keymap::EditableBinding,
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::notebooks::post_process_notebook;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cmd_or_ctrl_shift,
|
||||
editor::InteractionState,
|
||||
menu::{MenuItem, MenuItemFields},
|
||||
notebooks::editor::{model::NotebooksEditorModel, rich_text_styles},
|
||||
pane_group::{
|
||||
focus_state::PaneFocusHandle,
|
||||
pane::view,
|
||||
pane::view::header::components::{
|
||||
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
|
||||
CenteredHeaderEdgeWidth,
|
||||
},
|
||||
BackingView, PaneConfiguration, PaneEvent,
|
||||
},
|
||||
safe_warn, send_telemetry_from_ctx,
|
||||
server::telemetry::{NotebookActionEvent, NotebookTelemetryMetadata, TelemetryEvent},
|
||||
settings::FontSettings,
|
||||
terminal::model::session::Session,
|
||||
ui_components::icons::Icon,
|
||||
view_components::{MarkdownToggleEvent, MarkdownToggleView},
|
||||
workflows::{WorkflowSource, WorkflowType},
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
|
||||
use super::{
|
||||
context_menu::{show_rich_editor_context_menu, ContextMenuAction, ContextMenuState},
|
||||
editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
|
||||
link::{NotebookLinks, SessionSource},
|
||||
styles,
|
||||
telemetry::NotebookTelemetryAction,
|
||||
NotebookLocation,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code::editor_management::CodeSource;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::FileTarget;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use remote_server::manager::RemoteServerManager;
|
||||
use galaxy_core::ui::icons::ICON_DIMENSIONS;
|
||||
use galaxy_editor::model::CoreEditorModel;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_files::{FileModel, FileModelEvent};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::file::FileId;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::path::user_friendly_path;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
Align, Container, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
|
||||
Stack, Text,
|
||||
};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::context_menu::{show_rich_editor_context_menu, ContextMenuAction, ContextMenuState};
|
||||
use super::editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView};
|
||||
use super::link::{NotebookLinks, SessionSource};
|
||||
use super::telemetry::NotebookTelemetryAction;
|
||||
use super::{styles, NotebookLocation};
|
||||
use crate::appearance::Appearance;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code::editor_management::CodeSource;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::view;
|
||||
use crate::pane_group::pane::view::header::components::{
|
||||
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
|
||||
CenteredHeaderEdgeWidth,
|
||||
};
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
|
||||
use crate::server::telemetry::{NotebookActionEvent, NotebookTelemetryMetadata, TelemetryEvent};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::ui_components::icons::Icon;
|
||||
pub use crate::util::openable_file_type::is_markdown_file;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::FileTarget;
|
||||
use crate::view_components::{MarkdownToggleEvent, MarkdownToggleView};
|
||||
use crate::workflows::{WorkflowSource, WorkflowType};
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::{cmd_or_ctrl_shift, safe_warn, send_telemetry_from_ctx};
|
||||
|
||||
/// Display mode for markdown files shown via the header segmented control.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -105,6 +94,8 @@ pub struct FileNotebookView {
|
||||
/// This is preserved so we can restore it when toggling between raw and rendered Markdown.
|
||||
#[cfg(feature = "local_fs")]
|
||||
code_source: Option<CodeSource>,
|
||||
/// Persistent hover state for the header title tooltip.
|
||||
header_title_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -144,6 +135,7 @@ pub enum FileNotebookAction {
|
||||
OpenAsCode,
|
||||
ContextMenu(ContextMenuAction),
|
||||
ToggleMarkdownDisplayMode(MarkdownDisplayMode),
|
||||
ToggleMaximized,
|
||||
}
|
||||
|
||||
impl From<ContextMenuAction> for FileNotebookAction {
|
||||
@@ -153,33 +145,36 @@ impl From<ContextMenuAction> for FileNotebookAction {
|
||||
}
|
||||
|
||||
/// Information about the notebook's backing file.
|
||||
// TODO: This should probably build on the `galaxy_files` abstractions.
|
||||
#[derive(Debug, Clone)]
|
||||
enum SourceFile {
|
||||
Local {
|
||||
/// The full path to the open file - for now, _only_ local files are supported.
|
||||
///
|
||||
/// See [this comment](https://docs.google.com/document/d/18h7VzSAl6r5a94CovShlpPSahYqECX9WZliLjUqUsko/edit?disco=AAAA5Y1THuk);
|
||||
/// we cannot use [`PathBuf`] to represent non-local paths.
|
||||
local_path: PathBuf,
|
||||
/// A file identified by a local or remote path.
|
||||
FileBased {
|
||||
path: LocalOrRemotePath,
|
||||
/// The session context for this file. Only meaningful for local paths;
|
||||
/// remote paths carry their own host information.
|
||||
session: Option<Arc<Session>>,
|
||||
},
|
||||
Static {
|
||||
title: String,
|
||||
},
|
||||
/// Static content provided inline (not backed by a file on disk).
|
||||
Static { title: String },
|
||||
}
|
||||
|
||||
impl SourceFile {
|
||||
fn local_path(&self) -> Option<&Path> {
|
||||
/// Returns the path for this source, if it is file-based.
|
||||
fn path(&self) -> Option<&LocalOrRemotePath> {
|
||||
match self {
|
||||
SourceFile::Local { local_path, .. } => Some(local_path.as_path()),
|
||||
SourceFile::FileBased { path, .. } => Some(path),
|
||||
SourceFile::Static { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the local path, if this is a local file-based source.
|
||||
fn local_path(&self) -> Option<&Path> {
|
||||
self.path().and_then(|p| p.to_local_path())
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
match self {
|
||||
SourceFile::Local { local_path, .. } => local_path.display().to_string(),
|
||||
SourceFile::FileBased { path, .. } => path.display_path(),
|
||||
SourceFile::Static { title } => title.clone(),
|
||||
}
|
||||
}
|
||||
@@ -194,6 +189,11 @@ enum FileState {
|
||||
}
|
||||
|
||||
impl FileState {
|
||||
/// Returns the path for the open file, if it is file-based.
|
||||
fn path(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.source().and_then(|src| src.path())
|
||||
}
|
||||
|
||||
/// The path to the open file, if it exists and is local.
|
||||
fn local_path(&self) -> Option<&Path> {
|
||||
self.source().and_then(|src| src.local_path())
|
||||
@@ -244,7 +244,9 @@ impl FileNotebookView {
|
||||
|
||||
let editor_model = ctx.add_model(|ctx| {
|
||||
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
|
||||
NotebooksEditorModel::new(styles, window_id, ctx)
|
||||
let mut model = NotebooksEditorModel::new(styles, window_id, ctx);
|
||||
model.set_default_mermaid_display_mode(MarkdownDisplayMode::Rendered, ctx);
|
||||
model
|
||||
});
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
let mut view = RichTextEditorView::new(
|
||||
@@ -294,6 +296,7 @@ impl FileNotebookView {
|
||||
display_mode_segmented_control,
|
||||
#[cfg(feature = "local_fs")]
|
||||
code_source: None,
|
||||
header_title_mouse_state: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +371,36 @@ impl FileNotebookView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Open a file from a local or remote path.
|
||||
///
|
||||
/// For local paths, uses `FileModel` to load and watch for changes.
|
||||
/// The `session` is used to resolve display names and link context for
|
||||
/// local paths. If `None` for a local path, the view falls back to the
|
||||
/// active local session when one becomes available. For remote paths the
|
||||
/// session is ignored because the `RemotePath` already carries host info.
|
||||
/// For remote paths, fetches content via the remote server.
|
||||
pub fn open(
|
||||
&mut self,
|
||||
path: LocalOrRemotePath,
|
||||
session: Option<Arc<Session>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match path {
|
||||
LocalOrRemotePath::Local(local_path) => {
|
||||
// For local paths, try to resolve a local session if none was provided.
|
||||
let session = session.or_else(|| {
|
||||
ActiveSession::as_ref(ctx)
|
||||
.session(ctx.window_id())
|
||||
.filter(|s| s.is_local())
|
||||
});
|
||||
self.open_local(local_path, session, ctx);
|
||||
}
|
||||
LocalOrRemotePath::Remote(remote_path) => {
|
||||
self.open_remote(remote_path, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asynchronously open a local file, watching for local file changes.
|
||||
pub fn open_local(
|
||||
&mut self,
|
||||
@@ -388,8 +421,8 @@ impl FileNotebookView {
|
||||
});
|
||||
}
|
||||
|
||||
self.file_state = FileState::Loading(SourceFile::Local {
|
||||
local_path: local_path.clone(),
|
||||
self.file_state = FileState::Loading(SourceFile::FileBased {
|
||||
path: LocalOrRemotePath::Local(local_path.clone()),
|
||||
session: session.clone(),
|
||||
});
|
||||
|
||||
@@ -415,8 +448,7 @@ impl FileNotebookView {
|
||||
}
|
||||
match event {
|
||||
FileModelEvent::FileLoaded { content, .. } => {
|
||||
let cleaned = post_process_notebook(content);
|
||||
me.set_content(&cleaned, ctx);
|
||||
me.set_content(content, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::OpenNotebook(me.open_telemetry_metadata(ctx)),
|
||||
ctx
|
||||
@@ -425,8 +457,8 @@ impl FileNotebookView {
|
||||
// Record the canonical path instead of the input path when available.
|
||||
if let Some(canonical_path) = file_model.as_ref(ctx).file_path(file_id)
|
||||
{
|
||||
me.file_state = FileState::Loaded(SourceFile::Local {
|
||||
local_path: canonical_path,
|
||||
me.file_state = FileState::Loaded(SourceFile::FileBased {
|
||||
path: LocalOrRemotePath::Local(canonical_path),
|
||||
session: session.clone(),
|
||||
});
|
||||
}
|
||||
@@ -457,8 +489,7 @@ impl FileNotebookView {
|
||||
ctx.notify();
|
||||
}
|
||||
FileModelEvent::FileUpdated { content, .. } => {
|
||||
let cleaned = post_process_notebook(content);
|
||||
me.set_content(&cleaned, ctx);
|
||||
me.set_content(content, ctx);
|
||||
}
|
||||
FileModelEvent::FileSaved { .. } | FileModelEvent::FailedToSave { .. } => {}
|
||||
}
|
||||
@@ -473,8 +504,8 @@ impl FileNotebookView {
|
||||
safe: ("Local filesystem access is not available in this build"),
|
||||
full: ("Local filesystem access is not available in this build (feature \"local_fs\" disabled)")
|
||||
);
|
||||
self.file_state = FileState::Error(SourceFile::Local {
|
||||
local_path,
|
||||
self.file_state = FileState::Error(SourceFile::FileBased {
|
||||
path: LocalOrRemotePath::Local(local_path),
|
||||
session,
|
||||
});
|
||||
ctx.notify();
|
||||
@@ -524,32 +555,136 @@ impl FileNotebookView {
|
||||
fn reload_file(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// We can take the file state here because either it's (a) already NoFile or (b) about to
|
||||
// be replaced with a loading state.
|
||||
let (local_path, session) = match mem::replace(&mut self.file_state, FileState::NoFile) {
|
||||
let (path, session) = match mem::replace(&mut self.file_state, FileState::NoFile) {
|
||||
FileState::NoFile => return,
|
||||
FileState::Loading(source) | FileState::Error(source) | FileState::Loaded(source) => {
|
||||
match source {
|
||||
SourceFile::Local {
|
||||
local_path,
|
||||
session,
|
||||
} => (local_path, session),
|
||||
SourceFile::FileBased { path, session } => (path, session),
|
||||
SourceFile::Static { .. } => return,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.open_local(local_path, session, ctx);
|
||||
self.open(path, session, ctx);
|
||||
}
|
||||
|
||||
/// Open a remote file by fetching its content from the remote server.
|
||||
fn open_remote(&mut self, remote_path: RemotePath, ctx: &mut ViewContext<Self>) {
|
||||
let path_str = remote_path.path.as_str().to_string();
|
||||
let display_name = remote_path
|
||||
.path
|
||||
.file_name()
|
||||
.unwrap_or(path_str.as_str())
|
||||
.to_string();
|
||||
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config.set_title(display_name, ctx);
|
||||
});
|
||||
|
||||
let lor_path = LocalOrRemotePath::Remote(remote_path.clone());
|
||||
self.file_state = FileState::Loading(SourceFile::FileBased {
|
||||
path: lor_path,
|
||||
session: None,
|
||||
});
|
||||
|
||||
let host_id = remote_path.host_id.clone();
|
||||
let manager = remote_server::manager::RemoteServerManager::handle(ctx);
|
||||
|
||||
// Subscribe to host connect/disconnect events so the disconnection
|
||||
// banner appears/disappears when the remote session state changes.
|
||||
let watched_host_id = host_id.clone();
|
||||
ctx.subscribe_to_model(&manager, move |_me, _handle, event, ctx| {
|
||||
use remote_server::manager::RemoteServerManagerEvent;
|
||||
match event {
|
||||
RemoteServerManagerEvent::HostDisconnected { host_id }
|
||||
| RemoteServerManagerEvent::HostConnected { host_id }
|
||||
if *host_id == watched_host_id =>
|
||||
{
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
let request = remote_server::proto::ReadFileContextRequest {
|
||||
files: vec![remote_server::proto::ReadFileContextFile {
|
||||
path: path_str,
|
||||
line_ranges: vec![],
|
||||
}],
|
||||
max_file_bytes: None,
|
||||
max_batch_bytes: None,
|
||||
};
|
||||
|
||||
let handle = manager.as_ref(ctx).host_request_handle(&host_id);
|
||||
ctx.spawn(
|
||||
async move { handle.read_file_context(request).await },
|
||||
move |me, result, ctx| match result {
|
||||
Ok(response) => {
|
||||
if let Some(file_ctx) = response.file_contexts.first() {
|
||||
let text = match &file_ctx.content {
|
||||
Some(
|
||||
remote_server::proto::file_context_proto::Content::TextContent(
|
||||
text,
|
||||
),
|
||||
) => text.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
me.set_content(text, ctx);
|
||||
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
|
||||
FileState::Loading(source) => FileState::Loaded(source),
|
||||
other => other,
|
||||
};
|
||||
me.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config.refresh_pane_header_overflow_menu_items(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
ctx.emit(FileNotebookEvent::FileLoaded);
|
||||
} else if let Some(failed) = response.failed_files.first() {
|
||||
let error_msg = failed
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|e| e.message.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
safe_warn!(
|
||||
safe: ("Failed to read remote markdown file"),
|
||||
full: ("Failed to read remote markdown file: {error_msg}")
|
||||
);
|
||||
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
|
||||
FileState::Loading(source) => FileState::Error(source),
|
||||
other => other,
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
safe_warn!(
|
||||
safe: ("Remote server error reading markdown file"),
|
||||
full: ("Remote server error reading markdown file: {err}")
|
||||
);
|
||||
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
|
||||
FileState::Loading(source) => FileState::Error(source),
|
||||
other => other,
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn open_as_code(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(path) = self.local_path() {
|
||||
if let Some(path) = self.file_state.path().cloned() {
|
||||
// Emit an event to the pane group to handle the replacement
|
||||
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
|
||||
path: path.clone(),
|
||||
path,
|
||||
source: self.code_source.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The path to the currently-open file (local or remote), if any.
|
||||
pub fn path(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.file_state.path()
|
||||
}
|
||||
|
||||
/// The path to the currently-open file, if it is local.
|
||||
pub fn local_path(&self) -> Option<PathBuf> {
|
||||
self.file_state.local_path().map(Path::to_path_buf)
|
||||
@@ -564,19 +699,13 @@ impl FileNotebookView {
|
||||
self.links.clone()
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn is_markdown_file(&self) -> bool {
|
||||
self.file_state
|
||||
.local_path()
|
||||
.map(is_markdown_file)
|
||||
.path()
|
||||
.map(|p| is_markdown_file(Path::new(&p.display_path())))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn is_markdown_file(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn update_editor_display_mode(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.markdown_display_mode {
|
||||
MarkdownDisplayMode::Rendered => {
|
||||
@@ -787,13 +916,36 @@ impl FileNotebookView {
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
/// Returns `true` when this notebook is backed by a remote file whose
|
||||
/// host no longer has any connected session.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn is_remote_disconnected(&self, app: &AppContext) -> bool {
|
||||
let Some(LocalOrRemotePath::Remote(remote_path)) = self.file_state.path() else {
|
||||
return false;
|
||||
};
|
||||
RemoteServerManager::as_ref(app)
|
||||
.client_for_host(&remote_path.host_id)
|
||||
.is_none()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance, _app: &AppContext) -> Box<dyn Element> {
|
||||
let body = match &self.file_state {
|
||||
FileState::NoFile => self.render_no_file(appearance),
|
||||
FileState::Loading(source) => self.render_loading(source, appearance),
|
||||
FileState::Error(source) => self.render_error(source, appearance),
|
||||
FileState::Loaded(_) => ChildView::new(&self.editor).finish(),
|
||||
};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if matches!(self.file_state, FileState::Loaded(_)) && self.is_remote_disconnected(_app) {
|
||||
let banner =
|
||||
crate::code::local_code_editor::render_remote_disconnected_banner(appearance);
|
||||
let mut col = Flex::column();
|
||||
col.add_child(banner);
|
||||
col.add_child(Shrinkable::new(1., styles::wrap_body(body)).finish());
|
||||
return col.finish();
|
||||
}
|
||||
|
||||
styles::wrap_body(body)
|
||||
}
|
||||
}
|
||||
@@ -820,7 +972,7 @@ impl View for FileNotebookView {
|
||||
|
||||
let column = Flex::column().with_children([
|
||||
self.render_title(appearance, font_settings),
|
||||
Shrinkable::new(1., self.render_body(appearance)).finish(),
|
||||
Shrinkable::new(1., self.render_body(appearance, app)).finish(),
|
||||
]);
|
||||
|
||||
let mut stack = Stack::new().with_child(column.finish());
|
||||
@@ -864,24 +1016,30 @@ impl TypedActionView for FileNotebookView {
|
||||
FileNotebookAction::ReloadFile => self.reload_file(ctx),
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileNotebookAction::CopyFilePath => {
|
||||
if let Some(path) = self.local_path() {
|
||||
if let Some(path) = self.file_state.path() {
|
||||
ctx.clipboard()
|
||||
.write(ClipboardContent::plain_text(path.display().to_string()));
|
||||
.write(ClipboardContent::plain_text(path.display_path()));
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileNotebookAction::OpenInEditor => {
|
||||
if let Some(path) = self.local_path() {
|
||||
if let Some(local_path) = self.local_path() {
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
use crate::util::openable_file_type::resolve_file_target;
|
||||
// Resolve target and emit event - workspace will handle all cases
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
let target = resolve_file_target(&path, settings, None);
|
||||
let target = resolve_file_target(&local_path, settings, None);
|
||||
ctx.emit(FileNotebookEvent::OpenFileWithTarget {
|
||||
path,
|
||||
path: local_path,
|
||||
target,
|
||||
line_col: None,
|
||||
});
|
||||
} else if let Some(path) = self.file_state.path().cloned() {
|
||||
// For remote files, open as a code editor pane.
|
||||
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
|
||||
path,
|
||||
source: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "local_fs")]
|
||||
@@ -907,7 +1065,7 @@ impl TypedActionView for FileNotebookView {
|
||||
MarkdownDisplayMode::Raw => {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
if let Some(path) = self.local_path() {
|
||||
if let Some(path) = self.file_state.path().cloned() {
|
||||
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
|
||||
path,
|
||||
source: self.code_source.clone(),
|
||||
@@ -917,6 +1075,12 @@ impl TypedActionView for FileNotebookView {
|
||||
}
|
||||
}
|
||||
}
|
||||
FileNotebookAction::ToggleMaximized => {
|
||||
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ToggleMaximized));
|
||||
self.pane_configuration.update(ctx, |pane_config, ctx| {
|
||||
pane_config.refresh_pane_header_overflow_menu_items(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,14 +1100,20 @@ impl BackingView for FileNotebookView {
|
||||
|
||||
fn pane_header_overflow_menu_items(
|
||||
&self,
|
||||
_ctx: &AppContext,
|
||||
ctx: &AppContext,
|
||||
) -> Vec<MenuItem<FileNotebookAction>> {
|
||||
let mut actions = vec![];
|
||||
if let Some(SourceFile::Local {
|
||||
local_path: _local_path,
|
||||
..
|
||||
}) = self.file_state.source()
|
||||
{
|
||||
// Mirror the toggle that `CodeView` (the Raw markdown mode) exposes, so the Rendered
|
||||
// markdown pane's overflow menu offers the same "Maximize pane" / "Minimize pane" entry.
|
||||
let is_maximized = self
|
||||
.focus_handle
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.is_maximized(ctx));
|
||||
let mut actions = vec![MenuItemFields::toggle_pane_action(is_maximized)
|
||||
.with_on_select_action(FileNotebookAction::ToggleMaximized)
|
||||
.into_item()];
|
||||
|
||||
if let Some(SourceFile::FileBased { .. }) = self.file_state.source() {
|
||||
actions.push(MenuItem::Separator);
|
||||
actions.push(
|
||||
MenuItemFields::new("Refresh file")
|
||||
.with_on_select_action(FileNotebookAction::ReloadFile)
|
||||
@@ -1027,10 +1197,43 @@ impl BackingView for FileNotebookView {
|
||||
galaxyui::text_layout::ClipConfig::start(),
|
||||
);
|
||||
|
||||
// Wrap the title in a hoverable tooltip showing the full file path.
|
||||
let title_element: Box<dyn Element> =
|
||||
if let Some(display_path) = self.file_state.path().map(|p| p.display_path()) {
|
||||
use warpui::elements::{
|
||||
ChildAnchor, Hoverable, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
Hoverable::new(self.header_title_mouse_state.clone(), move |hover_state| {
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(title_text);
|
||||
if hover_state.is_hovered() {
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(display_path.clone())
|
||||
.build()
|
||||
.finish();
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 4.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ChildAnchor::TopMiddle,
|
||||
),
|
||||
);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
title_text
|
||||
};
|
||||
|
||||
view::HeaderContent::Custom {
|
||||
element: render_three_column_header(
|
||||
Empty::new().finish(),
|
||||
title_text,
|
||||
title_element,
|
||||
right_row.finish(),
|
||||
CenteredHeaderEdgeWidth {
|
||||
min: buttons_width,
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
use std::{path::Path, sync::Arc};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_files::FileModel;
|
||||
use galaxyui::{platform::WindowStyle, App, SingletonEntity, View};
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use repo_metadata::{repositories::DetectedRepositories, watcher::DirectoryWatcher};
|
||||
use string_offset::CharOffset;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::render::model::BlockItem;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_files::FileModel;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::{App, SingletonEntity, View};
|
||||
|
||||
use super::{FileNotebookView, FileState, MarkdownDisplayMode, SourceFile};
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::notebooks::context_menu::MenuSource;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::file::is_markdown_file;
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::{
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
notebooks::{editor::keys::NotebookKeybindings, file::is_markdown_file},
|
||||
search::files::model::FileSearchModel,
|
||||
server::server_api::ServerApiProvider,
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::model::session::Session,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
|
||||
use crate::notebooks::context_menu::MenuSource;
|
||||
|
||||
use super::{FileNotebookView, FileState};
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
fn init_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
@@ -123,10 +126,10 @@ fn test_load_before_session() {
|
||||
.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_local("../README.md", None, ctx);
|
||||
match &file_notebook.file_state {
|
||||
FileState::Loading(source) => {
|
||||
assert_eq!(source.local_path(), Some(Path::new("../README.md")))
|
||||
FileState::Loading(SourceFile::FileBased { path, .. }) => {
|
||||
assert_eq!(path.to_local_path(), Some(Path::new("../README.md")))
|
||||
}
|
||||
other => panic!("Expected FileState::Loading, got {other:?}"),
|
||||
other => panic!("Expected FileState::Loading(FileBased), got {other:?}"),
|
||||
}
|
||||
|
||||
let file_id = file_notebook
|
||||
@@ -148,10 +151,10 @@ fn test_load_before_session() {
|
||||
assert!(view.location.is_none());
|
||||
|
||||
match &view.file_state {
|
||||
FileState::Loaded(source) => {
|
||||
assert_eq!(source.local_path(), Some(expected_path.as_path()));
|
||||
FileState::Loaded(SourceFile::FileBased { path, .. }) => {
|
||||
assert_eq!(path.to_local_path(), Some(expected_path.as_path()));
|
||||
}
|
||||
other => panic!("Expected FileState::Loaded, got {other:?}"),
|
||||
other => panic!("Expected FileState::Loaded(FileBased), got {other:?}"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,6 +198,53 @@ fn test_load_static() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_notebook_mermaid_blocks_default_to_rendered() {
|
||||
App::test((), |mut app| async move {
|
||||
init_app(&mut app);
|
||||
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
|
||||
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
|
||||
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
|
||||
|
||||
handle.update(&mut app, |file_notebook, ctx| {
|
||||
file_notebook.open_static("Test Title", "```mermaid\ngraph TD\nA --> B\n```", ctx);
|
||||
});
|
||||
let render_state = handle.read(&app, |view, ctx| {
|
||||
view.editor
|
||||
.as_ref(ctx)
|
||||
.model()
|
||||
.as_ref(ctx)
|
||||
.render_state()
|
||||
.clone()
|
||||
});
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
|
||||
.await;
|
||||
|
||||
handle.read(&app, |view, ctx| {
|
||||
let editor = view.editor.as_ref(ctx);
|
||||
let model = editor.model().as_ref(ctx);
|
||||
let command = model
|
||||
.notebook_command_for_block(CharOffset::zero())
|
||||
.expect("Mermaid command should exist");
|
||||
assert_eq!(
|
||||
command.as_ref(ctx).mermaid_display_mode,
|
||||
MarkdownDisplayMode::Rendered
|
||||
);
|
||||
assert!(matches!(
|
||||
model
|
||||
.render_state()
|
||||
.as_ref(ctx)
|
||||
.content()
|
||||
.block_at_height(0.)
|
||||
.map(|item| item.item),
|
||||
Some(BlockItem::MermaidDiagram { .. })
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_file_detection() {
|
||||
assert!(is_markdown_file("README.md"));
|
||||
|
||||
+69
-36
@@ -1,33 +1,26 @@
|
||||
//! Link-opening behavior for notebooks.
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt,
|
||||
future::{self, Future},
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::future::{self, Future};
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::future::Either;
|
||||
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
use galaxyui::{
|
||||
r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity,
|
||||
WindowId,
|
||||
};
|
||||
use url::Url;
|
||||
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId};
|
||||
|
||||
use super::file::is_markdown_file;
|
||||
use crate::drive::OpenWarpDriveObjectArgs;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::{is_supported_image_file, resolve_file_target, FileTarget};
|
||||
use crate::{
|
||||
drive::OpenGalaxyDriveObjectArgs,
|
||||
terminal::model::session::Session,
|
||||
uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink},
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
|
||||
use super::file::is_markdown_file;
|
||||
use crate::workspace::ActiveSession;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "link_tests.rs"]
|
||||
@@ -221,7 +214,7 @@ impl NotebookLinks {
|
||||
match self.session_source.base_directory(ctx) {
|
||||
Some(directory) => directory.join(clean_path),
|
||||
None => {
|
||||
return Either::Right(future::ready(Err(ResolveError::MissingContext)))
|
||||
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -260,7 +253,7 @@ impl NotebookLinks {
|
||||
|
||||
/// Open a resolved link:
|
||||
/// * URLs are opened in the web browser or system-default application.
|
||||
/// * Markdown files are opened in Warp (if the `FileNotebooks` feature flag is enabled).
|
||||
/// * Markdown files are opened according to the user's Markdown Viewer preference.
|
||||
/// * Other files are opened in the configured editor or system-default application.
|
||||
pub fn open(&self, link: LinkTarget, ctx: &mut ModelContext<Self>) {
|
||||
match link {
|
||||
@@ -275,11 +268,27 @@ impl NotebookLinks {
|
||||
}
|
||||
LinkTarget::LocalFile {
|
||||
path,
|
||||
line_and_column,
|
||||
session,
|
||||
is_markdown: true,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
let _ = line_and_column;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
if *settings.prefer_markdown_viewer {
|
||||
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
|
||||
} else {
|
||||
open_file(path, line_and_column, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
|
||||
}
|
||||
}
|
||||
LinkTarget::LocalFile {
|
||||
path,
|
||||
@@ -343,6 +352,11 @@ impl NotebookLinks {
|
||||
}
|
||||
|
||||
/// Open a file respecting user's editor settings.
|
||||
///
|
||||
/// For targets that would be handed to the OS default handler (`SystemGeneric` /
|
||||
/// `SystemDefault`), we reveal the file in Finder / Explorer instead of opening it.
|
||||
/// This prevents a malicious markdown link from triggering arbitrary code execution
|
||||
/// via an executable disguised as a local file (e.g. an extensionless shell script).
|
||||
// The `line_and_column` argument is unused when there is no local filesystem.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
fn open_file(
|
||||
@@ -352,17 +366,36 @@ fn open_file(
|
||||
) {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
let target = if is_supported_image_file(&path) {
|
||||
FileTarget::SystemGeneric
|
||||
} else {
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
resolve_file_target(&path, settings, None)
|
||||
};
|
||||
ctx.emit(LinkEvent::OpenFileWithTarget {
|
||||
path,
|
||||
target,
|
||||
line_col: line_and_column,
|
||||
});
|
||||
// Images are safe to open with the system default viewer.
|
||||
if is_supported_image_file(&path) {
|
||||
ctx.emit(LinkEvent::OpenFileWithTarget {
|
||||
path,
|
||||
target: FileTarget::SystemGeneric,
|
||||
line_col: line_and_column,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
let target = resolve_file_target(&path, settings, None);
|
||||
match target {
|
||||
// Safe targets: open in a viewer/editor that won't execute the file.
|
||||
FileTarget::MarkdownViewer(_)
|
||||
| FileTarget::CodeEditor(_)
|
||||
| FileTarget::ExternalEditor(_)
|
||||
| FileTarget::EnvEditor => {
|
||||
ctx.emit(LinkEvent::OpenFileWithTarget {
|
||||
path,
|
||||
target,
|
||||
line_col: line_and_column,
|
||||
});
|
||||
}
|
||||
// Dangerous targets: the OS default handler could execute the file.
|
||||
// Reveal in Finder / Explorer instead.
|
||||
FileTarget::SystemGeneric | FileTarget::SystemDefault => {
|
||||
ctx.open_file_path_in_explorer(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
ctx.open_file_path(&path);
|
||||
|
||||
+101
-13
@@ -1,24 +1,25 @@
|
||||
use std::{
|
||||
io::ErrorKind,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::{App, ModelHandle, WindowId};
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::Mutex;
|
||||
use settings::Setting as _;
|
||||
use tempfile::tempdir;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
notebooks::{file::is_markdown_file, link::LinkEvent},
|
||||
terminal::{model::session::Session, shell::ShellType},
|
||||
util::openable_file_type::FileTarget,
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
use galaxyui::{App, ModelHandle, SingletonEntity, WindowId};
|
||||
|
||||
use super::{LinkTarget, NotebookLinks, ResolveError, SessionSource};
|
||||
use crate::notebooks::file::is_markdown_file;
|
||||
use crate::notebooks::link::LinkEvent;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
use crate::util::openable_file_type::FileTarget;
|
||||
use crate::workspace::ActiveSession;
|
||||
|
||||
fn url(s: &str) -> LinkTarget {
|
||||
LinkTarget::Url(Url::parse(s).expect("Invalid URL"))
|
||||
@@ -60,6 +61,8 @@ lazy_static! {
|
||||
/// Initialize the app and link resolver. For test purposes, we only care about the base
|
||||
/// directory's value, not how it was obtained.
|
||||
fn init_link_model(app: &mut App, base_directory: Option<&Path>) -> ModelHandle<NotebookLinks> {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let window_id = WindowId::new();
|
||||
let source = match base_directory {
|
||||
Some(dir) => SessionSource::Target {
|
||||
@@ -208,6 +211,43 @@ fn test_open_local_image_uses_system_generic_target() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_extensionless_non_text_file_does_not_emit_open_event() {
|
||||
// Regression test: an extensionless file (e.g. a disguised executable) is classified as
|
||||
// binary by `is_file_openable_in_warp`, which previously routed it to `SystemGeneric` and
|
||||
// ultimately `NSWorkspace.openURL` — allowing arbitrary code execution. After the fix,
|
||||
// such files are revealed in Finder / Explorer instead of opened, so no `OpenFileWithTarget`
|
||||
// event should be emitted.
|
||||
App::test((), |mut app| async move {
|
||||
let base = tempdir().unwrap();
|
||||
let base_path = base.path();
|
||||
let malicious_path = base_path.join("abc");
|
||||
touch(&malicious_path).await;
|
||||
let links = init_link_model(&mut app, Some(base_path));
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
{
|
||||
let events = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&links, move |_, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
links.update(&mut app, |links, ctx| {
|
||||
links.open(local_file(&malicious_path), ctx);
|
||||
});
|
||||
|
||||
let events = events.lock();
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"Expected no LinkEvent to be emitted for an extensionless non-text file, \
|
||||
but got: {events:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_valid_url() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -359,7 +399,7 @@ fn test_resolve_file_with_line() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_markdown_file() {
|
||||
fn test_open_markdown_file_uses_viewer_when_preferred() {
|
||||
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !root.join("README.md").exists() {
|
||||
root = root.parent().unwrap().to_path_buf();
|
||||
@@ -398,3 +438,51 @@ fn test_open_markdown_file() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_markdown_file_respects_disabled_viewer_preference() {
|
||||
// With `prefer_markdown_viewer = false`, the markdown file would otherwise
|
||||
// resolve to `FileTarget::SystemDefault`. The security fix in #25353 routes
|
||||
// both `SystemDefault` and `SystemGeneric` through
|
||||
// `open_file_path_in_explorer`, so no `OpenFileWithTarget` event is emitted
|
||||
// — the file is revealed in Finder / Explorer instead.
|
||||
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !root.join("README.md").exists() {
|
||||
root = root.parent().unwrap().to_path_buf();
|
||||
}
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let links = init_link_model(&mut app, Some(&root));
|
||||
|
||||
EditorSettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.prefer_markdown_viewer
|
||||
.set_value(false, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let events = Arc::new(Mutex::new(vec![]));
|
||||
{
|
||||
let events = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&links, move |_, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
links
|
||||
.update(&mut app, |links, ctx| {
|
||||
let future = links.resolve_and_open("./README.md", ctx);
|
||||
ctx.await_spawned_future(future.future_id())
|
||||
})
|
||||
.await;
|
||||
|
||||
let events = events.lock();
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"Expected no LinkEvent when markdown viewer is disabled (file is \
|
||||
revealed in explorer instead), but got: {events:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,26 @@
|
||||
use std::collections::{hash_map::Entry, HashMap};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures_util::stream::AbortHandle;
|
||||
use galaxyui::{
|
||||
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
use markdown_parser::markdown_parser::parse_markdown_to_raw_text;
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::persistence::{CloudModel, CloudModelEvent},
|
||||
Owner,
|
||||
},
|
||||
drive::OpenGalaxyDriveObjectSettings,
|
||||
pane_group::{NotebookPane, PaneContent},
|
||||
safe_debug, safe_warn,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::SyncId,
|
||||
},
|
||||
workspace::PaneViewLocator,
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{
|
||||
Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::{notebook::NotebookView, CloudNotebook};
|
||||
use super::notebook::NotebookView;
|
||||
use super::CloudNotebook;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::drive::OpenWarpDriveObjectSettings;
|
||||
use crate::pane_group::{NotebookPane, PaneContent};
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use crate::{safe_debug, safe_warn};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "manager_tests.rs"]
|
||||
@@ -146,7 +141,12 @@ impl NotebookManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
fn handle_cloud_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<CloudModel>,
|
||||
event: &CloudModelEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let CloudModelEvent::ObjectUpdated { type_and_id, .. } = event {
|
||||
if let Some(notebook_id) = type_and_id.as_notebook_id() {
|
||||
self.update_raw_text_for_notebook(notebook_id, ctx);
|
||||
@@ -292,6 +292,7 @@ impl NotebookManager {
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
_: ModelHandle<UpdateManager>,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
use std::sync::mpsc;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{
|
||||
platform::WindowStyle, AddSingletonModel, App, EntityId, ModelHandle, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::BlocklistAIHistoryModel,
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::{
|
||||
model::{actions::ObjectActions, persistence::CloudModel, view::CloudViewModel},
|
||||
Owner,
|
||||
},
|
||||
network::NetworkStatus,
|
||||
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
|
||||
pane_group::NotebookPane,
|
||||
persistence::ModelEvent,
|
||||
search::files::model::FileSearchModel,
|
||||
server::{
|
||||
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
|
||||
sync_queue::SyncQueue, telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings::PrivacySettings,
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::{
|
||||
keys::TerminalKeybindings, shared_session::permissions_manager::SessionPermissionsManager,
|
||||
},
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider,
|
||||
};
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::{AddSingletonModel, App, EntityId, ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use super::NotebookManager;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::actions::ObjectActions;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::CloudViewModel;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::notebook::NotebookView;
|
||||
use crate::pane_group::NotebookPane;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
struct TestState {
|
||||
manager: ModelHandle<NotebookManager>,
|
||||
|
||||
+20
-82
@@ -10,55 +10,24 @@ pub mod telemetry;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use anyhow::Result;
|
||||
use galaxyui::AppContext;
|
||||
use itertools::Itertools;
|
||||
use async_trait::async_trait;
|
||||
pub use cloud_object_models::{CloudNotebook, CloudNotebookModel, NotebookId, SerializedNotebook};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::{
|
||||
ai::document::ai_document_model::AIDocumentId,
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericServerObject, ObjectType, Owner, Revision, ServerCloudObject,
|
||||
UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{
|
||||
items::{notebook::WarpDriveNotebook, WarpDriveItem},
|
||||
CloudObjectTypeAndId,
|
||||
},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
ids::{ServerId, SyncId},
|
||||
server_api::object::ObjectClient,
|
||||
sync_queue::{QueueItem, SerializedModel},
|
||||
},
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::{
|
||||
CloudModelType, CloudObjectEventEntrypoint, CloudObjectUpsertParams, CreateCloudObjectResult,
|
||||
CreateObjectRequest, GenericServerObject, ObjectType, Owner, Revision, UpdateCloudObjectResult,
|
||||
};
|
||||
|
||||
/// Serialized representation of a notebook for sync queue
|
||||
/// The AIDocumentID and ConversationID are stored here to avoid polluting the
|
||||
/// generic CreateObjectRequest type.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct SerializedNotebook {
|
||||
pub(crate) data: String,
|
||||
pub(crate) ai_document_id: Option<String>,
|
||||
pub(crate) conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `CloudNotebook` is a notebook retrieved from the server.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct CloudNotebookModel {
|
||||
pub title: String,
|
||||
pub data: String,
|
||||
pub ai_document_id: Option<AIDocumentId>,
|
||||
/// This is the server-generated conversation token, not the client-side AIConversationId.
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
pub type CloudNotebook = GenericCloudObject<NotebookId, CloudNotebookModel>;
|
||||
use crate::drive::items::notebook::WarpDriveNotebook;
|
||||
use crate::drive::items::WarpDriveItem;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::object::ObjectClient;
|
||||
use crate::server::sync_queue::{QueueItem, SerializedModel};
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
@@ -90,14 +59,14 @@ impl CloudModelType for CloudNotebookModel {
|
||||
name.clone_into(&mut self.title);
|
||||
}
|
||||
|
||||
fn upsert_event(&self, notebook: &CloudNotebook) -> ModelEvent {
|
||||
fn upsert_event(params: CloudObjectUpsertParams<Self>) -> ModelEvent {
|
||||
ModelEvent::UpsertNotebook {
|
||||
notebook: notebook.clone(),
|
||||
notebook: CloudNotebook::from(params),
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_upsert_event(objects: &[CloudNotebook]) -> ModelEvent {
|
||||
ModelEvent::UpsertNotebooks(objects.to_vec())
|
||||
fn bulk_upsert_event(objects: Vec<CloudObjectUpsertParams<Self>>) -> ModelEvent {
|
||||
ModelEvent::UpsertNotebooks(objects.into_iter().map(CloudNotebook::from).collect())
|
||||
}
|
||||
|
||||
fn create_object_queue_item(
|
||||
@@ -145,18 +114,6 @@ impl CloudModelType for CloudNotebookModel {
|
||||
true
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::Notebook(server_notebook) = server_cloud_object {
|
||||
return Some(CloudNotebookModel {
|
||||
title: server_notebook.model.title.clone(),
|
||||
data: server_notebook.model.data.clone(),
|
||||
ai_document_id: server_notebook.model.ai_document_id,
|
||||
conversation_id: None, // conversation_id is not returned from server, just used for initial plan artifact creation
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn serialized(&self) -> SerializedModel {
|
||||
let serialized = SerializedNotebook {
|
||||
data: self.data.clone(),
|
||||
@@ -212,17 +169,6 @@ impl CloudModelType for CloudNotebookModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the notebook_id in the database associated with this notebook.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct NotebookId(ServerId);
|
||||
crate::server_id_traits! { NotebookId, "Notebook" }
|
||||
|
||||
impl From<NotebookId> for SyncId {
|
||||
fn from(id: NotebookId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A notebook location. Mainly, this lets us distinguish between cloud and file-based notebooks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum NotebookLocation {
|
||||
@@ -253,15 +199,6 @@ pub fn init(app: &mut AppContext) {
|
||||
self::editor::view::init(app);
|
||||
}
|
||||
|
||||
/// Post process a notebook's content read from an external system. This cleans up extra
|
||||
/// whitespace, and, in the future, may filter out unsupported syntax extensions.
|
||||
///
|
||||
/// See CLD-944.
|
||||
pub fn post_process_notebook(data: &str) -> String {
|
||||
// TODO(kevin): We should not strip out newlines in the code block.
|
||||
data.lines().filter(|line| !line.is_empty()).join("\n")
|
||||
}
|
||||
|
||||
/// Translate a notebook's Markdown content into an external Markdown format.
|
||||
///
|
||||
/// This:
|
||||
@@ -269,7 +206,8 @@ pub fn post_process_notebook(data: &str) -> String {
|
||||
/// * Includes extra context for embedded objects.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn export_notebook(data: &str, ctx: &AppContext) -> anyhow::Result<String> {
|
||||
use galaxy_editor::content::{buffer::Buffer, markdown::MarkdownStyle};
|
||||
use galaxy_editor::content::buffer::Buffer;
|
||||
use galaxy_editor::content::markdown::MarkdownStyle;
|
||||
|
||||
// Parse the Markdown directly rather than using [`Buffer::from_markdown`] so that we can
|
||||
// report errors to the exporter.
|
||||
|
||||
+79
-103
@@ -1,3 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_channel::Sender;
|
||||
use futures_util::stream::AbortHandle;
|
||||
@@ -5,116 +8,89 @@ use galaxy_core::context_flag::ContextFlag;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use settings::Setting as _;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
use crate::uri::web_intent_parser::open_url_on_desktop;
|
||||
|
||||
use galaxy_editor::{
|
||||
editor::NavigationKey,
|
||||
model::{CoreEditorModel, RichTextEditorModel},
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxy_editor::model::{CoreEditorModel, RichTextEditorModel};
|
||||
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::{
|
||||
Align, Clipped, ConstrainedBox, Container, CrossAxisAlignment, DispatchEventResult, Empty,
|
||||
EventHandler, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
SavePosition, Shrinkable, Stack,
|
||||
};
|
||||
use galaxyui::keymap::{EditableBinding, FixedBinding};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
accessibility::{AccessibilityContent, GalaxyA11yRole},
|
||||
clipboard::ClipboardContent,
|
||||
elements::{
|
||||
Align, Clipped, ConstrainedBox, Container, CrossAxisAlignment, DispatchEventResult, Empty,
|
||||
EventHandler, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
SavePosition, Shrinkable, Stack,
|
||||
},
|
||||
keymap::{EditableBinding, FixedBinding},
|
||||
presenter::ChildView,
|
||||
r#async::{SpawnedFutureHandle, Timer},
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, BlurContext, Element, Entity, FocusContext, ModelAsRef, ModelHandle,
|
||||
SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
blocklist::secret_redaction::find_secrets_in_text,
|
||||
document::ai_document_model::AIDocumentId,
|
||||
},
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
grab_edit_access_modal::{GrabEditAccessModal, GrabEditAccessModalEvent},
|
||||
model::{
|
||||
persistence::{CloudModel, CloudModelEvent, UpdateSource},
|
||||
view::{Editor, EditorState},
|
||||
},
|
||||
CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Space,
|
||||
},
|
||||
cmd_or_ctrl_shift,
|
||||
drive::{
|
||||
drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit,
|
||||
export::ExportManager, items::WarpDriveItemId, sharing::ShareableObject,
|
||||
CloudObjectTypeAndId, OpenGalaxyDriveObjectSettings,
|
||||
},
|
||||
editor::{
|
||||
EditOrigin, EditorView, Event as EditorEvent, InteractionState,
|
||||
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextColors, TextOptions,
|
||||
},
|
||||
features::FeatureFlag,
|
||||
menu::{MenuItem, MenuItemFields},
|
||||
network::{NetworkStatus, NetworkStatusEvent},
|
||||
notebooks::{
|
||||
editor::{model::NotebooksEditorModel, rich_text_styles},
|
||||
CloudNotebook,
|
||||
},
|
||||
pane_group::{
|
||||
focus_state::{PaneFocusHandle, PaneGroupFocusEvent},
|
||||
pane::view,
|
||||
BackingView, PaneConfiguration, PaneEvent,
|
||||
},
|
||||
report_if_error, safe_info, send_telemetry_from_ctx,
|
||||
server::{
|
||||
cloud_objects::update_manager::{FetchSingleObjectOption, UpdateManager},
|
||||
ids::{ClientId, ServerId, SyncId},
|
||||
telemetry::{
|
||||
CloudObjectTelemetryMetadata, NotebookActionEvent, NotebookTelemetryMetadata,
|
||||
SharingDialogSource, TelemetryCloudObjectType, TelemetryEvent,
|
||||
},
|
||||
},
|
||||
settings::{
|
||||
app_installation_detection::{UserAppInstallDetectionSettings, UserAppInstallStatus},
|
||||
decrease_notebook_font_size, increase_notebook_font_size, FontSettings,
|
||||
FontSettingsChangedEvent, NotebookFontSize,
|
||||
},
|
||||
terminal::safe_mode_settings::get_secret_obfuscation_mode,
|
||||
throttle::throttle,
|
||||
ui_components::icons::{self, Icon},
|
||||
util::bindings::{self, CustomAction},
|
||||
view_components::{DismissibleToast, ToastType},
|
||||
workflows::{WorkflowSource, WorkflowType},
|
||||
workspace::ToastStack,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use self::details_bar::DetailsBar;
|
||||
|
||||
use super::{
|
||||
active_notebook_data::{
|
||||
ActiveNotebook, ActiveNotebookData, ActiveNotebookDataEvent, Mode, SavingStatus,
|
||||
TrashStatus,
|
||||
},
|
||||
context_menu::{
|
||||
show_rich_editor_context_menu, show_text_editor_context_menu, ContextMenuAction,
|
||||
ContextMenuState,
|
||||
},
|
||||
editor::{
|
||||
view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
|
||||
NotebookWorkflow,
|
||||
},
|
||||
link::{NotebookLinks, SessionSource},
|
||||
manager::NotebookManager,
|
||||
styles,
|
||||
telemetry::NotebookTelemetryAction,
|
||||
CloudNotebookModel, NotebookId, NotebookLocation,
|
||||
use super::active_notebook_data::{
|
||||
ActiveNotebook, ActiveNotebookData, ActiveNotebookDataEvent, Mode, SavingStatus, TrashStatus,
|
||||
};
|
||||
use super::context_menu::{
|
||||
show_rich_editor_context_menu, show_text_editor_context_menu, ContextMenuAction,
|
||||
ContextMenuState,
|
||||
};
|
||||
use super::editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView};
|
||||
use super::editor::NotebookWorkflow;
|
||||
use super::link::{NotebookLinks, SessionSource};
|
||||
use super::manager::NotebookManager;
|
||||
use super::telemetry::NotebookTelemetryAction;
|
||||
use super::{styles, CloudNotebookModel, NotebookId, NotebookLocation};
|
||||
use crate::ai::blocklist::secret_redaction::find_secrets_in_text;
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::grab_edit_access_modal::{GrabEditAccessModal, GrabEditAccessModalEvent};
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
|
||||
use crate::cloud_object::model::view::{Editor, EditorState};
|
||||
use crate::cloud_object::{CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Space};
|
||||
use crate::drive::drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit;
|
||||
use crate::drive::export::ExportManager;
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::sharing::ShareableObject;
|
||||
use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectSettings};
|
||||
use crate::editor::{
|
||||
EditOrigin, EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextColors, TextOptions,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent};
|
||||
use crate::notebooks::editor::model::NotebooksEditorModel;
|
||||
use crate::notebooks::editor::rich_text_styles;
|
||||
use crate::notebooks::CloudNotebook;
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
|
||||
use crate::pane_group::pane::view;
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
|
||||
use crate::server::cloud_objects::update_manager::{FetchSingleObjectOption, UpdateManager};
|
||||
use crate::server::ids::{ClientId, ServerId, SyncId};
|
||||
use crate::server::telemetry::{
|
||||
CloudObjectTelemetryMetadata, NotebookActionEvent, NotebookTelemetryMetadata,
|
||||
SharingDialogSource, TelemetryCloudObjectType, TelemetryEvent,
|
||||
};
|
||||
use crate::settings::app_installation_detection::{
|
||||
UserAppInstallDetectionSettings, UserAppInstallStatus,
|
||||
};
|
||||
use crate::settings::{
|
||||
decrease_notebook_font_size, increase_notebook_font_size, FontSettings,
|
||||
FontSettingsChangedEvent, NotebookFontSize,
|
||||
};
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::throttle::throttle;
|
||||
use crate::ui_components::icons::{self, Icon};
|
||||
#[cfg(target_family = "wasm")]
|
||||
use crate::uri::web_intent_parser::open_url_on_desktop;
|
||||
use crate::util::bindings::{self, CustomAction};
|
||||
use crate::view_components::{DismissibleToast, ToastType};
|
||||
use crate::workflows::{WorkflowSource, WorkflowType};
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{cmd_or_ctrl_shift, report_if_error, safe_info, send_telemetry_from_ctx};
|
||||
|
||||
mod details_bar;
|
||||
|
||||
@@ -952,7 +928,7 @@ impl NotebookView {
|
||||
report_if_error!(self
|
||||
.save_tx
|
||||
.try_send(NotebookUpdateRequestDebounceArg {})
|
||||
.context("Error enqueing content save"));
|
||||
.context("Error enqueuing content save"));
|
||||
self.active_notebook_data.update(ctx, |data, ctx| {
|
||||
// Mark the notebook as saving as soon as there are changes to be saved. It won't be
|
||||
// marked as Saved until we get a response from the server.
|
||||
@@ -968,7 +944,7 @@ impl NotebookView {
|
||||
report_if_error!(self
|
||||
.save_tx
|
||||
.try_send(NotebookUpdateRequestDebounceArg {})
|
||||
.context("Error enqueing title save"));
|
||||
.context("Error enqueuing title save"));
|
||||
}
|
||||
|
||||
fn handle_input_editor_event(&mut self, event: &EditorViewEvent, ctx: &mut ViewContext<Self>) {
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
//! Components for the notebook header.
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
use galaxyui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
breadcrumbs::ContainingObject,
|
||||
model::view::{Editor, EditorState},
|
||||
},
|
||||
drive::sharing::ContentEditability,
|
||||
notebooks::{active_notebook_data::Mode, styles},
|
||||
ui_components::{
|
||||
breadcrumb::{render_breadcrumbs, BreadcrumbState},
|
||||
buttons::{accent_icon_button, icon_button},
|
||||
icons::Icon,
|
||||
},
|
||||
workspaces::user_profiles::UserProfiles,
|
||||
};
|
||||
|
||||
use super::{super::active_notebook_data::ActiveNotebookData, NotebookAction, EDIT_BUTTON_MARGIN};
|
||||
use super::super::active_notebook_data::ActiveNotebookData;
|
||||
use super::{NotebookAction, EDIT_BUTTON_MARGIN};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::breadcrumbs::ContainingObject;
|
||||
use crate::cloud_object::model::view::{Editor, EditorState};
|
||||
use crate::drive::sharing::ContentEditability;
|
||||
use crate::notebooks::active_notebook_data::Mode;
|
||||
use crate::notebooks::styles;
|
||||
use crate::ui_components::breadcrumb::{render_breadcrumbs, BreadcrumbState};
|
||||
use crate::ui_components::buttons::{accent_icon_button, icon_button};
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
|
||||
/// Component to show details about a notebook:
|
||||
/// * Interactive breadcrumbs for its location within Warp Drive
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
use galaxyui::{App, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
workspaces::user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
};
|
||||
|
||||
use super::editor_display_name;
|
||||
use crate::auth::UserUid;
|
||||
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
|
||||
@@ -2,63 +2,55 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use futures_util::future::BoxFuture;
|
||||
use itertools::Itertools;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::editor::EditorView;
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::telemetry::EventPayload;
|
||||
use galaxyui::{
|
||||
platform::WindowStyle, presenter::ChildView, r#async::Timer, telemetry::EventPayload,
|
||||
AddSingletonModel, App, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
auth_manager::AuthManager,
|
||||
user::{TEST_USER_EMAIL, TEST_USER_UID},
|
||||
AuthStateProvider, UserUid,
|
||||
},
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::ObjectActions,
|
||||
persistence::CloudModel,
|
||||
view::{CloudViewModel, Editor, EditorState},
|
||||
},
|
||||
Owner, Revision, ServerCloudObject, ServerMetadata, ServerNotebook, ServerPermissions,
|
||||
},
|
||||
drive::OpenGalaxyDriveObjectSettings,
|
||||
editor::{DisplayPoint, EditorAction, InteractionState, SelectAction},
|
||||
network::NetworkStatus,
|
||||
notebooks::{
|
||||
active_notebook_data::Mode,
|
||||
editor::{
|
||||
keys::NotebookKeybindings, notebook_command::NotebookCommand, view::EditorViewAction,
|
||||
},
|
||||
notebook::FocusedComponent,
|
||||
CloudNotebook, CloudNotebookModel, NotebookLocation,
|
||||
},
|
||||
pane_group::PaneEvent,
|
||||
search::files::model::FileSearchModel,
|
||||
server::{
|
||||
cloud_objects::update_manager::{InitialLoadResponse, UpdateManager},
|
||||
ids::{ClientId, SyncId::ServerId},
|
||||
server_api::ServerApiProvider,
|
||||
sync_queue::{QueueItem, SyncQueue, SyncQueueEvent},
|
||||
telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::keys::TerminalKeybindings,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workflows::{workflow::Workflow, WorkflowSource, WorkflowType},
|
||||
workspace::ActiveSession,
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus,
|
||||
user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
user_workspaces::UserWorkspaces,
|
||||
},
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider, PrivacySettings,
|
||||
};
|
||||
|
||||
use super::{NotebookEvent, NotebookView, EDIT_WINDOW_DURATION, SAVE_PERIOD};
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::user::{TEST_USER_EMAIL, TEST_USER_UID};
|
||||
use crate::auth::{AuthStateProvider, UserUid};
|
||||
use crate::cloud_object::model::actions::ObjectActions;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
|
||||
use crate::cloud_object::{
|
||||
Owner, Revision, ServerCloudObject, ServerMetadata, ServerNotebook, ServerPermissions,
|
||||
};
|
||||
use crate::drive::OpenWarpDriveObjectSettings;
|
||||
use crate::editor::{DisplayPoint, EditorAction, InteractionState, SelectAction};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::active_notebook_data::Mode;
|
||||
use crate::notebooks::editor::keys::NotebookKeybindings;
|
||||
use crate::notebooks::editor::notebook_command::NotebookCommand;
|
||||
use crate::notebooks::editor::view::EditorViewAction;
|
||||
use crate::notebooks::notebook::FocusedComponent;
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel, NotebookLocation};
|
||||
use crate::pane_group::PaneEvent;
|
||||
use crate::search::files::model::FileSearchModel;
|
||||
use crate::server::cloud_objects::update_manager::{InitialLoadResponse, UpdateManager};
|
||||
use crate::server::ids::ClientId;
|
||||
use crate::server::ids::SyncId::ServerId;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::{QueueItem, SyncQueue, SyncQueueEvent};
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::keys::TerminalKeybindings;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::{WorkflowSource, WorkflowType};
|
||||
use crate::workspace::ActiveSession;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, PrivacySettings};
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
@@ -162,15 +154,15 @@ fn cloud_notebook(title: impl Into<String>, data: impl Into<String>) -> CloudNot
|
||||
/// Mock a server notebook
|
||||
fn mock_server_notebook(title: impl Into<String>, data: impl Into<String>) -> ServerNotebook {
|
||||
let metadata_ts = Utc::now().into();
|
||||
ServerNotebook {
|
||||
id: ServerId(123.into()),
|
||||
model: CloudNotebookModel {
|
||||
ServerNotebook::new(
|
||||
ServerId(123.into()),
|
||||
CloudNotebookModel {
|
||||
title: title.into(),
|
||||
data: data.into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
metadata: ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid: 123.into(),
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: metadata_ts,
|
||||
@@ -181,13 +173,13 @@ fn mock_server_notebook(title: impl Into<String>, data: impl Into<String>) -> Se
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: metadata_ts,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Send changed objects to [`UpdateManager`] so that tests requiring "up-to-date" metadata can run.
|
||||
|
||||
+12
-16
@@ -1,23 +1,19 @@
|
||||
//! Shared styles for notebooks.
|
||||
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement,
|
||||
},
|
||||
fonts,
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent as _,
|
||||
units::{IntoPixels, Pixels},
|
||||
Element,
|
||||
use galaxyui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
|
||||
ParentElement,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent as _;
|
||||
use warpui::units::{IntoPixels, Pixels};
|
||||
use warpui::{fonts, Element};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
settings::{derived_notebook_font_size, FontSettings},
|
||||
themes::theme::Fill,
|
||||
ui_components::{buttons::icon_button, icons::Icon},
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::{derived_notebook_font_size, FontSettings};
|
||||
use crate::themes::theme::Fill;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
/// Multiplier of the notebook font size for the title. At the default size, this
|
||||
/// is 20px.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{server::ids::ServerId, workflows::WorkflowId};
|
||||
|
||||
use super::editor::BlockInsertionSource;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::workflows::WorkflowId;
|
||||
|
||||
/// A user action within a notebook. Some actions, like running a command, are not included here
|
||||
/// because they're covered by existing telemetry.
|
||||
|
||||
Reference in New Issue
Block a user