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
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions screenshots/mock-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export function installMockBackend(overrides: Partial<DemoState> = {}): 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:
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <nico.wiedemann@gmail.com>"]
repository = "https://github.com/EarMaster/stashpad"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
154 changes: 145 additions & 9 deletions src-tauri/src/stashes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,17 +301,37 @@ fn purge_stash_files(db: &DbManager, stashes: &[(String, Option<String>)]) {
}
}

/// 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:<id>)` links in the content (`src/lib/utils/stash-refs.ts`).
/// Matching `stash:<id>` 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<String>)> {
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<String>>(1)?))
Expand All @@ -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<String>>(1)?))
Expand All @@ -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" => {
Expand Down Expand Up @@ -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<String, UiError> {
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<DbState>>, stashes_list: Vec<StashItem>) -> Result<(), UiError> {
// This is used for REORDERING, which rewrites a row per visible stash.
Expand Down Expand Up @@ -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<String> {
let mut ids: Vec<String> = 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()]);
}
}
65 changes: 65 additions & 0 deletions src-tauri/src/transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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 = "<!-- stash-id: ";

/// The id recorded on a marker line, if this is one.
fn parse_stash_id_marker(line: &str) -> Option<&str> {
let id = line.trim().strip_prefix(STASH_ID_MARKER)?.strip_suffix(" -->")?;
(!id.is_empty() && !id.contains(char::is_whitespace)).then_some(id)
}

/// Point `stash:<old>` 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<String, String>) {
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;
Expand All @@ -391,6 +424,7 @@ fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument {
let mut files: Vec<String> = Vec::new();
let mut in_attachments = false;
let mut section_completed = false;
let mut new_ids: HashMap<String, String> = HashMap::new();

// Close the stash under construction and push it.
macro_rules! flush {
Expand Down Expand Up @@ -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;
Expand All @@ -473,6 +514,7 @@ fn parse_markdown(content: &str, context_id: &str) -> ParsedDocument {
}

flush!();
remap_references(&mut stashes, &new_ids);

ParsedDocument {
stashes,
Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ TAGS: <Only hashtags present in original input, space-separated. Omit section en
10. Make sure to preserve all aspects of the original input.
11. Follow the output format template exactly.
12. Return ONLY the enhanced prompt following the template. Do not include any meta-commentary or conversational filler.
13. Links of the form [label](stash:<id>) point to other stashes: copy every one EXACTLY, unchanged.
</rules>"#;

pub fn get_system_prompt_path() -> PathBuf {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading