generated from nhcarrigan/template
b3d79a82ef
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
725 lines
23 KiB
Rust
725 lines
23 KiB
Rust
// Clipboard history module for tracking and managing copied code snippets
|
|
// Implements issue #25 - Clipboard History feature
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Mutex;
|
|
use tauri_plugin_store::StoreExt;
|
|
use uuid::Uuid;
|
|
|
|
const STORE_FILE: &str = "hikari-clipboard.json";
|
|
const HISTORY_KEY: &str = "clipboard_history";
|
|
const MAX_HISTORY_SIZE: usize = 100;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ClipboardEntry {
|
|
pub id: String,
|
|
pub content: String,
|
|
pub language: Option<String>,
|
|
pub source: Option<String>,
|
|
pub timestamp: String,
|
|
pub is_pinned: bool,
|
|
}
|
|
|
|
impl ClipboardEntry {
|
|
pub fn new(content: String, language: Option<String>, source: Option<String>) -> Self {
|
|
Self {
|
|
id: Uuid::new_v4().to_string(),
|
|
content,
|
|
language,
|
|
source,
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
is_pinned: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
struct ClipboardHistory {
|
|
entries: Vec<ClipboardEntry>,
|
|
}
|
|
|
|
// Track last clipboard content to avoid duplicates
|
|
#[derive(Default)]
|
|
struct ClipboardState {
|
|
last_content: Option<String>,
|
|
}
|
|
|
|
static CLIPBOARD_STATE: Mutex<ClipboardState> = Mutex::new(ClipboardState { last_content: None });
|
|
|
|
fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
|
|
let store = app.store(STORE_FILE).ok();
|
|
store
|
|
.and_then(|s| s.get(HISTORY_KEY))
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
|
|
let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
|
|
store.set(
|
|
HISTORY_KEY,
|
|
serde_json::to_value(history).map_err(|e| e.to_string())?,
|
|
);
|
|
store.save().map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// List all clipboard entries, optionally filtered by language
|
|
#[tauri::command]
|
|
pub fn list_clipboard_entries(
|
|
app: tauri::AppHandle,
|
|
language: Option<String>,
|
|
) -> Result<Vec<ClipboardEntry>, String> {
|
|
let history = load_history(&app);
|
|
let entries = if let Some(lang) = language {
|
|
history
|
|
.entries
|
|
.into_iter()
|
|
.filter(|e| e.language.as_ref() == Some(&lang))
|
|
.collect()
|
|
} else {
|
|
history.entries
|
|
};
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Capture current clipboard content and add to history
|
|
#[tauri::command]
|
|
pub fn capture_clipboard(
|
|
app: tauri::AppHandle,
|
|
content: String,
|
|
language: Option<String>,
|
|
source: Option<String>,
|
|
) -> Result<ClipboardEntry, String> {
|
|
// Check for duplicate (same content as last capture)
|
|
{
|
|
let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
|
|
if state.last_content.as_ref() == Some(&content) {
|
|
// Return existing entry if content is the same
|
|
let history = load_history(&app);
|
|
if let Some(entry) = history.entries.first() {
|
|
if entry.content == content {
|
|
return Ok(entry.clone());
|
|
}
|
|
}
|
|
}
|
|
state.last_content = Some(content.clone());
|
|
}
|
|
|
|
let entry = ClipboardEntry::new(content, language, source);
|
|
let mut history = load_history(&app);
|
|
|
|
// Add to front of history
|
|
history.entries.insert(0, entry.clone());
|
|
|
|
// Enforce max size (keep pinned entries)
|
|
let mut pinned: Vec<ClipboardEntry> = history
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.is_pinned)
|
|
.cloned()
|
|
.collect();
|
|
let mut unpinned: Vec<ClipboardEntry> = history
|
|
.entries
|
|
.into_iter()
|
|
.filter(|e| !e.is_pinned)
|
|
.collect();
|
|
|
|
// Trim unpinned entries if over max size
|
|
if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
|
|
let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
|
|
unpinned.truncate(max_unpinned);
|
|
}
|
|
|
|
// Merge back, pinned first then unpinned
|
|
pinned.extend(unpinned);
|
|
history.entries = pinned;
|
|
|
|
// Sort by timestamp descending (newest first), pinned entries stay at top
|
|
history.entries.sort_by(|a, b| {
|
|
if a.is_pinned && !b.is_pinned {
|
|
std::cmp::Ordering::Less
|
|
} else if !a.is_pinned && b.is_pinned {
|
|
std::cmp::Ordering::Greater
|
|
} else {
|
|
b.timestamp.cmp(&a.timestamp)
|
|
}
|
|
});
|
|
|
|
save_history(&app, &history)?;
|
|
Ok(entry)
|
|
}
|
|
|
|
/// Delete a clipboard entry by ID
|
|
#[tauri::command]
|
|
pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
let mut history = load_history(&app);
|
|
history.entries.retain(|e| e.id != id);
|
|
save_history(&app, &history)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Toggle pin status of an entry
|
|
#[tauri::command]
|
|
pub fn toggle_pin_clipboard_entry(
|
|
app: tauri::AppHandle,
|
|
id: String,
|
|
) -> Result<ClipboardEntry, String> {
|
|
let mut history = load_history(&app);
|
|
let entry = history
|
|
.entries
|
|
.iter_mut()
|
|
.find(|e| e.id == id)
|
|
.ok_or("Entry not found")?;
|
|
|
|
entry.is_pinned = !entry.is_pinned;
|
|
let updated_entry = entry.clone();
|
|
|
|
// Re-sort to move pinned entries to top
|
|
history.entries.sort_by(|a, b| {
|
|
if a.is_pinned && !b.is_pinned {
|
|
std::cmp::Ordering::Less
|
|
} else if !a.is_pinned && b.is_pinned {
|
|
std::cmp::Ordering::Greater
|
|
} else {
|
|
b.timestamp.cmp(&a.timestamp)
|
|
}
|
|
});
|
|
|
|
save_history(&app, &history)?;
|
|
Ok(updated_entry)
|
|
}
|
|
|
|
/// Clear all non-pinned entries
|
|
#[tauri::command]
|
|
pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
|
|
let mut history = load_history(&app);
|
|
history.entries.retain(|e| e.is_pinned);
|
|
save_history(&app, &history)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Search clipboard entries by content
|
|
#[tauri::command]
|
|
pub fn search_clipboard_entries(
|
|
app: tauri::AppHandle,
|
|
query: String,
|
|
) -> Result<Vec<ClipboardEntry>, String> {
|
|
let history = load_history(&app);
|
|
let query_lower = query.to_lowercase();
|
|
let entries = history
|
|
.entries
|
|
.into_iter()
|
|
.filter(|e| {
|
|
e.content.to_lowercase().contains(&query_lower)
|
|
|| e.language
|
|
.as_ref()
|
|
.is_some_and(|l| l.to_lowercase().contains(&query_lower))
|
|
|| e.source
|
|
.as_ref()
|
|
.is_some_and(|s| s.to_lowercase().contains(&query_lower))
|
|
})
|
|
.collect();
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Get all unique languages from history
|
|
#[tauri::command]
|
|
pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result<Vec<String>, String> {
|
|
let history = load_history(&app);
|
|
let mut languages: Vec<String> = history
|
|
.entries
|
|
.iter()
|
|
.filter_map(|e| e.language.clone())
|
|
.collect();
|
|
languages.sort();
|
|
languages.dedup();
|
|
Ok(languages)
|
|
}
|
|
|
|
/// Update the language of an entry
|
|
#[tauri::command]
|
|
pub fn update_clipboard_language(
|
|
app: tauri::AppHandle,
|
|
id: String,
|
|
language: Option<String>,
|
|
) -> Result<ClipboardEntry, String> {
|
|
let mut history = load_history(&app);
|
|
let entry = history
|
|
.entries
|
|
.iter_mut()
|
|
.find(|e| e.id == id)
|
|
.ok_or("Entry not found")?;
|
|
|
|
entry.language = language;
|
|
let updated_entry = entry.clone();
|
|
|
|
save_history(&app, &history)?;
|
|
Ok(updated_entry)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// ==================== ClipboardEntry tests ====================
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_new() {
|
|
let entry = ClipboardEntry::new(
|
|
"let x = 42;".to_string(),
|
|
Some("rust".to_string()),
|
|
Some("main.rs".to_string()),
|
|
);
|
|
|
|
assert_eq!(entry.content, "let x = 42;");
|
|
assert_eq!(entry.language, Some("rust".to_string()));
|
|
assert_eq!(entry.source, Some("main.rs".to_string()));
|
|
assert!(!entry.is_pinned);
|
|
assert!(!entry.id.is_empty());
|
|
assert!(!entry.timestamp.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_new_without_optional_fields() {
|
|
let entry = ClipboardEntry::new("some content".to_string(), None, None);
|
|
|
|
assert_eq!(entry.content, "some content");
|
|
assert!(entry.language.is_none());
|
|
assert!(entry.source.is_none());
|
|
assert!(!entry.is_pinned);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_unique_ids() {
|
|
let entry1 = ClipboardEntry::new("content1".to_string(), None, None);
|
|
let entry2 = ClipboardEntry::new("content2".to_string(), None, None);
|
|
|
|
assert_ne!(entry1.id, entry2.id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_serialization() {
|
|
let entry = ClipboardEntry::new(
|
|
"fn main() {}".to_string(),
|
|
Some("rust".to_string()),
|
|
Some("lib.rs".to_string()),
|
|
);
|
|
|
|
let json = serde_json::to_string(&entry).unwrap();
|
|
assert!(json.contains("fn main() {}"));
|
|
assert!(json.contains("rust"));
|
|
assert!(json.contains("lib.rs"));
|
|
assert!(json.contains("is_pinned"));
|
|
|
|
let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.content, entry.content);
|
|
assert_eq!(deserialized.language, entry.language);
|
|
assert_eq!(deserialized.source, entry.source);
|
|
assert_eq!(deserialized.id, entry.id);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_clone() {
|
|
let entry = ClipboardEntry::new(
|
|
"original".to_string(),
|
|
Some("python".to_string()),
|
|
None,
|
|
);
|
|
|
|
let cloned = entry.clone();
|
|
assert_eq!(cloned.content, entry.content);
|
|
assert_eq!(cloned.id, entry.id);
|
|
assert_eq!(cloned.language, entry.language);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_entry_timestamp_is_rfc3339() {
|
|
let entry = ClipboardEntry::new("test".to_string(), None, None);
|
|
|
|
// RFC3339 timestamp should parse successfully
|
|
let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp);
|
|
assert!(parsed.is_ok());
|
|
}
|
|
|
|
// ==================== ClipboardHistory tests ====================
|
|
|
|
#[test]
|
|
fn test_clipboard_history_default() {
|
|
let history = ClipboardHistory::default();
|
|
assert!(history.entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_history_serialization() {
|
|
let mut history = ClipboardHistory::default();
|
|
history.entries.push(ClipboardEntry::new(
|
|
"entry1".to_string(),
|
|
Some("js".to_string()),
|
|
None,
|
|
));
|
|
history.entries.push(ClipboardEntry::new(
|
|
"entry2".to_string(),
|
|
None,
|
|
Some("file.txt".to_string()),
|
|
));
|
|
|
|
let json = serde_json::to_string(&history).unwrap();
|
|
assert!(json.contains("entry1"));
|
|
assert!(json.contains("entry2"));
|
|
assert!(json.contains("js"));
|
|
assert!(json.contains("file.txt"));
|
|
|
|
let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.entries.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_history_entries_order() {
|
|
let mut history = ClipboardHistory::default();
|
|
|
|
history.entries.push(ClipboardEntry::new("first".to_string(), None, None));
|
|
history.entries.push(ClipboardEntry::new("second".to_string(), None, None));
|
|
history.entries.push(ClipboardEntry::new("third".to_string(), None, None));
|
|
|
|
assert_eq!(history.entries[0].content, "first");
|
|
assert_eq!(history.entries[1].content, "second");
|
|
assert_eq!(history.entries[2].content, "third");
|
|
}
|
|
|
|
// ==================== ClipboardState tests ====================
|
|
|
|
#[test]
|
|
fn test_clipboard_state_default() {
|
|
let state = ClipboardState::default();
|
|
assert!(state.last_content.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clipboard_state_with_content() {
|
|
let state = ClipboardState {
|
|
last_content: Some("cached content".to_string()),
|
|
};
|
|
assert_eq!(state.last_content, Some("cached content".to_string()));
|
|
}
|
|
|
|
// ==================== MAX_HISTORY_SIZE constant test ====================
|
|
|
|
#[test]
|
|
fn test_max_history_size_is_reasonable() {
|
|
assert_eq!(MAX_HISTORY_SIZE, 100);
|
|
// Compile-time assertions for constant bounds
|
|
const _: () = assert!(MAX_HISTORY_SIZE > 0);
|
|
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
|
|
}
|
|
|
|
// ==================== Pinned entry sorting tests ====================
|
|
|
|
#[test]
|
|
#[allow(clippy::useless_vec)]
|
|
fn test_pinned_entries_sorting() {
|
|
let mut entries = vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "unpinned older".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "pinned".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
|
is_pinned: true,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "unpinned newer".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
];
|
|
|
|
// Apply the same sorting logic as used in the module
|
|
entries.sort_by(|a, b| {
|
|
if a.is_pinned && !b.is_pinned {
|
|
std::cmp::Ordering::Less
|
|
} else if !a.is_pinned && b.is_pinned {
|
|
std::cmp::Ordering::Greater
|
|
} else {
|
|
b.timestamp.cmp(&a.timestamp)
|
|
}
|
|
});
|
|
|
|
// Pinned should be first
|
|
assert!(entries[0].is_pinned);
|
|
assert_eq!(entries[0].id, "2");
|
|
|
|
// Then unpinned sorted by timestamp descending (newest first)
|
|
assert_eq!(entries[1].id, "3"); // newer unpinned
|
|
assert_eq!(entries[2].id, "1"); // older unpinned
|
|
}
|
|
|
|
#[test]
|
|
#[allow(clippy::useless_vec)]
|
|
fn test_multiple_pinned_entries_sorting() {
|
|
let mut entries = vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "pinned older".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
is_pinned: true,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "unpinned".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "pinned newer".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
|
is_pinned: true,
|
|
},
|
|
];
|
|
|
|
entries.sort_by(|a, b| {
|
|
if a.is_pinned && !b.is_pinned {
|
|
std::cmp::Ordering::Less
|
|
} else if !a.is_pinned && b.is_pinned {
|
|
std::cmp::Ordering::Greater
|
|
} else {
|
|
b.timestamp.cmp(&a.timestamp)
|
|
}
|
|
});
|
|
|
|
// Both pinned first, sorted by timestamp
|
|
assert!(entries[0].is_pinned);
|
|
assert_eq!(entries[0].id, "3"); // pinned newer
|
|
assert!(entries[1].is_pinned);
|
|
assert_eq!(entries[1].id, "1"); // pinned older
|
|
// Then unpinned
|
|
assert!(!entries[2].is_pinned);
|
|
assert_eq!(entries[2].id, "2");
|
|
}
|
|
|
|
// ==================== Entry filtering tests ====================
|
|
|
|
#[test]
|
|
fn test_filter_entries_by_language() {
|
|
let history = ClipboardHistory {
|
|
entries: vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "rust code".to_string(),
|
|
language: Some("rust".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "js code".to_string(),
|
|
language: Some("javascript".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "more rust".to_string(),
|
|
language: Some("rust".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
let filtered: Vec<_> = history
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.language.as_ref() == Some(&"rust".to_string()))
|
|
.collect();
|
|
|
|
assert_eq!(filtered.len(), 2);
|
|
assert!(filtered.iter().all(|e| e.language == Some("rust".to_string())));
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_entries_by_content() {
|
|
let history = ClipboardHistory {
|
|
entries: vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "fn hello_world()".to_string(),
|
|
language: Some("rust".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "function hello()".to_string(),
|
|
language: Some("javascript".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-02T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "def goodbye()".to_string(),
|
|
language: Some("python".to_string()),
|
|
source: None,
|
|
timestamp: "2024-01-03T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
let query = "hello";
|
|
let query_lower = query.to_lowercase();
|
|
let filtered: Vec<_> = history
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
|
.collect();
|
|
|
|
assert_eq!(filtered.len(), 2);
|
|
assert!(filtered[0].content.contains("hello"));
|
|
assert!(filtered[1].content.contains("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_entries_case_insensitive() {
|
|
let history = ClipboardHistory {
|
|
entries: vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "HELLO WORLD".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
let query = "hello";
|
|
let query_lower = query.to_lowercase();
|
|
let filtered: Vec<_> = history
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
|
.collect();
|
|
|
|
assert_eq!(filtered.len(), 1);
|
|
}
|
|
|
|
// ==================== Unique languages extraction test ====================
|
|
|
|
#[test]
|
|
fn test_extract_unique_languages() {
|
|
let history = ClipboardHistory {
|
|
entries: vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "".to_string(),
|
|
language: Some("rust".to_string()),
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "".to_string(),
|
|
language: Some("javascript".to_string()),
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "".to_string(),
|
|
language: Some("rust".to_string()), // Duplicate
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "4".to_string(),
|
|
content: "".to_string(),
|
|
language: None, // No language
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
let mut languages: Vec<String> = history
|
|
.entries
|
|
.iter()
|
|
.filter_map(|e| e.language.clone())
|
|
.collect();
|
|
languages.sort();
|
|
languages.dedup();
|
|
|
|
assert_eq!(languages.len(), 2);
|
|
assert!(languages.contains(&"rust".to_string()));
|
|
assert!(languages.contains(&"javascript".to_string()));
|
|
}
|
|
|
|
// ==================== Retain pinned entries test ====================
|
|
|
|
#[test]
|
|
fn test_retain_pinned_on_clear() {
|
|
let mut history = ClipboardHistory {
|
|
entries: vec![
|
|
ClipboardEntry {
|
|
id: "1".to_string(),
|
|
content: "pinned".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: true,
|
|
},
|
|
ClipboardEntry {
|
|
id: "2".to_string(),
|
|
content: "unpinned".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: false,
|
|
},
|
|
ClipboardEntry {
|
|
id: "3".to_string(),
|
|
content: "another pinned".to_string(),
|
|
language: None,
|
|
source: None,
|
|
timestamp: "".to_string(),
|
|
is_pinned: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Simulate clear (keep only pinned)
|
|
history.entries.retain(|e| e.is_pinned);
|
|
|
|
assert_eq!(history.entries.len(), 2);
|
|
assert!(history.entries.iter().all(|e| e.is_pinned));
|
|
}
|
|
}
|