From 7a6bde08928c0fa49ce7c0a1a6078fe3bf3689c8 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 00:07:26 +0900 Subject: [PATCH 1/6] feat(topic-index): move the topic store into a tauri-free crate (crates/topic-index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 索引とディレクトリの照合を `crates/topic-index/` へ出した。`docs/0-requirements.md` の「テストの配置」が既に正しい形として名指していたもので、`AppHandle` への依存を 外せば `cargo test` の対象になる。#119 の削除がこれを load-bearing にした——索引の 項目だけを消してファイルを残す削除は、次の読み出しの照合がそのまま元へ戻すためである。 移したもの: `LoggedPost` / `Topic` / `TopicIndex`、索引の読み書き、孤立ファイルの 照合、題の生成、一行の解析、そして `delete`。`delete` はファイルを先に、項目を後に 消す——この順序が load-bearing であり、逆順が「復活する削除」になる。 テスト 8 件。中心は `removing_only_the_entry_lets_the_next_read_resurrect_the_topic` で、項目だけを消す形を実際に走らせ、次の読み出しでトピックが戻ることを見る。#117 の 状態(項目は在るがファイルが無い)を消せることと、一ファイル名でない id の拒否も含む。 `src-tauri` 側の配線は次のコミットで行う。 #119 Co-Authored-By: Claude Opus 5 --- crates/topic-index/Cargo.lock | 269 ++++++++++++++++++ crates/topic-index/Cargo.toml | 22 ++ crates/topic-index/src/lib.rs | 516 ++++++++++++++++++++++++++++++++++ 3 files changed, 807 insertions(+) create mode 100644 crates/topic-index/Cargo.lock create mode 100644 crates/topic-index/Cargo.toml create mode 100644 crates/topic-index/src/lib.rs diff --git a/crates/topic-index/Cargo.lock b/crates/topic-index/Cargo.lock new file mode 100644 index 0000000..1eb927e --- /dev/null +++ b/crates/topic-index/Cargo.lock @@ -0,0 +1,269 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "topic-index" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/topic-index/Cargo.toml b/crates/topic-index/Cargo.toml new file mode 100644 index 0000000..1ae73d8 --- /dev/null +++ b/crates/topic-index/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "topic-index" +version = "0.1.0" +description = "The topics of a Pullcept room: a directory of topic files, and the index that annotates it" +authors = ["Liplus Project Contributors"] +license = "Apache-2.0" +edition = "2021" + +# Deliberately free of tauri, for the reason docs/0-requirements.md gives under +# テストの配置: a test binary that links the tauri dependency tree does not load +# on the GNU target, so pure logic left in `src-tauri/src/` is unverifiable the +# moment it is written. The reconciliation between the directory and the index +# was named there as the piece that had outgrown "read it and check" and +# belonged in a crate; deleting a topic is what made that load-bearing (#119), +# because a delete that left the file behind is undone by the next read. + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +uuid = { version = "1", features = ["v4"] } diff --git a/crates/topic-index/src/lib.rs b/crates/topic-index/src/lib.rs new file mode 100644 index 0000000..95425ae --- /dev/null +++ b/crates/topic-index/src/lib.rs @@ -0,0 +1,516 @@ +//! The topics of one room: a directory of topic files, and the index that +//! annotates it. +//! +//! ```text +//! {dir}/index.json one entry per topic: name, when it was made, +//! and the session each account was in +//! {dir}/{topic}.jsonl one topic's posts, append-only +//! ``` +//! +//! **The directory is what exists; the index annotates it.** A topic's posts +//! are the file, and [`read`] adopts any `.jsonl` it finds without an entry. +//! That is what makes lazy creation safe — a launch mints a topic and writes +//! nothing, so a run where nothing was said leaves no row in the list, and a +//! crash between the post reaching the file and the entry reaching the index +//! closes because the file alone is enough to rebuild the entry (#115). +//! +//! It is also what fixes the shape of a delete. The entry is an annotation, so +//! removing it removes an annotation: the file is still there, and the next +//! read adopts it back under a rebuilt entry. A topic is deleted by deleting +//! the file, and the entry goes with it (#119, decision 1). [`delete`] does +//! both in that order, and the test that matters here is the one that runs the +//! other order and watches the topic come back. +//! +//! Everything in this crate takes the room's directory as an argument. The path +//! to it is resolved by the app (`src-tauri/src/room_log.rs`), which is also +//! where the lock over read-modify-write of the index lives, and where the +//! screen is told the list has changed. What is here is the part that had to be +//! reachable by `cargo test`: `src-tauri` is compiled but not tested, for the +//! reason docs/0-requirements.md gives under テストの配置. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The index, as it is named inside the room's directory. +pub const INDEX_FILE: &str = "index.json"; + +/// The extension a topic's posts are kept under. +pub const TOPIC_EXTENSION: &str = "jsonl"; + +/// How long an auto-generated title is allowed to be, in characters. +/// +/// Characters rather than bytes: the titles are Japanese more often than not, +/// and a byte cut would land inside one. +const TITLE_CHARS: usize = 40; + +/// One post, as the log holds it. +/// +/// The same five fields going in and coming out. `hue` and `own` are not among +/// them, and why each is absent is written down where the mapping from a post +/// is made (`src-tauri/src/room_log.rs`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoggedPost { + pub message_id: String, + pub speaker: String, + pub content: String, + /// The participant this was addressed to, or absent when it was said to the + /// room. Omitted rather than written as null, so the two states are the + /// field's presence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + pub ts: String, +} + +/// One topic, as the index holds it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Topic { + /// Opaque, minted once, and the file name of this topic's posts. Never a + /// title: a title is edited, and a file whose name moved with it would + /// leave the posts behind. + pub topic_id: String, + /// What the list shows. Generated from the opening of the first post said + /// in it and editable afterwards (#115, decision 9). Empty until that first + /// post lands, which is a state the screen draws rather than a value + /// missing. + pub title: String, + /// When it was made, RFC 3339. The room's clock, or the first post's own + /// stamp for a topic adopted from a file. + pub created_at: String, + /// The session each account was in while this topic was open, keyed by + /// account id. + /// + /// This is what makes a topic a vessel rather than a transcript: reopening + /// it hands these back to the launch, and the participant returns carrying + /// its own context instead of being read a summary of it (#115, decisions 3 + /// and 4). Deleting a topic throws them away with it — the conversation and + /// the way back to whoever was in it are one thing, and #119 decision 1 is + /// the decision to treat them as one. + #[serde(default)] + pub sessions: BTreeMap, +} + +/// The topics, as the file holds them. +/// +/// Oldest first, by `created_at`. The screen reverses it — a list is read +/// newest first — and the file keeps the order the conversation happened in. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TopicIndex { + #[serde(default)] + pub topics: Vec, +} + +impl TopicIndex { + pub fn find(&self, topic_id: &str) -> Option<&Topic> { + self.topics.iter().find(|one| one.topic_id == topic_id) + } + + pub fn find_mut(&mut self, topic_id: &str) -> Option<&mut Topic> { + self.topics.iter_mut().find(|one| one.topic_id == topic_id) + } + + /// Make sure `topic_id` has an entry, and hand back a mutable hold on it. + /// + /// The one place a topic reaches the index. Every caller is a deliberate act + /// that has to survive the app closing — the first post, a session id, a + /// rename — and none of them is "the app was opened". + pub fn realize(&mut self, topic_id: &str, created_at: &str) -> &mut Topic { + if self.find(topic_id).is_none() { + self.topics.push(Topic { + topic_id: topic_id.to_string(), + title: String::new(), + created_at: created_at.to_string(), + sessions: BTreeMap::new(), + }); + } + self.find_mut(topic_id).expect("just inserted when absent") + } + + /// Take one topic's entry out. Answers whether there was one. + /// + /// Private, and a delete path cannot be built out of it by accident: the + /// entry is an annotation of a file that is still there, so a read after + /// this adopts the file back (see [`delete`]). + fn forget(&mut self, topic_id: &str) -> bool { + let before = self.topics.len(); + self.topics.retain(|one| one.topic_id != topic_id); + self.topics.len() != before + } +} + +/// Where the index lives inside the room's directory. +pub fn index_path(dir: &Path) -> PathBuf { + dir.join(INDEX_FILE) +} + +/// Where one topic's posts live. +/// +/// The id is a UUID minted by the app, so nothing a person types reaches a +/// path. A title with a slash in it would otherwise be a directory. +pub fn topic_path(dir: &Path, topic_id: &str) -> PathBuf { + dir.join(format!("{topic_id}.{TOPIC_EXTENSION}")) +} + +/// Whether `topic_id` names one file in the room's directory and nothing else. +/// +/// The ids this app mints are UUIDs, and the ids it adopts are file stems from +/// one non-recursive scan of that directory, so nothing reachable through the +/// app fails this. It is checked anyway on the one operation that removes a +/// file: an id carrying a separator makes [`topic_path`] a path out of the +/// directory, and being wrong there costs somebody else's file rather than a +/// failed read. +fn names_one_file(topic_id: &str) -> bool { + !topic_id.is_empty() + && !topic_id.contains(['/', '\\', ':']) + && topic_id != "." + && topic_id != ".." +} + +/// A title from the opening of the first thing said in the topic. +/// +/// The first line, whitespace collapsed, cut at `TITLE_CHARS`. A list of +/// timestamps is a list nobody can read (#115, decision 9), and the opening of +/// the first post is what a person would have written there anyway. +pub fn title_from(content: &str) -> String { + let flat = content.split_whitespace().collect::>().join(" "); + if flat.is_empty() { + return String::new(); + } + let mut chars = flat.chars(); + let head: String = chars.by_ref().take(TITLE_CHARS).collect(); + if chars.next().is_some() { + format!("{head}…") + } else { + head + } +} + +/// One topic's posts, oldest first. +/// +/// The tuple's second half is how many lines did not parse. A line that does +/// not parse is skipped rather than failing the read: the case it covers is a +/// torn tail from a run that ended mid-write, and refusing the whole topic over +/// the last line of it would lose everything to protect nothing. It is not +/// skipped quietly — the caller says how many, on the same surface a failed +/// append is said on. +pub fn read_posts(path: &Path) -> (Vec, usize) { + // No file is no history, not a failure. It is what a topic nobody has + // spoken in yet looks like. + let Ok(content) = std::fs::read_to_string(path) else { + return (Vec::new(), 0); + }; + + let mut posts = Vec::new(); + let mut skipped = 0usize; + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(post) => posts.push(post), + Err(_) => skipped += 1, + } + } + (posts, skipped) +} + +/// Read the index off disk and reconcile it with the directory. +/// +/// The reconciliation is not a repair path bolted on: it is what lets a topic +/// exist before any entry is written for it. An entry with no file stays — a +/// topic whose session was launched but which nobody has spoken in yet is that +/// case, and its session id is the whole reason to keep it. +/// +/// `fallback_created_at` dates an adopted file with nothing readable in it. A +/// topic made through the app carries its own stamp and never reaches that +/// branch. +/// +/// The second half of the answer is whether the caller should write the index +/// back: something was adopted, or there was no index file to begin with. +pub fn read(dir: &Path, fallback_created_at: &str) -> Result<(TopicIndex, bool), String> { + let path = index_path(dir); + let existed = path.exists(); + let mut index = if existed { + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read the topic index: {e}"))?; + serde_json::from_str::(&content) + .map_err(|e| format!("Failed to parse the topic index: {e}"))? + } else { + TopicIndex::default() + }; + + let adopted = adopt_orphans(dir, &mut index, fallback_created_at); + index + .topics + .sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.topic_id.cmp(&b.topic_id))); + Ok((index, adopted || !existed)) +} + +/// Give an entry to every topic file the index does not name. +/// +/// Answers true when it changed anything. +fn adopt_orphans(dir: &Path, index: &mut TopicIndex, fallback_created_at: &str) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + // No directory is no topics, not a failure. It is the first run. + return false; + }; + let mut adopted = false; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some(TOPIC_EXTENSION) { + continue; + } + let Some(topic_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + if index.find(topic_id).is_some() { + continue; + } + let (posts, _) = read_posts(&path); + let first = posts.first(); + index.topics.push(Topic { + topic_id: topic_id.to_string(), + title: first.map(|post| title_from(&post.content)).unwrap_or_default(), + // The first thing said in it, which is the closest thing a file + // carries to when it began. + created_at: first + .map(|post| post.ts.clone()) + .unwrap_or_else(|| fallback_created_at.to_string()), + sessions: BTreeMap::new(), + }); + adopted = true; + } + adopted +} + +/// Put the index back on disk. +pub fn write(dir: &Path, index: &TopicIndex) -> Result<(), String> { + let path = index_path(dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create the room log dir: {e}"))?; + } + let content = serde_json::to_string_pretty(index) + .map_err(|e| format!("Failed to serialize the topic index: {e}"))?; + std::fs::write(&path, content).map_err(|e| format!("Failed to write the topic index: {e}")) +} + +/// Delete one topic: its posts, and the entry that annotated them. +/// +/// **Both, and the file first.** The entry is an annotation of the file, so an +/// index with the entry taken out and the file left behind is not a deleted +/// topic — it is a topic with no annotation, and [`read`] adopts it back under +/// a rebuilt entry the next time anything reads the list (#119, decision 1). +/// The order follows from that: the file is the half that has to go, so it goes +/// first, and a failure between the two leaves an entry whose file is gone. +/// That state is visible in the list and is the one #117 already describes; the +/// other order fails by putting the topic back, and reports nothing while it +/// does. +/// +/// A missing file is not a failure. It is #117's own state — an entry the index +/// carries for a file that never existed — and taking the entry out is exactly +/// what is wanted for it. +/// +/// The caller writes the index back. It is holding the lock over the whole +/// read-modify-write, and this is one modify inside it. +pub fn delete(dir: &Path, index: &mut TopicIndex, topic_id: &str) -> Result<(), String> { + if !names_one_file(topic_id) { + return Err(format!( + "Refusing to delete a topic whose id is not one file name: {topic_id}" + )); + } + match std::fs::remove_file(topic_path(dir, topic_id)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("Failed to delete the topic's log: {e}")), + } + index.forget(topic_id); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + struct Scratch(PathBuf); + + impl Scratch { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("pullcept-topic-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + Scratch(dir) + } + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + const NOW: &str = "2026-08-28T00:00:00+09:00"; + + /// One topic file with one post in it. + fn put_topic(dir: &Path, topic_id: &str, content: &str, ts: &str) { + let post = LoggedPost { + message_id: Uuid::new_v4().to_string(), + speaker: "Lin".to_string(), + content: content.to_string(), + to: None, + ts: ts.to_string(), + }; + let mut line = serde_json::to_string(&post).expect("serialize"); + line.push('\n'); + std::fs::write(topic_path(dir, topic_id), line).expect("write topic"); + } + + fn read_now(dir: &Path) -> TopicIndex { + let (index, _) = read(dir, NOW).expect("read"); + index + } + + #[test] + fn adopts_a_topic_file_the_index_does_not_name() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "alpha", "最初の一言", "2026-08-27T10:00:00+09:00"); + + let (index, needs_write) = read(scratch.path(), NOW).expect("read"); + + assert!(needs_write, "an adoption is a change the caller has to write"); + let topic = index.find("alpha").expect("adopted"); + assert_eq!(topic.title, "最初の一言"); + assert_eq!(topic.created_at, "2026-08-27T10:00:00+09:00"); + } + + #[test] + fn deleting_takes_the_file_and_the_entry_together() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "alpha", "消す方", "2026-08-27T10:00:00+09:00"); + let mut index = read_now(scratch.path()); + assert!(index.find("alpha").is_some()); + + delete(scratch.path(), &mut index, "alpha").expect("delete"); + write(scratch.path(), &index).expect("write"); + + assert!(!topic_path(scratch.path(), "alpha").exists()); + assert!( + read_now(scratch.path()).find("alpha").is_none(), + "a deleted topic does not come back on the next read" + ); + } + + /// The reason [`delete`] removes the file. + /// + /// This is the index-only delete decision 1 refused, run as itself: the + /// entry goes, the file stays, and the very next read adopts the file back + /// under a rebuilt entry. Nothing reports a failure along the way, which is + /// why the shape had to be settled in the code rather than left to whoever + /// writes the next delete path. + #[test] + fn removing_only_the_entry_lets_the_next_read_resurrect_the_topic() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "alpha", "消えない方", "2026-08-27T10:00:00+09:00"); + let mut index = read_now(scratch.path()); + + index.forget("alpha"); + write(scratch.path(), &index).expect("write"); + assert!( + read_now(scratch.path()).find("alpha").is_some(), + "the file is still there, so the read adopts it back" + ); + + // The same topic, deleted the way `delete` does it, stays deleted. + let mut index = read_now(scratch.path()); + delete(scratch.path(), &mut index, "alpha").expect("delete"); + write(scratch.path(), &index).expect("write"); + assert!(read_now(scratch.path()).find("alpha").is_none()); + } + + #[test] + fn deleting_one_topic_leaves_the_others_where_they_were() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "alpha", "残る方", "2026-08-27T10:00:00+09:00"); + put_topic(scratch.path(), "beta", "消す方", "2026-08-27T11:00:00+09:00"); + put_topic(scratch.path(), "gamma", "残る方も", "2026-08-27T12:00:00+09:00"); + let mut index = read_now(scratch.path()); + + delete(scratch.path(), &mut index, "beta").expect("delete"); + write(scratch.path(), &index).expect("write"); + + let index = read_now(scratch.path()); + let ids: Vec<&str> = index.topics.iter().map(|one| one.topic_id.as_str()).collect(); + assert_eq!(ids, vec!["alpha", "gamma"]); + assert!(topic_path(scratch.path(), "alpha").exists()); + assert!(topic_path(scratch.path(), "gamma").exists()); + } + + /// #117's own state: an entry the index carries for a file that is not + /// there. Nothing else reaches it, and this is the way out. + #[test] + fn deleting_an_entry_whose_file_is_missing_is_not_a_failure() { + let scratch = Scratch::new(); + let mut index = TopicIndex::default(); + index.realize("orphaned", NOW); + write(scratch.path(), &index).expect("write"); + + let mut index = read_now(scratch.path()); + assert!(index.find("orphaned").is_some(), "an entry with no file stays"); + + delete(scratch.path(), &mut index, "orphaned").expect("delete"); + write(scratch.path(), &index).expect("write"); + + assert!(read_now(scratch.path()).find("orphaned").is_none()); + } + + #[test] + fn refuses_an_id_that_is_not_one_file_name() { + let scratch = Scratch::new(); + let outsider = scratch.path().join("outsider.jsonl"); + std::fs::write(&outsider, "").expect("write outsider"); + let inner = scratch.path().join("room"); + std::fs::create_dir_all(&inner).expect("inner dir"); + let mut index = TopicIndex::default(); + + for id in ["../outsider", "..\\outsider", "..", ".", ""] { + let refused = delete(&inner, &mut index, id); + assert!(refused.is_err(), "id {id:?} names more than one file"); + } + assert!( + outsider.exists(), + "nothing outside the room's directory was touched" + ); + } + + #[test] + fn a_title_is_the_first_line_collapsed_and_cut() { + assert_eq!(title_from(" こんにちは 部屋 "), "こんにちは 部屋"); + assert_eq!(title_from("一行目\n二行目"), "一行目 二行目"); + assert_eq!(title_from(" "), ""); + + let long = "あ".repeat(TITLE_CHARS + 5); + let cut = title_from(&long); + assert_eq!(cut.chars().count(), TITLE_CHARS + 1, "the cut plus its mark"); + assert!(cut.ends_with('…')); + + let exact = "い".repeat(TITLE_CHARS); + assert_eq!(title_from(&exact), exact, "no mark when nothing was cut"); + } + + #[test] + fn a_torn_line_is_skipped_and_counted_rather_than_failing_the_read() { + let scratch = Scratch::new(); + put_topic(scratch.path(), "alpha", "読める行", "2026-08-27T10:00:00+09:00"); + let path = topic_path(scratch.path(), "alpha"); + let mut content = std::fs::read_to_string(&path).expect("read"); + content.push_str("{\"message_id\":\"torn\",\"spea\n"); + std::fs::write(&path, content).expect("write"); + + let (posts, skipped) = read_posts(&path); + assert_eq!(posts.len(), 1); + assert_eq!(skipped, 1); + } +} From c249947b2790f9e3a703d500786d3a516def09f3 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 03:28:12 +0900 Subject: [PATCH 2/6] feat(room): delete a topic, its sessions, and the room's place in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `room_delete_topic` を足した。三つの行為をこの順で行う。 1. そのトピックで走っているセッションを終える(決定4)。トピックが消えれば 戻り道も消えるため、走らせたままにするとどのトピックにも属さないセッションが 残る。どのトピックの席かは起動時の事実であり、`RunningSession` が `topic_id` を持つようにした——トピックの `sessions` から引くと、以前ここで走り今は別の トピックで走っているアカウントに届いてしまう。走っていることを理由に削除を 拒まない(決定3)。 2. ファイルと索引の項目を消す(決定1、`room_log::delete_topic`)。順序が逆だと 決定4が禁じた状態——トピックが消えてセッションが残る——になる。 3. 部屋がそのトピックに居たなら新しいトピックへ移る(決定6)。起動直後と同じ 状態を使い回す。移った先を画面へ返す——画面は自分の現在トピックを持っており、 消えたトピックを指したままだと、描いている会話と記録先が食い違う。 `room_log` 側は crate への配線に置き換えた。`PtyState::kill_each` は `kill_all` をリストへ絞ったもので、失敗を報告しない理由も同じ(#96)。 #119 Co-Authored-By: Claude Opus 5 --- src-tauri/Cargo.lock | 9 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/pty.rs | 21 +++ src-tauri/src/room.rs | 56 +++++++ src-tauri/src/room_log.rs | 338 +++++++++++--------------------------- src-tauri/src/session.rs | 45 +++++ 7 files changed, 232 insertions(+), 239 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 16e4845..9aa3713 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2913,6 +2913,7 @@ dependencies = [ "tauri-plugin-opener", "tokio", "tokio-tungstenite", + "topic-index", "uuid", ] @@ -4276,6 +4277,14 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "topic-index" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 489001c..49a9bc4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1" portable-pty = { path = "../portable-pty-patch" } mcp-config = { path = "../crates/mcp-config" } room-floor = { path = "../crates/room-floor" } +topic-index = { path = "../crates/topic-index" } uuid = { version = "1", features = ["v4"] } parking_lot = "0.12" tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 43d8914..272ef72 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -50,6 +50,7 @@ pub fn run() { room::room_current_topic, room::room_new_topic, room::room_select_topic, + room::room_delete_topic, room_log::room_topics, room_log::room_topic_log, room_log::room_rename_topic, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 497f89d..ca4d62f 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -70,6 +70,27 @@ impl PtyState { let _ = pty.child.kill(); } } + + /// Kill the named sessions. + /// + /// `kill_all` narrowed to a list, and the same three things hold about it: + /// one acquisition of the lock over the whole set, the entry taken out + /// rather than left in place because dropping the instance is the half that + /// ends the tree, and no failure to report (see `kill_all` for why, and for + /// what #96 would have to change first). + /// + /// An id that is not in the map is a session that has already exited. That + /// is not an error here: the caller's list came from the seats, and a seat + /// is released by its session ending rather than by anyone calling for it + /// (`session::RoomSeats`). + pub fn kill_each(&self, ids: &[String]) { + let mut map = self.procs.lock(); + for id in ids { + if let Some(mut pty) = map.remove(id) { + let _ = pty.child.kill(); + } + } + } } #[tauri::command] diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index 2d83954..428e4d9 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -80,7 +80,9 @@ //! the room before the disk is touched, and a failure there costs the record, //! not the utterance (#48). +use crate::pty::PtyState; use crate::room_log::{self, TopicRef}; +use crate::session::RoomSeats; use futures_util::{SinkExt, StreamExt}; use parking_lot::Mutex; use room_floor::{Admission, Floor, Missed, Post}; @@ -1008,6 +1010,60 @@ pub fn room_select_topic( Ok(()) } +/// Delete a topic: its posts, its entry, and the sessions that were in it. +/// +/// Three acts, and the order between them is the whole of what this function +/// decides. Each half is done by whoever owns it — the seats end the sessions, +/// the log deletes the store, the room moves — and none of the three would be +/// right on its own. +/// +/// **The sessions first (#119, decision 4).** A topic holds the way back into +/// the sessions that were in it, so deleting it while they run leaves sessions +/// belonging to no topic — running, seated, and unreachable by any resume. Only +/// the seats started into *this* topic are ended: which topic a session belongs +/// to is a fact about its launch, and the seats carry it +/// (`RoomSeats::running_in_topic`). Running is not a reason to refuse the +/// delete (#119, decision 3). +/// +/// **The store second.** If it fails, the sessions are already gone and the +/// topic is still listed — visible, and the person can start a session again or +/// press delete again. The other order fails the way decision 4 forbids: the +/// topic gone and the sessions still running. +/// +/// **The room last, and only if it was in this one (#119, decision 6).** What +/// it moves to is a new topic, which is the state a launch already produces — +/// an empty room, in a topic not yet in the index. Moving to the next topic in +/// the list would open a conversation nobody asked for, and refusing to delete +/// the open one would ask the person to leave a topic before deciding they do +/// not want it. +/// +/// Answers the topic the room moved to, or `None` when the room was somewhere +/// else and did not move. The screen needs it: it holds its own current topic, +/// and a screen still pointing at a deleted one would draw one conversation +/// while the next post was recorded in another. +#[tauri::command] +pub fn room_delete_topic( + app: AppHandle, + state: tauri::State, + pty_state: tauri::State, + seats: tauri::State, + topic_id: String, +) -> Result, String> { + pty_state.kill_each(&seats.running_in_topic(&topic_id, &pty_state)); + + room_log::delete_topic(&app, &topic_id)?; + + let moved = (state.topic().topic_id == topic_id).then(|| { + let fresh = TopicRef::new(now_iso()); + state.enter_topic(fresh.clone()); + fresh + }); + // After the move, so a list arriving on this event finds the room already + // somewhere the deleted topic is not. + room_log::announce(&app); + Ok(moved) +} + /// Seat this screen's person in the room, under the name and hue they declared. /// /// A person is in the room by being there, not by speaking: without this the diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs index 1a384fb..9abbc9b 100644 --- a/src-tauri/src/room_log.rs +++ b/src-tauri/src/room_log.rs @@ -35,6 +35,17 @@ //! open, a crash between the post reaching the file and the entry reaching the //! index, closes because the file alone is enough to rebuild the entry. //! +//! **The store itself is the `topic-index` crate; this file is the app's door +//! to it.** What is here needs tauri — the path to the room's directory comes +//! out of `AppHandle`, the lock over read-modify-write of the index is held +//! here, and the screen is told from here that the list changed. What is not +//! here is everything that only ever needed the directory: the shapes, the +//! reconciliation, the title, the parse, and the delete. That split is the one +//! docs/0-requirements.md named under テストの配置 before it existed, and #119 +//! is what made it load-bearing rather than tidy — deleting a topic has to take +//! the file, because an index with the entry taken out and the file left is a +//! topic the very next read adopts back. +//! //! `logs/{room}.jsonl` from before this — the single flow — is carried in as //! one topic the first time the index is built. It is moved, not copied and not //! dropped (#115, decision 8), and the scan above is what gives it its entry. @@ -61,11 +72,18 @@ use parking_lot::Mutex; use room_floor::Post; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use serde::Serialize; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::{AppHandle, Emitter, Manager}; +use topic_index::TopicIndex; + +/// The shapes and the store, from the crate that holds them. +/// +/// Re-exported because they are what this module's commands hand back, and a +/// caller inside the app should not have to know which side of the tauri +/// boundary a topic's shape lives on. +pub use topic_index::{LoggedPost, Topic}; /// The room whose log this is, as it appears in the path. /// @@ -89,12 +107,6 @@ const LOG_ERROR_EVENT: &str = "room-log-error"; /// here rather than being trusted to keep a second copy in step. const TOPICS_EVENT: &str = "room-topics"; -/// How long an auto-generated title is allowed to be, in characters. -/// -/// Characters rather than bytes: the titles are Japanese more often than not, -/// and a byte cut would land inside one. -const TITLE_CHARS: usize = 40; - /// One acquisition for every read-modify-write of the index. /// /// The index is a single small file read and rewritten whole. Two writers @@ -104,69 +116,24 @@ const TITLE_CHARS: usize = 40; /// failed to resume. static INDEX_LOCK: Mutex<()> = Mutex::new(()); -/// One post, as the log holds it. +/// The five fields of a post, taken off the post itself. /// -/// The same five fields going in and coming out. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoggedPost { - pub message_id: String, - pub speaker: String, - pub content: String, - /// The participant this was addressed to, or absent when it was said to the - /// room. Omitted rather than written as null, so the two states are the - /// field's presence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub to: Option, - pub ts: String, -} - -impl LoggedPost { - /// The five fields of a post, taken off the post itself. - /// - /// A mapping rather than a `Serialize` on `Post`: `Post` carries `hue` as - /// well, and a derive would put it in the file. What is dropped here is - /// dropped on purpose, and this is where that is legible. - fn of(post: &Post) -> Self { - LoggedPost { - message_id: post.message_id.clone(), - speaker: post.speaker.clone(), - content: post.content.clone(), - to: post.to.clone(), - ts: post.ts.clone(), - } +/// A mapping rather than a `Serialize` on `Post`: `Post` carries `hue` as well, +/// and a derive would put it in the file. What is dropped here is dropped on +/// purpose, and this is where that is legible. A free function rather than an +/// inherent method, because the type it builds belongs to the `topic-index` +/// crate now — and this is the half that could not go with it, since `Post` is +/// the room's own shape. +fn logged(post: &Post) -> LoggedPost { + LoggedPost { + message_id: post.message_id.clone(), + speaker: post.speaker.clone(), + content: post.content.clone(), + to: post.to.clone(), + ts: post.ts.clone(), } } -/// One topic, as the index holds it. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Topic { - /// Opaque, minted once, and the file name of this topic's posts. Never a - /// title: a title is edited, and a file whose name moved with it would - /// leave the posts behind. - pub topic_id: String, - /// What the list shows. Generated from the opening of the first post said - /// in it and editable afterwards (#115, decision 9). Empty until that first - /// post lands, which is a state the screen draws rather than a value - /// missing. - pub title: String, - /// When it was made, RFC 3339. The room's clock (`room::now_iso`), or the - /// first post's own stamp for a topic adopted from a file. - pub created_at: String, - /// The session each account was in while this topic was open, keyed by - /// account id. - /// - /// This is what makes a topic a vessel rather than a transcript: reopening - /// it hands these back to the launch, and the participant returns carrying - /// its own context instead of being read a summary of it (#115, decisions 3 - /// and 4). - /// - /// Keyed on the account id, which is the identity (#53). Not on the name, - /// which is editable, and not on the room's connection id, which is minted - /// per connection and is gone by the time a topic is reopened. - #[serde(default)] - pub sessions: BTreeMap, -} - /// The topic the room is in, before anything has been said in it. /// /// Held by the room rather than written down. A launch opens a new topic @@ -187,26 +154,7 @@ impl TopicRef { } } -/// The topics, as the file holds them. -/// -/// Oldest first, by `created_at`. The screen reverses it — a list is read -/// newest first — and the file keeps the order the conversation happened in. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -struct TopicIndex { - #[serde(default)] - topics: Vec, -} - -impl TopicIndex { - fn find(&self, topic_id: &str) -> Option<&Topic> { - self.topics.iter().find(|one| one.topic_id == topic_id) - } - - fn find_mut(&mut self, topic_id: &str) -> Option<&mut Topic> { - self.topics.iter_mut().find(|one| one.topic_id == topic_id) - } -} - +/// Where this room's topics live. fn room_dir(app: &AppHandle) -> Result { let dir = app .path() @@ -215,16 +163,9 @@ fn room_dir(app: &AppHandle) -> Result { Ok(dir.join("logs").join(ROOM_NAME)) } -fn index_path(app: &AppHandle) -> Result { - Ok(room_dir(app)?.join("index.json")) -} - /// Where one topic's posts live. -/// -/// The id is a UUID minted by `TopicRef`, so nothing a person types reaches a -/// path. A title with a slash in it would otherwise be a directory. fn topic_path(app: &AppHandle, topic_id: &str) -> Result { - Ok(room_dir(app)?.join(format!("{topic_id}.jsonl"))) + Ok(topic_index::topic_path(&room_dir(app)?, topic_id)) } /// The single flow this log kept before topics existed. @@ -244,6 +185,11 @@ fn legacy_path(app: &AppHandle) -> Result { /// is what moves (#115, decision 8). The entry it gets is the scan's, like any /// other file in the directory. /// +/// It stays on this side of the boundary rather than going into the crate with +/// the rest of the store: what it reads is `logs/{room}.jsonl`, one level above +/// the room's own directory, and the crate is given that directory and nothing +/// around it. +/// /// Call under `INDEX_LOCK`. fn migrate_legacy(app: &AppHandle) -> Result<(), String> { let legacy = legacy_path(app)?; @@ -268,124 +214,28 @@ fn migrate_legacy(app: &AppHandle) -> Result<(), String> { /// Read the index off disk and reconcile it with the directory. /// -/// The reconciliation is not a repair path bolted on: it is what lets a topic -/// exist before any entry is written for it. An entry with no file stays — a -/// topic whose session was launched but which nobody has spoken in yet is that -/// case, and its session id is the whole reason to keep it. +/// The reconciliation is the crate's (`topic_index::read`); what is added here +/// is the legacy carry-in ahead of it, and writing back whatever the read +/// changed. A read that adopts is a read that has to be written down: the entry +/// it built is what the next reader would otherwise build again, and the +/// session ids recorded against it in between would land on an entry no file +/// agrees with. /// /// Call under `INDEX_LOCK`. fn read_index(app: &AppHandle) -> Result { migrate_legacy(app)?; - let path = index_path(app)?; - let mut index = if path.exists() { - let content = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read the topic index: {e}"))?; - serde_json::from_str::(&content) - .map_err(|e| format!("Failed to parse the topic index: {e}"))? - } else { - TopicIndex::default() - }; - - let adopted = adopt_orphans(app, &mut index)?; - index - .topics - .sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.topic_id.cmp(&b.topic_id))); - if adopted || !path.exists() { - write_index(app, &index)?; - } - Ok(index) -} - -/// Give an entry to every topic file the index does not name. -/// -/// Answers true when it changed anything. -fn adopt_orphans(app: &AppHandle, index: &mut TopicIndex) -> Result { let dir = room_dir(app)?; - let Ok(entries) = std::fs::read_dir(&dir) else { - // No directory is no topics, not a failure. It is the first run. - return Ok(false); - }; - let mut adopted = false; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } - let Some(topic_id) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - if index.find(topic_id).is_some() { - continue; - } - let (posts, _) = read_posts(&path); - let first = posts.first(); - index.topics.push(Topic { - topic_id: topic_id.to_string(), - title: first.map(|post| title_from(&post.content)).unwrap_or_default(), - // The first thing said in it, which is the closest thing a file - // carries to when it began. A topic made through the app has its - // own stamp and never reaches here. - created_at: first - .map(|post| post.ts.clone()) - .unwrap_or_else(crate::room::now_iso), - sessions: BTreeMap::new(), - }); - adopted = true; + let (index, needs_write) = topic_index::read(&dir, &crate::room::now_iso())?; + if needs_write { + topic_index::write(&dir, &index)?; } - Ok(adopted) + Ok(index) } /// Call under `INDEX_LOCK`. fn write_index(app: &AppHandle, index: &TopicIndex) -> Result<(), String> { - let path = index_path(app)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create the room log dir: {e}"))?; - } - let content = serde_json::to_string_pretty(index) - .map_err(|e| format!("Failed to serialize the topic index: {e}"))?; - std::fs::write(&path, content).map_err(|e| format!("Failed to write the topic index: {e}")) -} - -/// Make sure `topic` has an entry, and hand back a mutable hold on it. -/// -/// The one place a topic reaches the index. Every caller is a deliberate act -/// that has to survive the app closing — the first post, a session id, a -/// rename — and none of them is "the app was opened". -/// -/// Call under `INDEX_LOCK`. -fn realize<'a>(index: &'a mut TopicIndex, topic: &TopicRef) -> &'a mut Topic { - if index.find(&topic.topic_id).is_none() { - index.topics.push(Topic { - topic_id: topic.topic_id.clone(), - title: String::new(), - created_at: topic.created_at.clone(), - sessions: BTreeMap::new(), - }); - } - index - .find_mut(&topic.topic_id) - .expect("just inserted when absent") -} - -/// A title from the opening of the first thing said in the topic. -/// -/// The first line, whitespace collapsed, cut at `TITLE_CHARS`. A list of -/// timestamps is a list nobody can read (#115, decision 9), and the opening of -/// the first post is what a person would have written there anyway. -fn title_from(content: &str) -> String { - let flat = content.split_whitespace().collect::>().join(" "); - if flat.is_empty() { - return String::new(); - } - let mut chars = flat.chars(); - let head: String = chars.by_ref().take(TITLE_CHARS).collect(); - if chars.next().is_some() { - format!("{head}…") - } else { - head - } + topic_index::write(&room_dir(app)?, index) } /// Say on the screen that the log failed. @@ -399,7 +249,11 @@ pub fn report(app: &AppHandle, message: String) { } /// Hand the screen the topic list as it now stands. -fn announce(app: &AppHandle) { +/// +/// Reachable from `room.rs` as well as from here: deleting a topic changes this +/// list from a command that is not on this surface, and the screen is told +/// about the list from one place regardless of who changed it. +pub(crate) fn announce(app: &AppHandle) { let read = { let _guard = INDEX_LOCK.lock(); read_index(app) @@ -445,7 +299,7 @@ pub fn append(app: &AppHandle, topic_id: &str, post: &Post) -> Result { - let entry = realize(&mut index, topic); + let entry = index.realize(&topic.topic_id, &topic.created_at); if entry.title.is_empty() { - entry.title = title_from(content); + entry.title = topic_index::title_from(content); } match write_index(app, &index) { Ok(()) => true, @@ -494,35 +348,6 @@ pub fn realize_from_first_post(app: &AppHandle, topic: &TopicRef, content: &str) } } -/// One topic's posts, oldest first. -/// -/// The tuple's second half is how many lines did not parse. A line that does -/// not parse is skipped rather than failing the read: the case it covers is a -/// torn tail from a run that ended mid-write, and refusing the whole topic over -/// the last line of it would lose everything to protect nothing. It is not -/// skipped quietly — the count goes back to the screen on the same event a -/// failed append does. -fn read_posts(path: &Path) -> (Vec, usize) { - // No file is no history, not a failure. It is what a topic nobody has - // spoken in yet looks like. - let Ok(content) = std::fs::read_to_string(path) else { - return (Vec::new(), 0); - }; - - let mut posts = Vec::new(); - let mut skipped = 0usize; - for line in content.lines() { - if line.trim().is_empty() { - continue; - } - match serde_json::from_str::(line) { - Ok(post) => posts.push(post), - Err(_) => skipped += 1, - } - } - (posts, skipped) -} - /// One topic's posts, for a caller inside this process. /// /// The pull the sidecar's read tool reaches (`room.rs`), and the same read the @@ -530,7 +355,7 @@ fn read_posts(path: &Path) -> (Vec, usize) { /// topic contains. pub fn topic_posts(app: &AppHandle, topic_id: &str) -> Result, String> { let path = topic_path(app, topic_id)?; - let (posts, skipped) = read_posts(&path); + let (posts, skipped) = topic_index::read_posts(&path); if skipped > 0 { report(app, format!("読めなかった記録が {skipped} 件あります")); } @@ -576,7 +401,8 @@ pub fn record_session( { let _guard = INDEX_LOCK.lock(); let mut index = read_index(app)?; - realize(&mut index, topic) + index + .realize(&topic.topic_id, &topic.created_at) .sessions .insert(account_id.to_string(), session_id.to_string()); write_index(app, &index)?; @@ -585,6 +411,38 @@ pub fn record_session( Ok(()) } +/// Delete one topic: its posts, and the entry that annotated them. +/// +/// The store half of the delete. What surrounds it — stopping the sessions that +/// were running in the topic, and moving the room off it when it is the one the +/// room is in — is `room::room_delete_topic`, because neither of those is the +/// log's to do. +/// +/// Both halves go, and the file goes first (`topic_index::delete`). The entry +/// is an annotation of the file, so an index with the entry taken out and the +/// file still there is a topic the very next read adopts back under a rebuilt +/// entry — a delete that quietly undoes itself, reporting nothing while it does +/// (#119, decision 1). +/// +/// Refused when the index does not name the topic. Nothing the screen can press +/// reaches that: the current topic before anything has realised it is drawn from +/// the room rather than from the list, and it carries no delete (#119, decision +/// 5). What the refusal covers is the same thing `room_select_topic`'s does — a +/// caller naming a topic this room does not have — and it says so the same way. +/// +/// The screen is told the list changed by the caller, once the room has been put +/// somewhere the deleted topic is not. +pub fn delete_topic(app: &AppHandle, topic_id: &str) -> Result<(), String> { + let _guard = INDEX_LOCK.lock(); + let dir = room_dir(app)?; + let mut index = read_index(app)?; + if index.find(topic_id).is_none() { + return Err(format!("トピック {topic_id} は見つかりません。")); + } + topic_index::delete(&dir, &mut index, topic_id)?; + write_index(app, &index) +} + // ── Commands ───────────────────────────────────────────────────────────────── /// The topics, oldest first. @@ -639,7 +497,9 @@ pub fn room_rename_topic( if current.topic_id != topic_id { return Err(format!("トピック {topic_id} は見つかりません。")); } - realize(&mut index, ¤t).title = title; + index + .realize(¤t.topic_id, ¤t.created_at) + .title = title; } } write_index(&app, &index)?; diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 30ea819..a97b678 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -135,6 +135,20 @@ pub struct RunningSession { pub command: String, /// The working directory it was launched in. pub cwd: String, + /// The topic this session was started into. + /// + /// Which topic a session belongs to is a fact about the launch, not about + /// the account and not about the room now: the room moves between topics + /// while a session keeps running, and the seat stays where it was started + /// (#115). Recorded here because deleting a topic has to end the sessions + /// that were in it and nothing else (#119, decision 4) — read off the + /// topic's `sessions` map instead, a delete would reach an account that ran + /// in this topic once and is now running in another one. + /// + /// A seat still being claimed (`Seat::Starting`) has no entry here at all, + /// which is the one session a delete cannot see; see the accepted tradeoff + /// in docs/0-requirements.md. + pub topic_id: String, } /// One account's seat, as the screen reads it. @@ -200,6 +214,33 @@ impl RoomSeats { Ok(()) } + /// The PTY of every seat running in one topic. + /// + /// The sweep and the read are one acquisition, like `seated`: a seat whose + /// session has already exited is not a session to end, and the sweep is + /// what says so. + /// + /// A seat still starting is not here and cannot be: it holds no PTY yet, so + /// there is nothing to end. That is the window the delete cannot close, and + /// it is written down rather than papered over (docs/0-requirements.md, + /// 受容したトレードオフ). + pub fn running_in_topic(&self, topic_id: &str, ptys: &PtyState) -> Vec { + let mut seats = self.seats.lock(); + seats.retain(|_, seat| match seat { + Seat::Starting => true, + Seat::Running(session) => ptys.is_running(&session.pty_id), + }); + seats + .values() + .filter_map(|seat| match seat { + Seat::Starting => None, + Seat::Running(session) => { + (session.topic_id == topic_id).then(|| session.pty_id.clone()) + } + }) + .collect() + } + /// The launch got a session up; the seat is now held by that session. fn hold(&self, account_id: &str, session: RunningSession) { self.seats @@ -580,6 +621,10 @@ pub fn start_session( // launch command at all. command: launch_line.command.clone(), cwd: cwd.to_string_lossy().to_string(), + // The topic read at the top of this launch, not the room's + // current one: the two are the same here, and reading the + // room again would make them the same only by luck. + topic_id: topic.topic_id.clone(), }, ); Ok(started) From bc37d01195970411b504f3f06400abb50d9be524 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 03:31:14 +0900 Subject: [PATCH 3/6] feat(history): put a delete on the topic row, answered in a dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一覧の行に ❌ を足した。押すと `#topic-delete-dialog` が開き、そこで答える—— 削除は取り消せず、ログは git に載っておらず写しも無いため、確認の一段は必須である (決定2、決定1から導かれること)。`#end-dialog` / `#quit-dialog` と同じ作法であり、 CSS も同じ規則で描く。三つ目の問いに三つ目の写しを作らない。 ダイアログは二行を持つ。一行目はトピック名と、何が消えるか(発言の記録と、 セッションへの戻り道)。二行目はそのトピックで走っているセッションの名前であり、 走っていなければ出さない——決定4でセッションが止まる以上、ログの話だけをする ダイアログは、止まったセッションを不意打ちにする。走っているかどうかはアプリへ 毎回訊く。画面の写しは最後に更新された時点のものでしかない。 題の無い行には ❌ を出さない(決定5)。そう読まれる行は二つあり、どちらもここから 消す理由が無い——まだ何も言われていない現在のトピック(索引に無く、消すものが無い) と、索引に行があってファイルが無いトピック(#117)である。 行は flex のトラックになった。`.pick` が残りを取るため、長い題は ❌ を押し出さずに 省略される。 #119 Co-Authored-By: Claude Opus 5 --- index.html | 33 ++++++++++ src/main.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++++++- src/styles.css | 92 ++++++++++++++++++++++----- 3 files changed, 275 insertions(+), 15 deletions(-) diff --git a/index.html b/index.html index 44391f3..6be2718 100644 --- a/index.html +++ b/index.html @@ -406,6 +406,39 @@

アプリの終了

+ + + +
+

トピックの削除

+

+ + + + + + +
+
diff --git a/src/main.ts b/src/main.ts index 0d003a5..f838806 100644 --- a/src/main.ts +++ b/src/main.ts @@ -233,6 +233,11 @@ interface RunningSession { started_at: string; command: string; cwd: string; + /** The topic this session was started into. A launch's own fact: the room + * moves between topics while a session runs, and the seat stays where it + * was started. Read when a topic is deleted, which ends the sessions that + * were in that topic and no others (#119, decision 4). */ + topic_id: string; } /** One account holding a seat, and what it is running. */ @@ -399,6 +404,13 @@ const quitDialogEl = document.getElementById("quit-dialog") as HTMLDialogElement const quitMessageEl = document.getElementById("quit-dialog-message") as HTMLElement; const quitCancelEl = document.getElementById("quit-cancel") as HTMLButtonElement; const quitCommitEl = document.getElementById("quit-commit") as HTMLButtonElement; +const topicDeleteDialogEl = document.getElementById( + "topic-delete-dialog", +) as HTMLDialogElement; +const topicDeleteMessageEl = document.getElementById("topic-delete-message") as HTMLElement; +const topicDeleteSessionsEl = document.getElementById("topic-delete-sessions") as HTMLElement; +const topicDeleteCancelEl = document.getElementById("topic-delete-cancel") as HTMLButtonElement; +const topicDeleteCommitEl = document.getElementById("topic-delete-commit") as HTMLButtonElement; let accounts: Account[] = []; /** @@ -445,6 +457,15 @@ let homeDir = ""; */ let topics: Topic[] = []; let currentTopic: TopicRef | null = null; +/** + * The topic `#topic-delete-dialog` is standing open on, or null when it is not. + * + * The topic rather than its id, because the answer is reported by name and the + * row it was opened from is not under the pointer any more. Cleared on every + * path out of the dialog, so a close by Escape leaves nothing a later click + * could fire — the same discipline `endingAccount` keeps (#71). + */ +let deletingTopic: Topic | null = null; let lastSeenId: string | null = null; /** * Every `message_id` this screen has put on the glass. @@ -1152,7 +1173,20 @@ function renderTopics(): void { for (const topic of listed) topicListEl.appendChild(topicRow(topic)); } -/** One row of the list: the whole row is the control that opens it. */ +/** + * One row of the list: the row opens the topic, and ❌ deletes it. + * + * The mark is the one the participant panel's 終了 uses, for the same reason it + * is used there — this is the control on the row that cannot be taken back, and + * it sits beside one that merely changes what is showing. + * + * **A topic with no title carries no ❌ (#119, decision 5).** Two rows read that + * way and neither is a topic anyone has a reason to delete from here: the + * current topic before anything has been said in it, which is not in the index + * and has nothing to remove, and a row the index carries for a file that is not + * there (#117). Putting a button on every row that appears in the list is the + * form decision 5 refused. + */ function topicRow(topic: Topic): HTMLLIElement { const row = document.createElement("li"); row.className = "topic"; @@ -1183,9 +1217,22 @@ function topicRow(topic: Topic): HTMLLIElement { }); row.appendChild(pick); + if (topic.title) row.appendChild(deleteButton(topic)); return row; } +/** The ❌ on one row. It asks; `#topic-delete-dialog` is where it is answered. */ +function deleteButton(topic: Topic): HTMLButtonElement { + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "delete"; + remove.textContent = "❌"; + remove.title = `「${topic.title}」を削除する`; + remove.setAttribute("aria-label", `トピック「${topic.title}」を削除する`); + remove.addEventListener("click", () => void openTopicDeleteDialog(topic)); + return remove; +} + /** * Rename one topic in place. * @@ -1267,6 +1314,113 @@ async function openTopic(topic: Topic): Promise { } } +/** + * Ask whether one topic is to be deleted. Nothing is deleted until answered. + * + * The question names what goes: the posts, and the way back into the sessions + * that were in it. Both are gone for good — the log is not in git and nothing + * copies it — which is why this is a dialog and not a second click on the + * button (#119, 決定1から導かれること). + * + * The sessions running in the topic are named on their own line, and only when + * there are any: deleting the topic ends them (#119, decision 4), and a + * question that spoke only about the log would make a stopped session an + * ambush. Asked of the app rather than read off `seated`, for the reason + * `runningSeatNames` asks: the app is what would end them, and this screen's + * copy is only as fresh as whatever last refreshed it. + */ +async function openTopicDeleteDialog(topic: Topic): Promise { + deletingTopic = topic; + topicDeleteMessageEl.textContent = + `トピック「${topic.title}」を削除します。` + + `発言の記録と、セッションへの戻り道が消えます。取り消せません。`; + + const running = await runningNamesInTopic(topic.topic_id); + topicDeleteSessionsEl.hidden = running.length === 0; + topicDeleteSessionsEl.textContent = + running.length === 0 + ? "" + : `${running.join("、")} のセッションも終了します。`; + + // Opened after the read, so the question is whole the first time it is on the + // glass. A dialog that grew a line while it was open would be one the person + // may have already answered without it. + topicDeleteDialogEl.showModal(); +} + +/** + * The names of the accounts whose sessions are running in one topic. + * + * A seat carries the topic it was started into, so this is the topic's own + * sessions and not every seat in the room: the room moves between topics while + * a session runs, and an account that ran here once may be running elsewhere + * now (#119, decision 4). + * + * On a failed read this screen's own copy stands, for the reason + * `runningSeatNames` keeps it: a read that failed says nothing about who is + * running, and answering "nobody" would put the person in front of a question + * that does not mention the sessions it is about to end. + */ +async function runningNamesInTopic(topicId: string): Promise { + let held: SeatedAccount[]; + try { + held = await invoke("seated_accounts"); + } catch { + held = [...seated.values()]; + } + return held + .filter((seat) => seat.session?.topic_id === topicId) + .map( + (seat) => + accounts.find((one) => one.id === seat.account_id)?.name.trim() || seat.account_id, + ); +} + +/** Leave the question unanswered. Escape lands here too, by the close handler. */ +function closeTopicDeleteDialog(): void { + deletingTopic = null; + if (topicDeleteDialogEl.open) topicDeleteDialogEl.close(); +} + +/** The answer that acts. */ +function confirmTopicDeleteDialog(): void { + const topic = deletingTopic; + closeTopicDeleteDialog(); + if (topic) void deleteTopic(topic); +} + +/** + * Delete one topic. + * + * The app answers with the topic the room moved to, or null when the room was + * somewhere else and did not move. Deleting the open topic leaves the room in a + * new one, which is the state a launch already produces — an empty room, in a + * topic not yet in the index (#119, decision 6) — so what is drawn for it is + * what `startNewTopic` draws. + * + * The list itself arrives on `room-topics`; this redraw is for the current + * topic, which is this screen's own value and is not in that payload. + * + * The seats are re-read either way. Sessions running in the topic were ended by + * the delete, and the panel is drawn from a copy that does not know it yet. + */ +async function deleteTopic(topic: Topic): Promise { + try { + const moved = await invoke("room_delete_topic", { + topicId: topic.topic_id, + }); + if (moved) { + currentTopic = moved; + drawTopic([]); + } + renderTopics(); + status(`トピック「${topic.title}」を削除しました。`); + } catch (err) { + status(`トピックを削除できませんでした: ${err}`, "error"); + } + await refreshSeats(); +} + /** * Cut here: a new topic, and an empty room. * @@ -3144,6 +3298,15 @@ async function main(): Promise { endingAccount = null; }); + topicDeleteCancelEl.addEventListener("click", () => closeTopicDeleteDialog()); + topicDeleteCommitEl.addEventListener("click", () => confirmTopicDeleteDialog()); + // Escape closes the dialog itself, and it means 取消 — the same discipline the + // dialog above keeps, and for the same reason: no path out of it leaves a + // topic standing that a later click could delete. + topicDeleteDialogEl.addEventListener("close", () => { + deletingTopic = null; + }); + try { const config = await invoke("load_config"); accounts = config.accounts; diff --git a/src/styles.css b/src/styles.css index bbc7e07..f239be9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -555,11 +555,22 @@ body { gap: 0.15rem; } -/* One topic. A row that is picked, so it is the shape a picked row has on the - panel opposite — the whole row is the control, and the selected one is lifted - out of the panel's surface rather than tinted (#75). */ +/* One topic. The row is a track holding the control that opens it and, where + the topic has a name, the ❌ that deletes it (#119, decision 5). It was the + button itself until the ❌ arrived; what is drawn as picked is still the + button, so the panel opposite's shape is unchanged. */ +#history .topic { + display: flex; + align-items: center; + gap: 0.2rem; +} + +/* The control that opens the topic. It takes what the row has left, so a long + title ellipses against the ❌ rather than pushing it out of the panel. Picked + is the shape a picked row has on the panel opposite — lifted out of the + panel's surface rather than tinted (#75). */ #history .topic .pick { - width: 100%; + flex: 1; min-width: 0; display: block; font: inherit; @@ -615,6 +626,35 @@ body { padding: 0.25rem 0.35rem; } +/* Deleting the topic. The mark and the danger colour are the participant + panel's 終了 (`#participants .member .end`), because this is the same kind of + control in the same kind of list: the one thing on the row that cannot be + taken back, next to one that merely changes what is showing. Colour reaches + the outline and the mark's text fallback rather than the emoji itself, which + is drawn by a colour font — it is kept for the fallback, which is the case + that needs it. + + It does not shrink. A row whose title is long is the row where the button + would otherwise be squeezed to nothing, and that is not a state this control + has. */ +#history .topic .delete { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + font: inherit; + font-size: 0.75rem; + line-height: 1; + color: var(--danger); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0; + cursor: pointer; +} + #history .empty { padding: 0.45rem 0.9rem; color: var(--muted); @@ -1158,8 +1198,14 @@ body { /* Its own dialog, holding a question and two answers. It borrows the account dialog's frame because the two are the same kind of surface, and shares none - of its body: that one is a form, this one is a sentence (#71). */ -#end-dialog { + of its body: that one is a form, this one is a sentence (#71). + + `#topic-delete-dialog` (#119) is drawn by these same rules rather than by a + copy of them. They are two questions and two elements — one element holding + both would have to decide which of them it is opening in — but they are one + shape, and a second copy of the shape is what drifts from the first. */ +#end-dialog, +#topic-delete-dialog { width: min(22rem, calc(100vw - 3rem)); color: var(--fg); background: var(--bg); @@ -1169,31 +1215,46 @@ body { font-size: 0.85rem; } -#end-dialog::backdrop { +#end-dialog::backdrop, +#topic-delete-dialog::backdrop { background: rgb(0 0 0 / 0.45); } -#end-dialog .confirm { +#end-dialog .confirm, +#topic-delete-dialog .confirm { display: flex; flex-direction: column; gap: 0.7rem; padding: 1rem 1.1rem 0.9rem; } -#end-dialog h2 { +#end-dialog h2, +#topic-delete-dialog h2 { margin: 0; font-size: 0.95rem; } /* Which session, by name. The name is the whole content of the question — the row that was clicked is no longer under the pointer once this is open. */ -#end-dialog p { +#end-dialog p, +#topic-delete-dialog p { margin: 0; color: var(--muted); overflow-wrap: anywhere; } -#end-dialog .actions { +/* Which sessions the delete would end (#119, decision 4). Hidden when none + are, so the dialog never says it about a topic where it is not true. It sits + under the sentence about the log as a second sentence, because it is a second + thing being lost rather than a qualification of the first. */ +#topic-delete-dialog .sessions { + margin: 0; + color: var(--danger); + overflow-wrap: anywhere; +} + +#end-dialog .actions, +#topic-delete-dialog .actions { display: flex; align-items: center; gap: 0.5rem; @@ -1201,11 +1262,13 @@ body { padding: 0; } -#end-dialog .actions .spacer { +#end-dialog .actions .spacer, +#topic-delete-dialog .actions .spacer { flex: 1; } -#end-dialog .actions button { +#end-dialog .actions button, +#topic-delete-dialog .actions button { font: inherit; color: var(--fg); background: var(--bg); @@ -1218,7 +1281,8 @@ body { /* The answer that acts, filled in the danger colour. 取消 stays a plain button and holds the focus, so the two are told apart by more than their position and the easy answer is the one that changes nothing. */ -#end-dialog .actions .danger { +#end-dialog .actions .danger, +#topic-delete-dialog .actions .danger { color: var(--bg); background: var(--danger); border-color: var(--danger); From 3ec3cecd5c357edb2f8293e5760e70c0148948e9 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 03:34:12 +0900 Subject: [PATCH 4/6] docs(requirements): record the topic delete and the store crate that carries it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「部屋のログ」に削除の項を足した——ファイルと索引の項目の両方を消すこと、 ファイルが先であること、そして逆順が黙って復活する削除になること。 「トピック」に決定 1-6 を書いた。三つの行為(セッション → 保存 → 部屋)の順序と、 その順序がなぜそれでなければならないかを含む。「トピックの一覧」に行の `❌` と ダイアログの作法を足し、「未実装」から「トピックの削除」を落として「実装済み」へ 移した。 「テストの配置」を書き直した。#115 の時点で「照合は読んで確かめられる範囲を出て いる、crate へ出すのが正しい形である、この issue の範囲では行っていない」と書いて あったものが、#119 で行われた。削除は照合の裏返しであり、読んで確かめられる性質の ものではない。CI の表に `crates/topic-index` の行を足した。 「受容したトレードオフ」の #115 の行を現状へ更新し、起動途中の席(`Seat::Starting`) が削除から見えないことを新しい行として足した。 #119 Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 ++++ README.md | 1 + docs/0-requirements.md | 41 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdab4d3..3b0892e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,10 @@ jobs: working-directory: crates/room-floor run: cargo test + - name: Test the topic-index crate + working-directory: crates/topic-index + run: cargo test + # Gate job: single Required status check for branch protection. # Add new jobs to `needs` when CI grows. No Settings change needed. CI: diff --git a/README.md b/README.md index 3464c11..fb38ca7 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ src/ チャットルーム UI(TypeScript) src-tauri/src/ Tauri、部屋ソケット、PTY、設定・セッション保存の Rust 実装 crates/mcp-config/ .mcp.json 登録と起動フラグ検査(tauri 非依存、テスト対象) crates/room-floor/ 同時発話の順序付け(tauri 非依存、テスト対象) +crates/topic-index/ トピックの保存と索引の照合(tauri 非依存、テスト対象) portable-pty-patch/ Windows 対応を含む portable-pty のローカルパッチ .github/workflows/ Windows CI とリリース用 CD ``` diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 14eadc5..c2ea7ae 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -245,6 +245,7 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の - **`hue` と `own` は載せない。** `own` は見る側の属性であって話者の属性ではなく(`room.rs` の型が自らそう言っている)、記録すれば一人の視点を発話の一部として残すことになる。`hue` は参加した時点の宣言であり、読み戻す頃にはそれを持っていた席が無い——引き直せば、誰も宣言していない色を描くことになる。 - **一トピック一ファイルとする。** 一ファイル+`topic_id` 欄の案は採らない。理由は二つあり、両方が要る。一つのトピックを読むのに一つのファイルだけを読めばよくなること(#114 はこれで解消する)と、トピックが発言には無いもの——そこに居たセッションの id——を持つことである。投稿に欄を増やす形にはその置き場が無い(#39、参加者クラスを値域に取る欄を作らない)。 - **実在はディレクトリであり、索引はその注釈である。** 索引に無い `.jsonl` は読み出しのときに拾って項目を建てる。これが遅延生成を安全にしている——起動のたびに空の行が索引へ残らず、発言がファイルへ届いて索引へ届く前に落ちても、ファイルだけから項目を建て直せる。 +- **削除はファイルと索引の項目の両方を消す。ファイルが先である(#119、決定1)。** 索引の項目はファイルの注釈であるため、項目だけを消してファイルを残した状態は「削除されたトピック」ではなく「注釈の無いトピック」であり、次の読み出しの照合がそのまま項目を建て直す——何も失敗を報告しないまま復活する。順序はここから決まる。消えるべき半分はファイルであるから先に消し、二つの間で失敗すれば「項目は在るがファイルが無い」状態(#117 が既に述べているもの)が残る。これは一覧に見えている状態であり、逆順の失敗(トピックが戻ってくる)とは違う。ファイルが無いことは失敗ではない——それが #117 の状態そのものであり、項目を消すことがまさに求められているものである。 - **索引へ書くのは意図のある行為だけである。** 最初の発言、セッション id の記録、名前の変更。「アプリが開かれた」はそこに含まれない。 - **判定と同じロックの内側で書く。** `deliver()` が床を判定した一回の取得の中で追記する。ロックを離してから書く形では、床が既に順序を付けた二人が逆順でディスクへ届きうるため、一つの会話に二つの順序が生まれる。並びの権威は部屋であるという既存の形(上記「床」)を、ファイルの側でも崩さない。書き込み先のトピックも同じ取得の中で読む——外で読めば、切り替えが二つの操作の間に挟まったとき、片方のトピックの順序で受理された発言がもう片方のファイルへ落ちる。 - **索引はロックの外で書く。** 別のファイルの読み書きであり、部屋を止めて行う仕事ではない。発言はそのどれにも待たされない。 @@ -284,6 +285,24 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の **返すものは「あなた宛ではない」と明示する。** 読み出しが返す発言は、その参加者へ配られたことが一度も無いものである。届いた発言として読めば答えるべきものになる。 +**トピックは一覧から消せる(#119)。** 消すとは、そのトピックのファイルと索引の項目の両方を捨てることである(決定1)。索引の項目だけを消す形は取らない——上記「部屋のログ」の照合が次の読み出しで建て直すためであり、黙って復活する削除になる。 + +**消えたものは戻らない。** ログは git に載っておらず、他所に写しも無い。`rules/model/subtractive-structural-beauty.md` の削除判断でいう「ローカルの非 git な意味のある状態」であり、復旧コストは高い側に立つ。したがって**確認の一段は任意ではなく必須**である(決定2)。形は明示のダイアログであり、二度押しや猶予ではない。 + +**セッション id も一緒に消える。** 「ログは残してセッションだけ捨てる」形は選択肢から外れている。トピックを消すとは、その会話と、そこに居た参加者への戻り道を、同時に捨てるということである。 + +**走っているセッションを持つトピックも消せる(決定3)。** 走っていることを理由に拒まない。 + +**消すときは、そのトピックで走っている CLI プロセスを止める(決定4)。** 席に残さない——トピックが消えれば戻り道も消えるため、走らせたままにすると、どのトピックにも属さないセッションが残る。**どのトピックの席かは起動時の事実であり、席がそれを持つ。** トピックの `sessions` から引く形は取らない——以前このトピックで走り、今は別のトピックで走っているアカウントに届いてしまう。 + +**確認ダイアログはセッションが止まることを述べる。** ログの削除だけを述べていると、止まったセッションが不意打ちになる。走っているセッションが無いときは述べない——そのときは事実ではない。 + +**空タイトルの行には削除ボタンを出さない(決定5)。** 一覧に出る行すべてにボタンを付ける形は取らない。そう読まれる行は二つあり、どちらもここから消す理由が無い——まだ何も言われていない現在のトピック(索引に無く、消すものが無い)と、索引に行があってファイルが無いトピック(#117)である。 + +**開いているトピックを消したら、新しいトピックへ移る(決定6、AI 判断)。** 消した直後の状態は起動直後の状態と同じもの——部屋は空で、まだ索引に無いトピックに居る——であり、アプリが既に持っている状態を使い回す。「次のトピックへ移る」は取らない:頼んでいない会話が開くうえ、要らないものを続けて消しているとき、次も消したいトピックへ落ちる。「開いているものは消せない」も取らない:見ているから要らないと判断したのに、一度離れてから消せというのは順序が逆である。 + +**三つの行為の順序は、セッション → 保存 → 部屋である。** セッションを先に止めるのは決定4のためである。保存を先に消して止めるのが後だと、失敗したときに決定4が禁じた状態(トピックが消えてセッションが残る)になる。逆に保存の削除が失敗したときは、セッションは既に終わっていてトピックは一覧に残る——見えている状態であり、押し直せる。部屋を最後に動かすのは、移った先を画面へ返す必要があるためである:画面は自分の現在トピックを持っており、消えたトピックを指したままだと、描いている会話と記録先が食い違う。 + **トピック名は最初の発言の冒頭から自動生成し、後から手で変更できる。** 日時のみの一覧は可読性が低い。生成は最初の発言が着いた時点で一度だけ行い、既に名前があるトピックには触れない——話す前に名前を付けたトピックの名前を、最初の発言が取り返す形にしない。 ### MCP サーバの実装方式 @@ -318,6 +337,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 拒否が返した発言の描画(画面。`message_id` による重複除去、返ってきた最新への透かしの前進、発言に載せて運ぶ宣言色、描いた分だけを述べる状況表示) - 部屋のログ(`logs/main/{トピック}.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、トピックを指定する読み出しコマンド、参加者への再配信をしないこと) - トピック(手動の区切り、起動時の新規トピックと遅延生成、索引とディレクトリの照合、最初の発言からの命名とその場での変更、既存 `logs/main.jsonl` の移行、切り替え時の床の初期化) +- トピックの削除(行の `❌`、確認のダイアログ、ファイルと索引の項目の両方の削除、そのトピックで走っているセッションの終了、開いているトピックを消したときの新しいトピックへの移動、題の無い行にボタンを出さないこと) - トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開できない席の新規着席) - 参加者からの読み出し(サイドカーの `read_room_history`、`history` / `history_result` フレーム、pull のみ、ページと `before` の巻き戻し) - トピックの一覧(会話面の左の列、新しい順、現在のトピックの描画、選択による部屋への読み戻し、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) @@ -438,7 +458,6 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - トピックを切り替えたときに、席を持っている参加者がそれをどう受け取るかの実機確認。床は空になり席は入口へ戻るが(上記「トピック」)、走っているセッション自身は自分の文脈をそのまま持ったままである。 - 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}/`、現在は `main` に固定)、部屋そのものは一つである。 - トピックからの引用操作。読み戻した行は部屋の本文に在るが、行を選んで入力欄へ持っていく経路は持たない。 -- トピックの削除。一覧から消す経路は持たない。 - plugin としての allowlist 掲載(配布の第二段階) ### 二つの地色 @@ -469,6 +488,14 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre **名前はその場で直す。** 行をダブルクリックすると入力欄になり、Enter が決定、Escape とフォーカス喪失が取消である。生成された題は編集できる欄に置かれた出発点であり、アカウントの名前と同じものである。 +**行から消せる(#119)。** 行の右端に `❌` を置く。押すと確認のダイアログが開き、そこで答える——参加者パネルの `終了` と同じ記号・同じ危険色であり、同じ種類の操作だからである:その行で唯一取り消せない操作が、面を切り替えるだけの操作の隣に在る。行そのものは flex のトラックになり、`.pick` が残りの幅を取る。長い題は `❌` を押し出さずに省略される。 + +**題の無い行には出さない(決定5)。** 詳細と、そう読まれる二つの行については上記「トピック」に書いた。 + +**ダイアログは `#end-dialog` / `#quit-dialog` と同じ作法である。** webview 内の ``、取消に焦点、Escape は取消。要素は別に持つ——一つの要素が三つの問いを持てば、開くたびにどの顔で開くかを決めることになり、答えは訊いた側へ帰る道を探すことになる。CSS は同じ規則で描く。形は一つであり、二つ目の写しは一つ目からずれる。 + +**ダイアログは二行を持つ。** 一行目はトピック名と、何が消えるか。二行目は止まるセッションの名前であり、走っていなければ出さない(上記「トピック」、決定4から導かれること)。二行目は一行目の限定ではなく、失われる二つ目のものであるため、別の文にする。 + **「新規」は一覧の見出しの隣に置く。** 作る操作はそれが現れるリストの隣に置く——参加者パネルの「+」と同じ形である(#59)。 **参加者パネルと対にする。** 幅(16.5rem)と地色を揃え、枠は会話面に向く側に置く(左は `border-right`、右は `border-left`)。部屋を挟む二つの面が別々の寸法を持てば、揃っていないこと自体が読まれる。中の部品は同じ語彙(`.panel-head` / `.panel-title` / `.empty`)を使う。選択中の行は、参加者パネルの選択中の行と同じく枠のある窓の地の箱である(#75)。 @@ -893,13 +920,17 @@ slug は読みやすさのためだけにあり、ASCII 英数字と `-` に落 ## テストの配置 -`.mcp.json` への登録と起動フラグの検査は `crates/mcp-config/`、床の判定と刻印は `crates/room-floor/` という、いずれも tauri 非依存の crate に置く。 +`.mcp.json` への登録と起動フラグの検査は `crates/mcp-config/`、床の判定と刻印は `crates/room-floor/`、トピックの保存(索引とディレクトリの照合、削除、題の生成、一行の解析)は `crates/topic-index/` という、いずれも tauri 非依存の crate に置く。 理由は依存の正しさと、テストが実行できることの両方である。これらのロジックが tauri を必要とする理由はそもそも無い。加えて `src-tauri` 側に置くと、テストバイナリが tauri の依存ツリー全体をリンクするため、GNU ターゲットでは `STATUS_ENTRYPOINT_NOT_FOUND`(`0xc0000139`)でプロセスが起動せず、アサーションが一度も実行されない。これはローカル環境固有ではなく CI でも再現する(run 32431917979)。特定の依存クレートまでは切り分けていない。 したがって `src-tauri/src/` に残すのは、tauri の `State` / `AppHandle` に触れる部分だけとする。純粋ロジックをそちらへ書き足すと、書いた時点で検証不能になる。床の順序付けは読んでも確かめられない性質のもの(二人が同時に呼んだとき片方だけが通る)であり、この方針が最も効く箇所である。`src-tauri/src/room.rs` が持つのは、その判定を既存ロックの内側で呼ぶことと、結果をフレームとイベントへ載せることだけである。 -部屋のログ(`src-tauri/src/room_log.rs`)はこの方針の例外ではなく、境界の側である。パスの解決は `AppHandle` の `app_data_dir` に依存し、追記も読み出しもその解決の上に載るため、tauri 非依存の crate へは出せない。ただし純粋な部分は残っている——発言から五欄への写像、一行の解析、題の生成、そして索引とディレクトリの照合である。**これらは `cargo test` の対象になっていない。** 隠さずここに書く。写像は落とす欄を明示するために手で書いてあり(`LoggedPost::of`)、解析は `serde_json` そのものであるため、読んで確かめられる範囲に収めてある。題の生成(`title_from`)と照合(`adopt_orphans`)は #115 で増えた分であり、前者は純粋関数、後者はディレクトリの読みに依存する。**照合のほうは読んで確かめられる範囲を出ている**——遅延生成の安全性がこれに乗っているためであり、`AppHandle` への依存を外して crate へ出すのが正しい形である。この issue の範囲では行っていない。床の順序付けのように読んでも確かめられない性質のものは、この面には無い——追記の順序は床の判定と同じロックの内側で決まっており、その順序付け自体は既に `crates/room-floor/` が持っている。 +部屋のログ(`src-tauri/src/room_log.rs`)は境界の側である。パスの解決は `AppHandle` の `app_data_dir` に依存し、索引の read-modify-write のロックと画面へのイベントもここに在るため、そこは crate へは出せない。出したのは、部屋のディレクトリさえ渡れば済む部分すべてである——形(`LoggedPost` / `Topic` / `TopicIndex`)、索引の読み書き、照合、削除、題の生成、一行の解析。`room_log.rs` に残るのは、そのどれに触るかを決める側と、`Post` から五欄への写像(`Post` は部屋の形であり、crate へは行けない)と、旧 `logs/{部屋名}.jsonl` の引き継ぎ(部屋のディレクトリの一つ上を読むため、ディレクトリだけを渡す crate の外に在る)である。 + +**#115 の時点でこれは行われておらず、その状態はここに書かれていた**——「照合のほうは読んで確かめられる範囲を出ている。`AppHandle` への依存を外して crate へ出すのが正しい形である。この issue の範囲では行っていない」。#119 がそれを load-bearing にした。トピックの削除は照合の裏返しであり、索引の項目だけを消す削除は照合がそのまま元へ戻す——読んで確かめられる性質のものではなく、走らせて見るものである。`crates/topic-index/` のテストの中心はそれであり、項目だけを消す形を実際に走らせて、次の読み出しでトピックが戻ることを見る。 + +追記の順序については、この面に確かめるものは無い——順序は床の判定と同じロックの内側で決まっており、その順序付け自体は既に `crates/room-floor/` が持っている。 CI が実行するもの: @@ -911,6 +942,7 @@ CI が実行するもの: | `cargo check --target x86_64-pc-windows-gnu` | アプリのコンパイル | | `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全、起動フラグ検査、起動しないサーバの名指し | | `cargo test`(`crates/room-floor`) | 床の判定(未読による拒否、自分の発言の除外、解決できない `last_seen`、席の位置、同時発話の順序付け、拒否が運ぶ宣言色) | +| `cargo test`(`crates/topic-index`) | トピックの保存(索引に無いファイルの採録、削除がファイルと項目の両方を取ること、項目だけを消した場合の復活、他のトピックを乱さないこと、ファイルの無い項目の削除、一ファイル名でない id の拒否、題の生成、千切れた行の読み飛ばしと計数) | ## 往復が成立しないときの切り分け @@ -981,7 +1013,8 @@ CI が実行するもの: | 「出力中」が端末の出力だけを言う(#82) | 「出力中」と出ても部屋へ発言が来ないことがある。語は部屋への到着を主張していないため受容する(Master、2026-08-24) | | 読み戻した行が宣言色を持たない(#115) | ログは `hue` を持たないため、トピックを開き直すと過去の発言は名前から導いた色で出る。同じ名前を名乗った二人は一色になり、この画面自身の過去の発言もアクセントでは出ない。誰も宣言していない色を保存しないことの代償である | | セッション id の置換子を起動オプションへ書く(#115) | 「新しいセッションを始める」側に専用の欄が無く、`{session_id}` を自分で書く必要がある。どのフラグが id を運ぶかは CLI ごとの問いであり、アプリがフラグを持てば `claude` 以外で外れる | -| 索引をディレクトリと照合して建て直す(#115) | 遅延生成の安全性がこの照合に乗るが、照合そのものは `AppHandle` に依存するため `cargo test` の外に在る(上記「テストの配置」) | +| 索引をディレクトリと照合して建て直す(#115) | 遅延生成の安全性がこの照合に乗る。#115 の時点では `AppHandle` 依存のため `cargo test` の外に在ったが、#119 が `crates/topic-index/` へ出した(上記「テストの配置」) | +| 起動途中の席は削除から見えない(#119) | 席は PTY が立つ前に確保されるため(`Seat::Starting`)、その窓の中に在るセッションには止めるものがまだ無く、削除はそれを終えられない。トピックが消えた後にその起動が完了すれば、セッション id はどのトピックにも属さないまま残る。押す操作が二つ同時に要る幅であり、席のロックと索引のロックを起動をまたいで両方持つ形を避けるほうを採る | ## 位置づけ From 3cf1bd4494e78ef1d0bbbd4b0a5ebd9bc2d7451f Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 03:35:29 +0900 Subject: [PATCH 5/6] docs(pty): name every kill path in the exit thread's comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kill_pty` だけを名指していたが、席を取り出して殺す経路は `kill_each`(#119)と `kill_all` の掃き出しもある。どれが先に取り出しても、終了を告げるのはこのスレッドで あることは変わらない。 #119 Co-Authored-By: Claude Opus 5 --- src-tauri/src/pty.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index ca4d62f..16672c7 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -190,8 +190,9 @@ pub fn spawn_pty( let ended = ptys_clone.lock().remove(&id_clone); let exit_code: Option = match ended { Some(mut pty) => pty.child.wait().ok().map(|status| status.exit_code()), - // Already removed: `kill_pty` took it. The exit is this thread's to - // announce either way; the code is not knowable from here. + // Already removed: a kill took it — `kill_pty`, `kill_each`, or the + // `kill_all` sweep. The exit is this thread's to announce either + // way; the code is not knowable from here. None => None, }; // Emit exit event with exit code payload (None if killed by signal/unknown) From 05a7ccf5a9e99b7631179ad4e3b5e1ed74134adb Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Fri, 28 Aug 2026 03:38:33 +0900 Subject: [PATCH 6/6] fix(history): do not open a second delete question over a standing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openTopicDeleteDialog` は走っているセッションをアプリへ訊いてからダイアログを 開く。その await をまたいで二度目の ❌ が入ると、二つの呼び出しが絡む——二度目が 一度目のトピックと一文目を上書きし、そのあと一度目の読みが返ってダイアログが開く。 出るのは、片方の名前ともう片方のセッションを載せた問いである。二度目の読みが返ると `showModal()` が既に開いた dialog に対して投げる。 問いが立っている間は開かない形にした。`deletingTopic` はダイアログから出るどの 経路でも消えるため、これで詰まる状態は無い。 #119 Co-Authored-By: Claude Opus 5 --- src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.ts b/src/main.ts index f838806..c583450 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1330,6 +1330,12 @@ async function openTopic(topic: Topic): Promise { * copy is only as fresh as whatever last refreshed it. */ async function openTopicDeleteDialog(topic: Topic): Promise { + // A question is already standing, so this one is not opened. Without it the + // two interleave across the read below: the second click overwrites the first + // one's topic and its sentence, then the first read returns and opens the + // dialog carrying one topic's name over the other's sessions. `deletingTopic` + // is cleared on every path out of the dialog, so nothing is wedged by it. + if (deletingTopic) return; deletingTopic = topic; topicDeleteMessageEl.textContent = `トピック「${topic.title}」を削除します。` +