Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/rust-lite-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
paths:
- "rust/**"
- "lite-rs/**"
- "src/**"
- "lite/**"
- "herdr-plugin.toml"
- "docs/rust-lite-parity-proof.md"
- "scripts/parity-lite.py"
- "scripts/parity-lite.sh"
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ type = "plugin_action"
command = "annotate.copy-context"
description = "copy annotations as context"

[[keys.command]]
key = "prefix+ctrl+a"
type = "plugin_action"
command = "annotate.copy-archive"
description = "copy annotations as context and archive them"

[[keys.command]]
key = "prefix+m"
type = "plugin_action"
Expand Down Expand Up @@ -106,6 +112,12 @@ type = "plugin_action"
command = "annotate.copy-context"
description = "copy annotations as context"

[[keys.command]]
key = "prefix+ctrl+a"
type = "plugin_action"
command = "annotate.copy-archive"
description = "copy annotations as context and archive them"

[[keys.command]]
key = "prefix+m"
type = "plugin_action"
Expand All @@ -130,6 +142,7 @@ herdr server reload-config
|---|---|
| `Ctrl+B A` | comment on the selected text · `Ctrl+S` saves |
| `Ctrl+B Shift+A` | copy all annotations as Markdown |
| `Ctrl+B Ctrl+A` | copy all annotations as Markdown, then archive them |
| `Ctrl+B M` | manage · `y` copy one · `c` copy all · `Shift+C` copy and archive · `Tab` archives (`y` copy · `u` restore · `d d` delete) |

### Review documents and agent replies
Expand Down
76 changes: 48 additions & 28 deletions docs/rust-lite-parity-proof.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/rust-lite-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ separately so test coverage is not confused with behavior observed inside Herdr.
JSON is mode 0600; pane-open failure removes it.
- [x] `export.ts` → `herdr-annotate copy-context`: loads newest first, formats Markdown, copies, and
sends the same singular/plural notifications and errors.
- [x] `export-archive.ts` → `herdr-annotate copy-archive`: injects the dependencies `manager.ts` passes
to `copyAndArchiveAnnotations`, maps the three outcomes to the same notifications, and exits 0 on an
empty store and 1 on a real failure.
- [x] `open-manager.ts` → `herdr-annotate manage`: requires `HERDR_PLUGIN_ROOT` and opens the same
focused 100×30 popup.
- [x] `editor.ts` → `herdr-annotate editor`: pending-file and invocation-context fallback, delete-on-
Expand All @@ -21,6 +24,8 @@ separately so test coverage is not confused with behavior observed inside Herdr.
permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior.
- [x] `lite-rs/herdr-plugin.toml` preserves plugin id `annotate`, action ids, pane ids, placements,
dimensions, contexts, and supported platform declarations. All commands are the one native binary.
`scripts/parity-lite.py` compares those declarations against `lite/herdr-plugin.toml` and the root
Full manifest field by field.

## Modules and compatibility boundaries

