Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 |
@@ -8,6 +8,7 @@ pub mod help_commands;
|
|||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
pub mod migration_commands;
|
pub mod migration_commands;
|
||||||
|
pub mod notes_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
pub mod settings_export_commands;
|
pub mod settings_export_commands;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::models::Note;
|
||||||
|
use crate::storage::notes_store;
|
||||||
|
|
||||||
|
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||||
|
/// recently edited.
|
||||||
|
///
|
||||||
|
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||||
|
/// of the same list — cannot drift into two different orders.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||||
|
let mut notes = notes_store::load(&project_id)?;
|
||||||
|
notes.sort_by(|a, b| {
|
||||||
|
b.pinned
|
||||||
|
.cmp(&a.pinned)
|
||||||
|
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||||
|
});
|
||||||
|
Ok(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace one note.
|
||||||
|
///
|
||||||
|
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||||
|
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||||
|
/// here is editing one note.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||||
|
notes_store::upsert(&project_id, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||||
|
notes_store::delete(&project_id, ¬e_id)
|
||||||
|
}
|
||||||
@@ -722,6 +722,15 @@ pub async fn remove_project(
|
|||||||
// holding an entire snapshot image that nothing will ever reference again.
|
// holding an entire snapshot image that nothing will ever reference again.
|
||||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
|
// A project's notes are the one piece of its state that is purely the
|
||||||
|
// user's prose, so removal takes them with it rather than leaving an
|
||||||
|
// orphan file keyed by an id nothing will ever look up again. Logged and
|
||||||
|
// not propagated: an orphaned notes file is harmless, and a project that
|
||||||
|
// cannot be removed is not.
|
||||||
|
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
|
||||||
|
log::warn!("Could not remove notes for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
// Stop and remove container if it exists. Everything named in `report`
|
// Stop and remove container if it exists. Everything named in `report`
|
||||||
// below is what will be unreachable the moment this function drops the
|
// below is what will be unreachable the moment this function drops the
|
||||||
// project record — see [`ProjectRemovalReport`] and
|
// project record — see [`ProjectRemovalReport`] and
|
||||||
|
|||||||
@@ -470,6 +470,10 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
commands::project_commands::reconcile_project_statuses,
|
||||||
|
// Notes
|
||||||
|
commands::notes_commands::list_notes,
|
||||||
|
commands::notes_commands::save_note,
|
||||||
|
commands::notes_commands::delete_note,
|
||||||
// Container base-image migration
|
// Container base-image migration
|
||||||
commands::migration_commands::get_container_staleness,
|
commands::migration_commands::get_container_staleness,
|
||||||
commands::migration_commands::migrate_project_to_base,
|
commands::migration_commands::migrate_project_to_base,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
pub mod project;
|
|
||||||
pub mod container_config;
|
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod container_config;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod note;
|
||||||
|
pub mod project;
|
||||||
pub mod settings_export;
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
|
||||||
pub use container_config::*;
|
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use container_config::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
|
pub use note::*;
|
||||||
|
pub use project::*;
|
||||||
pub use settings_export::*;
|
pub use settings_export::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||||
|
/// session.
|
||||||
|
///
|
||||||
|
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||||
|
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||||
|
/// classification decision at writing time is one the user is least willing to
|
||||||
|
/// make, and it would turn one pane into two features.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
/// Pinned notes sort first, then by `updated_at` descending.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
pub fn new(title: String, body: String) -> Self {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
Self {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
pinned: false,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod migration_store;
|
pub mod migration_store;
|
||||||
|
pub mod notes_store;
|
||||||
pub mod pending_cleanup;
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! Host-side persistence for per-project notes.
|
||||||
|
//!
|
||||||
|
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||||
|
//! free-function shape as `migration_store` — no struct, nothing in
|
||||||
|
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||||
|
//! caches the project list; a store that reads and writes the file per call
|
||||||
|
//! has nothing to cache and nothing to guard.
|
||||||
|
//!
|
||||||
|
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||||
|
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||||
|
//! there would mean the whole project list is rewritten per edit, and a note
|
||||||
|
//! save racing a Config save would silently drop one of them.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::models::Note;
|
||||||
|
|
||||||
|
/// Serialises the read-modify-write half of an upsert or delete.
|
||||||
|
///
|
||||||
|
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||||
|
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||||
|
/// those interleaving would lose whichever note was written first. The read
|
||||||
|
/// path does not take it.
|
||||||
|
fn write_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||||
|
pub fn notes_dir() -> Result<PathBuf, String> {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||||
|
})?
|
||||||
|
.join("triple-c")
|
||||||
|
.join("notes");
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||||
|
/// the write anywhere but the notes directory.
|
||||||
|
fn sanitize(project_id: &str) -> String {
|
||||||
|
project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||||
|
dir.join(format!("{}.json", sanitize(project_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||||
|
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||||
|
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||||
|
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||||
|
|
||||||
|
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||||
|
load_in(¬es_dir()?, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||||
|
upsert_in(¬es_dir()?, project_id, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||||
|
delete_in(¬es_dir()?, project_id, note_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project's notes file entirely. Missing is success.
|
||||||
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
|
clear_in(¬es_dir()?, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Implementation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a project's notes. A missing file is an empty list.
|
||||||
|
///
|
||||||
|
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||||
|
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||||
|
/// that project with no way out through the UI; deleting instead would destroy
|
||||||
|
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||||
|
/// corruption cannot overwrite the first — which is the one taken before
|
||||||
|
/// anything rewrote the file, and therefore the one worth having.
|
||||||
|
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||||
|
match serde_json::from_str::<Vec<Note>>(&data) {
|
||||||
|
Ok(notes) => Ok(notes),
|
||||||
|
Err(e) => {
|
||||||
|
keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||||
|
log::error!(
|
||||||
|
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||||
|
left in place and a copy was kept beside it",
|
||||||
|
project_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||||
|
let backup = path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")));
|
||||||
|
if backup.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::copy(path, &backup) {
|
||||||
|
log::error!("Could not keep a copy of the unreadable notes file: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace one note, leaving the rest untouched.
|
||||||
|
///
|
||||||
|
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||||
|
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||||
|
/// `updated_at` is stamped here for the same reason.
|
||||||
|
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut notes = load_in(dir, project_id)?;
|
||||||
|
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||||
|
Some(existing) => {
|
||||||
|
note.created_at = existing.created_at.clone();
|
||||||
|
*existing = note.clone();
|
||||||
|
}
|
||||||
|
None => notes.push(note.clone()),
|
||||||
|
}
|
||||||
|
save_all(dir, project_id, ¬es)?;
|
||||||
|
Ok(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||||
|
/// retry a delete whose result it never saw.
|
||||||
|
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut notes = load_in(dir, project_id)?;
|
||||||
|
let before = notes.len();
|
||||||
|
notes.retain(|n| n.id != note_id);
|
||||||
|
if notes.len() == before {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
save_all(dir, project_id, ¬es)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to remove notes: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically **and durably** write the whole list.
|
||||||
|
///
|
||||||
|
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||||
|
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||||
|
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||||
|
/// applied and the data not written — a truncated file, produced by the code
|
||||||
|
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||||
|
/// the directory after it, since the rename is directory metadata. Notes are
|
||||||
|
/// prose the user typed and nothing else holds a copy.
|
||||||
|
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
let data = serde_json::to_string_pretty(notes)
|
||||||
|
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file =
|
||||||
|
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||||
|
file.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||||
|
sync_dir(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||||
|
///
|
||||||
|
/// Best effort only where it is meaningless: Windows has no directory handle
|
||||||
|
/// to sync and returns an error for the attempt, so a failure is logged rather
|
||||||
|
/// than propagated. The file's own `sync_all` carries the data and is not best
|
||||||
|
/// effort.
|
||||||
|
fn sync_dir(path: &Path) {
|
||||||
|
let Some(dir) = path.parent() else { return };
|
||||||
|
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||||
|
log::debug!(
|
||||||
|
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||||
|
dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-notes-{}-{}",
|
||||||
|
tag,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ids_cannot_escape_the_notes_directory() {
|
||||||
|
// The id arrives over IPC. It must not be able to steer the write.
|
||||||
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
|
assert_eq!(sanitize("a/b"), "a_b");
|
||||||
|
assert_eq!(sanitize("a\\b"), "a_b");
|
||||||
|
// A real UUID must survive untouched, or every note file would move
|
||||||
|
// the first time this function changed.
|
||||||
|
assert_eq!(
|
||||||
|
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||||
|
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||||
|
let dir = temp_dir("missing");
|
||||||
|
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upserted_note_round_trips() {
|
||||||
|
let dir = temp_dir("roundtrip");
|
||||||
|
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||||
|
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
assert_eq!(saved.id, note.id);
|
||||||
|
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].body, "one\ntwo");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||||
|
let dir = temp_dir("replace");
|
||||||
|
let mut note = Note::new("Title".into(), "first".into());
|
||||||
|
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
|
||||||
|
note.body = "second".into();
|
||||||
|
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||||
|
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||||
|
assert_eq!(loaded[0].body, "second");
|
||||||
|
assert_ne!(
|
||||||
|
saved.created_at, "1999-01-01T00:00:00Z",
|
||||||
|
"created_at is owned by the store, not by whatever the webview sent"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||||
|
let dir = temp_dir("delete");
|
||||||
|
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||||
|
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||||
|
|
||||||
|
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].id, keep.id);
|
||||||
|
|
||||||
|
// Idempotent: removing what is already gone is not an error, because
|
||||||
|
// the UI can retry a delete it never saw the result of.
|
||||||
|
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||||
|
// Same reasoning as migration_store: a corrupt file must not make the
|
||||||
|
// tab permanently unusable, and the bytes must not be destroyed.
|
||||||
|
let dir = temp_dir("corrupt");
|
||||||
|
let path = notes_path_in(&dir, "p1");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||||
|
assert!(path.exists(), "the unreadable file is left in place");
|
||||||
|
|
||||||
|
let copies: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(copies.len(), 1, "the bytes must be kept exactly once");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_leaves_no_temp_file_behind() {
|
||||||
|
let dir = temp_dir("tmp");
|
||||||
|
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||||
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||||
|
.collect();
|
||||||
|
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||||
|
let dir = temp_dir("clear");
|
||||||
|
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||||
|
assert!(notes_path_in(&dir, "p1").exists());
|
||||||
|
|
||||||
|
clear_in(&dir, "p1").unwrap();
|
||||||
|
assert!(!notes_path_in(&dir, "p1").exists());
|
||||||
|
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||||
|
// `remove_project` must not be able to fail because a project simply
|
||||||
|
// never had any notes — an orphaned notes file is harmless, a project
|
||||||
|
// that cannot be removed is not.
|
||||||
|
let dir = temp_dir("removal");
|
||||||
|
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
|
||||||
import AddProjectDialog from "./AddProjectDialog";
|
|
||||||
|
|
||||||
const add = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../hooks/useProjects", () => ({
|
|
||||||
useProjects: () => ({ add }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
|
||||||
open: vi.fn(async () => null),
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
|
||||||
function deferred() {
|
|
||||||
let resolve!: (v: unknown) => void;
|
|
||||||
const promise = new Promise((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillValidForm() {
|
|
||||||
fireEvent.change(screen.getByLabelText("Project name"), {
|
|
||||||
target: { value: "my-project" },
|
|
||||||
});
|
|
||||||
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
|
||||||
target: { value: "/home/user/my-project" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitButton() {
|
|
||||||
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AddProjectDialog", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds the project with the name and folder entered", async () => {
|
|
||||||
add.mockResolvedValue({ id: "p1" });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(<AddProjectDialog onClose={onClose} />);
|
|
||||||
fillValidForm();
|
|
||||||
fireEvent.click(submitButton());
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(add).toHaveBeenCalledWith("my-project", [
|
|
||||||
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the submit button announced, and explains why, while adding", async () => {
|
|
||||||
const { promise, resolve } = deferred();
|
|
||||||
add.mockReturnValue(promise);
|
|
||||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
|
||||||
fillValidForm();
|
|
||||||
fireEvent.click(submitButton());
|
|
||||||
|
|
||||||
// Native `disabled` would remove the button from the accessibility tree
|
|
||||||
// exactly when it has something to say.
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
|
||||||
);
|
|
||||||
expect(submitButton()).not.toBeDisabled();
|
|
||||||
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
|
||||||
|
|
||||||
await act(async () => resolve({ id: "p1" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
|
||||||
const { promise, resolve } = deferred();
|
|
||||||
add.mockReturnValue(promise);
|
|
||||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
|
||||||
fillValidForm();
|
|
||||||
fireEvent.click(submitButton());
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(submitButton());
|
|
||||||
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
|
||||||
fireEvent.keyDown(submitButton(), { key: " " });
|
|
||||||
expect(add).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await act(async () => resolve({ id: "p1" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a form submit raised from elsewhere while adding", async () => {
|
|
||||||
const { promise, resolve } = deferred();
|
|
||||||
add.mockReturnValue(promise);
|
|
||||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
|
||||||
fillValidForm();
|
|
||||||
fireEvent.click(submitButton());
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enter in a text field submits a form regardless of the submit button's
|
|
||||||
// state, so the handler has to guard itself too.
|
|
||||||
// Modal portals to document.body, so the form is not under `container`.
|
|
||||||
const form = document.querySelector("form");
|
|
||||||
expect(form).not.toBeNull();
|
|
||||||
fireEvent.submit(form!);
|
|
||||||
expect(add).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await act(async () => resolve({ id: "p1" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the submit button plainly available when idle", () => {
|
|
||||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
|
||||||
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
|
||||||
expect(submitButton()).toHaveAccessibleDescription("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -55,10 +55,6 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
// The submit button is `aria-disabled` rather than `disabled` while an add
|
|
||||||
// is in flight, and Enter inside a text field submits the form without
|
|
||||||
// touching the button at all. Both routes end here, so the guard does too.
|
|
||||||
if (loading) return;
|
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Project name is required");
|
setError("Project name is required");
|
||||||
return;
|
return;
|
||||||
@@ -101,19 +97,7 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
<Button size="md" variant="ghost" onClick={onClose}>
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||||
size="md"
|
|
||||||
variant="primary"
|
|
||||||
type="submit"
|
|
||||||
form={formId}
|
|
||||||
unavailable={loading}
|
|
||||||
unavailableReason="The project is being added. Wait for it to finish."
|
|
||||||
title={
|
|
||||||
loading
|
|
||||||
? "The project is being added. Wait for it to finish."
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loading ? "Adding…" : "Add Project"}
|
{loading ? "Adding…" : "Add Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ describe("ProjectRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only allows opening a terminal while the container runs", () => {
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
unmount();
|
||||||
|
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -131,38 +139,6 @@ describe("ProjectRow", () => {
|
|||||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
|
||||||
render(<ProjectRow project={baseProject} />);
|
|
||||||
const button = screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
});
|
|
||||||
// Native `disabled` would drop the button out of the accessibility tree
|
|
||||||
// and out of the tab order, taking the reason with it.
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(button).toHaveAccessibleDescription(/is not running/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
|
||||||
render(<ProjectRow project={baseProject} />);
|
|
||||||
const button = screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
});
|
|
||||||
fireEvent.click(button);
|
|
||||||
fireEvent.keyDown(button, { key: "Enter" });
|
|
||||||
fireEvent.keyDown(button, { key: " " });
|
|
||||||
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops aria-disabled once the container is running", () => {
|
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
|
||||||
const button = screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
});
|
|
||||||
expect(button).not.toHaveAttribute("aria-disabled");
|
|
||||||
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows container progress inline rather than in a blocking modal", () => {
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { Project } from "../../lib/types";
|
|||||||
import { useAppState, homeTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
import { useUnavailable } from "../ui/unavailable";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -32,15 +31,6 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
|
||||||
// attribute that hides the button — and the reason — from anyone not using a
|
|
||||||
// mouse and eyes.
|
|
||||||
const terminal = useUnavailable({
|
|
||||||
unavailable: !isRunning,
|
|
||||||
reason: `${project.name} is not running. Start it to open a terminal.`,
|
|
||||||
onClick: () => openClaudeTerminal(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
@@ -123,14 +113,11 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
{...terminal.controlProps}
|
disabled={!isRunning}
|
||||||
title={
|
onClick={() => openClaudeTerminal()}
|
||||||
isRunning
|
title={`Open a Claude terminal for ${project.name}`}
|
||||||
? `Open a Claude terminal for ${project.name}`
|
|
||||||
: `${project.name} is not running. Start it to open a terminal.`
|
|
||||||
}
|
|
||||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -147,7 +134,6 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
<line x1="13" y1="15" x2="17" y2="15" />
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{terminal.reasonNode}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import Button from "./Button";
|
|
||||||
|
|
||||||
const onClick = vi.fn();
|
|
||||||
const onKeyDown = vi.fn();
|
|
||||||
|
|
||||||
describe("Button", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still supports the native disabled attribute", () => {
|
|
||||||
render(
|
|
||||||
<Button disabled onClick={onClick}>
|
|
||||||
Save
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays in the accessibility tree when unavailable, and says why", () => {
|
|
||||||
render(
|
|
||||||
<Button unavailable unavailableReason="Stop the container first.">
|
|
||||||
Save
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
const button = screen.getByRole("button", { name: "Save" });
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
||||||
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
|
||||||
// The reason is a description, not part of the name.
|
|
||||||
expect(button).toHaveAccessibleName("Save");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guards clicks and Enter/Space while unavailable", () => {
|
|
||||||
render(
|
|
||||||
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
|
||||||
Save
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
const button = screen.getByRole("button", { name: "Save" });
|
|
||||||
fireEvent.click(button);
|
|
||||||
fireEvent.keyDown(button, { key: "Enter" });
|
|
||||||
fireEvent.keyDown(button, { key: " " });
|
|
||||||
expect(onClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still forwards keys that are not activation keys", () => {
|
|
||||||
render(
|
|
||||||
<Button
|
|
||||||
unavailable
|
|
||||||
unavailableReason="Stop the container first."
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
|
||||||
key: "Escape",
|
|
||||||
});
|
|
||||||
expect(onKeyDown).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("behaves like an ordinary button when available", () => {
|
|
||||||
render(
|
|
||||||
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
|
||||||
Save
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
const button = screen.getByRole("button", { name: "Save" });
|
|
||||||
expect(button).not.toHaveAttribute("aria-disabled");
|
|
||||||
expect(button).toHaveAccessibleDescription("");
|
|
||||||
fireEvent.click(button);
|
|
||||||
expect(onClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import { useUnavailable } from "./unavailable";
|
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
export type ButtonSize = "sm" | "md";
|
export type ButtonSize = "sm" | "md";
|
||||||
@@ -8,37 +7,22 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/**
|
|
||||||
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
|
||||||
* `unavailableReason` to `aria-describedby` instead of using the native
|
|
||||||
* `disabled` attribute, which would take the button out of the tab order and
|
|
||||||
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
|
||||||
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
|
||||||
* worth telling the user.
|
|
||||||
*/
|
|
||||||
unavailable?: boolean;
|
|
||||||
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
|
||||||
unavailableReason?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real buttons with visible bounds and a ≥24px hit target.
|
* Real buttons with visible bounds and a ≥24px hit target.
|
||||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||||
* `--accent` stays reserved for foreground/link use.
|
* `--accent` stays reserved for foreground/link use.
|
||||||
*
|
|
||||||
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
|
||||||
* `disabled:` variant only matches the native attribute, which `unavailable`
|
|
||||||
* deliberately does not set. Keep the two lists in step.
|
|
||||||
*/
|
*/
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
danger:
|
danger:
|
||||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
||||||
ghost:
|
ghost:
|
||||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -51,30 +35,16 @@ export default function Button({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
unavailable = false,
|
|
||||||
unavailableReason = "",
|
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { controlProps, reasonNode } = useUnavailable({
|
|
||||||
unavailable,
|
|
||||||
reason: unavailableReason,
|
|
||||||
onClick: rest.onClick,
|
|
||||||
onKeyDown: rest.onKeyDown,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
{...rest}
|
{...rest}
|
||||||
{...controlProps}
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
{/* Outside the button: inside, the reason would join its accessible name. */}
|
|
||||||
{reasonNode}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
useId,
|
|
||||||
type KeyboardEventHandler,
|
|
||||||
type MouseEventHandler,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
/** Keys a native `<button>` turns into a click. */
|
|
||||||
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
|
||||||
|
|
||||||
export interface UnavailableControlProps {
|
|
||||||
"aria-disabled"?: true;
|
|
||||||
"aria-describedby"?: string;
|
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnavailableControl {
|
|
||||||
/** Spread onto the control. Carries the guarded handlers. */
|
|
||||||
controlProps: UnavailableControlProps;
|
|
||||||
/**
|
|
||||||
* Render as a *sibling* of the control — inside it the reason would be
|
|
||||||
* appended to the accessible name instead of the description.
|
|
||||||
*/
|
|
||||||
reasonNode: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a control unavailable without hiding it from assistive technology.
|
|
||||||
*
|
|
||||||
* `disabled` takes an element out of the tab order *and* out of the
|
|
||||||
* accessibility tree, so the `title` explaining why it cannot be used is
|
|
||||||
* announced to nobody and shown only to a sighted user with a mouse. That is
|
|
||||||
* backwards: the people who most need the reason are the ones who never get
|
|
||||||
* it. `aria-disabled` keeps the control focusable and announced, and
|
|
||||||
* `aria-describedby` hands over the reason.
|
|
||||||
*
|
|
||||||
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
|
||||||
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
|
||||||
* along with the attributes, so a call site cannot take the announcement
|
|
||||||
* without the guard. Handlers that a form can reach without going through the
|
|
||||||
* control (Enter inside a text field submits the form) still have to guard
|
|
||||||
* themselves.
|
|
||||||
*/
|
|
||||||
export function useUnavailable({
|
|
||||||
unavailable,
|
|
||||||
reason,
|
|
||||||
onClick,
|
|
||||||
onKeyDown,
|
|
||||||
}: {
|
|
||||||
unavailable: boolean;
|
|
||||||
reason: string;
|
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
|
||||||
}): UnavailableControl {
|
|
||||||
const reasonId = `${useId()}unavailable`;
|
|
||||||
|
|
||||||
if (!unavailable) {
|
|
||||||
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
controlProps: {
|
|
||||||
"aria-disabled": true,
|
|
||||||
"aria-describedby": reasonId,
|
|
||||||
onClick: (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
onKeyDown: (e) => {
|
|
||||||
if (!ACTIVATION_KEYS.has(e.key)) {
|
|
||||||
onKeyDown?.(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Suppress the default action before it can become a click, submit a
|
|
||||||
// form, or scroll the page.
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reasonNode: (
|
|
||||||
<span id={reasonId} className="sr-only">
|
|
||||||
{reason}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { useNotes } from "./useNotes";
|
||||||
|
import type { Note } from "../lib/types";
|
||||||
|
|
||||||
|
const listNotes = vi.fn();
|
||||||
|
const saveNote = vi.fn();
|
||||||
|
const deleteNote = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
listNotes: (p: string) => listNotes(p),
|
||||||
|
saveNote: (p: string, n: Note) => saveNote(p, n),
|
||||||
|
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
vi.mock("../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||||
|
{ getState: () => ({ pushToast }) },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy",
|
||||||
|
body: "one\ntwo",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listNotes.mockResolvedValue([note()]);
|
||||||
|
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||||
|
deleteNote.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useNotes", () => {
|
||||||
|
it("loads a project's notes on mount", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(listNotes).toHaveBeenCalledWith("p1");
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed save instead of swallowing it", async () => {
|
||||||
|
// Silent save failure is data loss: the user sees their text on screen and
|
||||||
|
// believes it is stored. Same reason `useSaveState` exists.
|
||||||
|
saveNote.mockRejectedValueOnce(new Error("disk full"));
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let ok: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
ok = await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(result.current.saveState.status).toBe("failed");
|
||||||
|
expect(pushToast).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the saved note in place rather than appending", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Mock the re-read to return the edited note
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].body).toBe("edited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a deleted note from the list", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteNote("n1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not load anything for an empty project id", async () => {
|
||||||
|
// The dock renders with no project selected; it must not fire a command
|
||||||
|
// for the empty string.
|
||||||
|
renderHook(() => useNotes(""));
|
||||||
|
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Change to a different project before the new fetch resolves
|
||||||
|
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
|
||||||
|
// The old notes should be cleared immediately
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no stale notes on screen when a load fails", async () => {
|
||||||
|
listNotes.mockResolvedValueOnce([note()]);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Switch to a project whose load fails
|
||||||
|
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
expect(pushToast).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with the list the backend returned when saving a new note", async () => {
|
||||||
|
// Initially one note
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
||||||
|
const newNote = note({ id: "n2", title: "New" });
|
||||||
|
listNotes.mockResolvedValueOnce([newNote, note()]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(newNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(2);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the list after a successful save rather than patching in place", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const callCountBefore = listNotes.mock.calls.length;
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// listNotes should be called again after the save
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { Note } from "../lib/types";
|
||||||
|
import type { SaveState } from "./useSaveState";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
|
||||||
|
/** A blank note, ordered to the top so the user can start typing immediately. */
|
||||||
|
function draft(): Note {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
// The backend owns the real id; this one only has to be unique enough to
|
||||||
|
// key the list until the first save returns.
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
pinned: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A project's notes, cached from the backend.
|
||||||
|
*
|
||||||
|
* The backend is the source of truth and this is a cache — every mutation goes
|
||||||
|
* through a command and the returned record replaces the local one, so the
|
||||||
|
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
|
||||||
|
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
|
||||||
|
* user staring at text they believe is stored.
|
||||||
|
*/
|
||||||
|
export function useNotes(projectId: string) {
|
||||||
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setNotes([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setNotes([]);
|
||||||
|
commands
|
||||||
|
.listNotes(projectId)
|
||||||
|
.then((loaded) => {
|
||||||
|
if (!cancelled) setNotes(loaded);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setNotes([]);
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not load notes for this project",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId, pushToast]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const succeeded = useCallback(() => {
|
||||||
|
setSaveState({ status: "saved", error: null });
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
resetTimer.current = setTimeout(
|
||||||
|
() => setSaveState({ status: "idle", error: null }),
|
||||||
|
2500,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveNote = useCallback(
|
||||||
|
async (note: Note) => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setSaveState({ status: "saving", error: null });
|
||||||
|
try {
|
||||||
|
await commands.saveNote(projectId, note);
|
||||||
|
// Re-read the canonical list from the backend. A successful save stamps a new
|
||||||
|
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
|
||||||
|
// so the record's position has changed and positional patching would disagree with
|
||||||
|
// what a reload would show.
|
||||||
|
try {
|
||||||
|
const reloaded = await commands.listNotes(projectId);
|
||||||
|
setNotes(reloaded);
|
||||||
|
} catch {
|
||||||
|
// Keep the save reported as successful (it was) and leave the existing list alone
|
||||||
|
// rather than clearing it if the re-read fails.
|
||||||
|
}
|
||||||
|
succeeded();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const message = String(e);
|
||||||
|
setSaveState({ status: "failed", error: message });
|
||||||
|
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast, succeeded],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createNote = useCallback(async () => {
|
||||||
|
const note = draft();
|
||||||
|
// Held locally first so the editor can focus it immediately; the save
|
||||||
|
// happens on blur like every other edit. The note does not exist backend-side,
|
||||||
|
// so no re-read can place it and no backend ordering applies to it yet. Prepending
|
||||||
|
// puts it at the top where the user can see it immediately, and on the first save
|
||||||
|
// its canonical position is established.
|
||||||
|
setNotes((current) => [note, ...current]);
|
||||||
|
return note;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteNote = useCallback(
|
||||||
|
async (noteId: string) => {
|
||||||
|
try {
|
||||||
|
await commands.deleteNote(projectId, noteId);
|
||||||
|
setNotes((current) => current.filter((n) => n.id !== noteId));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { notes, loading, saveState, createNote, saveNote, deleteNote };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
export const reconcileProjectStatuses = () =>
|
export const reconcileProjectStatuses = () =>
|
||||||
invoke<Project[]>("reconcile_project_statuses");
|
invoke<Project[]>("reconcile_project_statuses");
|
||||||
|
|
||||||
|
// Notes — per-project, host-side, readable with the container stopped.
|
||||||
|
export const listNotes = (projectId: string) =>
|
||||||
|
invoke<Note[]>("list_notes", { projectId });
|
||||||
|
/** Insert or replace one note. `created_at` and `id` are owned by the backend. */
|
||||||
|
export const saveNote = (projectId: string, note: Note) =>
|
||||||
|
invoke<Note>("save_note", { projectId, note });
|
||||||
|
export const deleteNote = (projectId: string, noteId: string) =>
|
||||||
|
invoke<void>("delete_note", { projectId, noteId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
|
|||||||
@@ -565,6 +565,16 @@ export interface SchedulerNotification {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One project note. Mirrors `models::Note` — field names are the Rust ones. */
|
||||||
|
export interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Which loopback family the container-side listener was found on.
|
/** Which loopback family the container-side listener was found on.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
|||||||
|
# Project Notes — design
|
||||||
|
|
||||||
|
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
|
||||||
|
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
|
||||||
|
|
||||||
|
A per-project notes surface, with a per-note **Send to agent** action that puts the note
|
||||||
|
into a running Claude session's prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this earns a slot
|
||||||
|
|
||||||
|
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
|
||||||
|
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
|
||||||
|
answers it **while the container is stopped**, which is the gap the stop/start container
|
||||||
|
model creates and the same reasoning that made Sessions/Resume the flagship.
|
||||||
|
|
||||||
|
Two things already do part of this job, and the design is shaped to avoid both:
|
||||||
|
|
||||||
|
- `Project.claude_instructions` (`models/project.rs:405`, editor at
|
||||||
|
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
|
||||||
|
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
|
||||||
|
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
|
||||||
|
instructions box, it is redundant with a feature that already ships.
|
||||||
|
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
|
||||||
|
user when the container is stopped, and invisible to the fleet view.
|
||||||
|
|
||||||
|
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
|
||||||
|
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
|
||||||
|
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
|
||||||
|
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
|
||||||
|
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
|
||||||
|
| Tab position | Last, after Browser | A companion to the work, not a step in it |
|
||||||
|
|
||||||
|
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
|
||||||
|
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
|
||||||
|
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
|
||||||
|
classification decision at the moment of writing — which is the moment the user is least
|
||||||
|
willing to make one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Storage
|
||||||
|
|
||||||
|
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
|
||||||
|
on `projects_store.rs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<data_dir>/triple-c/notes/{project_id}.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
|
||||||
|
over IPC; it must not be able to steer the write.
|
||||||
|
- **Atomic *and durable* write** — `.tmp`, `sync_all()`, `rename()`, then fsync the
|
||||||
|
directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That
|
||||||
|
file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write`
|
||||||
|
returns once the bytes are in the page cache, so losing power in the window leaves the
|
||||||
|
rename applied and the data not written — a truncated file produced by the very code meant
|
||||||
|
to prevent one. Notes are user prose; that is the data least worth losing to a half-write.
|
||||||
|
- **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125` —
|
||||||
|
timestamped, capped, and never overwriting an earlier copy, because the first copy is the
|
||||||
|
one taken before anything rewrote the file.
|
||||||
|
- **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public
|
||||||
|
wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes
|
||||||
|
`dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its
|
||||||
|
own tests only exercise free functions. The notes store should not inherit that limit.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Note {
|
||||||
|
id: String, // uuid v4
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
pinned: bool,
|
||||||
|
created_at: String, // RFC 3339
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
struct ProjectNotes { version: u32, notes: Vec<Note> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
||||||
|
|
||||||
|
### Why not a field on `Project`
|
||||||
|
|
||||||
|
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||||
|
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
|
||||||
|
Config). Long user prose on that record means (a) the whole project list is rewritten every
|
||||||
|
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
|
||||||
|
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
|
||||||
|
for why *it* is not in `projects.json`. Notes inherit it.
|
||||||
|
|
||||||
|
A per-project file also means a corrupt notes file loses notes for one project, not the
|
||||||
|
project list.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
|
||||||
|
an orphaned notes file is harmless, a project that cannot be removed is not.
|
||||||
|
|
||||||
|
## 2. Commands and frontend state
|
||||||
|
|
||||||
|
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
|
||||||
|
**no** entry in `capabilities/default.json`.
|
||||||
|
|
||||||
|
- `list_notes(projectId) -> Vec<Note>`
|
||||||
|
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
|
||||||
|
- `delete_note(projectId, noteId)`
|
||||||
|
|
||||||
|
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
|
||||||
|
storage choice above exists to avoid.
|
||||||
|
|
||||||
|
`notes_store` is a **free-function module** keyed by project id, exactly like
|
||||||
|
`migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes.
|
||||||
|
`ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store
|
||||||
|
that reads and writes the file per call has nothing to cache and nothing to guard. What it
|
||||||
|
does need is that each upsert's read-modify-write is not interleaved with another's, so the
|
||||||
|
module holds one process-wide write lock (`OnceLock<Mutex<()>>`, the idiom already in
|
||||||
|
`browser_view/popout.rs`) taken for the read-modify-write, not for the read path.
|
||||||
|
|
||||||
|
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
|
||||||
|
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
|
||||||
|
|
||||||
|
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
|
||||||
|
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
|
||||||
|
`notes-changed` event, both windows listen.
|
||||||
|
|
||||||
|
## 3. Editor — plain text, deliberately
|
||||||
|
|
||||||
|
A note is a title and a `<textarea>`, saved on blur, following
|
||||||
|
`ClaudeInstructionsEditor.tsx` (which saves in `onBlur` and holds no timer) and reporting
|
||||||
|
the outcome through `ui/SaveIndicator`. There is no debounce anywhere in the existing save
|
||||||
|
path — `useSaveState.ts`'s only timer is a 2500 ms reset of the "Saved ✓" label — and notes
|
||||||
|
add none. **No rich editor, no markdown library, and no markdown
|
||||||
|
rendering** — it is a scratchpad for reminders, and it stays one.
|
||||||
|
|
||||||
|
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
|
||||||
|
there is no state to get wrong and no moment where the text the user is looking at is not
|
||||||
|
the text that would be sent.
|
||||||
|
|
||||||
|
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
|
||||||
|
It was written for Help content, it entity-escapes before converting to make its
|
||||||
|
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
|
||||||
|
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
|
||||||
|
converter a second caller with different content — cost and risk, for formatting a
|
||||||
|
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
|
||||||
|
this change.
|
||||||
|
|
||||||
|
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
|
||||||
|
box. Nothing is transformed on the way out except the newline substitution in §5.
|
||||||
|
|
||||||
|
## 4. The Notes tab
|
||||||
|
|
||||||
|
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
|
||||||
|
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
|
||||||
|
shape `{ project: Project }`.
|
||||||
|
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
|
||||||
|
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
|
||||||
|
existing order runs roughly from "what is this" to "what is in it".
|
||||||
|
- Layout is master/detail: title list left, editor right.
|
||||||
|
|
||||||
|
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
|
||||||
|
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
|
||||||
|
not worth changing here.
|
||||||
|
|
||||||
|
## 5. Send to agent
|
||||||
|
|
||||||
|
### The newline problem, and why it is already solved
|
||||||
|
|
||||||
|
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
|
||||||
|
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
|
||||||
|
messages.
|
||||||
|
|
||||||
|
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
|
||||||
|
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
|
||||||
|
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
|
||||||
|
would *run* the line. So:
|
||||||
|
|
||||||
|
```
|
||||||
|
payload = note.body.replace(/\r?\n/g, "\x1b\r")
|
||||||
|
```
|
||||||
|
|
||||||
|
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
|
||||||
|
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
|
||||||
|
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
|
||||||
|
|
||||||
|
Two consequences follow from that same comment:
|
||||||
|
|
||||||
|
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
|
||||||
|
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
|
||||||
|
at all.
|
||||||
|
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
|
||||||
|
knowledge in that comment is hard-won and must not be duplicated away from it.
|
||||||
|
|
||||||
|
### Target resolution
|
||||||
|
|
||||||
|
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
|
||||||
|
`sessionType` and `sessionName`, so no new plumbing is needed.
|
||||||
|
|
||||||
|
| Claude sessions for this project | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Button disabled, "no running session for this project" |
|
||||||
|
| 1 | Send |
|
||||||
|
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
|
||||||
|
|
||||||
|
The display-name rule is currently written **twice**, both copies non-exported and local to
|
||||||
|
`MainTabs.tsx` — `tabLabel` (:192-203) and inline in `renderTab` (:362-367). The picker would
|
||||||
|
be a third copy of a rule that already disagrees with itself the moment one copy is edited,
|
||||||
|
so it is extracted once to a shared helper and both existing sites call it. That is a
|
||||||
|
targeted improvement to code this feature depends on, not unrelated refactoring.
|
||||||
|
|
||||||
|
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
|
||||||
|
(it pins at record-start so text does not land in whatever tab is active at stop time).
|
||||||
|
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
|
||||||
|
exposed as `sendInput` at `:135-141`) → `terminal_input` → `exec_manager.send_input`. That
|
||||||
|
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
|
||||||
|
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
|
||||||
|
- After sending, switch the active tab to that terminal so the user watches it land. This is
|
||||||
|
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
|
||||||
|
succeeded.
|
||||||
|
- **Body only, not the title.** The title is an index label for the list, not content.
|
||||||
|
|
||||||
|
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
|
||||||
|
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
|
||||||
|
clicking a note is too large an implicit action, and that timing hack should not spread.
|
||||||
|
|
||||||
|
## 6. Surface — a dock that takes space inward
|
||||||
|
|
||||||
|
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
|
||||||
|
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
|
||||||
|
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
|
||||||
|
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
|
||||||
|
|
||||||
|
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
|
||||||
|
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
|
||||||
|
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
|
||||||
|
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
|
||||||
|
correctly, with no new code.
|
||||||
|
|
||||||
|
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
|
||||||
|
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
|
||||||
|
- **The dock follows the active tab's project** — terminal tab → that session's project,
|
||||||
|
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
|
||||||
|
`TerminalSession.projectId` already provide this.
|
||||||
|
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
|
||||||
|
arithmetic, no platform checks. §6.1 is why.
|
||||||
|
|
||||||
|
### 6.1 Why the dock does not widen the window — Phase 0 spike
|
||||||
|
|
||||||
|
The original design had the dock "expand outward" by widening the OS window, so the terminal
|
||||||
|
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
|
||||||
|
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
|
||||||
|
today and the behavior could not be predicted. It was run twice, once per GDK backend,
|
||||||
|
which turned out to matter more than the platform.
|
||||||
|
|
||||||
|
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
|
||||||
|
`GDK_BACKEND=x11` in `AppRun`, citing
|
||||||
|
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|---|---|
|
||||||
|
| Grow while floating | asked +420, got +420 — exact |
|
||||||
|
| Shrink back | asked -420, got -420 — exact |
|
||||||
|
| `outer_position()` | readable, correct |
|
||||||
|
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
|
||||||
|
| Grow while maximized / fullscreen | ignored, as designed |
|
||||||
|
|
||||||
|
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
|
||||||
|
hook) the same binary failed — and failed *silently*, which is the part that decided this:
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|---|---|
|
||||||
|
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
|
||||||
|
| Shrink back | asked -420, got **-240**; height **+276** again |
|
||||||
|
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
|
||||||
|
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
|
||||||
|
| Grow while maximized / fullscreen | ignored, as designed |
|
||||||
|
| `set_position` | ignored, as expected |
|
||||||
|
|
||||||
|
Two independent failures, either one sufficient:
|
||||||
|
|
||||||
|
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
|
||||||
|
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
|
||||||
|
every write built on a read-back compounds it. There is no size that can be read and
|
||||||
|
safely written back. Three calls in, the window is larger than the display.
|
||||||
|
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
|
||||||
|
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
|
||||||
|
"do not grow" never triggers, because the value looks perfectly valid. The room check
|
||||||
|
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
|
||||||
|
|
||||||
|
The second point is what rules out a runtime fallback. A clean failure could have been
|
||||||
|
handled; a plausible wrong answer cannot be detected from the value itself.
|
||||||
|
|
||||||
|
Growing therefore works on one packaging channel and corrupts on another — the split is by
|
||||||
|
**packaging, not platform**, which is worse than a platform split because two users on
|
||||||
|
identical hardware and OS would see different behavior. A dock that takes space inward
|
||||||
|
behaves identically on every backend, OS and package, needs no detection, and reuses a
|
||||||
|
resize path that is already exercised by every terminal in the app.
|
||||||
|
|
||||||
|
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
|
||||||
|
does not move the window and must not start.
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
|
||||||
|
CLAUDE.md's Testing section.
|
||||||
|
|
||||||
|
**Rust (`notes_store.rs`)**
|
||||||
|
- `sanitize()` rejects traversal and separator characters in a project id
|
||||||
|
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
|
||||||
|
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
|
||||||
|
- removing a project deletes its notes file; a delete failure does not fail removal
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
|
||||||
|
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
|
||||||
|
- the dock's project resolution: terminal tab, home tab, and nothing active
|
||||||
|
- save-on-blur persists, and dock width round-trips through localStorage
|
||||||
|
|
||||||
|
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
|
||||||
|
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
|
||||||
|
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
|
||||||
|
that — but anything touching real key handling needs a manual check in Chromium.
|
||||||
|
|
||||||
|
## 8. Out of scope for v1
|
||||||
|
|
||||||
|
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
|
||||||
|
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
|
||||||
|
narrowly scoped capability granting the notes window `core:event:allow-listen` /
|
||||||
|
`allow-unlisten` — `capabilities/default.json` scopes those to `"windows": ["main"]` today,
|
||||||
|
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
|
||||||
|
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
|
||||||
|
handler would need review at that point.
|
||||||
|
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
|
||||||
|
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
||||||
|
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||||
|
sync direction is a v2 conversation.
|
||||||
|
- Tags, full-text search, manual reordering, note history.
|
||||||
|
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||||
|
versus fired-on-demand items.
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
|
||||||
|
table above.
|
||||||
Reference in New Issue
Block a user