Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions crates/ropy-core/src/repository/clipboard_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,22 @@ fn normalize_file_path(path: &str) -> Option<String> {
return None;
}

let uri_path = path
.strip_prefix("file://localhost")
.or_else(|| path.strip_prefix("file://"));

// Percent escapes belong to URIs; decoding a filesystem path changes
// literal filenames and makes repeated normalization destructive.
// Whitespace in a nonempty filesystem path is part of its identity.
Some(uri_path.map_or_else(|| path.to_string(), decode_percent_encoded))
// Percent escapes belong to URIs; whitespace and percent characters in a
// filesystem path are part of its identity.
let Some(uri) = path.strip_prefix("file://") else {
return Some(path.to_string());
};
let path_start = uri.find('/')?;
let authority = &uri[..path_start];
if !authority.is_empty() && !authority.eq_ignore_ascii_case("localhost") {
// A remote URI cannot be treated as a relative path on this machine.
return None;
}
Some(decode_percent_encoded(&uri[path_start..]))
}

/// Normalize file URIs while preserving literal filesystem percent characters.
/// Decode local file URIs, ignoring remote authorities and blank entries.
/// Literal filesystem whitespace and percent characters are preserved.
#[must_use]
pub fn normalize_file_paths(paths: &[String]) -> Vec<String> {
paths
Expand Down Expand Up @@ -124,11 +129,28 @@ mod tests {
let paths = vec![
"file:///tmp/hello%20world.txt".to_string(),
"file://localhost/tmp/demo.txt".to_string(),
"file://LOCALHOST/tmp/case.txt".to_string(),
];

let normalized = normalize_file_paths(&paths);

assert_eq!(normalized, vec!["/tmp/hello world.txt", "/tmp/demo.txt"]);
assert_eq!(
normalized,
vec!["/tmp/hello world.txt", "/tmp/demo.txt", "/tmp/case.txt"]
);
}

#[rstest::rstest]
#[case("file://server/share/report.txt")]
#[case("file://localhost.example/tmp/report.txt")]
#[case("file://localhost")]
#[case("file://relative")]
fn test_normalize_file_paths_unsupported_authority_is_not_a_local_path(#[case] uri: &str) {
let paths = vec![uri.to_string()];
assert_eq!(normalize_file_paths(&paths), Vec::<String>::new());
let serialized = serde_json::to_string(&paths)
.unwrap_or_else(|error| panic!("serialize fixture: {error}"));
assert_eq!(deserialize_file_paths(&serialized), Vec::<String>::new());
}

#[test]
Expand Down
15 changes: 15 additions & 0 deletions src/gui/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,27 @@ impl RopyBoard {
// Input and selection controls retain window-bound subscriptions. Rebuild them
// when a Wayland surface is reopened; keep history, tasks and update state alive.
pub(crate) fn attach_window(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) {
// Native registration can outlive a Wayland surface. Keep its draft and
// submission guard until completion, even as window-bound inputs change.
let pending_hotkey_save = self
.settings_editor
.hotkey
.saving
.then(|| self.resolve_activation_key_input(cx));
self.window_activation_subscription = Self::observe_activation(window, cx);
self.window_appearance_subscription = Self::observe_appearance(window, cx);
self.theme_catalog_subscription = Self::observe_themes(window, cx);
self.search_input = cx.new(|cx| InputState::new(window, cx));
self.search_input_subscription = Self::observe_search(&self.search_input, cx);
self.settings_editor = SettingsEditor::new(window, cx);
if let Some(candidate) = pending_hotkey_save {
self.settings_editor.hotkey.saving = true;
self.settings_editor.pending_hotkey.clone_from(&candidate);
self.settings_editor
.hotkey_before_recording
.clone_from(&candidate);
self.sync_activation_key_input_from_candidate(&candidate, window, cx);
}
if let Some(draft) = self
.theme_workspace
.as_mut()
Expand Down
135 changes: 80 additions & 55 deletions src/gui/board/records_list/row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,50 +202,37 @@ fn render_file_badge(badge_background: gpui_kit::Hsla, cx: &App) -> gpui_kit::Di
.child(Icon::empty().path("icons/filter-files.svg").size(px(12.0)))
}

fn create_preview(
content_type: &ContentType,
record_content: &str,
window: &Window,
cx: &mut App,
) -> AnyView {
match content_type {
ContentType::Image => preview::image_tooltip(record_content, window, cx),
ContentType::FilePath => preview::simple_tooltip(
truncate(
&file_preview_content(record_content),
TOOLTIP_CONTENT_PREVIEW_LIMIT,
TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES),
),
window,
cx,
),
ContentType::Text | ContentType::RichText => preview::simple_tooltip(
truncate(
record_content,
TOOLTIP_CONTENT_PREVIEW_LIMIT,
TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES),
),
window,
cx,
),
}
}