Expand Down Expand Up @@ -57,6 +62,7 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha
| `test/layout.test.ts` | `layout::tests` (3 grouped tests covering every assertion) |
| `test/store.test.ts` | `store::tests` (6 tests) |
| `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) |
| `test/export-archive.test.ts` | `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` |
| `test/manager-copy.test.ts` | `manager_copy::tests` (3 tests) |

Additional Rust-only coverage:
Expand Down
7 changes: 7 additions & 0 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ description = "Copy all saved annotations to the clipboard as Markdown."
contexts = ["global"]
command = ["bun", "src/export.ts"]

[[actions]]
id = "copy-archive"
title = "Copy annotations as context and archive them"
description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
contexts = ["global"]
command = ["bun", "src/export-archive.ts"]

[[actions]]
id = "manage"
title = "Manage annotations"
Expand Down
7 changes: 7 additions & 0 deletions lite-rs/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ description = "Copy all saved annotations to the clipboard as Markdown."
contexts = ["global"]
command = ["./bin/herdr-annotate.exe", "copy-context"]

[[actions]]
id = "copy-archive"
title = "Copy annotations as context and archive them"
description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
contexts = ["global"]
command = ["./bin/herdr-annotate.exe", "copy-archive"]

[[actions]]
id = "manage"
title = "Manage annotations"
Expand Down
7 changes: 7 additions & 0 deletions lite/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ description = "Copy all saved annotations to the clipboard as Markdown."
contexts = ["global"]
command = ["bun", "../src/export.ts"]

[[actions]]
id = "copy-archive"
title = "Copy annotations as context and archive them"
description = "Copy all saved annotations to the clipboard as Markdown, then archive them."
contexts = ["global"]
command = ["bun", "../src/export-archive.ts"]

[[actions]]
id = "manage"
title = "Manage annotations"
Expand Down
153 changes: 149 additions & 4 deletions rust/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
//! One native command boundary for the five Herdr entrypoints.
//! One native command boundary for the six Herdr entrypoints.

use std::cell::Cell;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use chrono::{SecondsFormat, Utc};
use serde_json::Value;
use uuid::Uuid;

use crate::archive_workflow::{
CopyAndArchiveDependencies, CopyAndArchiveOutcome, copy_and_archive_annotations,
};
use crate::clipboard::{read_clipboard, write_clipboard};
use crate::format::format_annotations;
use crate::handoff::take_default_handoff;
use crate::herdr::{notify, run_herdr};
use crate::paths::{normalize_windows_path, plugin_root, state_dir};
use crate::store::{load_annotations, newest_first_annotations};
use crate::store::{
append_archived_set, load_annotations, newest_first_annotations, remove_annotations_by_id,
};
use crate::types::{
PendingAnnotation, javascript_trim, parse_invocation_context, selected_text_from_invocation,
ArchivedAnnotationSet, PendingAnnotation, javascript_trim, parse_invocation_context,
selected_text_from_invocation,
};

const USAGE: &str = "Usage: herdr-annotate <capture|copy-context|editor|manage|manager>";
const USAGE: &str =
"Usage: herdr-annotate <capture|copy-context|copy-archive|editor|manage|manager>";

/// Dispatch one native binary subcommand.
pub fn run(args: &[String]) -> Result<(), String> {
Expand All @@ -29,6 +38,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
Some("copy-context") if args.len() == 1 => copy_context().inspect_err(|message| {
notify("Copy failed", Some(message));
}),
Some("copy-archive") if args.len() == 1 => copy_archive(),
Some("manage") if args.len() == 1 => manage().inspect_err(|message| {
notify("Unable to open annotations", Some(message));
}),
Expand Down Expand Up @@ -130,6 +140,77 @@ fn copy_context() -> Result<(), String> {
Ok(())
}

/// The notification and exit status one copy-and-archive action reports.
#[derive(Debug, Clone, PartialEq, Eq)]
struct CopyArchiveReport {
title: String,
body: String,
failure: bool,
}

/// Map one copy-and-archive outcome to the action's notification and exit status.
///
/// `loaded_empty` separates the nothing-to-do case from a real failure: both are `StayOpen`,
/// but an empty store is reported like `copy-context` and returns success.
fn copy_archive_report(outcome: CopyAndArchiveOutcome, loaded_empty: bool) -> CopyArchiveReport {
match outcome {
CopyAndArchiveOutcome::Close { archived_count } => CopyArchiveReport {
title: "Annotations copied and archived".to_owned(),
body: format!(
"{archived_count} annotation{} copied as Markdown and archived.",
if archived_count == 1 { "" } else { "s" }
),
failure: false,
},
CopyAndArchiveOutcome::ArchivedActiveRetained { message } => CopyArchiveReport {
title: "Copy and archive incomplete".to_owned(),
body: format!("Copied and archived, but active annotations remain: {message}"),
failure: true,
},
CopyAndArchiveOutcome::StayOpen { .. } if loaded_empty => CopyArchiveReport {
title: "No annotations".to_owned(),
body: "There is nothing to copy yet.".to_owned(),
failure: false,
},
CopyAndArchiveOutcome::StayOpen { message } => CopyArchiveReport {
title: "Copy and archive failed".to_owned(),
body: message,
failure: true,
},
}
}

fn copy_archive() -> Result<(), String> {
let Some(dir) = state_dir() else {
let message = "HERDR_PLUGIN_STATE_DIR is not set".to_owned();
notify("Copy and archive failed", Some(&message));
return Err(message);
};

let loaded_empty = Cell::new(false);
let outcome = copy_and_archive_annotations(CopyAndArchiveDependencies {
load_active: || {
let loaded = load_annotations(&dir);
if matches!(&loaded, Ok(active) if active.is_empty()) {
loaded_empty.set(true);
}
loaded
},
write_clipboard: |text: String| write_clipboard(&text),
save_archive: |archive: ArchivedAnnotationSet| append_archived_set(&dir, &archive),
remove_active: |ids: Vec<String>| remove_annotations_by_id(&dir, &ids),
create_archive_id: || Uuid::new_v4().to_string(),
now: now_iso,
});

let report = copy_archive_report(outcome, loaded_empty.get());
notify(&report.title, Some(&report.body));
if report.failure {
return Err(report.body);
}
Ok(())
}

fn manage() -> Result<(), String> {
let root = plugin_root().ok_or_else(|| "HERDR_PLUGIN_ROOT is not set".to_owned())?;
run_herdr(&[
Expand Down Expand Up @@ -188,5 +269,69 @@ mod tests {
run(&["capture".to_owned(), "extra".to_owned()]),
Err(USAGE.to_owned())
);
assert_eq!(
run(&["copy-archive".to_owned(), "extra".to_owned()]),
Err(USAGE.to_owned())
);
}

#[test]
fn copy_archive_maps_every_outcome_to_its_notification_and_exit_status() {
assert_eq!(
copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 1 }, false),
CopyArchiveReport {
title: "Annotations copied and archived".to_owned(),
body: "1 annotation copied as Markdown and archived.".to_owned(),
failure: false,
}
);
assert_eq!(
copy_archive_report(CopyAndArchiveOutcome::Close { archived_count: 3 }, false),
CopyArchiveReport {
title: "Annotations copied and archived".to_owned(),
body: "3 annotations copied as Markdown and archived.".to_owned(),
failure: false,
}
);
assert_eq!(
copy_archive_report(
CopyAndArchiveOutcome::StayOpen {
message: "Nothing to copy and archive.".to_owned(),
},
true,
),
CopyArchiveReport {
title: "No annotations".to_owned(),
body: "There is nothing to copy yet.".to_owned(),
failure: false,
}
);
assert_eq!(
copy_archive_report(
CopyAndArchiveOutcome::StayOpen {
message: "clipboard write failed".to_owned(),
},
false,
),
CopyArchiveReport {
title: "Copy and archive failed".to_owned(),
body: "clipboard write failed".to_owned(),
failure: true,
}
);
assert_eq!(
copy_archive_report(
CopyAndArchiveOutcome::ArchivedActiveRetained {
message: "store is busy".to_owned(),
},
false,
),
CopyArchiveReport {
title: "Copy and archive incomplete".to_owned(),
body: "Copied and archived, but active annotations remain: store is busy"
.to_owned(),
failure: true,
}
);
}
}
Loading
Loading