use std::mem; use std::path::{Path, PathBuf}; use std::sync::Arc; use pathfinder_geometry::vector::vec2f; #[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)] pub enum MarkdownDisplayMode { Rendered, Raw, } /// View for a read-only notebook backed by a file, rather than Warp Drive. pub struct FileNotebookView { /// The location of the open file. This is cached for displaying the title and breadcrumbs. location: Option, /// Read-only rich text editor used to show the notebook contents. editor: ViewHandle, retry_button_mouse_state: MouseStateHandle, /// Tracks the state for loading the backing Markdown file. file_state: FileState, /// File watcher id for the currently opened file (if any). Only needed when we have local fs /// access. #[cfg(feature = "local_fs")] file_id: Option, pane_configuration: ModelHandle, focus_handle: Option, links: ModelHandle, context_menu: ContextMenuState, view_position_id: String, /// Display mode for markdown content (rendered vs editable) markdown_display_mode: MarkdownDisplayMode, display_mode_segmented_control: ViewHandle, /// The CodeSource that was used to open this file, if it came from a CodePane. /// This is preserved so we can restore it when toggling between raw and rendered Markdown. #[cfg(feature = "local_fs")] code_source: Option, /// Persistent hover state for the header title tooltip. header_title_mouse_state: MouseStateHandle, } #[derive(Debug, Clone)] pub enum FileNotebookEvent { RunWorkflow { workflow: Arc, source: WorkflowSource, }, TitleUpdated, FileLoaded, Pane(PaneEvent), #[cfg(feature = "local_fs")] OpenFileWithTarget { path: PathBuf, target: FileTarget, line_col: Option, }, } impl From for FileNotebookEvent { fn from(event: PaneEvent) -> Self { FileNotebookEvent::Pane(event) } } #[derive(Debug, Clone)] pub enum FileNotebookAction { Focus, Close, FocusTerminalInput, ReloadFile, #[cfg(feature = "local_fs")] CopyFilePath, #[cfg(feature = "local_fs")] OpenInEditor, #[cfg(feature = "local_fs")] OpenAsCode, ContextMenu(ContextMenuAction), ToggleMarkdownDisplayMode(MarkdownDisplayMode), ToggleMaximized, } impl From for FileNotebookAction { fn from(action: ContextMenuAction) -> Self { FileNotebookAction::ContextMenu(action) } } /// Information about the notebook's backing file. #[derive(Debug, Clone)] enum SourceFile { /// 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>, }, /// Static content provided inline (not backed by a file on disk). Static { title: String }, } impl SourceFile { /// Returns the path for this source, if it is file-based. fn path(&self) -> Option<&LocalOrRemotePath> { match self { 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::FileBased { path, .. } => path.display_path(), SourceFile::Static { title } => title.clone(), } } } #[derive(Debug)] enum FileState { NoFile, Loading(SourceFile), Error(SourceFile), Loaded(SourceFile), } 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()) } fn source(&self) -> Option<&SourceFile> { match self { FileState::NoFile => None, FileState::Loading(source) | FileState::Error(source) | FileState::Loaded(source) => { Some(source) } } } fn display_name(&self) -> Option { self.source().map(|src| src.display_name()) } } pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_editable_bindings([ EditableBinding::new( "notebookview:focus_terminal_input", "Focus Terminal Input from File", FileNotebookAction::FocusTerminalInput, ) .with_context_predicate(id!("FileNotebookView")) .with_key_binding(cmd_or_ctrl_shift("l")), EditableBinding::new( "notebookview:reload_file", "Reload file", FileNotebookAction::ReloadFile, ) .with_context_predicate(id!("FileNotebookView")), ]) } impl FileNotebookView { /// Create a new file notebook view, with no open file. pub fn new(ctx: &mut ViewContext) -> Self { let window_id = ctx.window_id(); // Use the active session for links until we have something more specific. let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx)); let view_position_id = format!("file_notebook_view_{}", ctx.view_id()); let editor_model = ctx.add_model(|ctx| { let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(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( view_position_id.clone(), editor_model, links.clone(), RichTextEditorConfig::default(), ctx, ); view.set_interaction_state(InteractionState::Selectable, ctx); view }); ctx.subscribe_to_view(&editor, Self::handle_editor_event); let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("")); ctx.observe( &ActiveSession::handle(ctx), Self::handle_active_session_change, ); let context_menu = ContextMenuState::new(ctx); let display_mode_segmented_control = ctx.add_typed_action_view(|ctx| { MarkdownToggleView::new(MarkdownDisplayMode::Rendered, ctx) }); ctx.subscribe_to_view(&display_mode_segmented_control, |view, _, event, ctx| { let MarkdownToggleEvent::ModeSelected(mode) = event; view.handle_action(&FileNotebookAction::ToggleMarkdownDisplayMode(*mode), ctx); }); Self { location: None, editor, file_state: FileState::NoFile, retry_button_mouse_state: Default::default(), #[cfg(feature = "local_fs")] file_id: None, pane_configuration, focus_handle: None, links, context_menu, view_position_id, markdown_display_mode: MarkdownDisplayMode::Rendered, display_mode_segmented_control, #[cfg(feature = "local_fs")] code_source: None, header_title_mouse_state: Default::default(), } } /// Set the CodeSource that was used to open this file. /// This is preserved so we can restore it when toggling between raw and rendered Markdown. #[cfg(feature = "local_fs")] pub fn set_code_source(&mut self, source: Option) { self.code_source = source; } pub fn title(&self) -> String { // Prefer the location name that's been resolved against a Session, but if that's not // available yet, fall back to the raw file path. self.location .as_ref() .map(|location| location.name.clone()) .or_else(|| self.file_state.display_name()) .unwrap_or_else(|| "Untitled".to_string()) } pub fn focus(&self, ctx: &mut ViewContext) { // Emit accessibility content for the notebook, rather than the generic text input. if let Some(a11y_content) = self.accessibility_contents(ctx) { ctx.emit_a11y_content(a11y_content); } ctx.focus(&self.editor); } /// Reset the rich text contents based on the given Markdown content. pub fn set_content(&mut self, content: &str, ctx: &mut ViewContext) { let doc_path = self.file_state.local_path().map(|p| p.to_path_buf()); self.editor.update(ctx, |editor, ctx| { editor.reset_with_markdown(content, ctx); // Set the document path for resolving relative image paths editor.model().update(ctx, |model, ctx| { model.set_document_path(doc_path, ctx); }); }); } #[cfg(feature = "local_fs")] fn open_telemetry_metadata(&self, ctx: &ViewContext) -> NotebookTelemetryMetadata { NotebookTelemetryMetadata::new(None, None, NotebookLocation::LocalFile, None) .with_markdown_table_count( self.editor .as_ref(ctx) .model() .as_ref(ctx) .markdown_table_count(ctx), ) } /// Set the notebook's location context. fn set_context(&mut self, path: &Path, session: Arc, ctx: &mut ViewContext) { self.location = Some(FileLocation::new(path, session.home_dir())); let title = self.title(); self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_title(title, ctx); }); if let Some(parent) = path.parent() { self.links.update(ctx, |links, ctx| { links.set_session_source( SessionSource::Target { session, base_directory: parent.to_path_buf(), }, ctx, ) }) } 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>, ctx: &mut ViewContext, ) { 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, path: impl Into, session: Option>, ctx: &mut ViewContext, ) { let local_path = path.into(); // If a session is available, initialize the location and link context now. Otherwise, // we'll wait until one is available. if let Some(session) = &session { self.set_context(&local_path, session.clone(), ctx); } else { // Set a temporary title until the context is available. self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_title(local_path.display().to_string(), ctx); }); } self.file_state = FileState::Loading(SourceFile::FileBased { path: LocalOrRemotePath::Local(local_path.clone()), session: session.clone(), }); #[cfg(feature = "local_fs")] { // Cancel in-flight loads and unsubscribe from any previously opened file. if let Some(prev_id) = self.file_id.take() { FileModel::handle(ctx).update(ctx, |m, ctx| { m.cancel(prev_id); m.unsubscribe(prev_id, ctx) }); } let file_model = FileModel::handle(ctx); let file_id = file_model.update(ctx, |m, ctx| m.open(&local_path, true, ctx)); self.file_id = Some(file_id); ctx.subscribe_to_model( &file_model, move |me, file_model: ModelHandle, event: &FileModelEvent, ctx| { if event.file_id() != file_id { return; } match event { FileModelEvent::FileLoaded { content, .. } => { me.set_content(content, ctx); send_telemetry_from_ctx!( TelemetryEvent::OpenNotebook(me.open_telemetry_metadata(ctx)), ctx ); // 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::FileBased { path: LocalOrRemotePath::Local(canonical_path), session: session.clone(), }); } me.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.refresh_pane_header_overflow_menu_items(ctx); }); // Notify the view to re-render now that file_state // has transitioned from Loading to Loaded. ctx.notify(); // Trigger to save the open file path for session restoration. ctx.emit(FileNotebookEvent::FileLoaded); } FileModelEvent::FailedToLoad { error, .. } => { safe_warn!( safe: ("Unable to read local notebook file"), full: ("Unable to read local notebook file: {error}") ); me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) { FileState::NoFile => FileState::NoFile, FileState::Loading(source) | FileState::Loaded(source) | FileState::Error(source) => FileState::Error(source), }; ctx.notify(); } FileModelEvent::FileUpdated { content, .. } => { me.set_content(content, ctx); } FileModelEvent::FileSaved { .. } | FileModelEvent::FailedToSave { .. } => {} } }, ); } #[cfg(not(feature = "local_fs"))] { // WASM builds should never call `open_local`, so we should never get here! safe_warn!( 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::FileBased { path: LocalOrRemotePath::Local(local_path), session, }); ctx.notify(); } } /// Open static Markdown as a file pane. pub fn open_static( &mut self, title: impl Into, content: &str, ctx: &mut ViewContext, ) { #[cfg(feature = "local_fs")] { // Unsubscribe from any previously opened file. if let Some(prev_id) = self.file_id.take() { FileModel::handle(ctx).update(ctx, |m, ctx| m.unsubscribe(prev_id, ctx)); } } self.set_content(content, ctx); let title = title.into(); self.pane_configuration.update(ctx, |pane_config, ctx| { pane_config.set_title(title.clone(), ctx); pane_config.refresh_pane_header_overflow_menu_items(ctx); }); self.file_state = FileState::Loaded(SourceFile::Static { title }); } /// Send a [`NotebookTelemetryAction`] telemetry event. fn send_telemetry_action(&self, action: NotebookTelemetryAction, ctx: &mut ViewContext) { send_telemetry_from_ctx!( TelemetryEvent::NotebookAction(NotebookActionEvent { action, metadata: NotebookTelemetryMetadata::new( None, None, NotebookLocation::LocalFile, None ) }), ctx ); } /// Reload the file that was most recently opened (or attempted to open). fn reload_file(&mut self, ctx: &mut ViewContext) { // 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 (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::FileBased { path, session } => (path, session), SourceFile::Static { .. } => return, } } }; 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) { 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) { 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, 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 { self.file_state.local_path().map(Path::to_path_buf) } pub fn pane_configuration(&self) -> ModelHandle { self.pane_configuration.clone() } /// Model for resolving and opening links relative to this notebook. pub fn links(&self) -> ModelHandle { self.links.clone() } fn is_markdown_file(&self) -> bool { self.file_state .path() .map(|p| is_markdown_file(Path::new(&p.display_path()))) .unwrap_or(false) } fn update_editor_display_mode(&mut self, ctx: &mut ViewContext) { match self.markdown_display_mode { MarkdownDisplayMode::Rendered => { self.editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Selectable, ctx); }); } MarkdownDisplayMode::Raw => { // For Raw we switch panes entirely (to CodePane). Interaction state here remains // in the rendered notebook mode. } } } fn handle_editor_event( &mut self, _handle: ViewHandle, event: &EditorViewEvent, ctx: &mut ViewContext, ) { match event { EditorViewEvent::Focused => ctx.emit(FileNotebookEvent::Pane(PaneEvent::FocusSelf)), EditorViewEvent::RunWorkflow(workflow) => { let workflow_type = workflow.named_workflow(|| { self.location .as_ref() .map(|location| format!("Command from {}", location.name)) }); let source = workflow.source.unwrap_or(WorkflowSource::Notebook { notebook_id: None, team_uid: None, location: NotebookLocation::LocalFile, }); ctx.emit(FileNotebookEvent::RunWorkflow { workflow: workflow_type, source, }); } EditorViewEvent::OpenedBlockInsertionMenu(source) => self.send_telemetry_action( NotebookTelemetryAction::OpenBlockInsertionMenu { source: *source }, ctx, ), EditorViewEvent::OpenedEmbeddedObjectSearch => { self.send_telemetry_action(NotebookTelemetryAction::OpenEmbeddedObjectSearch, ctx) } EditorViewEvent::OpenedFindBar => { self.send_telemetry_action(NotebookTelemetryAction::OpenFindBar, ctx) } EditorViewEvent::InsertedEmbeddedObject(info) => self .send_telemetry_action(NotebookTelemetryAction::InsertEmbeddedObject(*info), ctx), EditorViewEvent::CopiedBlock { block, entrypoint } => self.send_telemetry_action( NotebookTelemetryAction::CopyBlock { block: *block, entrypoint: *entrypoint, }, ctx, ), EditorViewEvent::NavigatedCommands => { self.send_telemetry_action(NotebookTelemetryAction::CommandKeyboardNavigation, ctx) } EditorViewEvent::ChangedSelectionMode(mode) => self.send_telemetry_action( NotebookTelemetryAction::ChangeSelectionMode { mode: *mode }, ctx, ), EditorViewEvent::Navigate(_) | EditorViewEvent::Edited | EditorViewEvent::EditWorkflow(_) | EditorViewEvent::CmdEnter | EditorViewEvent::EscapePressed | EditorViewEvent::TextSelectionChanged => (), EditorViewEvent::OpenFile { .. } => { // We don't support opening files from the notebook view. // File paths rely on a Session to be present, and this is only set from the AI document view today. } } } fn handle_active_session_change( &mut self, handle: ModelHandle, ctx: &mut ViewContext, ) { // If this file notebook is opened without a target session, we wait for one to start and // use that instead. if self.location.is_none() { let Some(path) = self.local_path() else { return; }; if let Some(active_session) = handle.as_ref(ctx).session(ctx.window_id()) { if active_session.is_local() { self.set_context(&path, active_session, ctx); ctx.unsubscribe_to_model(&handle); } } } } fn render_title( &self, appearance: &Appearance, font_settings: &FontSettings, ) -> Box { let title = Text::new_inline( self.title(), appearance.ui_font_family(), styles::title_font_size(font_settings), ) .with_color(styles::title_text_fill(appearance).into()) .with_style(styles::TITLE_FONT_PROPERTIES) .finish(); let details = self.location.as_ref().map(|location| { appearance .ui_builder() .span(location.breadcrumbs.clone()) .with_style(UiComponentStyles { font_color: Some(styles::title_text_fill(appearance).into_solid()), ..Default::default() }) .build() .finish() }); styles::wrap_title(title, details) } /// Style for loading/error states. fn state_style(&self, appearance: &Appearance) -> UiComponentStyles { UiComponentStyles { font_color: Some( appearance .theme() .sub_text_color(appearance.theme().background()) .into_solid(), ), ..Default::default() } } /// Render an error state for when loading the source file failed. fn render_error(&self, source: &SourceFile, appearance: &Appearance) -> Box { let error_text_color = appearance .theme() .sub_text_color(appearance.theme().background()); let error = Flex::column() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( appearance .ui_builder() .paragraph(format!("Could not read {}", source.display_name())) .with_style(self.state_style(appearance)) .build() .finish(), ) .with_child( Container::new( appearance .ui_builder() .button(ButtonVariant::Basic, self.retry_button_mouse_state.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::TextFirst, "Try again".to_string(), Icon::Refresh.to_galaxyui_icon(error_text_color), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(16., 16.), ) .with_inner_padding(4.), ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(FileNotebookAction::ReloadFile) }) .finish(), ) .with_margin_top(8.) .finish(), ); Align::new(error.finish()).finish() } /// Render the loading state while the source file is still being read. fn render_loading(&self, source: &SourceFile, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .paragraph(format!("Loading {}...", source.display_name())) .with_style(self.state_style(appearance)) .build() .finish(), ) .finish() } /// Renders a placeholder for when no file has been specified. fn render_no_file(&self, appearance: &Appearance) -> Box { Align::new( appearance .ui_builder() .paragraph("Missing source file".to_string()) .with_style(self.state_style(appearance)) .build() .finish(), ) .finish() } /// 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 { 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) } } impl Entity for FileNotebookView { type Event = FileNotebookEvent; } impl View for FileNotebookView { fn ui_name() -> &'static str { "FileNotebookView" } fn accessibility_contents(&self, _ctx: &AppContext) -> Option { Some(AccessibilityContent::new_without_help( format!("{} notebook", self.title()), GalaxyA11yRole::TextRole, )) } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let font_settings = FontSettings::as_ref(app); let column = Flex::column().with_children([ self.render_title(appearance, font_settings), Shrinkable::new(1., self.render_body(appearance, app)).finish(), ]); let mut stack = Stack::new().with_child(column.finish()); self.context_menu.render(&mut stack); let parent_position_id = self.view_position_id.clone(); let editor = self.editor.clone(); SavePosition::new( EventHandler::new(Align::new(stack.finish()).top_left().finish()) .on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(FileNotebookAction::Focus); DispatchEventResult::StopPropagation }) .on_right_mouse_down(move |ctx, _, position| { show_rich_editor_context_menu::( ctx, position, &parent_position_id, &editor, ); DispatchEventResult::StopPropagation }) .finish(), &self.view_position_id, ) .finish() } } impl TypedActionView for FileNotebookView { type Action = FileNotebookAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { FileNotebookAction::Focus => ctx.focus_self(), FileNotebookAction::Close => ctx.emit(FileNotebookEvent::Pane(PaneEvent::Close)), FileNotebookAction::FocusTerminalInput => { ctx.emit(FileNotebookEvent::Pane(PaneEvent::FocusActiveSession)) } FileNotebookAction::ReloadFile => self.reload_file(ctx), #[cfg(feature = "local_fs")] FileNotebookAction::CopyFilePath => { if let Some(path) = self.file_state.path() { ctx.clipboard() .write(ClipboardContent::plain_text(path.display_path())); } } #[cfg(feature = "local_fs")] FileNotebookAction::OpenInEditor => { 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(&local_path, settings, None); ctx.emit(FileNotebookEvent::OpenFileWithTarget { 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")] FileNotebookAction::OpenAsCode => self.open_as_code(ctx), FileNotebookAction::ContextMenu(action) => { if matches!(action, ContextMenuAction::Open(_)) { self.send_telemetry_action(NotebookTelemetryAction::OpenContextMenu, ctx); } self.context_menu.handle_action(action, ctx); } FileNotebookAction::ToggleMarkdownDisplayMode(mode) => { self.markdown_display_mode = *mode; self.display_mode_segmented_control .update(ctx, |control, ctx| { control.set_selected_mode(*mode, ctx); }); match mode { MarkdownDisplayMode::Rendered => { // Already in FileNotebookView with rendered content; nothing else to do. self.update_editor_display_mode(ctx); } MarkdownDisplayMode::Raw => { #[cfg(feature = "local_fs")] { if let Some(path) = self.file_state.path().cloned() { ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane { path, source: self.code_source.clone(), })); } } } } } 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); }); } } } } impl BackingView for FileNotebookView { type PaneHeaderOverflowMenuAction = FileNotebookAction; type CustomAction = (); type AssociatedData = (); fn handle_pane_header_overflow_menu_action( &mut self, action: &Self::PaneHeaderOverflowMenuAction, ctx: &mut ViewContext, ) { self.handle_action(action, ctx); } fn pane_header_overflow_menu_items( &self, ctx: &AppContext, ) -> Vec> { // 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) .into_item(), ); #[cfg(feature = "local_fs")] { // The markdown rendered/raw toggle is always visible in the pane header, so we don't // duplicate it in the overflow menu. Keep "Open in editor" available for local files. actions.push( MenuItemFields::new("Open in editor") .with_on_select_action(FileNotebookAction::OpenInEditor) .into_item(), ); actions.extend([ MenuItem::Separator, MenuItemFields::new("Copy file path") .with_on_select_action(FileNotebookAction::CopyFilePath) .into_item(), ]); } } actions } fn close(&mut self, ctx: &mut ViewContext) { #[cfg(feature = "local_fs")] { // Unsubscribe from the file watcher before closing. if let Some(prev_id) = self.file_id.take() { FileModel::handle(ctx).update(ctx, |m, ctx| m.unsubscribe(prev_id, ctx)); } } ctx.emit(FileNotebookEvent::Pane(PaneEvent::Close)); } fn focus_contents(&mut self, ctx: &mut ViewContext) { self.focus(ctx); } fn render_header_content( &self, ctx: &view::HeaderRenderContext<'_>, app: &AppContext, ) -> view::HeaderContent { let title = self.pane_configuration.as_ref(app).title().to_owned(); if self.is_markdown_file() { // For markdown files we use a custom header // so that the title stays centered identically in both rendered and raw (CodeView) modes. let appearance = Appearance::as_ref(app); let is_pane_dragging = ctx.draggable_state.is_dragging(); let mut right_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); right_row.add_child(ChildView::new(&self.display_mode_segmented_control).finish()); let show_close_button = self .focus_handle .as_ref() .is_some_and(|h| h.is_in_split_pane(app)); right_row.add_child(render_pane_header_buttons::( ctx, appearance, show_close_button, None, None, )); let button_count = show_close_button as u32 + ctx.has_overflow_items as u32; let buttons_width = button_count as f32 * ICON_DIMENSIONS; let title_text = render_pane_header_title_text( title, appearance, galaxyui::text_layout::ClipConfig::start(), ); // Wrap the title in a hoverable tooltip showing the full file path. let title_element: Box = 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_element, right_row.finish(), CenteredHeaderEdgeWidth { min: buttons_width, max: 220.0, }, ctx.header_left_inset, is_pane_dragging, ), has_custom_draggable_behavior: false, } } else { // Non-markdown files: use the standard header. view::HeaderContent::Standard(view::StandardHeader { title, title_secondary: None, title_style: None, title_clip_config: galaxyui::text_layout::ClipConfig::start(), title_max_width: None, left_of_title: None, right_of_title: None, left_of_overflow: None, options: Default::default(), }) } } fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext) { self.focus_handle = Some(focus_handle.clone()); self.context_menu.set_focus_handle(focus_handle); } } /// Location information for a file, used to show its title and context. struct FileLocation { /// Breadcrumb path to the file. breadcrumbs: String, /// The file's name. name: String, } impl FileLocation { fn new(path: &Path, home_directory: Option<&str>) -> Self { let breadcrumbs = match path.parent() { Some(directory) => { user_friendly_path(directory.to_string_lossy().as_ref(), home_directory) .into_owned() } None => String::new(), }; let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "Unnamed".to_string()); Self { breadcrumbs, name } } } #[cfg(test)] #[path = "mod_tests.rs"] mod tests;