struct PreviewData {
content_type: ContentType,
record_content: String,
}

impl PreviewData {
fn new(record: &ClipboardRecord) -> Self {
let truncate_preview = |content: &str| {
truncate(
content,
TOOLTIP_CONTENT_PREVIEW_LIMIT,
TruncateOptions::with_max_lines(TOOLTIP_CONTENT_PREVIEW_MAX_LINES),
)
};
let record_content = match record.content_type {
ContentType::Image => record.content.clone(),
ContentType::FilePath => truncate_preview(&file_preview_content(&record.content)),
ContentType::Text | ContentType::RichText => truncate_preview(&record.content),
};
Self {
content_type: record.content_type.clone(),
record_content: record.content.clone(),
record_content,
}
}

fn build(&self, window: &Window, cx: &mut App) -> AnyView {
create_preview(&self.content_type, &self.record_content, window, cx)
if self.content_type == ContentType::Image {
preview::image_tooltip(&self.record_content, window, cx)
} else {
preview::simple_tooltip(&self.record_content, window, cx)
}
}
}

Expand Down Expand Up @@ -367,17 +354,16 @@ impl RecordsListState {
}

fn render_list_row(&self, index: usize, window: &Window, cx: &mut App) -> AnyElement {
let Some(record) = self.record_for_filtered_index(index) else {
let records = read_or_recover(&self.records);
let Some(record_index) = self.filtered_record_indices.get(index).copied() else {
return div().into_any_element();
};

render_list_item(&self.render_context(index, &record), window, cx)
}

fn record_for_filtered_index(&self, filtered_index: usize) -> Option<ClipboardRecord> {
let record_index = self.filtered_record_indices.get(filtered_index).copied()?;
let guard = read_or_recover(&self.records);
guard.get(record_index).cloned()
let Some(record) = records.get(record_index) else {
return div().into_any_element();
};
let item = render_list_item(&self.render_context(index, record), window, cx);
drop(records);
item
}

pub(super) fn render_context<'a>(
Expand Down Expand Up @@ -418,24 +404,16 @@ impl RecordsListState {
}
}

