Skip to content

Commit 5fc0e14

Browse files
committed
Merge PR #157: feat: open local markdown links in file manager
2 parents 44ec0fd + d2cfe5f commit 5fc0e14

5 files changed

Lines changed: 324 additions & 73 deletions

File tree

src/app/central_panel.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::markdown::{
1717
push_video_webview_render_slot, rendered_editor_id, CodeExecutionUi, CsvViewer, EditorMode,
1818
MarkdownEditor, TreeViewer, VideoWebViewParent, WikilinkContext,
1919
};
20+
use crate::path_utils::open_in_file_manager;
2021
use crate::preview::{ScrollOrigin, SyncScrollState};
2122
use crate::state::{OpenResult, SpecialTabKind, TabContent, TabKind};
2223
use crate::theme::ThemeColors;
@@ -790,7 +791,7 @@ impl FerriteApp {
790791
}
791792

792793
if let Some(path) = tab_context_reveal_path {
793-
if let Err(e) = open::that(&path) {
794+
if let Err(e) = open_in_file_manager(&path) {
794795
warn!("Failed to reveal tab in explorer: {}", e);
795796
self.state
796797
.show_error(t!("error.explorer_failed", error = e.to_string()).to_string());

src/app/file_ops.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::files::dialogs::{
1111
open_folder_dialog, open_multiple_files_dialog, portal_install_instructions, save_file_dialog,
1212
DialogResult,
1313
};
14+
use crate::path_utils::open_in_file_manager;
1415
use crate::state::{
1516
complete_external_file_open, is_external_open_extension, FileType, OpenResult,
1617
};
@@ -2002,7 +2003,7 @@ impl FerriteApp {
20022003
path.parent().map(|p| p.to_path_buf()).unwrap_or(path)
20032004
};
20042005

2005-
if let Err(e) = open::that(&folder) {
2006+
if let Err(e) = open_in_file_manager(&folder) {
20062007
warn!("Failed to reveal in explorer: {}", e);
20072008
self.state
20082009
.show_error(t!("error.explorer_failed", error = e.to_string()).to_string());

src/markdown/editor.rs

Lines changed: 11 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ use crate::markdown::widgets::{
6666
CodeBlockData, EditableCodeBlock, EditableTable, MermaidBlock, MermaidBlockData,
6767
RenderedLinkState, RenderedLinkWidget, TableData, TableEditState, WidgetColors,
6868
};
69+
use crate::path_utils::resolve_local_link_path;
6970
use crate::ui::{render_nav_buttons, NavAction};
7071
use eframe::egui::{
7172
self, Color32, ColorImage, FontId, Key, Margin, Response, RichText, ScrollArea, Stroke,
@@ -6029,12 +6030,20 @@ fn render_link(
60296030
})
60306031
.clone()
60316032
});
6033+
let link_ctx = ui.memory(|mem| {
6034+
mem.data
6035+
.get_temp::<WikilinkContext>(egui::Id::new("wikilink_resolution_context"))
6036+
});
60326037

60336038
// Create and show the rendered link widget
60346039
let output = RenderedLinkWidget::new(&mut link_state, title)
60356040
.font_size(font_size)
60366041
.colors(widget_colors)
60376042
.id(link_id)
6043+
.resolution_context(
6044+
link_ctx.as_ref().and_then(|ctx| ctx.current_dir.clone()),
6045+
link_ctx.as_ref().and_then(|ctx| ctx.workspace_root.clone()),
6046+
)
60386047
.show(ui);
60396048

60406049
// Update stored state
@@ -6263,40 +6272,8 @@ fn resolve_image_path(
62636272
}
62646273

62656274
// Skip web URLs — we only support local images
6266-
if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("data:") {
6267-
return None;
6268-
}
6269-
6270-
// Strip leading file:// protocol if present
6271-
let path_str = url.strip_prefix("file://").unwrap_or(url);
6272-
6273-
let path = Path::new(path_str);
6274-
6275-
// If absolute path, use directly
6276-
if path.is_absolute() {
6277-
if path.is_file() {
6278-
return Some(path.to_path_buf());
6279-
}
6280-
return None;
6281-
}
6282-
6283-
// Resolve relative to current document directory
6284-
if let Some(dir) = current_dir {
6285-
let resolved = dir.join(path_str);
6286-
if resolved.is_file() {
6287-
return Some(resolved);
6288-
}
6289-
}
6290-
6291-
// Fall back to workspace root
6292-
if let Some(root) = workspace_root {
6293-
let resolved = root.join(path_str);
6294-
if resolved.is_file() {
6295-
return Some(resolved);
6296-
}
6297-
}
6298-
6299-
None
6275+
let resolved = resolve_local_link_path(url, current_dir, workspace_root)?;
6276+
resolved.is_file().then_some(resolved)
63006277
}
63016278

63026279
/// Load an image from disk, decode it, and create an egui texture.

src/markdown/widgets.rs

Lines changed: 71 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ use crate::markdown::code_execution::{
2323
use crate::markdown::parser::{
2424
CalloutType, HeadingLevel, ListType, MarkdownNode, MarkdownNodeType, TableAlignment,
2525
};
26+
use crate::path_utils::{
27+
open_in_file_manager, resolve_openable_link_target, OpenableLinkTarget,
28+
};
2629
use crate::terminal::TerminalTheme;
2730
use crate::ui::phosphor_icons::{
2831
phosphor_rich_text, ARROWS_CLOCKWISE, ARROWS_LEFT_RIGHT, BUILDINGS, CALENDAR, CARET_DOWN,
@@ -4596,6 +4599,10 @@ pub struct RenderedLinkWidget<'a> {
45964599
colors: Option<WidgetColors>,
45974600
/// Unique ID for this link
45984601
id: Option<egui::Id>,
4602+
/// Current file directory for resolving relative local links.
4603+
current_dir: Option<std::path::PathBuf>,
4604+
/// Workspace root for resolving relative local links.
4605+
workspace_root: Option<std::path::PathBuf>,
45994606
}
46004607

46014608
impl<'a> RenderedLinkWidget<'a> {
@@ -4607,6 +4614,8 @@ impl<'a> RenderedLinkWidget<'a> {
46074614
font_size: 14.0,
46084615
colors: None,
46094616
id: None,
4617+
current_dir: None,
4618+
workspace_root: None,
46104619
}
46114620
}
46124621

@@ -4631,11 +4640,25 @@ impl<'a> RenderedLinkWidget<'a> {
46314640
self
46324641
}
46334642

4643+
/// Provide path resolution context for local markdown links.
4644+
#[must_use]
4645+
pub fn resolution_context(
4646+
mut self,
4647+
current_dir: Option<std::path::PathBuf>,
4648+
workspace_root: Option<std::path::PathBuf>,
4649+
) -> Self {
4650+
self.current_dir = current_dir;
4651+
self.workspace_root = workspace_root;
4652+
self
4653+
}
4654+
46344655
/// Show the link widget and return the output.
46354656
pub fn show(self, ui: &mut Ui) -> RenderedLinkOutput {
46364657
let colors = self
46374658
.colors
46384659
.unwrap_or_else(|| WidgetColors::resolved(ui, Theme::System));
4660+
let current_dir = self.current_dir.as_deref();
4661+
let workspace_root = self.workspace_root.as_deref();
46394662

46404663
let link_id = self.id.expect("RenderedLinkWidget requires an explicit ID");
46414664

@@ -4683,34 +4706,31 @@ impl<'a> RenderedLinkWidget<'a> {
46834706

46844707
// Track whether we consumed a click (to prevent parent from entering edit mode)
46854708
let mut click_consumed = false;
4709+
let open_target =
4710+
resolve_openable_link_target(&self.state.edit_url, current_dir, workspace_root);
46864711

46874712
// Handle click interactions
4688-
// Check for middle-click first (always opens in browser)
4713+
// Check for middle-click first (always opens the target directly)
46894714
if link_response.middle_clicked() {
46904715
click_consumed = true;
4691-
let can_open = self.state.edit_url.starts_with("http://")
4692-
|| self.state.edit_url.starts_with("https://");
4693-
if can_open {
4694-
if let Err(e) = open::that(&self.state.edit_url) {
4695-
log::error!("Failed to open URL: {}", e);
4716+
if let Some(target) = open_target.as_ref() {
4717+
if let Err(e) = open_link_target(target) {
4718+
log::error!("Failed to open link target: {}", e);
46964719
} else {
4697-
log::debug!("Opened URL via middle-click: {}", self.state.edit_url);
4720+
log::debug!("Opened link target via middle-click: {:?}", target);
46984721
}
46994722
}
47004723
} else if clicked_on_link {
47014724
click_consumed = true;
47024725
// Check if Ctrl/Cmd was held during the click
4703-
let open_in_browser = modifiers.ctrl || modifiers.command;
4704-
4705-
if open_in_browser {
4706-
// Ctrl+Click / Cmd+Click: Open URL in default browser
4707-
let can_open = self.state.edit_url.starts_with("http://")
4708-
|| self.state.edit_url.starts_with("https://");
4709-
if can_open {
4710-
if let Err(e) = open::that(&self.state.edit_url) {
4711-
log::error!("Failed to open URL: {}", e);
4726+
let open_directly = modifiers.ctrl || modifiers.command;
4727+
4728+
if open_directly {
4729+
if let Some(target) = open_target.as_ref() {
4730+
if let Err(e) = open_link_target(target) {
4731+
log::error!("Failed to open link target: {}", e);
47124732
} else {
4713-
log::debug!("Opened URL via Ctrl+Click: {}", self.state.edit_url);
4733+
log::debug!("Opened link target via modifier-click: {:?}", target);
47144734
}
47154735
}
47164736
} else {
@@ -4721,15 +4741,16 @@ impl<'a> RenderedLinkWidget<'a> {
47214741

47224742
// Show tooltip with URL and interaction hint when hovering (if popup not open)
47234743
if link_response.hovered() && !self.state.popup_open {
4724-
let can_open = self.state.edit_url.starts_with("http://")
4725-
|| self.state.edit_url.starts_with("https://");
4726-
let tooltip = if can_open {
4727-
format!(
4744+
let tooltip = match open_target.as_ref() {
4745+
Some(OpenableLinkTarget::WebUrl(_)) => format!(
47284746
"{}\n\nClick to edit • Ctrl+Click to open in browser",
47294747
self.state.edit_url
4730-
)
4731-
} else {
4732-
format!("{}\n\nClick to edit", self.state.edit_url)
4748+
),
4749+
Some(OpenableLinkTarget::LocalPath(_)) => format!(
4750+
"{}\n\nClick to edit • Ctrl+Click to reveal in file manager",
4751+
self.state.edit_url
4752+
),
4753+
None => format!("{}\n\nClick to edit", self.state.edit_url),
47334754
};
47344755
link_response.on_hover_text(tooltip);
47354756
}
@@ -4818,31 +4839,37 @@ impl<'a> RenderedLinkWidget<'a> {
48184839

48194840
// Action buttons
48204841
ui.horizontal(|ui| {
4821-
// Open Link button
4822-
let can_open = self.state.edit_url.starts_with("http://")
4823-
|| self.state.edit_url.starts_with("https://");
4842+
let popup_open_target = resolve_openable_link_target(
4843+
&self.state.edit_url,
4844+
current_dir,
4845+
workspace_root,
4846+
);
48244847

48254848
let open_button = ui.add_enabled(
4826-
can_open,
4849+
popup_open_target.is_some(),
48274850
egui::Button::new(t!("widgets.link.open").to_string()),
48284851
);
48294852

48304853
// Store clicked state before consuming response
48314854
let open_clicked = open_button.clicked();
48324855

48334856
// Show appropriate hover text
4834-
let hover_text = if can_open {
4835-
"Open URL in browser"
4836-
} else {
4837-
"Only http/https URLs can be opened"
4857+
let hover_text = match popup_open_target.as_ref() {
4858+
Some(OpenableLinkTarget::WebUrl(_)) => "Open URL in browser",
4859+
Some(OpenableLinkTarget::LocalPath(_)) => {
4860+
"Reveal path in file manager"
4861+
}
4862+
None => "Only resolvable web or local paths can be opened",
48384863
};
48394864
open_button.on_hover_text(hover_text);
48404865

4841-
if open_clicked && can_open {
4842-
if let Err(e) = open::that(&self.state.edit_url) {
4843-
log::error!("Failed to open URL: {}", e);
4844-
} else {
4845-
log::debug!("Opened URL: {}", self.state.edit_url);
4866+
if open_clicked {
4867+
if let Some(target) = popup_open_target.as_ref() {
4868+
if let Err(e) = open_link_target(target) {
4869+
log::error!("Failed to open link target: {}", e);
4870+
} else {
4871+
log::debug!("Opened link target from popup: {:?}", target);
4872+
}
48464873
}
48474874
}
48484875

@@ -4896,6 +4923,13 @@ impl<'a> RenderedLinkWidget<'a> {
48964923
}
48974924
}
48984925

4926+
fn open_link_target(target: &OpenableLinkTarget) -> std::io::Result<()> {
4927+
match target {
4928+
OpenableLinkTarget::WebUrl(url) => open::that(url),
4929+
OpenableLinkTarget::LocalPath(path) => open_in_file_manager(path),
4930+
}
4931+
}
4932+
48994933
// ─────────────────────────────────────────────────────────────────────────────
49004934
// Mermaid Diagram Widget
49014935
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)