diff --git a/CHANGELOG.md b/CHANGELOG.md index 014ec1f..be17930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,27 @@ popover, not for the person who wrote the commit. ## [Unreleased] -### Fixed +## [1.9.0] - 2026-09-27 + +### Added +- **A stash can now link to other stashes in the same context.** Type `>>` in the editor and + pick one. The link shows as a chip with the other stash's current first line. Hover it to + preview that stash, or click it to scroll there. A completed stash is unfolded first, and a + stash that was moved is followed into its new context. A deleted stash's link stays in the + text, marked as gone, and deleting a stash that others link to warns you first +- **Copying a stash to an AI tool brings the stashes it links to.** Each linked stash is added + under the text, one level deep, up to 2,000 characters each and 8,000 in all. A longer one + is cut short, and its full text is saved as a file listed next to the attachments. Links + inside a linked stash are not followed +- **Auto Clear Completed keeps completed stashes that an open stash still links to.** They + are cleared as usual once that stash is completed too. Only a direct link from an open + stash counts, so a chain of old completed stashes cannot keep each other forever. + Clearing completed stashes by hand still clears them all, after a warning + +### Fixed +- **Attachment previews stay inside a narrow window.** Hovering an attachment near the edge + of the window pushed its preview further off the edge instead of back in, so part of the + picture or text was cut off. This applies to attachments in the queue and in the editor - **Importing a context whose rules match your current ones no longer reports a conflict.** The check compared the imported and current rules as JSON text, and an export writes a rule's fields in a different order than the app does - so any context with auto-switch diff --git a/package.json b/package.json index 684a5d0..e4f220f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stashpad", - "version": "1.8.10", + "version": "1.9.0", "description": "The staging area for your AI context.", "author": { "name": "Nico Wiedemann", diff --git a/screenshots/mock-backend.ts b/screenshots/mock-backend.ts index e76fc8b..2e3707d 100644 --- a/screenshots/mock-backend.ts +++ b/screenshots/mock-backend.ts @@ -195,6 +195,8 @@ export function installMockBackend(overrides: Partial = {}): void { return true; case 'get_system_prompt': return 'You are a precise engineering assistant. Rewrite the note as a task.'; + case 'write_reference_file': + return `/home/dev/.stashpad/cache/${payload?.contextId}/${payload?.stashId}/refs/${payload?.targetId}.md`; case 'get_system_prompt_path_str': return '/home/dev/.stashpad/system-prompt.md'; default: diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d4a716e..8c9c008 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5858,7 +5858,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stashpad" -version = "1.8.10" +version = "1.9.0" dependencies = [ "active-win-pos-rs", "aes-gcm", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b6cd237..f95d76a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stashpad" -version = "1.8.10" +version = "1.9.0" description = "The staging area for your AI context." authors = ["Nico Wiedemann "] repository = "https://github.com/EarMaster/stashpad" diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f398edd..7c35aa1 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -101,7 +101,7 @@ impl DbManager { Ok(()) } - fn init_tables(&self) -> Result<()> { + pub(crate) fn init_tables(&self) -> Result<()> { // Contexts table self.conn.execute( "CREATE TABLE IF NOT EXISTS contexts ( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5040944..225d1e5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -497,6 +497,7 @@ pub fn run() { stashes::mark_stashes_synced, stashes::delete_stash, stashes::delete_completed_stashes, + stashes::write_reference_file, stashes::trigger_auto_cleanup, stashes::save_asset, stashes::save_asset_from_path, diff --git a/src-tauri/src/stashes.rs b/src-tauri/src/stashes.rs index 9da6432..248689b 100644 --- a/src-tauri/src/stashes.rs +++ b/src-tauri/src/stashes.rs @@ -301,17 +301,37 @@ fn purge_stash_files(db: &DbManager, stashes: &[(String, Option)]) { } } -/// Completed, not-yet-deleted stashes, optionally only those completed before `cutoff` -/// (an RFC3339 timestamp). +/// Keeps auto-clear away from a completed stash that an open stash in the same context +/// still references. +/// +/// Auto-clear's "clear" is a soft delete that syncs as a tombstone, and the cloud purges +/// tombstones for good 30 days later, so without this a working reference would quietly +/// turn into a dead one. It reaches one level only: the referrer must itself be open, so +/// a completed stash protects nothing, and a chain of old completed stashes cannot keep +/// each other alive. Once the open referrer is completed, its targets clear on the next +/// run. +/// +/// References are `[label](stash:)` links in the content (`src/lib/utils/stash-refs.ts`). +/// Matching `stash:` anywhere, including inside code, errs towards keeping a stash. +const NOT_REFERENCED_BY_OPEN_STASH: &str = "AND NOT EXISTS (\ + SELECT 1 FROM stashes r \ + WHERE r.deleted = 0 AND r.completed = 0 AND r.id <> s.id \ + AND r.context_id = s.context_id \ + AND (instr(lower(r.content), 'stash:' || lower(s.id)) > 0 \ + OR instr(lower(COALESCE(r.enhanced_content, '')), 'stash:' || lower(s.id)) > 0))"; + +/// Completed, not-yet-deleted stashes that auto-clear may remove, optionally only those +/// completed before `cutoff` (an RFC3339 timestamp). fn completed_stashes(db: &DbManager, cutoff: Option<&str>) -> Vec<(String, Option)> { let result = match cutoff { Some(before) => db .conn - .prepare( - "SELECT id, context_id FROM stashes \ - WHERE completed = 1 AND deleted = 0 AND completed_at IS NOT NULL \ - AND completed_at < ?1", - ) + .prepare(&format!( + "SELECT s.id, s.context_id FROM stashes s \ + WHERE s.completed = 1 AND s.deleted = 0 AND s.completed_at IS NOT NULL \ + AND s.completed_at < ?1 {}", + NOT_REFERENCED_BY_OPEN_STASH + )) .and_then(|mut stmt| { let rows = stmt.query_map(params![before], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) @@ -320,7 +340,11 @@ fn completed_stashes(db: &DbManager, cutoff: Option<&str>) -> Vec<(String, Optio }), None => db .conn - .prepare("SELECT id, context_id FROM stashes WHERE completed = 1 AND deleted = 0") + .prepare(&format!( + "SELECT s.id, s.context_id FROM stashes s \ + WHERE s.completed = 1 AND s.deleted = 0 {}", + NOT_REFERENCED_BY_OPEN_STASH + )) .and_then(|mut stmt| { let rows = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) @@ -344,7 +368,13 @@ pub fn perform_startup_cleanup(db: &mut DbManager, settings: &Settings) -> usize let stale = completed_stashes(db, None); log::info!("Startup cleanup: clearing {} completed stash(es)", stale.len()); purge_stash_files(db, &stale); - let _ = db.delete_completed_stashes(None); + // One by one rather than `delete_completed_stashes`, which clears every + // completed stash and would ignore the references that kept some back. + for (id, _) in &stale { + if let Err(e) = db.delete_stash(id) { + log::error!("Cleanup: could not delete stash {}: {}", id, e); + } + } stale.len() } "after-n-days" => { @@ -377,6 +407,31 @@ pub fn perform_startup_cleanup(db: &mut DbManager, settings: &Settings) -> usize } } +/// Write the full text of a referenced stash that was too long to copy inline, and +/// return where it went. +/// +/// The file lives in the referring stash's own cache folder, under `refs/`, so it is +/// removed with that stash like any attachment. It is rewritten on every copy, so it +/// always holds the target as it was when the copy was made. +#[tauri::command] +pub async fn write_reference_file( + context_id: String, + stash_id: String, + target_id: String, + content: String, +) -> Result { + let dir = get_stash_cache_path(&stash_id, Some(&context_id)).join("refs"); + let path = dir.join(format!("{}.md", safe_component(&target_id))); + tauri::async_runtime::spawn_blocking(move || { + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + fs::write(&path, content).map_err(|e| e.to_string())?; + Ok::<_, String>(path.to_string_lossy().into_owned()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(UiError::from) +} + #[tauri::command] pub async fn save_stashes(state: State<'_, Arc>, stashes_list: Vec) -> Result<(), UiError> { // This is used for REORDERING, which rewrites a row per visible stash. @@ -999,3 +1054,84 @@ pub async fn import_positions( .map_err(|e| e.to_string()) .map_err(UiError::from) } + +#[cfg(test)] +mod reference_cleanup_tests { + use super::*; + use rusqlite::Connection; + + const A: &str = "aaaaaaaa-0000-4000-8000-000000000001"; + const B: &str = "bbbbbbbb-0000-4000-8000-000000000002"; + const C: &str = "cccccccc-0000-4000-8000-000000000003"; + + fn db() -> DbManager { + let db = DbManager { conn: Connection::open_in_memory().unwrap() }; + db.init_tables().unwrap(); + db + } + + fn insert(db: &DbManager, id: &str, content: &str, completed: bool) { + db.conn + .execute( + "INSERT INTO stashes (id, context_id, content, files, created_at, completed, completed_at) \ + VALUES (?1, 'default', ?2, '[]', '2026-01-01T00:00:00Z', ?3, ?4)", + params![ + id, + content, + completed, + completed.then_some("2026-01-02T00:00:00Z") + ], + ) + .unwrap(); + } + + fn clearable(db: &DbManager) -> Vec { + let mut ids: Vec = completed_stashes(db, Some("2026-06-01T00:00:00Z")) + .into_iter() + .map(|(id, _)| id) + .collect(); + ids.sort(); + ids + } + + #[test] + fn an_open_referrer_keeps_a_completed_stash() { + let db = db(); + insert(&db, A, &format!("see [b](stash:{B})"), false); + insert(&db, B, "done", true); + assert!(clearable(&db).is_empty()); + } + + #[test] + fn a_completed_referrer_keeps_nothing() { + let db = db(); + insert(&db, A, &format!("see [b](stash:{B})"), true); + insert(&db, B, "done", true); + assert_eq!(clearable(&db), vec![A.to_string(), B.to_string()]); + } + + #[test] + fn protection_reaches_one_level_only() { + // A (open) -> B (completed) -> C (completed): B is kept for A, and C, referenced + // only by the completed B, clears on schedule. + let db = db(); + insert(&db, A, &format!("[b](stash:{B})"), false); + insert(&db, B, &format!("[c](stash:{C})"), true); + insert(&db, C, "old", true); + assert_eq!(clearable(&db), vec![C.to_string()]); + } + + #[test] + fn a_deleted_referrer_or_another_context_keeps_nothing() { + let db = db(); + insert(&db, A, &format!("[b](stash:{B})"), false); + insert(&db, B, "done", true); + db.conn.execute("UPDATE stashes SET deleted = 1 WHERE id = ?1", params![A]).unwrap(); + assert_eq!(clearable(&db), vec![B.to_string()]); + + db.conn + .execute("UPDATE stashes SET deleted = 0, context_id = 'other' WHERE id = ?1", params![A]) + .unwrap(); + assert_eq!(clearable(&db), vec![B.to_string()]); + } +} diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 55a7f08..caf8349 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -287,6 +287,10 @@ fn build_markdown( for stash in group.iter() { out.push_str(&format!("### {}\n\n", format_heading_date(&stash.created_at))); + // The id, so references between stashes in this archive can be pointed at + // the new ids an import gives them. A comment renders as nothing, so the + // document still reads cleanly, and older builds import it as invisible text. + out.push_str(&format!("{}{} -->\n\n", STASH_ID_MARKER, stash.id)); if !stash.content.trim().is_empty() { out.push_str(stash.content.trim_end()); @@ -369,6 +373,35 @@ fn strip_archive_prefix(name: &str) -> String { /// Deliberately reproduces the previous parser's behaviour, quirks included: blank lines /// inside a stash's content are dropped, and a bare `---` ends the stash. Changing either /// would change how already-exported archives import, which is a separate decision. +/// Opens the line under each stash heading that records the stash's id. +const STASH_ID_MARKER: &str = "")?; + (!id.is_empty() && !id.contains(char::is_whitespace)).then_some(id) +} + +/// Point `stash:` references at the ids an import gave their targets. +/// +/// Only targets inside the archive are rewritten. A reference to anything else keeps +/// its id, which still resolves if that stash exists on this device. +fn remap_references(stashes: &mut [StashItem], new_ids: &HashMap) { + if new_ids.is_empty() { + return; + } + for stash in stashes.iter_mut() { + if !stash.content.contains("stash:") { + continue; + } + let mut content = stash.content.clone(); + for (old, new) in new_ids { + content = content.replace(&format!("(stash:{})", old), &format!("(stash:{})", new)); + } + stash.content = content; + } +} + fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument { let mut metadata = ArchiveMetadata::default(); let mut body = content; @@ -391,6 +424,7 @@ fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument { let mut files: Vec = Vec::new(); let mut in_attachments = false; let mut section_completed = false; + let mut new_ids: HashMap = HashMap::new(); // Close the stash under construction and push it. macro_rules! flush { @@ -450,6 +484,13 @@ fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument { continue; } + if content_lines.is_empty() { + if let (Some(old), Some(stash)) = (parse_stash_id_marker(line), current.as_ref()) { + new_ids.insert(old.to_lowercase(), stash.id.clone()); + continue; + } + } + if line.starts_with("**Attachments:**") { in_attachments = true; continue; @@ -473,6 +514,7 @@ fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument { } flush!(); + remap_references(&mut stashes, &new_ids); ParsedDocument { stashes, @@ -1063,6 +1105,29 @@ mod tests { assert!(done.created_at.starts_with("2026-08-17T09:30:00")); } + #[test] + fn references_inside_the_archive_follow_their_targets_to_new_ids() { + let a = "aaaaaaaa-0000-4000-8000-000000000001"; + let b = "bbbbbbbb-0000-4000-8000-000000000002"; + let outside = "cccccccc-0000-4000-8000-000000000003"; + let stashes = vec![ + stash(a, &format!("see [b](stash:{b}) and [c](stash:{outside})"), "2026-08-18T10:00:00Z", false), + stash(b, "the target", "2026-08-17T09:30:00Z", true), + ]; + + let md = build_markdown("Work", &metadata(), &stashes, None, Utc::now()); + let parsed = parse_markdown(&md, "ctx"); + + let referrer = parsed.stashes.iter().find(|s| !s.completed).unwrap(); + let target = parsed.stashes.iter().find(|s| s.completed).unwrap(); + assert_ne!(target.id, b, "an import mints new ids"); + assert_eq!(target.content, "the target", "the id marker is not content"); + assert_eq!( + referrer.content, + format!("see [b](stash:{}) and [c](stash:{outside})", target.id) + ); + } + /// Non-ASCII names are left intact rather than mistaken for a prefixed one. #[test] fn a_non_ascii_attachment_name_keeps_its_leading_characters() { diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index e98abfd..cbcb94a 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -78,6 +78,7 @@ TAGS: ) point to other stashes: copy every one EXACTLY, unchanged. "#; pub fn get_system_prompt_path() -> PathBuf { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 27f7374..dff8e88 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "stashpad", - "version": "1.8.10", + "version": "1.9.0", "identifier": "org.stashpad", "build": { "frontendDist": "../dist", diff --git a/src/App.svelte b/src/App.svelte index 45093d3..21ddd67 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -64,6 +64,7 @@ let movingStash = $state(null); let newlyAddedStashId = $state(null); let allTags = $state([]); + let refCandidates = $state([]); let autoDetectedWindowTitle = $state(undefined); // Draft state persistence @@ -775,6 +776,7 @@ bind:content={editorDraft} bind:files={editorFiles} availableTags={allTags} + {refCandidates} pasteAsAttachmentThreshold={settings.pasteAsAttachmentThreshold ?? 500} resizeImages={settings.resizeImages ?? true} @@ -793,6 +795,8 @@ contextSelectorOpen = true; }} bind:allTags + bind:refCandidates + onSwitchContext={(id) => selectContext(id)} stripTagsOnCopy={settings.stripTagsOnCopy ?? true} aiConfig={settings.aiConfig} {autoDetectedWindowTitle} diff --git a/src/app.css b/src/app.css index 034092d..d87f47c 100644 --- a/src/app.css +++ b/src/app.css @@ -291,6 +291,77 @@ border-color: rgba(255, 255, 255, 0.2); } +/* References to other stashes, rendered via safeParse() / injectRefs(). */ +.stash-ref { + display: inline-flex !important; + align-items: center; + gap: 0.25rem; + max-width: 100%; + border-radius: 0.375rem; + border: 1px solid color-mix(in srgb, var(--primary), transparent 70%); + padding: 0 0.375rem; + margin: 0 0.1em; + vertical-align: baseline; + background-color: color-mix(in srgb, var(--primary), transparent 90%); + color: var(--primary); + font-weight: 500; + text-decoration: none; + cursor: pointer; +} + +/* An arrow, matching the `>>` that inserts a reference. */ +.stash-ref::before { + content: "→"; + opacity: 0.7; +} + +.stash-ref[role="link"]:hover, +.stash-ref[role="link"]:focus-visible { + background-color: color-mix(in srgb, var(--primary), transparent 80%); + outline: none; +} + +.stash-ref-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stash-ref-note { + font-size: 0.85em; + font-weight: 400; + opacity: 0.7; + white-space: nowrap; +} + +.stash-ref-completed .stash-ref-label { + text-decoration: line-through; +} + +.stash-ref-completed::before { + content: "✓"; +} + +.stash-ref-other_context { + border-style: dashed; +} + +.stash-ref-gone { + border-style: dashed; + border-color: var(--border); + background-color: transparent; + color: var(--muted-foreground); + font-style: italic; + cursor: default; +} + +/* Brief highlight on a stash revealed by clicking a reference to it. */ +.stash-ref-target { + border-radius: 0.5rem; + box-shadow: 0 0 0 2px var(--primary); + transition: box-shadow 0.3s; +} + /* External links in prose containers: Cmd/Ctrl+Click to open */ diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b4ef105..0995844 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -36,6 +36,12 @@ Minimize2, } from "lucide-svelte"; import { getCaretCoordinates } from "$lib/utils/caret"; + import { + findRefTrigger, + makeRefLink, + rankRefCandidates, + refLabel, + } from "$lib/utils/stash-refs"; import { applyWrap, applyCode, @@ -75,6 +81,7 @@ saveLabel, autoFocus = false, availableTags = [], + refCandidates = [], pasteAsAttachmentThreshold = 500, resizeImages = true, minHeight, @@ -93,6 +100,8 @@ saveLabel?: string; autoFocus?: boolean; availableTags?: string[]; + /** Stashes of this context that `>>` can reference */ + refCandidates?: StashItem[]; /** Number of bytes before pasted text becomes an attachment. 0 = ask user */ pasteAsAttachmentThreshold?: number; resizeImages?: boolean; @@ -135,6 +144,12 @@ let triggerStart = $state(null); let hoveringSuggestions = $state(false); + // Stash reference picker, opened by `>>` + let showRefSuggestions = $state(false); + let refSuggestions = $state([]); + let focusedRefIndex = $state(0); + let refTriggerStart = $state(null); + // File preview state let previewModalOpen = $state(false); let selectedPreviewFilePath = $state(""); @@ -331,6 +346,50 @@ function handleInput() { checkForTagTrigger(); + checkForRefTrigger(); + } + + function checkForRefTrigger() { + if (!textareaRef) return; + + const end = getCursorParams().end; + const trigger = findRefTrigger(content.slice(0, end)); + if (!trigger) { + showRefSuggestions = false; + return; + } + + refSuggestions = rankRefCandidates( + trigger.query, + refCandidates, + existingStashId, + ); + if (refSuggestions.length === 0) { + showRefSuggestions = false; + return; + } + showSuggestions = false; + showRefSuggestions = true; + focusedRefIndex = 0; + refTriggerStart = trigger.start; + updateSuggestionPosition(end); + } + + function insertRef(target: StashItem) { + if (!textareaRef || refTriggerStart === null) return; + + const before = content.slice(0, refTriggerStart); + const after = content.slice(textareaRef.selectionEnd); + const link = makeRefLink(target); + content = before + link + " " + after; + + const newCursorPos = before.length + link.length + 1; + showRefSuggestions = false; + + setTimeout(() => { + textareaRef?.focus(); + textareaRef?.setSelectionRange(newCursorPos, newCursorPos); + }, 0); } function checkForTagTrigger() { @@ -713,6 +772,32 @@ } function handleKeydown(e: KeyboardEvent) { + if (showRefSuggestions && refSuggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + focusedRefIndex = (focusedRefIndex + 1) % refSuggestions.length; + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + focusedRefIndex = + (focusedRefIndex - 1 + refSuggestions.length) % refSuggestions.length; + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (refSuggestions[focusedRefIndex]) { + insertRef(refSuggestions[focusedRefIndex]); + } + return; + } + if (e.key === "Escape") { + e.preventDefault(); + showRefSuggestions = false; + return; + } + } + if (showSuggestions && filteredTags.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -882,7 +967,6 @@ let tooltipX = $state(0); let tooltipY = $state(0); - let xOffset = $state(0); let showBelow = $state(false); function updateTooltipPosition(target: HTMLElement) { @@ -905,24 +989,7 @@ showBelow = false; tooltipY = rect.top - TOOLTIP_OFFSET; } - - // Calculate horizontal offset to keep tooltip centered on element - const TOOLTIP_MAX_WIDTH = 280; - const viewportPadding = 8; - - // Calculate if tooltip would overflow - const tooltipLeft = centerX - TOOLTIP_MAX_WIDTH / 2; - const tooltipRight = centerX + TOOLTIP_MAX_WIDTH / 2; - - if (tooltipLeft < viewportPadding) { - // Would overflow left - xOffset = tooltipLeft - viewportPadding; - } else if (tooltipRight > window.innerWidth - viewportPadding) { - // Would overflow right - xOffset = tooltipRight - (window.innerWidth - viewportPadding); - } else { - xOffset = 0; - } + // Keeping it inside the window horizontally is Tooltip's job: it knows its width. } /** @@ -1207,7 +1274,10 @@ onpaste={handlePaste} onblur={() => setTimeout(() => { - if (!hoveringSuggestions) showSuggestions = false; + if (!hoveringSuggestions) { + showSuggestions = false; + showRefSuggestions = false; + } }, 200)} use:focusOnMount > @@ -1237,6 +1307,39 @@ {/if} + {#if showRefSuggestions} +
(hoveringSuggestions = true)} + onmouseleave={() => (hoveringSuggestions = false)} + role="listbox" + aria-label={$_("editor.referenceStash")} + tabindex="-1" + > + {#each refSuggestions as candidate, i (candidate.id)} + + {/each} +
+ {/if} +
@@ -1294,7 +1397,6 @@ x={tooltipX} y={tooltipY} position={showBelow ? "bottom" : "top"} - {xOffset} > {#snippet children()} {#if isLoadingHoverPreview} diff --git a/src/lib/components/FilePreviewTooltip.svelte b/src/lib/components/FilePreviewTooltip.svelte index cbc2871..f063875 100644 --- a/src/lib/components/FilePreviewTooltip.svelte +++ b/src/lib/components/FilePreviewTooltip.svelte @@ -74,7 +74,6 @@ let error = $state(""); let tooltipX = $state(0); let tooltipY = $state(0); - let xOffset = $state(0); let showBelow = $state(false); let hoverTimeout: ReturnType | null = null; @@ -170,24 +169,7 @@ showBelow = false; tooltipY = rect.top - TOOLTIP_OFFSET; } - - // Calculate horizontal offset to keep tooltip centered on element - const TOOLTIP_MAX_WIDTH = 280; - const viewportPadding = 8; - - // Calculate if tooltip would overflow - const tooltipLeft = centerX - TOOLTIP_MAX_WIDTH / 2; - const tooltipRight = centerX + TOOLTIP_MAX_WIDTH / 2; - - if (tooltipLeft < viewportPadding) { - // Would overflow left - xOffset = tooltipLeft - viewportPadding; - } else if (tooltipRight > window.innerWidth - viewportPadding) { - // Would overflow right - xOffset = tooltipRight - (window.innerWidth - viewportPadding); - } else { - xOffset = 0; - } + // Keeping it inside the window horizontally is Tooltip's job: it knows its width. } function handleDragStart(event: DragEvent) { @@ -256,7 +238,6 @@ x={tooltipX} y={tooltipY} position={showBelow ? "bottom" : "top"} - {xOffset} > {#snippet children()} {#if isLoading} diff --git a/src/lib/components/Queue.svelte b/src/lib/components/Queue.svelte index 358c33b..e108d56 100644 --- a/src/lib/components/Queue.svelte +++ b/src/lib/components/Queue.svelte @@ -59,6 +59,11 @@ matchesFilters, type AttachmentFilter, } from "$lib/utils/stash-filters"; + import { + parseRefIds, + resolveRef, + type ResolvedRef, + } from "$lib/utils/stash-refs"; let { transferMode, @@ -69,6 +74,8 @@ newStashId, onStashHandled, allTags = $bindable([]), + refCandidates = $bindable([]), + onSwitchContext, stripTagsOnCopy = true, aiConfig, autoDetectedWindowTitle, @@ -81,6 +88,10 @@ newStashId?: string | null; onStashHandled?: () => void; allTags?: string[]; + /** Live stashes of the current context, offered by the `>>` picker */ + refCandidates?: StashItem[]; + /** Switch the queue to another context, to follow a reference there */ + onSwitchContext?: (contextId: string) => void; stripTagsOnCopy?: boolean; aiConfig?: AIConfig; /** Window title from auto-detection (only set when auto-detection matched) */ @@ -265,6 +276,65 @@ showMenu = false; } + // References between stashes. Resolved against every live stash this device has, + // so a target moved to another context is still found and can be followed. + const resolveStashRef = (id: string): ResolvedRef => + resolveRef(id, stashes, currentContextId); + + const contextName = (id: string): string => + contexts.find((c) => c.id === id)?.name ?? id; + + $effect(() => { + refCandidates = [...activeStashes, ...completedStashes].filter( + (s) => !s.isDndShadowItem, + ); + }); + + /** How many other live stashes reference the given one. */ + function referrerCount(id: string): number { + return stashes.filter( + (s) => s.id !== id && !s.deleted && parseRefIds(s.content).includes(id), + ).length; + } + + /** A stash to reveal once the context switched to for it has loaded. */ + let pendingRevealId = $state(null); + + /** Follow a reference: bring its target into view, switching context if needed. */ + function followRef(ref: ResolvedRef) { + if (!ref.stash) return; + if (ref.state === "other_context") { + pendingRevealId = ref.stash.id; + onSwitchContext?.(ref.stash.contextId); + return; + } + revealStash(ref.stash.id); + } + + async function revealStash(id: string) { + const target = stashes.find((s) => s.id === id); + if (!target) return; + + // A filter hiding the target would make the click look broken. + if ( + hasActiveFilters && + !matchesFilters(target, selectedTags, selectedAttachmentFilters) + ) { + selectedTags = []; + selectedAttachmentFilters = []; + } + if (target.completed) completedCollapsed = false; + await tick(); + + const el = scrollContainer?.querySelector( + `[data-stash-id="${id}"]`, + ) as HTMLElement | null; + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("stash-ref-target"); + setTimeout(() => el.classList.remove("stash-ref-target"), 1500); + } + // Sync all tags (Current Context Only) - counts only from active stashes $effect(() => { const tags = new Set(); @@ -390,6 +460,16 @@ } }); + // Declared after the two context effects above on purpose: a switch resets the + // collapsed state and the scroll position, and the reveal has to come after both. + $effect(() => { + currentContextId; + const id = untrack(() => pendingRevealId); + if (!id) return; + pendingRevealId = null; + tick().then(() => revealStash(id)); + }); + $effect(() => { // Trigger load when refreshTrigger changes refreshTrigger; @@ -946,6 +1026,10 @@ availableTags={allTags} {aiConfig} {currentContext} + resolveRef={resolveStashRef} + {contextName} + onReferenceClick={followRef} + {refCandidates} {autoDetectedWindowTitle} /> {/if} @@ -1047,6 +1131,10 @@ enhancedContent, )} {currentContext} + resolveRef={resolveStashRef} + {contextName} + onReferenceClick={followRef} + {refCandidates} {autoDetectedWindowTitle} />
@@ -1068,7 +1156,11 @@ 0 + ? $_("stashRef.deleteReferenced", { + values: { count: referrerCount(stashToDelete) }, + }) + : $_("stashCard.deleteStashConfirm")} confirmText={$_("common.delete")} variant="destructive" onConfirm={() => { @@ -1083,7 +1175,9 @@ referrerCount(s.id) > 0) + ? `${$_("queue.deleteAllCompleted")} ${$_("stashRef.clearReferenced")}` + : $_("queue.deleteAllCompleted")} confirmText={$_("common.delete")} variant="destructive" onConfirm={() => { diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 00fd0ad..77db03e 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -1145,6 +1145,14 @@
{$_("settings.clearCompleted.description")}
+ {#if (settings.clearCompletedStrategy ?? "never") !== "never"} +
+ {$_("settings.clearCompleted.referencedHint")} +
+ {/if}
{#each ["never", "on-close", "after-n-days"] as strategy} diff --git a/src/lib/components/StashCard.svelte b/src/lib/components/StashCard.svelte index 1ab438d..2eb2fd0 100644 --- a/src/lib/components/StashCard.svelte +++ b/src/lib/components/StashCard.svelte @@ -58,7 +58,19 @@ import { stat } from "@tauri-apps/plugin-fs"; import { open, message } from "@tauri-apps/plugin-dialog"; import { getRelativeTime } from "$lib/utils/date"; - import { safeParse, isHexColor, extractTagsAndColors } from "$lib/utils/markdown"; + import { + safeParse, + isHexColor, + extractTagsAndColors, + type RefDisplay, + } from "$lib/utils/markdown"; + import { + formatCopyRefs, + planCopyRefs, + refLabel, + type ResolvedRef, + } from "$lib/utils/stash-refs"; + import StashRefPreview from "./StashRefPreview.svelte"; import ActionButton from "./ActionButton.svelte"; import TagBadge from "./TagBadge.svelte"; import ColorBadge from "./ColorBadge.svelte"; @@ -88,6 +100,10 @@ aiConfig, currentContext, autoDetectedWindowTitle, + resolveRef, + contextName, + onReferenceClick, + refCandidates = [], } = $props<{ item: StashItem; mode: "Drag" | "Copy"; @@ -111,6 +127,14 @@ currentContext?: Context; /** Window title from auto-detection (only set when auto-detection matched) */ autoDetectedWindowTitle?: string; + /** Resolve a `stash:` reference in this stash's content */ + resolveRef?: (id: string) => ResolvedRef; + /** Display name of a context, for references to a stash moved elsewhere */ + contextName?: (id: string) => string; + /** Follow a reference to the stash it points at */ + onReferenceClick?: (ref: ResolvedRef) => void; + /** Stashes the editor's `>>` picker offers */ + refCandidates?: StashItem[]; }>(); const adapter = new DesktopStorageAdapter(); @@ -188,6 +212,87 @@ } } + // How each reference chip reads. Derived from the resolver, which reads the + // queue's stashes, so a chip updates when its target is completed or edited. + function displayRef(id: string): RefDisplay { + const ref = resolveRef?.(id); + if (!ref) return { state: "unknown" }; + if (!ref.stash) return { state: "gone", note: $_("stashRef.gone") }; + const label = refLabel(ref.stash.content) || undefined; + if (ref.state === "other_context") { + return { + state: ref.state, + label, + note: $_("stashRef.inContext", { + values: { + name: contextName?.(ref.stash.contextId) ?? ref.stash.contextId, + }, + }), + }; + } + return { state: ref.state, label }; + } + + let renderedContent = $derived(safeParse(displayContent, displayRef)); + + function refIdAt(target: EventTarget | null): string | null { + const el = (target as HTMLElement | null)?.closest?.("[data-ref-id]"); + return el?.getAttribute("data-ref-id") ?? null; + } + + /** Follow a clicked chip, and keep the click from copying the card. */ + function handleContentClick(e: MouseEvent) { + const id = refIdAt(e.target); + if (!id) return; + e.stopPropagation(); + e.preventDefault(); + hideRefPreview(); + const ref = resolveRef?.(id); + if (ref && ref.state !== "gone") onReferenceClick?.(ref); + } + + function handleContentKeydown(e: KeyboardEvent) { + if (e.key !== "Enter" && e.key !== " ") return; + const id = refIdAt(e.target); + if (!id) return; + e.stopPropagation(); + e.preventDefault(); + const ref = resolveRef?.(id); + if (ref && ref.state !== "gone") onReferenceClick?.(ref); + } + + // One hover card per stash card, moved to whichever chip is hovered or focused. + let hoveredRef = $state(null); + let refPreviewX = $state(0); + let refPreviewY = $state(0); + let refPreviewBelow = $state(false); + let refHoverTimeout: ReturnType | undefined; + let refHoverChip: Element | null = null; + + function showRefPreview(target: EventTarget | null) { + const chip = (target as HTMLElement | null)?.closest?.("[data-ref-id]") ?? null; + if (chip === refHoverChip) return; + hideRefPreview(); + if (!chip) return; + refHoverChip = chip; + const id = chip.getAttribute("data-ref-id"); + refHoverTimeout = setTimeout(() => { + if (!id || !resolveRef) return; + const rect = chip.getBoundingClientRect(); + refPreviewX = rect.left + rect.width / 2; + refPreviewBelow = rect.top < window.innerHeight / 2; + refPreviewY = refPreviewBelow ? rect.bottom + 8 : rect.top - 8; + hoveredRef = resolveRef(id); + }, 300); + } + + function hideRefPreview() { + clearTimeout(refHoverTimeout); + refHoverTimeout = undefined; + refHoverChip = null; + hoveredRef = null; + } + let stashData = $derived(extractTagsAndColors(item.content)); let stashTags = $derived(() => stashData.tags); let stashColors = $derived(() => stashData.colors); @@ -207,18 +312,45 @@ // Strip tags if setting is enabled (and not inverted) or disabled (and inverted) const shouldStrip = stripTagsOnCopy ? !invert : invert; - - if (shouldStrip) { - content = content + const stripTags = (text: string) => + text .replace(/TAGS:[\s\S]*$/, "") .replace(/#[\w-]+/g, "") .trim(); + + if (shouldStrip) { + content = stripTags(content); + } + + // Referenced stashes travel with the copy, one level deep and size-capped. One + // too long to inline is written to a file beside this stash's attachments, so + // the AI tool can read it when it needs to without it flooding the clipboard. + const { body, refs } = resolveRef + ? planCopyRefs(content, resolveRef, shouldStrip ? stripTags : undefined) + : { body: content, refs: [] }; + const refFiles = new Map(); + for (const ref of refs) { + if (!ref.full) continue; + try { + refFiles.set( + ref.id, + await adapter.writeReferenceFile(item.contextId, item.id, ref.id, ref.full), + ); + } catch (err) { + console.error("Failed to write referenced stash to a file", err); + } } - const text = - item.attachments.length > 0 - ? `${content}\n\n---\n# SYSTEM CONTEXT - LOCAL FILES\n${item.attachments.map((a) => a.filePath).join("\n")}` - : content; + let text = body; + const refSection = formatCopyRefs(refs, refFiles); + if (refSection) text += `\n\n---\n${refSection}`; + const localFiles = [ + ...item.attachments.map((a) => a.filePath), + ...refFiles.values(), + ]; + if (localFiles.length > 0) { + text += `\n\n---\n# SYSTEM CONTEXT - LOCAL FILES\n${localFiles.join("\n")}`; + } await adapter.copyToClipboard(text); copied = true; setTimeout(() => (copied = false), 2000); @@ -506,6 +638,7 @@ saveLabel={$_("common.save")} autoFocus={true} {availableTags} + {refCandidates} minHeight={stashHeight} />
@@ -587,6 +720,9 @@ class="relative {isClamped ? 'overflow-hidden' : ''}" style:max-height={isClamped ? `${COLLAPSED_MAX_PX}px` : undefined} > + +
showRefPreview(e.target)} + onmouseleave={hideRefPreview} + onfocusin={(e) => showRefPreview(e.target)} + onfocusout={hideRefPreview} role="presentation" > - {@html safeParse(displayContent)} + {@html renderedContent}
{#if isClamped}
+ + + + + + + {#snippet children()} +
+
+ {status} + {#if attachmentCount > 0} + + + + {attachmentCount} + + {/if} +
+ {#if previewHtml && ref?.state !== "gone"} +
+ {@html previewHtml} +
+ {/if} +
+ {/snippet} +
+ + diff --git a/src/lib/components/Tooltip.svelte b/src/lib/components/Tooltip.svelte index f6fa539..a60d5ac 100644 --- a/src/lib/components/Tooltip.svelte +++ b/src/lib/components/Tooltip.svelte @@ -23,6 +23,9 @@ * Renders to document.body to avoid overflow clipping * Handles positioning, arrow, and styling * Content is provided via slot + * + * `x` is where the arrow points. The card is centred on it, and shifted sideways by + * however far it would otherwise leave the window. */ let { @@ -30,20 +33,41 @@ x = 0, y = 0, position = "top", - xOffset = 0, children, } = $props<{ visible: boolean; x: number; y: number; position?: "top" | "bottom"; - xOffset?: number; children: Snippet; }>(); const showBelow = $derived(position === "bottom"); + + /** Gap kept between the card and either edge of the window. */ + const VIEWPORT_PADDING = 8; + + let innerWidth = $state(0); + let cardWidth = $state(0); + + // Measured, not assumed. Each caller used to guess the card was 280px wide and work + // out the shift itself - with the sign reversed, so a card near an edge was pushed + // further out of the window instead of back in. An image preview is wider than 280px + // anyway, so even the right sign left it hanging over the edge. + const xOffset = $derived.by(() => { + if (!cardWidth || !innerWidth) return 0; + const left = x - cardWidth / 2; + const right = x + cardWidth / 2; + if (left < VIEWPORT_PADDING) return VIEWPORT_PADDING - left; + if (right > innerWidth - VIEWPORT_PADDING) { + return innerWidth - VIEWPORT_PADDING - right; + } + return 0; + }); + + {#if visible}
{@render children()} diff --git a/src/lib/constants/ai-prompts.ts b/src/lib/constants/ai-prompts.ts index d465303..b2f7a56 100644 --- a/src/lib/constants/ai-prompts.ts +++ b/src/lib/constants/ai-prompts.ts @@ -46,4 +46,5 @@ TAGS: ) point to other stashes: copy every one EXACTLY, unchanged. `; diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 84b8318..861ad70 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -131,7 +131,8 @@ "description": "Erledigte Stashes automatisch entfernen", "never": "Nie", "onClose": "Beim Neustart", - "afterNDays": "Nach {days} Tagen" + "afterNDays": "Nach {days} Tagen", + "referencedHint": "Erledigte Stashes, auf die ein offener Stash noch verweist, bleiben erhalten, bis auch dieser erledigt ist." }, "clearCompletedDays": { "label": "Aufbewahrungsdauer", @@ -482,7 +483,17 @@ "regenerate": "Neu generieren", "keepEnhanced": "Beibehalten" }, + "stashRef": { + "stateOpen": "Offen", + "stateCompleted": "Erledigt", + "stateCompletedAt": "Erledigt {time}", + "inContext": "in {name}", + "gone": "gelöscht oder nicht verfügbar", + "deleteReferenced": "Diesen Stash löschen? {count, plural, one {# anderer Stash verweist darauf} other {# andere Stashes verweisen darauf}}, diese Verweise funktionieren danach nicht mehr.", + "clearReferenced": "Auf einige davon verweisen noch andere Stashes." + }, "editor": { + "referenceStash": "Auf einen Stash verweisen", "placeholder": "Stash hier eingeben… (Cmd+Enter zum Speichern)", "addStash": "Stashen", "saving": "Speichere…", diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 8c93484..7821744 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -131,7 +131,8 @@ "description": "Automatically remove completed stashes", "never": "Never", "onClose": "On Restart", - "afterNDays": "After {days} days" + "afterNDays": "After {days} days", + "referencedHint": "Completed stashes that an open stash still references are kept until that stash is completed too." }, "clearCompletedDays": { "label": "Days to Keep", @@ -482,7 +483,17 @@ "regenerate": "Regenerate", "keepEnhanced": "Keep current" }, + "stashRef": { + "stateOpen": "Open", + "stateCompleted": "Completed", + "stateCompletedAt": "Completed {time}", + "inContext": "in {name}", + "gone": "deleted or unavailable", + "deleteReferenced": "Delete this stash? {count, plural, one {# other stash references it} other {# other stashes reference it}}, and those references will stop working.", + "clearReferenced": "Some of them are still referenced by other stashes." + }, "editor": { + "referenceStash": "Reference a stash", "placeholder": "Type your stash here… (Cmd+Enter to save)", "addStash": "Stash", "saving": "Saving…", diff --git a/src/lib/services/desktop-adapter.ts b/src/lib/services/desktop-adapter.ts index 576be9c..4ee7eee 100644 --- a/src/lib/services/desktop-adapter.ts +++ b/src/lib/services/desktop-adapter.ts @@ -145,6 +145,10 @@ export class DesktopStorageAdapter implements IStorageService { await invoke('copy_to_clipboard', { text }); } + async writeReferenceFile(contextId: string, stashId: string, targetId: string, content: string): Promise { + return await invoke('write_reference_file', { contextId, stashId, targetId, content }); + } + async startDrag(text: string, files: string[]): Promise { await invoke('start_drag', { text, files }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 4099747..ea46163 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -174,6 +174,11 @@ export interface IStorageService { getPreviousAppInfo(): Promise; getSmartTransferTarget(): Promise<'GUI' | 'CLI'>; copyToClipboard(text: string): Promise; + /** + * Write the full text of a referenced stash that was too long to copy inline, into + * the referring stash's cache folder, and return the file's path. + */ + writeReferenceFile(contextId: string, stashId: string, targetId: string, content: string): Promise; // WebSockets connectWebSocket(): Promise; disconnectWebSocket(): Promise; diff --git a/src/lib/utils/__tests__/stash-refs.test.ts b/src/lib/utils/__tests__/stash-refs.test.ts new file mode 100644 index 0000000..3ff314e --- /dev/null +++ b/src/lib/utils/__tests__/stash-refs.test.ts @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright (C) 2026 Nico Wiedemann +// +// This file is part of Stashpad. +// Stashpad is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License, version 3, +// as published by the Free Software Foundation. +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU Affero General Public License for more details. + +import { describe, it, expect } from 'vitest'; +import type { StashItem } from '$lib/types'; +import { + findRefTrigger, + formatCopyRefs, + makeRefLink, + parseRefIds, + planCopyRefs, + rankRefCandidates, + refLabel, + resolveRef, + REF_COPY_LIMITS, +} from '../stash-refs'; +import { safeParse } from '../markdown'; + +const A = 'aaaaaaaa-0000-4000-8000-000000000001'; +const B = 'bbbbbbbb-0000-4000-8000-000000000002'; +const C = 'cccccccc-0000-4000-8000-000000000003'; + +function stash(id: string, content: string, extra: Partial = {}): StashItem { + return { + id, + content, + attachments: [], + createdAt: '2026-09-01T00:00:00Z', + contextId: 'work', + ...extra, + }; +} + +describe('parseRefIds', () => { + it('finds each referenced id once, in order', () => { + expect(parseRefIds(`[x](stash:${B}) then [y](stash:${A}) and [x](stash:${B})`)).toEqual([B, A]); + }); + + it('ignores links inside code', () => { + expect(parseRefIds('`[x](stash:' + B + ')`\n```\n[y](stash:' + A + ')\n```')).toEqual([]); + }); + + it('ignores other links and malformed ids', () => { + expect(parseRefIds('[x](https://example.com) [y](stash:not-a-uuid)')).toEqual([]); + }); +}); + +describe('refLabel / makeRefLink', () => { + it('takes the first meaningful line without markup or tags', () => { + expect(refLabel('\n## Fix the #parser crash\nmore')).toBe('Fix the crash'); + }); + + it('drops link syntax and brackets so the label cannot break the link', () => { + expect(refLabel(`see [other](stash:${A}) [x]`)).toBe('see other x'); + }); + + it('truncates long lines', () => { + const label = refLabel('x'.repeat(200)); + expect(label.length).toBeLessThanOrEqual(60); + expect(label.endsWith('…')).toBe(true); + }); + + it('writes a link the parser reads back', () => { + const link = makeRefLink(stash(B, 'Target stash')); + expect(link).toBe(`[Target stash](stash:${B})`); + expect(parseRefIds(link)).toEqual([B]); + }); +}); + +describe('resolveRef', () => { + const stashes = [ + stash(A, 'open one'), + stash(B, 'done one', { completed: true }), + stash(C, 'elsewhere', { contextId: 'home' }), + ]; + + it('tells every state apart', () => { + expect(resolveRef(A, stashes, 'work').state).toBe('open'); + expect(resolveRef(B, stashes, 'work').state).toBe('completed'); + expect(resolveRef(C, stashes, 'work').state).toBe('other_context'); + expect(resolveRef('dddddddd-0000-4000-8000-000000000004', stashes, 'work').state).toBe('gone'); + }); + + it('treats a deleted stash as gone', () => { + expect(resolveRef(A, [stash(A, 'x', { deleted: true })], 'work')).toEqual({ id: A, state: 'gone' }); + }); +}); + +describe('findRefTrigger', () => { + it('opens on >> at the start of a word', () => { + expect(findRefTrigger('see >>pars')).toEqual({ start: 4, query: 'pars' }); + expect(findRefTrigger('>>')).toEqual({ start: 0, query: '' }); + }); + + it('stays shut for shell redirects, quotes and conflict markers', () => { + expect(findRefTrigger('echo x >> log.txt')).toBeNull(); + expect(findRefTrigger('>> quoted')).toBeNull(); + expect(findRefTrigger('>>>>>>> main')).toBeNull(); + expect(findRefTrigger('>>>')).toBeNull(); + expect(findRefTrigger('a>>b')).toBeNull(); + }); + + it('stays shut inside code', () => { + expect(findRefTrigger('```\n>>x')).toBeNull(); + expect(findRefTrigger('run `>>x')).toBeNull(); + expect(findRefTrigger('```\ncode\n```\n>>x')).toEqual({ start: 13, query: 'x' }); + }); +}); + +describe('rankRefCandidates', () => { + const stashes = [ + stash(A, 'Parser crash', { completed: true }), + stash(B, 'Update the parser'), + stash(C, 'Unrelated'), + ]; + + it('puts open stashes first and filters by the query', () => { + expect(rankRefCandidates('pars', stashes).map((s) => s.id)).toEqual([B, A]); + }); + + it('leaves out the stash being edited', () => { + expect(rankRefCandidates('', stashes, B).map((s) => s.id)).toEqual([C, A]); + }); +}); + +describe('planCopyRefs / formatCopyRefs', () => { + const resolveIn = (stashes: StashItem[]) => (id: string) => resolveRef(id, stashes, 'work'); + + it('numbers the links and inlines their targets one level deep', () => { + const stashes = [ + stash(B, `Target text linking [c](stash:${C})`), + stash(C, 'never inlined'), + ]; + const { body, refs } = planCopyRefs(`Do it, see [label](stash:${B}).`, resolveIn(stashes)); + expect(body).toBe('Do it, see [label] (ref 1).'); + expect(refs).toHaveLength(1); + expect(refs[0].inline).toBe('Target text linking [c]'); + + const section = formatCopyRefs(refs, new Map()); + expect(section).toContain('# REFERENCED STASHES'); + expect(section).toContain('## Ref 1: Target text linking c'); + expect(section).not.toContain('never inlined'); + }); + + it('does not follow a cycle', () => { + const stashes = [stash(A, `a [b](stash:${B})`), stash(B, `b [a](stash:${A})`)]; + const { refs } = planCopyRefs(`[b](stash:${B})`, resolveIn(stashes)); + expect(refs.map((r) => r.id)).toEqual([B]); + }); + + it('notes gone and moved targets without their content', () => { + const stashes = [stash(C, 'Title\nsecret elsewhere', { contextId: 'home' })]; + const { refs } = planCopyRefs(`[old](stash:${A}) [moved](stash:${C})`, resolveIn(stashes)); + const section = formatCopyRefs(refs, new Map()); + expect(section).toContain('## Ref 1: old\n(This stash was deleted'); + expect(section).toContain('moved to another context'); + expect(section).not.toContain('secret elsewhere'); + }); + + it('truncates a long target and points at its file', () => { + const long = 'y'.repeat(REF_COPY_LIMITS.each + 1); + const { refs } = planCopyRefs(`[t](stash:${B})`, resolveIn([stash(B, long)])); + expect(refs[0].inline).toHaveLength(REF_COPY_LIMITS.preview); + expect(refs[0].full).toBe(long); + const section = formatCopyRefs(refs, new Map([[B, '/cache/refs/b.md']])); + expect(section).toContain('[Truncated. Full text: /cache/refs/b.md]'); + }); + + it('caps the total inlined across all references', () => { + const each = 'z'.repeat(REF_COPY_LIMITS.each); + const ids = [A, B, C, 'dddddddd-0000-4000-8000-000000000004', 'eeeeeeee-0000-4000-8000-000000000005']; + const stashes = ids.map((id) => stash(id, each)); + const content = ids.map((id) => `[x](stash:${id})`).join(' '); + const { refs } = planCopyRefs(content, resolveIn(stashes)); + const inlined = refs.reduce((n, r) => n + (r.inline?.length ?? 0), 0); + expect(refs.filter((r) => r.full)).toHaveLength(1); + expect(inlined).toBeLessThanOrEqual(REF_COPY_LIMITS.total + REF_COPY_LIMITS.preview); + }); + + it('applies the tag stripping to referenced text too', () => { + const { refs } = planCopyRefs( + `[t](stash:${B})`, + resolveIn([stash(B, 'fix it #urgent')]), + (t) => t.replace(/#[\w-]+/g, '').trim(), + ); + expect(refs[0].inline).toBe('fix it'); + }); +}); + +describe('safeParse with references', () => { + it('renders a stash link as a focusable chip with the live label', () => { + const html = safeParse(`see [stored](stash:${B})`, () => ({ state: 'open', label: 'Live