tui: persist composer history
This commit is contained in:
parent
ff5f985f70
commit
64b7ff7c7c
|
|
@ -1,4 +1,5 @@
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use std::path::Path;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use protocol::{
|
use protocol::{
|
||||||
|
|
@ -13,6 +14,9 @@ use crate::cache::FileCache;
|
||||||
use crate::command::{
|
use crate::command::{
|
||||||
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
||||||
};
|
};
|
||||||
|
use crate::composer_history::{
|
||||||
|
COMPOSER_INPUT_HISTORY_LIMIT, ComposerHistoryStore, segments_are_blank,
|
||||||
|
};
|
||||||
use crate::input::InputBuffer;
|
use crate::input::InputBuffer;
|
||||||
use crate::scroll::Scroll;
|
use crate::scroll::Scroll;
|
||||||
use crate::task::TaskStore;
|
use crate::task::TaskStore;
|
||||||
|
|
@ -107,8 +111,6 @@ impl QueuedInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPOSER_INPUT_HISTORY_LIMIT: usize = 100;
|
|
||||||
|
|
||||||
struct ComposerInputHistory {
|
struct ComposerInputHistory {
|
||||||
entries: VecDeque<Vec<Segment>>,
|
entries: VecDeque<Vec<Segment>>,
|
||||||
browse: Option<ComposerInputHistoryBrowse>,
|
browse: Option<ComposerInputHistoryBrowse>,
|
||||||
|
|
@ -127,18 +129,26 @@ impl ComposerInputHistory {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record(&mut self, segments: Vec<Segment>) {
|
fn with_entries(entries: VecDeque<Vec<Segment>>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries,
|
||||||
|
browse: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, segments: Vec<Segment>) -> bool {
|
||||||
if segments_are_blank(&segments) {
|
if segments_are_blank(&segments) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
self.browse = None;
|
self.browse = None;
|
||||||
if self.entries.back() == Some(&segments) {
|
if self.entries.back() == Some(&segments) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
||||||
self.entries.pop_front();
|
self.entries.pop_front();
|
||||||
}
|
}
|
||||||
self.entries.push_back(segments);
|
self.entries.push_back(segments);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_browsing(&self) -> bool {
|
fn is_browsing(&self) -> bool {
|
||||||
|
|
@ -283,6 +293,9 @@ pub struct App {
|
||||||
/// TUI-local readline-style composer input history. This is intentionally
|
/// TUI-local readline-style composer input history. This is intentionally
|
||||||
/// client-side only: recalled entries are plain drafts until submitted again.
|
/// client-side only: recalled entries are plain drafts until submitted again.
|
||||||
input_history: ComposerInputHistory,
|
input_history: ComposerInputHistory,
|
||||||
|
/// User-data backed persistence for composer recall entries. The saved
|
||||||
|
/// contents are private input drafts and must not be logged or sent to Pod.
|
||||||
|
input_history_store: Option<ComposerHistoryStore>,
|
||||||
/// Local submit state kept until the accepted run either completes
|
/// Local submit state kept until the accepted run either completes
|
||||||
/// normally or reports that the empty assistant turn was rolled back.
|
/// normally or reports that the empty assistant turn was rolled back.
|
||||||
pending_submit_rollback: Option<RollbackSubmitState>,
|
pending_submit_rollback: Option<RollbackSubmitState>,
|
||||||
|
|
@ -330,11 +343,72 @@ impl App {
|
||||||
task_pane_scroll: 0,
|
task_pane_scroll: 0,
|
||||||
queued_inputs: VecDeque::new(),
|
queued_inputs: VecDeque::new(),
|
||||||
input_history: ComposerInputHistory::new(),
|
input_history: ComposerInputHistory::new(),
|
||||||
|
input_history_store: None,
|
||||||
pending_submit_rollback: None,
|
pending_submit_rollback: None,
|
||||||
last_rolled_back_input: None,
|
last_rolled_back_input: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn new_with_persistent_input_history(pod_name: String, workspace_root: &Path) -> Self {
|
||||||
|
let mut app = Self::new(pod_name);
|
||||||
|
match ComposerHistoryStore::default_for_workspace(workspace_root) {
|
||||||
|
Ok(Some(store)) => {
|
||||||
|
match store.load() {
|
||||||
|
Ok(entries) => {
|
||||||
|
app.input_history = ComposerInputHistory::with_entries(entries);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.flash_actionbar_notice(
|
||||||
|
"Could not load saved composer input history; starting with empty local history.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.input_history_store = Some(store);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
app.flash_actionbar_notice(
|
||||||
|
"Composer input history persistence is disabled because the yoi data directory could not be resolved.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.flash_actionbar_notice(
|
||||||
|
"Composer input history persistence is disabled because the history store could not be initialized.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn new_with_input_history_store(pod_name: String, store: ComposerHistoryStore) -> Self {
|
||||||
|
let mut app = Self::new(pod_name);
|
||||||
|
match store.load() {
|
||||||
|
Ok(entries) => {
|
||||||
|
app.input_history = ComposerInputHistory::with_entries(entries);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.flash_actionbar_notice_at(
|
||||||
|
"Could not load saved composer input history; starting with empty local history.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
Instant::now(),
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.input_history_store = Some(store);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggle_task_pane(&mut self) {
|
pub fn toggle_task_pane(&mut self) {
|
||||||
self.task_pane_open = !self.task_pane_open;
|
self.task_pane_open = !self.task_pane_open;
|
||||||
if !self.task_pane_open {
|
if !self.task_pane_open {
|
||||||
|
|
@ -556,7 +630,7 @@ impl App {
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.input_history.record(segments.clone());
|
self.record_input_history(segments.clone());
|
||||||
if self.running {
|
if self.running {
|
||||||
self.queued_inputs.push_back(QueuedInput::new(segments));
|
self.queued_inputs.push_back(QueuedInput::new(segments));
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
|
|
@ -582,6 +656,23 @@ impl App {
|
||||||
Method::Run { input: segments }
|
Method::Run { input: segments }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_input_history(&mut self, segments: Vec<Segment>) {
|
||||||
|
if !self.input_history.record(segments) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(store) = &self.input_history_store else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if store.save(&self.input_history.entries).is_err() {
|
||||||
|
self.flash_actionbar_notice(
|
||||||
|
"Could not save composer input history; continuing with in-memory local history.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn queued_input_count(&self) -> usize {
|
pub fn queued_input_count(&self) -> usize {
|
||||||
self.queued_inputs.len()
|
self.queued_inputs.len()
|
||||||
}
|
}
|
||||||
|
|
@ -1934,17 +2025,6 @@ fn rollback_input_preview(text: &str) -> String {
|
||||||
one_line
|
one_line
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the submitted segment list carries no user-visible content
|
|
||||||
/// (only whitespace / newlines, no paste, no typed atoms). Used to
|
|
||||||
/// decide whether an empty Enter should be a no-op or trigger a
|
|
||||||
/// `Resume` when the Pod is paused.
|
|
||||||
fn segments_are_blank(segments: &[Segment]) -> bool {
|
|
||||||
segments.iter().all(|s| match s {
|
|
||||||
Segment::Text { content } => content.trim().is_empty(),
|
|
||||||
_ => false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
||||||
match source {
|
match source {
|
||||||
AlertSource::Pod => "pod",
|
AlertSource::Pod => "pod",
|
||||||
|
|
@ -2029,6 +2109,146 @@ mod actionbar_notice_tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod composer_history_persistence_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::composer_history::{COMPOSER_INPUT_HISTORY_LIMIT, ComposerHistoryStore};
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recall_history_survives_reload_for_same_workspace() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path());
|
||||||
|
let mut app = App::new_with_input_history_store("test".into(), store.clone());
|
||||||
|
submit_text(&mut app, "first synthetic entry");
|
||||||
|
submit_text(&mut app, "second synthetic entry");
|
||||||
|
|
||||||
|
let mut reloaded = App::new_with_input_history_store("test".into(), store);
|
||||||
|
assert!(reloaded.browse_input_history_older());
|
||||||
|
assert_eq!(input_text(&reloaded), "second synthetic entry");
|
||||||
|
assert!(reloaded.browse_input_history_older());
|
||||||
|
assert_eq!(input_text(&reloaded), "first synthetic entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recall_histories_are_separated_by_workspace() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace_a = TempDir::new().unwrap();
|
||||||
|
let workspace_b = TempDir::new().unwrap();
|
||||||
|
let store_a = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_a.path());
|
||||||
|
let store_b = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace_b.path());
|
||||||
|
let mut app_a = App::new_with_input_history_store("test".into(), store_a.clone());
|
||||||
|
let mut app_b = App::new_with_input_history_store("test".into(), store_b.clone());
|
||||||
|
|
||||||
|
submit_text(&mut app_a, "workspace a synthetic entry");
|
||||||
|
submit_text(&mut app_b, "workspace b synthetic entry");
|
||||||
|
|
||||||
|
let mut reloaded_a = App::new_with_input_history_store("test".into(), store_a);
|
||||||
|
let mut reloaded_b = App::new_with_input_history_store("test".into(), store_b);
|
||||||
|
assert!(reloaded_a.browse_input_history_older());
|
||||||
|
assert!(reloaded_b.browse_input_history_older());
|
||||||
|
assert_eq!(input_text(&reloaded_a), "workspace a synthetic entry");
|
||||||
|
assert_eq!(input_text(&reloaded_b), "workspace b synthetic entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persistence_keeps_typed_segments_instead_of_flattening() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path());
|
||||||
|
let mut app = App::new_with_input_history_store("test".into(), store.clone());
|
||||||
|
app.input.replace_with_segments(&[
|
||||||
|
Segment::text("inspect "),
|
||||||
|
Segment::FileRef {
|
||||||
|
path: "src/lib.rs".into(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
||||||
|
|
||||||
|
let mut reloaded = App::new_with_input_history_store("test".into(), store);
|
||||||
|
assert!(reloaded.browse_input_history_older());
|
||||||
|
let segments = reloaded.input.submit_segments();
|
||||||
|
assert_eq!(segments.len(), 2);
|
||||||
|
assert!(matches!(&segments[0], Segment::Text { content } if content == "inspect "));
|
||||||
|
assert!(matches!(&segments[1], Segment::FileRef { path } if path == "src/lib.rs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persistence_bounds_history_and_suppresses_consecutive_duplicates() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path());
|
||||||
|
let mut app = App::new_with_input_history_store("test".into(), store.clone());
|
||||||
|
submit_text(&mut app, "duplicate synthetic entry");
|
||||||
|
submit_text(&mut app, "duplicate synthetic entry");
|
||||||
|
for i in 0..COMPOSER_INPUT_HISTORY_LIMIT + 5 {
|
||||||
|
submit_text(&mut app, &format!("bounded synthetic entry {i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let reloaded = App::new_with_input_history_store("test".into(), store);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.input_history.entries.len(),
|
||||||
|
COMPOSER_INPUT_HISTORY_LIMIT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.input_history.entries.front(),
|
||||||
|
Some(&vec![Segment::text("bounded synthetic entry 5")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.input_history.entries.back(),
|
||||||
|
Some(&vec![Segment::text("bounded synthetic entry 34")])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_history_file_falls_back_to_empty_with_bounded_warning() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path());
|
||||||
|
fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||||
|
fs::write(store.path(), b"not json").unwrap();
|
||||||
|
|
||||||
|
let app = App::new_with_input_history_store("test".into(), store);
|
||||||
|
assert_eq!(app.input_history.entries.len(), 0);
|
||||||
|
let notice = app.current_actionbar_notice(Instant::now()).unwrap();
|
||||||
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
|
assert!(
|
||||||
|
notice
|
||||||
|
.text
|
||||||
|
.contains("Could not load saved composer input history")
|
||||||
|
);
|
||||||
|
assert!(!notice.text.contains("not json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persistence_does_not_write_workspace_yoi_directory() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let workspace = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), workspace.path());
|
||||||
|
let mut app = App::new_with_input_history_store("test".into(), store);
|
||||||
|
submit_text(&mut app, "synthetic entry outside workspace yoi");
|
||||||
|
|
||||||
|
assert!(data_dir.path().join("composer-history").exists());
|
||||||
|
assert!(!workspace.path().join(".yoi").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_text(app: &mut App, text: &str) -> Vec<Segment> {
|
||||||
|
for c in text.chars() {
|
||||||
|
app.insert_char(c);
|
||||||
|
}
|
||||||
|
match app.submit_input() {
|
||||||
|
Some(Method::Run { input }) => input,
|
||||||
|
other => panic!("expected Run, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_text(app: &App) -> String {
|
||||||
|
Segment::flatten_to_text(&app.input.submit_segments())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod completion_flow_tests {
|
mod completion_flow_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
240
crates/tui/src/composer_history.rs
Normal file
240
crates/tui/src/composer_history.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use protocol::Segment;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub(crate) const COMPOSER_INPUT_HISTORY_LIMIT: usize = 30;
|
||||||
|
const COMPOSER_HISTORY_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ComposerHistoryStore {
|
||||||
|
path: PathBuf,
|
||||||
|
workspace: WorkspaceIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum ComposerHistoryLoadError {
|
||||||
|
Io(io::Error),
|
||||||
|
Json(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ComposerHistoryLoadError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => write!(f, "{error}"),
|
||||||
|
Self::Json(error) => write!(f, "{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ComposerHistoryLoadError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for ComposerHistoryLoadError {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for ComposerHistoryLoadError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Json(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct ComposerHistoryFile {
|
||||||
|
version: u32,
|
||||||
|
workspace: WorkspaceIdentity,
|
||||||
|
entries: Vec<Vec<Segment>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct WorkspaceIdentity {
|
||||||
|
root: String,
|
||||||
|
label: String,
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComposerHistoryStore {
|
||||||
|
pub(crate) fn default_for_workspace(workspace_root: &Path) -> io::Result<Option<Self>> {
|
||||||
|
let Some(data_dir) = manifest::paths::data_dir() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(Self::for_data_dir(data_dir, workspace_root)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn for_data_dir(data_dir: impl AsRef<Path>, workspace_root: &Path) -> Self {
|
||||||
|
let workspace = workspace_identity(workspace_root);
|
||||||
|
let path = data_dir
|
||||||
|
.as_ref()
|
||||||
|
.join("composer-history")
|
||||||
|
.join("workspaces")
|
||||||
|
.join(format!("{}-{}", workspace.label, workspace.key))
|
||||||
|
.join("history.json");
|
||||||
|
Self { path, workspace }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn load(&self) -> Result<VecDeque<Vec<Segment>>, ComposerHistoryLoadError> {
|
||||||
|
let bytes = match fs::read(&self.path) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(VecDeque::new()),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
let persisted: ComposerHistoryFile = serde_json::from_slice(&bytes)?;
|
||||||
|
Ok(normalized_entries(persisted.entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn save(
|
||||||
|
&self,
|
||||||
|
entries: &VecDeque<Vec<Segment>>,
|
||||||
|
) -> Result<(), ComposerHistoryLoadError> {
|
||||||
|
let persisted = ComposerHistoryFile {
|
||||||
|
version: COMPOSER_HISTORY_VERSION,
|
||||||
|
workspace: self.workspace.clone(),
|
||||||
|
entries: entries.iter().cloned().collect(),
|
||||||
|
};
|
||||||
|
let bytes = serde_json::to_vec_pretty(&persisted)?;
|
||||||
|
if let Some(parent) = self.path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, bytes)?;
|
||||||
|
fs::rename(tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalized_entries(entries: Vec<Vec<Segment>>) -> VecDeque<Vec<Segment>> {
|
||||||
|
let mut normalized = VecDeque::new();
|
||||||
|
for segments in entries {
|
||||||
|
if segments_are_blank(&segments) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if normalized.back() == Some(&segments) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if normalized.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
||||||
|
normalized.pop_front();
|
||||||
|
}
|
||||||
|
normalized.push_back(segments);
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn segments_are_blank(segments: &[Segment]) -> bool {
|
||||||
|
segments.iter().all(|s| match s {
|
||||||
|
Segment::Text { content } => content.trim().is_empty(),
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_identity(workspace_root: &Path) -> WorkspaceIdentity {
|
||||||
|
let root = normalized_workspace_key(workspace_root);
|
||||||
|
let label = workspace_leaf(&root);
|
||||||
|
let key = fnv1a64_hex(root.as_bytes());
|
||||||
|
WorkspaceIdentity { root, label, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_workspace_key(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_leaf(workspace_root: &str) -> String {
|
||||||
|
let leaf = workspace_root
|
||||||
|
.rsplit('/')
|
||||||
|
.find(|part| !part.is_empty())
|
||||||
|
.unwrap_or("workspace");
|
||||||
|
let sanitized = leaf
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches('-')
|
||||||
|
.to_string();
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"workspace".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fnv1a64_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut hash = 0xcbf29ce484222325u64;
|
||||||
|
for byte in bytes {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
format!("{hash:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_path_is_workspace_scoped_under_data_dir() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
|
||||||
|
let other = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
|
||||||
|
|
||||||
|
assert!(store.path().starts_with(data_dir.path()));
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.path()
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains("composer-history/workspaces/yoi-")
|
||||||
|
);
|
||||||
|
assert_ne!(store.path(), other.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_records_workspace_identity_metadata_and_typed_segments() {
|
||||||
|
let data_dir = TempDir::new().unwrap();
|
||||||
|
let store = ComposerHistoryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
|
||||||
|
let entries = VecDeque::from([vec![
|
||||||
|
Segment::text("see "),
|
||||||
|
Segment::FileRef {
|
||||||
|
path: "src/main.rs".into(),
|
||||||
|
},
|
||||||
|
]]);
|
||||||
|
|
||||||
|
store.save(&entries).unwrap();
|
||||||
|
let raw: serde_json::Value =
|
||||||
|
serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap();
|
||||||
|
assert_eq!(raw["workspace"]["root"], "/repo/yoi");
|
||||||
|
assert_eq!(raw["workspace"]["label"], "yoi");
|
||||||
|
assert!(raw["workspace"]["key"].as_str().unwrap().len() >= 16);
|
||||||
|
assert_eq!(raw["entries"][0][1]["kind"], "file_ref");
|
||||||
|
assert_eq!(store.load().unwrap(), entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalization_bounds_blanks_and_consecutive_duplicates() {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
entries.push(vec![Segment::text(" ")]);
|
||||||
|
entries.push(vec![Segment::text("same")]);
|
||||||
|
entries.push(vec![Segment::text("same")]);
|
||||||
|
for i in 0..COMPOSER_INPUT_HISTORY_LIMIT + 5 {
|
||||||
|
entries.push(vec![Segment::text(format!("entry-{i}"))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalized_entries(entries);
|
||||||
|
assert_eq!(normalized.len(), COMPOSER_INPUT_HISTORY_LIMIT);
|
||||||
|
assert_eq!(normalized.front(), Some(&vec![Segment::text("entry-5")]));
|
||||||
|
assert_eq!(normalized.back(), Some(&vec![Segment::text("entry-34")]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ mod app;
|
||||||
mod block;
|
mod block;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod command;
|
mod command;
|
||||||
|
mod composer_history;
|
||||||
mod input;
|
mod input;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
mod markdown;
|
mod markdown;
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,8 @@ async fn run_connected_pod(
|
||||||
client: PodClient,
|
client: PodClient,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = App::new(pod_name);
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
|
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
run_loop(terminal, &mut app, client, runtime_command).await
|
run_loop(terminal, &mut app, client, runtime_command).await
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +274,8 @@ async fn run(
|
||||||
socket_path: &std::path::Path,
|
socket_path: &std::path::Path,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = App::new(pod_name);
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
|
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root);
|
||||||
|
|
||||||
match PodClient::connect(socket_path).await {
|
match PodClient::connect(socket_path).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user