fn render_record_body(
ctx: &RenderContext<'_>,
preview_data: &PreviewData,
styles: &ItemStyle,
cx: &App,
) -> AnyElement {
fn render_record_body(ctx: &RenderContext<'_>, styles: &ItemStyle, cx: &App) -> AnyElement {
let compact = ctx.layout_mode == LayoutMode::Grid;
let mut content = div()
.flex_1()
.min_w_0()
.id(("record-content", ctx.record.id));

if !ctx.flags.preview_visible() && ctx.flags.hover_preview_enabled() {
let preview_content_type = preview_data.content_type.clone();
let preview_record_content = preview_data.record_content.clone();
content = content.tooltip(move |window, cx| {
create_preview(&preview_content_type, &preview_record_content, window, cx)
});
let preview_data = PreviewData::new(ctx.record);
content = content.tooltip(move |window, cx| preview_data.build(window, cx));
}

if compact {
Expand Down Expand Up @@ -735,7 +713,6 @@ pub(super) fn render_list_item_with_grid_height(
grid_height_override: Option<f32>,
) -> AnyElement {
let compact = ctx.layout_mode == LayoutMode::Grid;
let preview_data = PreviewData::new(ctx.record);
let styles = ItemStyle::from_app(cx);

let card = if compact {
Expand All @@ -755,7 +732,7 @@ pub(super) fn render_list_item_with_grid_height(
.w_full()
.gap_1()
.child(render_grid_record_header(ctx, &styles, cx))
.child(render_record_body(ctx, &preview_data, &styles, cx)),
.child(render_record_body(ctx, &styles, cx)),
),
ctx,
&styles,
Expand All @@ -767,7 +744,7 @@ pub(super) fn render_list_item_with_grid_height(
.justify_between()
.items_start()
.gap_2()
.child(render_record_body(ctx, &preview_data, &styles, cx))
.child(render_record_body(ctx, &styles, cx))
.child(render_record_actions(ctx, cx)),
),
ctx,
Expand All @@ -782,7 +759,11 @@ pub(super) fn render_list_item_with_grid_height(
};

if should_render_selected_preview(ctx.flags) {
item = item.child(render_selected_preview(&preview_data, window, cx));
item = item.child(render_selected_preview(
&PreviewData::new(ctx.record),
window,
cx,
));
}

item.into_any_element()
Expand All @@ -808,6 +789,50 @@ mod tests {
}
}

#[test]
fn test_preview_data_long_text_retains_only_visible_unicode_prefix() {
let record = crate::repository::ClipboardRecord::new(
1,
"界".repeat(1_000_000),
chrono::Local::now(),
crate::repository::ContentType::Text,
);
let preview = super::PreviewData::new(&record);
let expected = format!("{}...", "界".repeat(500));
assert!(
preview.record_content == expected,
"preview retained {} bytes",
preview.record_content.len()
);
}

#[rstest::rstest]
#[case(crate::repository::ContentType::Text, "short\ntext", "short\ntext")]
#[case(
crate::repository::ContentType::RichText,
"1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11",
"1\n2\n3\n4\n5\n6\n7\n8\n9\n10..."
)]
#[case(
crate::repository::ContentType::FilePath,
r#"["file:///tmp/a%20b","/tmp/literal%20"]"#,
"/tmp/a b\n/tmp/literal%20"
)]
#[case(
crate::repository::ContentType::Image,
"/tmp/image.png",
"/tmp/image.png"
)]
fn test_preview_data_preserves_each_payload_presentation(
#[case] kind: crate::repository::ContentType,
#[case] content: &str,
#[case] expected: &str,
) {
let record =
crate::repository::ClipboardRecord::new(1, content.into(), chrono::Local::now(), kind);
assert_eq!(super::PreviewData::new(&record).record_content, expected);
}

#[test]
fn selected_preview_remains_available_when_hover_preview_is_disabled() {
assert!(should_render_selected_preview(flags(
Expand Down
50 changes: 40 additions & 10 deletions src/gui/board/settings_handler/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,27 +141,57 @@ impl RopyBoard {
return;
}
};
let previous_hotkey =
GlobalSettings::read(cx, |settings| settings.hotkey.activation_key.clone());
self.settings_editor.hotkey.saving = true;
cx.notify();
cx.spawn_in(window, async move |this, cx| {
let result = response
.recv()
.await
.unwrap_or(Err(HotkeyUpdateError::Disconnected));
let _ = this.update_in(cx, |board, window, cx| {
// The board outlives its native popup; release the guard even if
// there is no current window for the visual completion below.
let _ = this.update(cx, |board, cx| {
board.settings_editor.hotkey.saving = false;
cx.notify();
});
let _ = this.update_in(cx, |board, window, cx| {
match result {
Ok(()) => {
board.settings_editor.hotkey.recording = false;
board
.settings_editor
.pending_hotkey
.clone_from(&activation_key);
board
// Persistence already published the new live hotkey. Resolve
// against the old value to detect edits made while waiting.
let input = board
.settings_editor
.hotkey_before_recording
.clone_from(&activation_key);
board.sync_activation_key_input("", &activation_key, window, cx);
.settings_activation_key_input
.read(cx)
.value();
let candidate = Self::resolve_hotkey_candidate_input(
&input,
&board.settings_editor.pending_hotkey,
&previous_hotkey,
);
if !board.settings_editor.hotkey.recording && candidate == activation_key {
board
.settings_editor
.pending_hotkey
.clone_from(&activation_key);
board
.settings_editor
.hotkey_before_recording
.clone_from(&activation_key);
board.sync_activation_key_input("", &activation_key, window, cx);
} else {
if !input.is_empty()
&& board.settings_editor.pending_hotkey == previous_hotkey
{
board
.settings_editor
.pending_hotkey
.clone_from(&activation_key);
}
board.refresh_activation_key_placeholder(window, cx);
}
Self::notify_settings_success(window, cx, t!("settings_save_success"));
}
Err(error) => Self::notify_hotkey_update_failed(&error, window, cx),
Expand Down
Loading
Loading