Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 11 changed files with 1647 additions and 142 deletions.
| @@ -1313,6 +1313,12 @@ dependencies = [ | ||
| 1313 | 1313 | "linktime-proc-macro", |
| 1314 | 1314 | ] |
| 1315 | 1315 | |
| 1316 | + [[package]] | |
| 1317 | + name = "data-encoding" | |
| 1318 | + version = "2.11.1" | |
| 1319 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1320 | + checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | |
| 1321 | + | |
| 1316 | 1322 | [[package]] |
| 1317 | 1323 | name = "data-url" |
| 1318 | 1324 | version = "0.3.2" |
| @@ -6082,6 +6088,17 @@ dependencies = [ | ||
| 6082 | 6088 | "serde", |
| 6083 | 6089 | ] |
| 6084 | 6090 | |
| 6091 | + [[package]] | |
| 6092 | + name = "sha1" | |
| 6093 | + version = "0.11.0" | |
| 6094 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6095 | + checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 6096 | + dependencies = [ | |
| 6097 | + "cfg-if", | |
| 6098 | + "cpufeatures 0.3.1", | |
| 6099 | + "digest 0.11.3", | |
| 6100 | + ] | |
| 6101 | + | |
| 6085 | 6102 | [[package]] |
| 6086 | 6103 | name = "sha1_smol" |
| 6087 | 6104 | version = "1.0.1" |
| @@ -7025,6 +7042,22 @@ dependencies = [ | ||
| 7025 | 7042 | "core_maths", |
| 7026 | 7043 | ] |
| 7027 | 7044 | |
| 7045 | + [[package]] | |
| 7046 | + name = "tungstenite" | |
| 7047 | + version = "0.30.0" | |
| 7048 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7049 | + checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" | |
| 7050 | + dependencies = [ | |
| 7051 | + "bytes", | |
| 7052 | + "data-encoding", | |
| 7053 | + "http", | |
| 7054 | + "httparse", | |
| 7055 | + "log", | |
| 7056 | + "rand 0.10.3", | |
| 7057 | + "sha1", | |
| 7058 | + "thiserror 2.0.21", | |
| 7059 | + ] | |
| 7060 | + | |
| 7028 | 7061 | [[package]] |
| 7029 | 7062 | name = "typeid" |
| 7030 | 7063 | version = "1.0.3" |
| @@ -7303,6 +7336,7 @@ dependencies = [ | ||
| 7303 | 7336 | "serde", |
| 7304 | 7337 | "serde_json", |
| 7305 | 7338 | "smol", |
| 7339 | + "tungstenite", | |
| 7306 | 7340 | "ureq", |
| 7307 | 7341 | ] |
| 7308 | 7342 | |
| @@ -13,6 +13,7 @@ libc = "0.2" | ||
| 13 | 13 | smol = "2" |
| 14 | 14 | base64 = "0.22" |
| 15 | 15 | ureq = { version = "2", default-features = false, features = ["tls"] } |
| 16 | + tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } | |
| 16 | 17 | chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } |
| 17 | 18 | |
| 18 | 19 | [profile.dev] |
| @@ -151,6 +151,10 @@ pub enum LineKind { | ||
| 151 | 151 | pub struct DiffLine { |
| 152 | 152 | pub kind: LineKind, |
| 153 | 153 | pub text: String, |
| 154 | + /// Where the line sits in the old and the new file. Added lines have no | |
| 155 | + /// old number, removed ones no new number, hunk rows neither. | |
| 156 | + pub old_line: Option<u32>, | |
| 157 | + pub new_line: Option<u32>, | |
| 154 | 158 | } |
| 155 | 159 | |
| 156 | 160 | #[derive(Clone, Debug)] |
| @@ -161,16 +165,42 @@ pub struct FileDiff { | ||
| 161 | 165 | } |
| 162 | 166 | |
| 163 | 167 | /// The unified diff between two snapshots, optionally for one path. |
| 164 | - pub fn diff_between(cwd: &Path, base: &str, head: &str, path: Option<&str>) -> Result<Vec<FileDiff>, String> { | |
| 165 | - let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, head]; | |
| 168 | + /// `ignore_whitespace` leaves out whitespace-only edits, and with them the | |
| 169 | + /// files that changed nothing else. | |
| 170 | + pub fn diff_between( | |
| 171 | + cwd: &Path, | |
| 172 | + base: &str, | |
| 173 | + head: &str, | |
| 174 | + path: Option<&str>, | |
| 175 | + ignore_whitespace: bool, | |
| 176 | + ) -> Result<Vec<FileDiff>, String> { | |
| 177 | + let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3"]; | |
| 178 | + if ignore_whitespace { | |
| 179 | + args.push("--ignore-all-space"); | |
| 180 | + } | |
| 181 | + args.extend([base, head]); | |
| 166 | 182 | if let Some(path) = path { |
| 167 | 183 | args.extend(["--", path]); |
| 168 | 184 | } |
| 169 | - Ok(parse_diff(&git(cwd, &args)?)) | |
| 185 | + let mut files = parse_diff(&git(cwd, &args)?); | |
| 186 | + if ignore_whitespace { | |
| 187 | + // Newer gits drop such a file; older ones keep its header, hunkless. | |
| 188 | + files.retain(|file| file.binary || !file.lines.is_empty()); | |
| 189 | + } | |
| 190 | + Ok(files) | |
| 191 | + } | |
| 192 | + | |
| 193 | + /// The first old and new line of a hunk, from `@@ -a,b +c,d @@ context`. | |
| 194 | + fn parse_hunk(line: &str) -> Option<(u32, u32)> { | |
| 195 | + let mut ranges = line.strip_prefix("@@ ")?.split(' '); | |
| 196 | + let start = |range: Option<&str>, sign: char| range?.strip_prefix(sign)?.split(',').next()?.parse().ok(); | |
| 197 | + Some((start(ranges.next(), '-')?, start(ranges.next(), '+')?)) | |
| 170 | 198 | } |
| 171 | 199 | |
| 172 | 200 | fn parse_diff(text: &str) -> Vec<FileDiff> { |
| 173 | 201 | let mut files: Vec<FileDiff> = Vec::new(); |
| 202 | + // The numbers the next old and new line will have. | |
| 203 | + let (mut old, mut new) = (0, 0); | |
| 174 | 204 | for line in text.lines() { |
| 175 | 205 | if let Some(rest) = line.strip_prefix("diff --git ") { |
| 176 | 206 | // `a/path b/path`: take the new side. |
| @@ -179,34 +209,65 @@ fn parse_diff(text: &str) -> Vec<FileDiff> { | ||
| 179 | 209 | continue; |
| 180 | 210 | } |
| 181 | 211 | let Some(file) = files.last_mut() else { continue }; |
| 182 | - let kind = if line.starts_with("@@") { | |
| 183 | - LineKind::Hunk | |
| 212 | + let (kind, text, old_line, new_line) = if line.starts_with("@@") { | |
| 213 | + (old, new) = parse_hunk(line).unwrap_or_default(); | |
| 214 | + (LineKind::Hunk, line, None, None) | |
| 184 | 215 | } else if line.starts_with("Binary files") { |
| 185 | 216 | file.binary = true; |
| 186 | 217 | continue; |
| 187 | 218 | } else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) { |
| 188 | 219 | continue; |
| 189 | 220 | } else if let Some(text) = line.strip_prefix('+') { |
| 190 | - file.lines.push(DiffLine { kind: LineKind::Added, text: text.to_string() }); | |
| 191 | - continue; | |
| 221 | + new += 1; | |
| 222 | + (LineKind::Added, text, None, Some(new - 1)) | |
| 192 | 223 | } else if let Some(text) = line.strip_prefix('-') { |
| 193 | - file.lines.push(DiffLine { kind: LineKind::Removed, text: text.to_string() }); | |
| 194 | - continue; | |
| 224 | + old += 1; | |
| 225 | + (LineKind::Removed, text, Some(old - 1), None) | |
| 195 | 226 | } else if let Some(text) = line.strip_prefix(' ') { |
| 196 | - file.lines.push(DiffLine { kind: LineKind::Context, text: text.to_string() }); | |
| 197 | - continue; | |
| 227 | + (old, new) = (old + 1, new + 1); | |
| 228 | + (LineKind::Context, text, Some(old - 1), Some(new - 1)) | |
| 198 | 229 | } else { |
| 199 | 230 | // Headers: index, mode and new/deleted file lines. |
| 200 | 231 | continue; |
| 201 | 232 | }; |
| 202 | - file.lines.push(DiffLine { kind, text: line.to_string() }); | |
| 233 | + file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line }); | |
| 203 | 234 | } |
| 204 | 235 | files |
| 205 | 236 | } |
| 206 | 237 | |
| 207 | 238 | #[cfg(test)] |
| 208 | 239 | mod tests { |
| 209 | - use super::{LineKind, parse_diff}; | |
| 240 | + use super::{LineKind, parse_diff, parse_hunk}; | |
| 241 | + | |
| 242 | + #[::core::prelude::v1::test] | |
| 243 | + fn parses_hunk_headers() { | |
| 244 | + assert_eq!(parse_hunk("@@ -1,2 +3,4 @@ fn a() {"), Some((1, 3))); | |
| 245 | + assert_eq!(parse_hunk("@@ -0,0 +1 @@"), Some((0, 1))); | |
| 246 | + assert_eq!(parse_hunk("@@ -7 +7 @@"), Some((7, 7))); | |
| 247 | + assert_eq!(parse_hunk("@@@ -1,2 -1,2 +1,3 @@@"), None); | |
| 248 | + } | |
| 249 | + | |
| 250 | + #[::core::prelude::v1::test] | |
| 251 | + fn numbers_lines() { | |
| 252 | + let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -10,4 +12,5 @@ fn a() {\n keep\n-gone\n+one\n+two\n keep\n@@ -40 +43 @@\n-x\n+y\n"; | |
| 253 | + let files = parse_diff(diff); | |
| 254 | + let numbers: Vec<_> = files[0].lines.iter().map(|line| (line.old_line, line.new_line)).collect(); | |
| 255 | + assert_eq!( | |
| 256 | + numbers, | |
| 257 | + [ | |
| 258 | + (None, None), | |
| 259 | + (Some(10), Some(12)), | |
| 260 | + (Some(11), None), | |
| 261 | + (None, Some(13)), | |
| 262 | + (None, Some(14)), | |
| 263 | + (Some(12), Some(15)), | |
| 264 | + (None, None), | |
| 265 | + (Some(40), None), | |
| 266 | + (None, Some(43)), | |
| 267 | + ] | |
| 268 | + ); | |
| 269 | + assert_eq!(files[0].lines[0].text, "@@ -10,4 +12,5 @@ fn a() {"); | |
| 270 | + } | |
| 210 | 271 | |
| 211 | 272 | #[::core::prelude::v1::test] |
| 212 | 273 | fn parses_unified_diff() { |
| @@ -8,6 +8,7 @@ mod clock; | ||
| 8 | 8 | mod git; |
| 9 | 9 | mod http; |
| 10 | 10 | mod models; |
| 11 | + mod remote; | |
| 11 | 12 | mod session; |
| 12 | 13 | mod settings; |
| 13 | 14 | mod store; |
| @@ -0,0 +1,170 @@ | ||
| 1 | + //! The client side: a connection to another device's app on its own thread, | |
| 2 | + //! reconnecting when it drops. Messages from the host reach the workspace as | |
| 3 | + //! [`ClientEvent`]s. | |
| 4 | + | |
| 5 | + use std::{ | |
| 6 | + io::ErrorKind, | |
| 7 | + net::{TcpStream, ToSocketAddrs}, | |
| 8 | + sync::{ | |
| 9 | + Arc, | |
| 10 | + atomic::{AtomicBool, Ordering}, | |
| 11 | + mpsc, | |
| 12 | + }, | |
| 13 | + thread, | |
| 14 | + time::Duration, | |
| 15 | + }; | |
| 16 | + | |
| 17 | + use tungstenite::{Message, WebSocket}; | |
| 18 | + | |
| 19 | + use super::{ClientMessage, PROTOCOL, ServerMessage, with_port}; | |
| 20 | + | |
| 21 | + const CONNECT_TIMEOUT: Duration = Duration::from_secs(6); | |
| 22 | + const POLL: Duration = Duration::from_millis(40); | |
| 23 | + | |
| 24 | + #[derive(Clone, Debug, PartialEq)] | |
| 25 | + pub enum ConnectionStatus { | |
| 26 | + Connecting, | |
| 27 | + /// Connected to the host of that name. | |
| 28 | + Connected(String), | |
| 29 | + Failed(String), | |
| 30 | + /// The host refused the key; no more retries until the settings change. | |
| 31 | + Denied(String), | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub enum ClientEvent { | |
| 35 | + Status(ConnectionStatus), | |
| 36 | + Message(ServerMessage), | |
| 37 | + } | |
| 38 | + | |
| 39 | + pub struct RemoteClient { | |
| 40 | + outbox: mpsc::Sender<String>, | |
| 41 | + stop: Arc<AtomicBool>, | |
| 42 | + pub events: smol::channel::Receiver<ClientEvent>, | |
| 43 | + } | |
| 44 | + | |
| 45 | + impl RemoteClient { | |
| 46 | + /// Connects to `address` (`host` or `host:port`) and keeps reconnecting | |
| 47 | + /// until dropped. | |
| 48 | + pub fn connect(address: &str, key: String, name: String) -> Self { | |
| 49 | + let address = with_port(address); | |
| 50 | + let stop = Arc::new(AtomicBool::new(false)); | |
| 51 | + let (outbox, inbox) = mpsc::channel::<String>(); | |
| 52 | + let (events_tx, events) = smol::channel::unbounded(); | |
| 53 | + { | |
| 54 | + let stop = stop.clone(); | |
| 55 | + let _ = thread::Builder::new().name("remote-client".into()).spawn(move || { | |
| 56 | + let mut backoff = Duration::from_secs(2); | |
| 57 | + while !stop.load(Ordering::Relaxed) { | |
| 58 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connecting)); | |
| 59 | + match open(&address, &key, &name) { | |
| 60 | + Ok((mut socket, host)) => { | |
| 61 | + backoff = Duration::from_secs(2); | |
| 62 | + // Whatever was asked while offline is stale now. | |
| 63 | + while inbox.try_recv().is_ok() {} | |
| 64 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connected(host))); | |
| 65 | + let reason = run(&mut socket, &inbox, &events_tx, &stop); | |
| 66 | + let _ = socket.close(None); | |
| 67 | + if stop.load(Ordering::Relaxed) { | |
| 68 | + break; | |
| 69 | + } | |
| 70 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason))); | |
| 71 | + } | |
| 72 | + Err(Failure::Denied(reason)) => { | |
| 73 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Denied(reason))); | |
| 74 | + break; | |
| 75 | + } | |
| 76 | + Err(Failure::Other(reason)) => { | |
| 77 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason))); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + // Wait before retrying, a little longer each time. | |
| 81 | + let mut waited = Duration::ZERO; | |
| 82 | + while waited < backoff && !stop.load(Ordering::Relaxed) { | |
| 83 | + thread::sleep(Duration::from_millis(200)); | |
| 84 | + waited += Duration::from_millis(200); | |
| 85 | + } | |
| 86 | + backoff = (backoff * 2).min(Duration::from_secs(30)); | |
| 87 | + } | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + Self { outbox, stop, events } | |
| 91 | + } | |
| 92 | + | |
| 93 | + pub fn send(&self, message: &ClientMessage) { | |
| 94 | + if let Ok(text) = serde_json::to_string(message) { | |
| 95 | + let _ = self.outbox.send(text); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + impl Drop for RemoteClient { | |
| 101 | + fn drop(&mut self) { | |
| 102 | + self.stop.store(true, Ordering::Relaxed); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + enum Failure { | |
| 107 | + Denied(String), | |
| 108 | + Other(String), | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Connects, says hello, and waits for the welcome. | |
| 112 | + fn open(address: &str, key: &str, name: &str) -> Result<(WebSocket<TcpStream>, String), Failure> { | |
| 113 | + let other = |err: &dyn std::fmt::Display| Failure::Other(err.to_string()); | |
| 114 | + let target = address.to_socket_addrs().map_err(|err| other(&err))?.next().ok_or_else(|| other(&"no such host"))?; | |
| 115 | + let stream = TcpStream::connect_timeout(&target, CONNECT_TIMEOUT).map_err(|err| other(&err))?; | |
| 116 | + let _ = stream.set_nodelay(true); | |
| 117 | + stream.set_read_timeout(Some(Duration::from_secs(15))).map_err(|err| other(&err))?; | |
| 118 | + let (mut socket, _) = tungstenite::client(format!("ws://{address}/"), stream).map_err(|err| other(&err))?; | |
| 119 | + let hello = ClientMessage::Hello { key: key.to_string(), name: name.to_string(), protocol: PROTOCOL }; | |
| 120 | + socket | |
| 121 | + .send(Message::text(serde_json::to_string(&hello).unwrap_or_default())) | |
| 122 | + .map_err(|err| other(&err))?; | |
| 123 | + loop { | |
| 124 | + match socket.read().map_err(|err| other(&err))? { | |
| 125 | + Message::Text(text) => { | |
| 126 | + return match serde_json::from_str::<ServerMessage>(text.as_str()) { | |
| 127 | + Ok(ServerMessage::Welcome { host, .. }) => { | |
| 128 | + let _ = socket.get_mut().set_read_timeout(Some(POLL)); | |
| 129 | + Ok((socket, host)) | |
| 130 | + } | |
| 131 | + Ok(ServerMessage::Denied { reason }) => Err(Failure::Denied(reason)), | |
| 132 | + _ => Err(other(&"the host answered something unexpected")), | |
| 133 | + }; | |
| 134 | + } | |
| 135 | + Message::Close(_) => return Err(Failure::Denied("The host closed the connection.".into())), | |
| 136 | + _ => {} | |
| 137 | + } | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + /// Trades messages until the connection drops; returns why it did. | |
| 142 | + fn run( | |
| 143 | + socket: &mut WebSocket<TcpStream>, | |
| 144 | + inbox: &mpsc::Receiver<String>, | |
| 145 | + events: &smol::channel::Sender<ClientEvent>, | |
| 146 | + stop: &AtomicBool, | |
| 147 | + ) -> String { | |
| 148 | + while !stop.load(Ordering::Relaxed) { | |
| 149 | + while let Ok(text) = inbox.try_recv() { | |
| 150 | + if let Err(err) = socket.send(Message::text(text)) { | |
| 151 | + return err.to_string(); | |
| 152 | + } | |
| 153 | + } | |
| 154 | + match socket.read() { | |
| 155 | + Ok(Message::Text(text)) => match serde_json::from_str::<ServerMessage>(text.as_str()) { | |
| 156 | + Ok(message) => { | |
| 157 | + if events.send_blocking(ClientEvent::Message(message)).is_err() { | |
| 158 | + return "closed".into(); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + Err(err) => eprintln!("vectrum-code: unreadable message from host: {err}"), | |
| 162 | + }, | |
| 163 | + Ok(Message::Close(_)) => return "The host closed the connection.".into(), | |
| 164 | + Ok(_) => {} | |
| 165 | + Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} | |
| 166 | + Err(err) => return err.to_string(), | |
| 167 | + } | |
| 168 | + } | |
| 169 | + "closed".into() | |
| 170 | + } | |
| @@ -0,0 +1,252 @@ | ||
| 1 | + //! Remote connections, like T3 Code's environments: one copy of the app can | |
| 2 | + //! let others on the network see and drive its projects and threads. The | |
| 3 | + //! host runs a small WebSocket server guarded by a key; a client connects | |
| 4 | + //! with the host's address and that key, and its threads show up in the | |
| 5 | + //! client's sidebar under the host's name. | |
| 6 | + //! | |
| 7 | + //! The protocol is JSON over WebSocket text messages. A client says | |
| 8 | + //! [`ClientMessage::Hello`] with the key, gets the host's projects and | |
| 9 | + //! threads in [`ServerMessage::State`], subscribes to the threads it opens, | |
| 10 | + //! and receives each change as a [`ServerMessage::Thread`] carrying the | |
| 11 | + //! transcript from the first item that changed. | |
| 12 | + | |
| 13 | + pub mod client; | |
| 14 | + pub mod server; | |
| 15 | + | |
| 16 | + use std::path::PathBuf; | |
| 17 | + | |
| 18 | + use serde::{Deserialize, Serialize}; | |
| 19 | + | |
| 20 | + use crate::git::TurnChanges; | |
| 21 | + use crate::models::Provider; | |
| 22 | + use crate::session::{QueuedMessage, SavedItem}; | |
| 23 | + use crate::worktree::PullRequest; | |
| 24 | + | |
| 25 | + /// The port a host listens on unless told otherwise. | |
| 26 | + pub const DEFAULT_PORT: u16 = 47_823; | |
| 27 | + /// Bumped when the messages change incompatibly. | |
| 28 | + pub const PROTOCOL: u32 = 1; | |
| 29 | + | |
| 30 | + /// A thread as the sidebar needs it. | |
| 31 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 32 | + #[serde(default)] | |
| 33 | + pub struct ThreadSummary { | |
| 34 | + pub id: String, | |
| 35 | + pub project: PathBuf, | |
| 36 | + pub title: String, | |
| 37 | + pub provider: Provider, | |
| 38 | + pub instance: String, | |
| 39 | + pub model: Option<String>, | |
| 40 | + pub permission_mode: String, | |
| 41 | + pub reasoning: String, | |
| 42 | + pub fast_mode: bool, | |
| 43 | + /// `starting`, `idle`, `working` or `exited`. | |
| 44 | + pub status: String, | |
| 45 | + /// A tool call or question waits for the user. | |
| 46 | + pub attention: bool, | |
| 47 | + pub settled: bool, | |
| 48 | + pub pinned: bool, | |
| 49 | + /// Milliseconds since the epoch, like every time here. | |
| 50 | + pub snoozed_until: Option<u64>, | |
| 51 | + pub created: u64, | |
| 52 | + pub updated: u64, | |
| 53 | + pub last_message: u64, | |
| 54 | + /// The thread's worktree branch, if it runs in one. | |
| 55 | + pub branch: Option<String>, | |
| 56 | + pub total_cost: f64, | |
| 57 | + /// Whether anything was sent in it yet. | |
| 58 | + pub has_items: bool, | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// The rest of an open thread's state, sent with its transcript. | |
| 62 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 63 | + #[serde(default)] | |
| 64 | + pub struct ThreadDetail { | |
| 65 | + pub cwd: PathBuf, | |
| 66 | + pub active_model: Option<String>, | |
| 67 | + pub thinking: bool, | |
| 68 | + pub preparing: bool, | |
| 69 | + /// How long the running turn has been going. | |
| 70 | + pub turn_elapsed_ms: Option<u64>, | |
| 71 | + pub context_tokens: Option<u64>, | |
| 72 | + pub context_window: Option<u64>, | |
| 73 | + pub queue: Vec<QueuedMessage>, | |
| 74 | + pub limit_resets: Option<u64>, | |
| 75 | + pub resume_at: Option<u64>, | |
| 76 | + /// Bound for a worktree made with the first message. | |
| 77 | + pub pending_worktree: bool, | |
| 78 | + pub pull_request: Option<PullRequest>, | |
| 79 | + pub turn_changes: Vec<(usize, TurnChanges)>, | |
| 80 | + } | |
| 81 | + | |
| 82 | + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 83 | + #[serde(default)] | |
| 84 | + pub struct ProjectInfo { | |
| 85 | + pub path: PathBuf, | |
| 86 | + pub name: String, | |
| 87 | + /// `None` outside a git repository. | |
| 88 | + pub branch: Option<String>, | |
| 89 | + pub branches: Vec<String>, | |
| 90 | + pub git: bool, | |
| 91 | + pub added: u64, | |
| 92 | + pub removed: u64, | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// A file sent along with a message. | |
| 96 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 97 | + pub struct Upload { | |
| 98 | + pub name: String, | |
| 99 | + /// Base64. | |
| 100 | + pub data: String, | |
| 101 | + } | |
| 102 | + | |
| 103 | + /// What a client asks of a thread on the host. | |
| 104 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 105 | + #[serde(tag = "action", rename_all = "snake_case")] | |
| 106 | + pub enum ThreadAction { | |
| 107 | + Send { text: String, attachments: Vec<Upload> }, | |
| 108 | + Queue { text: String, attachments: Vec<Upload> }, | |
| 109 | + SteerQueued { ix: usize }, | |
| 110 | + RemoveQueued { ix: usize }, | |
| 111 | + Interrupt, | |
| 112 | + /// `allow`, `always` or `deny`. | |
| 113 | + Permission { ix: usize, decision: String }, | |
| 114 | + Answer { ix: usize, option: usize }, | |
| 115 | + SetModel { model: Option<String> }, | |
| 116 | + SetPermissionMode { mode: String }, | |
| 117 | + SetReasoning { reasoning: String }, | |
| 118 | + SetFastMode { on: bool }, | |
| 119 | + Settle, | |
| 120 | + Unsettle, | |
| 121 | + Pin { pinned: bool }, | |
| 122 | + Snooze { until: Option<u64> }, | |
| 123 | + Rename { title: String }, | |
| 124 | + CancelResume, | |
| 125 | + ScheduleResume { at: u64 }, | |
| 126 | + Delete, | |
| 127 | + } | |
| 128 | + | |
| 129 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 130 | + #[serde(tag = "type", rename_all = "snake_case")] | |
| 131 | + pub enum ClientMessage { | |
| 132 | + Hello { key: String, name: String, protocol: u32 }, | |
| 133 | + Subscribe { thread: String }, | |
| 134 | + Unsubscribe { thread: String }, | |
| 135 | + Action { thread: String, action: ThreadAction }, | |
| 136 | + /// Starts a thread in one of the host's projects with its first message. | |
| 137 | + Create { | |
| 138 | + reference: u64, | |
| 139 | + project: PathBuf, | |
| 140 | + instance: String, | |
| 141 | + provider: Provider, | |
| 142 | + model: Option<String>, | |
| 143 | + permission_mode: String, | |
| 144 | + reasoning: String, | |
| 145 | + fast_mode: bool, | |
| 146 | + worktree: bool, | |
| 147 | + /// The branch a worktree starts from. | |
| 148 | + base: Option<String>, | |
| 149 | + text: String, | |
| 150 | + attachments: Vec<Upload>, | |
| 151 | + }, | |
| 152 | + } | |
| 153 | + | |
| 154 | + #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 155 | + #[serde(tag = "type", rename_all = "snake_case")] | |
| 156 | + pub enum ServerMessage { | |
| 157 | + Welcome { host: String, version: String, protocol: u32 }, | |
| 158 | + Denied { reason: String }, | |
| 159 | + State { projects: Vec<ProjectInfo>, threads: Vec<ThreadSummary> }, | |
| 160 | + /// A subscribed thread changed: its state, and its transcript from item | |
| 161 | + /// `from` on (earlier items are as last sent); `len` items in all. | |
| 162 | + Thread { summary: ThreadSummary, detail: ThreadDetail, from: usize, len: usize, items: Vec<SavedItem> }, | |
| 163 | + Created { reference: u64, thread: String }, | |
| 164 | + Error { message: String }, | |
| 165 | + } | |
| 166 | + | |
| 167 | + /// A new random key: 24 bytes, hex. | |
| 168 | + pub fn new_key() -> String { | |
| 169 | + let mut bytes = [0u8; 24]; | |
| 170 | + let read = std::fs::File::open("/dev/urandom").and_then(|mut file| std::io::Read::read_exact(&mut file, &mut bytes)); | |
| 171 | + if read.is_err() { | |
| 172 | + // Not as good, but never empty. | |
| 173 | + let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); | |
| 174 | + for (ix, byte) in bytes.iter_mut().enumerate() { | |
| 175 | + *byte = (seed >> ((ix % 16) * 8)) as u8 ^ (ix as u8).wrapping_mul(151); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + bytes.iter().map(|byte| format!("{byte:02x}")).collect() | |
| 179 | + } | |
| 180 | + | |
| 181 | + /// Compares keys in time independent of where they differ. | |
| 182 | + pub fn keys_match(a: &str, b: &str) -> bool { | |
| 183 | + a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// A pairing code bundles a host's address and key: `192.168.2.10:47823/<key>`. | |
| 187 | + pub fn pairing_code(address: &str, key: &str) -> String { | |
| 188 | + format!("{address}/{key}") | |
| 189 | + } | |
| 190 | + | |
| 191 | + /// Splits a pairing code, or an address alone, into address and key. | |
| 192 | + pub fn parse_pairing(code: &str) -> (String, Option<String>) { | |
| 193 | + let code = code.trim().trim_start_matches("ws://").trim_start_matches("vectrum://"); | |
| 194 | + match code.split_once('/') { | |
| 195 | + Some((address, key)) if !key.trim().is_empty() => (address.to_string(), Some(key.trim().to_string())), | |
| 196 | + Some((address, _)) => (address.to_string(), None), | |
| 197 | + None => (code.to_string(), None), | |
| 198 | + } | |
| 199 | + } | |
| 200 | + | |
| 201 | + /// `host` or `host:port`, with the default port filled in. | |
| 202 | + pub fn with_port(address: &str) -> String { | |
| 203 | + let address = address.trim(); | |
| 204 | + let has_port = match address.rsplit_once(':') { | |
| 205 | + // An IPv6 address without brackets has colons but no port. | |
| 206 | + Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok() && (!host.contains(':') || host.ends_with(']')), | |
| 207 | + None => false, | |
| 208 | + }; | |
| 209 | + if has_port { address.to_string() } else { format!("{address}:{DEFAULT_PORT}") } | |
| 210 | + } | |
| 211 | + | |
| 212 | + /// This machine's IPv4 addresses on the network, for showing where to connect. | |
| 213 | + pub fn local_addresses() -> Vec<String> { | |
| 214 | + let output = std::process::Command::new("hostname").arg("-I").stderr(std::process::Stdio::null()).output(); | |
| 215 | + let text = output.map(|output| String::from_utf8_lossy(&output.stdout).to_string()).unwrap_or_default(); | |
| 216 | + text.split_whitespace().filter(|address| address.parse::<std::net::Ipv4Addr>().is_ok()).map(str::to_string).collect() | |
| 217 | + } | |
| 218 | + | |
| 219 | + #[cfg(test)] | |
| 220 | + mod tests { | |
| 221 | + use super::*; | |
| 222 | + | |
| 223 | + #[::core::prelude::v1::test] | |
| 224 | + fn pairing_codes() { | |
| 225 | + assert_eq!(parse_pairing("192.168.2.10:47823/abc"), ("192.168.2.10:47823".into(), Some("abc".into()))); | |
| 226 | + assert_eq!(parse_pairing(" 10.0.0.2 "), ("10.0.0.2".into(), None)); | |
| 227 | + assert_eq!(with_port("10.0.0.2"), format!("10.0.0.2:{DEFAULT_PORT}")); | |
| 228 | + assert_eq!(with_port("10.0.0.2:9000"), "10.0.0.2:9000"); | |
| 229 | + assert_eq!(with_port("box.local"), format!("box.local:{DEFAULT_PORT}")); | |
| 230 | + assert_eq!(with_port("[::1]:9000"), "[::1]:9000"); | |
| 231 | + } | |
| 232 | + | |
| 233 | + #[::core::prelude::v1::test] | |
| 234 | + fn keys() { | |
| 235 | + let key = new_key(); | |
| 236 | + assert_eq!(key.len(), 48); | |
| 237 | + assert_ne!(key, new_key()); | |
| 238 | + assert!(keys_match(&key, &key.clone())); | |
| 239 | + assert!(!keys_match(&key, &new_key())); | |
| 240 | + assert!(!keys_match("a", "ab")); | |
| 241 | + } | |
| 242 | + | |
| 243 | + #[::core::prelude::v1::test] | |
| 244 | + fn messages_round_trip() { | |
| 245 | + let message = ClientMessage::Action { | |
| 246 | + thread: "t1".into(), | |
| 247 | + action: ThreadAction::Send { text: "hi".into(), attachments: Vec::new() }, | |
| 248 | + }; | |
| 249 | + let text = serde_json::to_string(&message).unwrap(); | |
| 250 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 251 | + } | |
| 252 | + } | |
| @@ -0,0 +1,188 @@ | ||
| 1 | + //! The host side: a WebSocket listener on its own threads. Each connection | |
| 2 | + //! proves it has the key, then trades JSON messages with the workspace, | |
| 3 | + //! which answers on the UI thread through [`ServerEvent`]s. | |
| 4 | + | |
| 5 | + use std::{ | |
| 6 | + collections::HashMap, | |
| 7 | + io::ErrorKind, | |
| 8 | + net::{TcpListener, TcpStream}, | |
| 9 | + sync::{ | |
| 10 | + Arc, Mutex, | |
| 11 | + atomic::{AtomicBool, AtomicU64, Ordering}, | |
| 12 | + mpsc, | |
| 13 | + }, | |
| 14 | + thread, | |
| 15 | + time::Duration, | |
| 16 | + }; | |
| 17 | + | |
| 18 | + use tungstenite::{Message, WebSocket}; | |
| 19 | + | |
| 20 | + use super::{ClientMessage, PROTOCOL, ServerMessage, keys_match}; | |
| 21 | + | |
| 22 | + /// How long a connection may take to say hello. | |
| 23 | + const HELLO_TIMEOUT: Duration = Duration::from_secs(10); | |
| 24 | + /// How often a connection's thread looks for messages to send. | |
| 25 | + const POLL: Duration = Duration::from_millis(40); | |
| 26 | + | |
| 27 | + pub enum ServerEvent { | |
| 28 | + Connected { conn: u64, name: String, address: String }, | |
| 29 | + Message { conn: u64, message: ClientMessage }, | |
| 30 | + Disconnected { conn: u64 }, | |
| 31 | + } | |
| 32 | + | |
| 33 | + /// A client that's connected, for the settings to list. | |
| 34 | + #[derive(Clone, Debug)] | |
| 35 | + pub struct Peer { | |
| 36 | + pub conn: u64, | |
| 37 | + pub name: String, | |
| 38 | + pub address: String, | |
| 39 | + } | |
| 40 | + | |
| 41 | + pub struct RemoteServer { | |
| 42 | + pub port: u16, | |
| 43 | + stop: Arc<AtomicBool>, | |
| 44 | + outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>>, | |
| 45 | + pub events: smol::channel::Receiver<ServerEvent>, | |
| 46 | + } | |
| 47 | + | |
| 48 | + impl RemoteServer { | |
| 49 | + /// Starts listening on every interface. Connections must say `key`. | |
| 50 | + pub fn start(port: u16, key: String, host: String) -> std::io::Result<Self> { | |
| 51 | + let listener = TcpListener::bind(("0.0.0.0", port))?; | |
| 52 | + listener.set_nonblocking(true)?; | |
| 53 | + let stop = Arc::new(AtomicBool::new(false)); | |
| 54 | + let outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>> = Arc::default(); | |
| 55 | + let (events_tx, events) = smol::channel::unbounded(); | |
| 56 | + let next_conn = Arc::new(AtomicU64::new(1)); | |
| 57 | + { | |
| 58 | + let stop = stop.clone(); | |
| 59 | + let outboxes = outboxes.clone(); | |
| 60 | + thread::Builder::new().name("remote-listener".into()).spawn(move || { | |
| 61 | + while !stop.load(Ordering::Relaxed) { | |
| 62 | + match listener.accept() { | |
| 63 | + Ok((stream, address)) => { | |
| 64 | + let conn = next_conn.fetch_add(1, Ordering::Relaxed); | |
| 65 | + let (stop, outboxes, events, key, host) = | |
| 66 | + (stop.clone(), outboxes.clone(), events_tx.clone(), key.clone(), host.clone()); | |
| 67 | + let _ = thread::Builder::new().name("remote-connection".into()).spawn(move || { | |
| 68 | + serve(conn, stream, address.ip().to_string(), &key, &host, &stop, &outboxes, &events); | |
| 69 | + outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&conn); | |
| 70 | + }); | |
| 71 | + } | |
| 72 | + Err(err) if err.kind() == ErrorKind::WouldBlock => thread::sleep(Duration::from_millis(150)), | |
| 73 | + Err(_) => thread::sleep(Duration::from_millis(500)), | |
| 74 | + } | |
| 75 | + } | |
| 76 | + })?; | |
| 77 | + } | |
| 78 | + Ok(Self { port, stop, outboxes, events }) | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn send(&self, conn: u64, message: &ServerMessage) { | |
| 82 | + let Ok(text) = serde_json::to_string(message) else { return }; | |
| 83 | + if let Some(outbox) = self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn) { | |
| 84 | + let _ = outbox.send(text); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + pub fn broadcast(&self, message: &ServerMessage) { | |
| 89 | + let Ok(text) = serde_json::to_string(message) else { return }; | |
| 90 | + for outbox in self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).values() { | |
| 91 | + let _ = outbox.send(text.clone()); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// Drops a client's connection. | |
| 96 | + pub fn disconnect(&self, conn: u64) { | |
| 97 | + // Its thread ends once its outbox is gone. | |
| 98 | + self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&conn); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + impl Drop for RemoteServer { | |
| 103 | + fn drop(&mut self) { | |
| 104 | + self.stop.store(true, Ordering::Relaxed); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + #[allow(clippy::too_many_arguments)] | |
| 109 | + fn serve( | |
| 110 | + conn: u64, | |
| 111 | + stream: TcpStream, | |
| 112 | + address: String, | |
| 113 | + key: &str, | |
| 114 | + host: &str, | |
| 115 | + stop: &AtomicBool, | |
| 116 | + outboxes: &Mutex<HashMap<u64, mpsc::Sender<String>>>, | |
| 117 | + events: &smol::channel::Sender<ServerEvent>, | |
| 118 | + ) { | |
| 119 | + let _ = stream.set_nonblocking(false); | |
| 120 | + let _ = stream.set_nodelay(true); | |
| 121 | + let _ = stream.set_read_timeout(Some(HELLO_TIMEOUT)); | |
| 122 | + let Ok(mut socket) = tungstenite::accept(stream) else { return }; | |
| 123 | + | |
| 124 | + let name = match read_message(&mut socket) { | |
| 125 | + Some(ClientMessage::Hello { key: offered, name, protocol }) if keys_match(&offered, key) && protocol == PROTOCOL => name, | |
| 126 | + Some(ClientMessage::Hello { protocol, .. }) if protocol != PROTOCOL => { | |
| 127 | + deny(&mut socket, "This app's version speaks a different protocol; update both devices."); | |
| 128 | + return; | |
| 129 | + } | |
| 130 | + _ => { | |
| 131 | + // Slow down anyone guessing keys. | |
| 132 | + thread::sleep(Duration::from_secs(1)); | |
| 133 | + deny(&mut socket, "Wrong key."); | |
| 134 | + return; | |
| 135 | + } | |
| 136 | + }; | |
| 137 | + let welcome = ServerMessage::Welcome { host: host.to_string(), version: env!("CARGO_PKG_VERSION").into(), protocol: PROTOCOL }; | |
| 138 | + if socket.send(Message::text(serde_json::to_string(&welcome).unwrap_or_default())).is_err() { | |
| 139 | + return; | |
| 140 | + } | |
| 141 | + let (outbox, inbox) = mpsc::channel::<String>(); | |
| 142 | + outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(conn, outbox); | |
| 143 | + let _ = events.send_blocking(ServerEvent::Connected { conn, name, address }); | |
| 144 | + let _ = socket.get_mut().set_read_timeout(Some(POLL)); | |
| 145 | + | |
| 146 | + 'connection: while !stop.load(Ordering::Relaxed) { | |
| 147 | + loop { | |
| 148 | + match inbox.try_recv() { | |
| 149 | + Ok(text) => { | |
| 150 | + if socket.send(Message::text(text)).is_err() { | |
| 151 | + break 'connection; | |
| 152 | + } | |
| 153 | + } | |
| 154 | + Err(mpsc::TryRecvError::Empty) => break, | |
| 155 | + Err(mpsc::TryRecvError::Disconnected) => break 'connection, | |
| 156 | + } | |
| 157 | + } | |
| 158 | + match socket.read() { | |
| 159 | + Ok(Message::Text(text)) => { | |
| 160 | + if let Ok(message) = serde_json::from_str::<ClientMessage>(text.as_str()) { | |
| 161 | + let _ = events.send_blocking(ServerEvent::Message { conn, message }); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + Ok(Message::Close(_)) => break, | |
| 165 | + Ok(_) => {} | |
| 166 | + Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} | |
| 167 | + Err(_) => break, | |
| 168 | + } | |
| 169 | + } | |
| 170 | + let _ = socket.close(None); | |
| 171 | + let _ = events.send_blocking(ServerEvent::Disconnected { conn }); | |
| 172 | + } | |
| 173 | + | |
| 174 | + fn read_message(socket: &mut WebSocket<TcpStream>) -> Option<ClientMessage> { | |
| 175 | + loop { | |
| 176 | + match socket.read().ok()? { | |
| 177 | + Message::Text(text) => return serde_json::from_str(text.as_str()).ok(), | |
| 178 | + Message::Ping(_) | Message::Pong(_) => continue, | |
| 179 | + _ => return None, | |
| 180 | + } | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + fn deny(socket: &mut WebSocket<TcpStream>, reason: &str) { | |
| 185 | + let denied = ServerMessage::Denied { reason: reason.into() }; | |
| 186 | + let _ = socket.send(Message::text(serde_json::to_string(&denied).unwrap_or_default())); | |
| 187 | + let _ = socket.close(None); | |
| 188 | + } | |
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | use std::{ |
| 5 | 5 | collections::{HashMap, HashSet, VecDeque}, |
| 6 | 6 | path::{Path, PathBuf}, |
| 7 | + rc::Rc, | |
| 7 | 8 | sync::atomic::{AtomicU64, Ordering}, |
| 8 | 9 | time::{Duration, Instant, SystemTime, UNIX_EPOCH}, |
| 9 | 10 | }; |
| @@ -17,6 +18,7 @@ use serde_json::{Value, json}; | ||
| 17 | 18 | use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent}; |
| 18 | 19 | use crate::git::{self, TurnChanges}; |
| 19 | 20 | use crate::models::{Catalog, Provider}; |
| 21 | + use crate::remote::{ClientMessage, ThreadAction, ThreadDetail, ThreadSummary, Upload, client::RemoteClient}; | |
| 20 | 22 | use crate::settings::{self, Streaming}; |
| 21 | 23 | use crate::worktree::{self, PullRequest, Worktree, WorktreePlan}; |
| 22 | 24 | |
| @@ -136,6 +138,24 @@ pub enum ChatItem { | ||
| 136 | 138 | TurnEnd { duration_ms: u64, cost: Option<f64>, at: SystemTime }, |
| 137 | 139 | } |
| 138 | 140 | |
| 141 | + /// A thread that lives on another device's app, mirrored here: actions go to | |
| 142 | + /// the host, and the host sends back how the thread changed. | |
| 143 | + pub struct RemoteLink { | |
| 144 | + /// The connection's id in the settings. | |
| 145 | + pub env: String, | |
| 146 | + /// The thread's id on the host; `None` until its first message creates it. | |
| 147 | + pub thread: Option<String>, | |
| 148 | + pub client: Rc<RemoteClient>, | |
| 149 | + /// Asked the host for the transcript. | |
| 150 | + pub subscribed: bool, | |
| 151 | + /// Got the transcript at least once. | |
| 152 | + pub loaded: bool, | |
| 153 | + /// What the host last said about pending prompts, until the transcript is in. | |
| 154 | + pub attention: bool, | |
| 155 | + /// The request that creates the thread, while it's on its way. | |
| 156 | + pub creating: Option<u64>, | |
| 157 | + } | |
| 158 | + | |
| 139 | 159 | /// What a session tells the views around it. |
| 140 | 160 | pub enum SessionEvent { |
| 141 | 161 | /// A finished turn's changes on disk are known, by its `TurnEnd` index. |
| @@ -250,6 +270,8 @@ pub struct Session { | ||
| 250 | 270 | /// The worktree to create when the first message is sent. |
| 251 | 271 | pub pending_worktree: Option<WorktreePlan>, |
| 252 | 272 | pub pull_request: Option<PullRequest>, |
| 273 | + /// Set for a thread mirrored from another device. | |
| 274 | + pub remote: Option<RemoteLink>, | |
| 253 | 275 | pub items: Vec<ChatItem>, |
| 254 | 276 | pub status: SessionStatus, |
| 255 | 277 | pub thinking: bool, |
| @@ -367,6 +389,7 @@ impl Session { | ||
| 367 | 389 | worktree: None, |
| 368 | 390 | pending_worktree: worktree, |
| 369 | 391 | pull_request: None, |
| 392 | + remote: None, | |
| 370 | 393 | items: Vec::new(), |
| 371 | 394 | status: SessionStatus::Starting, |
| 372 | 395 | thinking: false, |
| @@ -416,6 +439,9 @@ impl Session { | ||
| 416 | 439 | |
| 417 | 440 | /// True while a tool call or question is waiting for the user. |
| 418 | 441 | pub fn needs_attention(&self) -> bool { |
| 442 | + if let Some(remote) = self.remote.as_ref().filter(|remote| !remote.loaded) { | |
| 443 | + return remote.attention; | |
| 444 | + } | |
| 419 | 445 | self.items.iter().any(|item| match item { |
| 420 | 446 | ChatItem::Tool(ToolCall { permission: Some(prompt), .. }) => { |
| 421 | 447 | prompt.state == PermissionState::Pending |
| @@ -523,6 +549,10 @@ impl Session { | ||
| 523 | 549 | /// path for the agent to read. The thread's first message also creates |
| 524 | 550 | /// its worktree, if it starts in one. |
| 525 | 551 | pub fn send_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) { |
| 552 | + if self.remote.is_some() { | |
| 553 | + self.send_remote(text, attachments, false, cx); | |
| 554 | + return; | |
| 555 | + } | |
| 526 | 556 | if text.trim().is_empty() && attachments.is_empty() { |
| 527 | 557 | return; |
| 528 | 558 | } |
| @@ -588,6 +618,11 @@ impl Session { | ||
| 588 | 618 | |
| 589 | 619 | /// Holds a message until the running turn ends; sends it now if none runs. |
| 590 | 620 | pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) { |
| 621 | + if self.remote.is_some() { | |
| 622 | + let queue = self.is_working() && self.remote.as_ref().is_some_and(|remote| remote.thread.is_some()); | |
| 623 | + self.send_remote(text, attachments, queue, cx); | |
| 624 | + return; | |
| 625 | + } | |
| 591 | 626 | if !self.is_working() { |
| 592 | 627 | self.send_message(text, attachments, cx); |
| 593 | 628 | return; |
| @@ -601,6 +636,9 @@ impl Session { | ||
| 601 | 636 | |
| 602 | 637 | /// Sends a queued message now instead of after the turn. |
| 603 | 638 | pub fn steer_queued(&mut self, ix: usize, cx: &mut Context<Self>) { |
| 639 | + if self.forward(ThreadAction::SteerQueued { ix }) { | |
| 640 | + return; | |
| 641 | + } | |
| 604 | 642 | if ix < self.queue.len() { |
| 605 | 643 | let message = self.queue.remove(ix); |
| 606 | 644 | self.send_message(message.text, message.attachments, cx); |
| @@ -608,6 +646,9 @@ impl Session { | ||
| 608 | 646 | } |
| 609 | 647 | |
| 610 | 648 | pub fn remove_queued(&mut self, ix: usize, cx: &mut Context<Self>) { |
| 649 | + if self.forward(ThreadAction::RemoveQueued { ix }) { | |
| 650 | + return; | |
| 651 | + } | |
| 611 | 652 | if ix < self.queue.len() { |
| 612 | 653 | self.queue.remove(ix); |
| 613 | 654 | cx.notify(); |
| @@ -725,6 +766,7 @@ impl Session { | ||
| 725 | 766 | } |
| 726 | 767 | |
| 727 | 768 | pub fn rename(&mut self, title: String, cx: &mut Context<Self>) { |
| 769 | + self.forward(ThreadAction::Rename { title: title.trim().to_string() }); | |
| 728 | 770 | let title = title.trim(); |
| 729 | 771 | if title.is_empty() { |
| 730 | 772 | return; |
| @@ -737,6 +779,14 @@ impl Session { | ||
| 737 | 779 | /// Picks where the (still empty) thread will run: in the project folder, |
| 738 | 780 | /// or a worktree made with its first message. |
| 739 | 781 | pub fn set_worktree_plan(&mut self, plan: Option<WorktreePlan>, cx: &mut Context<Self>) { |
| 782 | + if self.remote.is_some() { | |
| 783 | + // The host makes the worktree; this only says whether to. | |
| 784 | + if self.remote.as_ref().is_some_and(|remote| remote.thread.is_none()) { | |
| 785 | + self.pending_worktree = plan; | |
| 786 | + cx.notify(); | |
| 787 | + } | |
| 788 | + return; | |
| 789 | + } | |
| 740 | 790 | if !self.items.is_empty() || self.worktree.is_some() { |
| 741 | 791 | return; |
| 742 | 792 | } |
| @@ -757,11 +807,13 @@ impl Session { | ||
| 757 | 807 | } |
| 758 | 808 | |
| 759 | 809 | pub fn set_pinned(&mut self, pinned: bool, cx: &mut Context<Self>) { |
| 810 | + self.forward(ThreadAction::Pin { pinned }); | |
| 760 | 811 | self.pinned = pinned; |
| 761 | 812 | cx.notify(); |
| 762 | 813 | } |
| 763 | 814 | |
| 764 | 815 | pub fn snooze(&mut self, until: Option<SystemTime>, cx: &mut Context<Self>) { |
| 816 | + self.forward(ThreadAction::Snooze { until: until.map(to_millis) }); | |
| 765 | 817 | self.snoozed_until = until; |
| 766 | 818 | if until.is_none() { |
| 767 | 819 | self.updated = SystemTime::now(); |
| @@ -780,6 +832,7 @@ impl Session { | ||
| 780 | 832 | |
| 781 | 833 | /// Drops the continuation scheduled for when a usage limit resets. |
| 782 | 834 | pub fn cancel_resume(&mut self, cx: &mut Context<Self>) { |
| 835 | + self.forward(ThreadAction::CancelResume); | |
| 783 | 836 | self.resume_at = None; |
| 784 | 837 | cx.notify(); |
| 785 | 838 | } |
| @@ -798,6 +851,11 @@ impl Session { | ||
| 798 | 851 | /// Files the thread under Settled and stops its process. The transcript |
| 799 | 852 | /// stays, and the next message resumes the conversation. |
| 800 | 853 | pub fn settle(&mut self, cx: &mut Context<Self>) { |
| 854 | + if self.forward(ThreadAction::Settle) { | |
| 855 | + self.settled = true; | |
| 856 | + cx.notify(); | |
| 857 | + return; | |
| 858 | + } | |
| 801 | 859 | self.flush_text(cx); |
| 802 | 860 | self.process.take(); |
| 803 | 861 | // Drop the event stream too, so the process exit isn't reported as a crash. |
| @@ -820,12 +878,16 @@ impl Session { | ||
| 820 | 878 | /// Brings a settled thread back to the open list. Its process starts again |
| 821 | 879 | /// with the next message. |
| 822 | 880 | pub fn unsettle(&mut self, cx: &mut Context<Self>) { |
| 881 | + self.forward(ThreadAction::Unsettle); | |
| 823 | 882 | self.settled = false; |
| 824 | 883 | self.updated = SystemTime::now(); |
| 825 | 884 | cx.notify(); |
| 826 | 885 | } |
| 827 | 886 | |
| 828 | 887 | pub fn interrupt(&mut self, cx: &mut Context<Self>) { |
| 888 | + if self.forward(ThreadAction::Interrupt) { | |
| 889 | + return; | |
| 890 | + } | |
| 829 | 891 | if !self.is_working() || self.interrupt_requested || self.process.is_none() { |
| 830 | 892 | return; |
| 831 | 893 | } |
| @@ -838,6 +900,11 @@ impl Session { | ||
| 838 | 900 | } |
| 839 | 901 | |
| 840 | 902 | pub fn set_model(&mut self, model: Option<String>, cx: &mut Context<Self>) { |
| 903 | + if self.forward(ThreadAction::SetModel { model: model.clone() }) { | |
| 904 | + self.model = model; | |
| 905 | + cx.notify(); | |
| 906 | + return; | |
| 907 | + } | |
| 841 | 908 | self.model = model.clone(); |
| 842 | 909 | match self.provider { |
| 843 | 910 | Provider::Claude => self.control( |
| @@ -855,6 +922,11 @@ impl Session { | ||
| 855 | 922 | } |
| 856 | 923 | |
| 857 | 924 | pub fn set_permission_mode(&mut self, mode: PermissionMode, cx: &mut Context<Self>) { |
| 925 | + if self.forward(ThreadAction::SetPermissionMode { mode: mode.as_cli().into() }) { | |
| 926 | + self.permission_mode = mode; | |
| 927 | + cx.notify(); | |
| 928 | + return; | |
| 929 | + } | |
| 858 | 930 | // Pi has no permission prompts to configure. |
| 859 | 931 | if self.provider != Provider::Claude { |
| 860 | 932 | return; |
| @@ -868,6 +940,11 @@ impl Session { | ||
| 868 | 940 | } |
| 869 | 941 | |
| 870 | 942 | pub fn set_reasoning(&mut self, reasoning: Reasoning, cx: &mut Context<Self>) { |
| 943 | + if self.forward(ThreadAction::SetReasoning { reasoning: reasoning.key().into() }) { | |
| 944 | + self.reasoning = reasoning; | |
| 945 | + cx.notify(); | |
| 946 | + return; | |
| 947 | + } | |
| 871 | 948 | self.reasoning = reasoning; |
| 872 | 949 | match self.provider { |
| 873 | 950 | Provider::Claude => self.control( |
| @@ -890,6 +967,11 @@ impl Session { | ||
| 890 | 967 | } |
| 891 | 968 | |
| 892 | 969 | pub fn set_fast_mode(&mut self, on: bool, cx: &mut Context<Self>) { |
| 970 | + if self.forward(ThreadAction::SetFastMode { on }) { | |
| 971 | + self.fast_mode = on; | |
| 972 | + cx.notify(); | |
| 973 | + return; | |
| 974 | + } | |
| 893 | 975 | if self.provider != Provider::Claude { |
| 894 | 976 | return; |
| 895 | 977 | } |
| @@ -973,6 +1055,14 @@ impl Session { | ||
| 973 | 1055 | decision: PermissionDecision, |
| 974 | 1056 | cx: &mut Context<Self>, |
| 975 | 1057 | ) { |
| 1058 | + let decision_key = match decision { | |
| 1059 | + PermissionDecision::Allow => "allow", | |
| 1060 | + PermissionDecision::AllowAlways => "always", | |
| 1061 | + PermissionDecision::Deny => "deny", | |
| 1062 | + }; | |
| 1063 | + if self.forward(ThreadAction::Permission { ix, decision: decision_key.into() }) { | |
| 1064 | + return; | |
| 1065 | + } | |
| 976 | 1066 | let Some(process) = &self.process else { return }; |
| 977 | 1067 | let Some(ChatItem::Tool(tool)) = self.items.get_mut(ix) else { return }; |
| 978 | 1068 | let Some(prompt) = tool.permission.as_mut() else { return }; |
| @@ -1019,6 +1109,9 @@ impl Session { | ||
| 1019 | 1109 | |
| 1020 | 1110 | /// Answers an extension's question with one of its options. |
| 1021 | 1111 | pub fn answer_question(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) { |
| 1112 | + if self.forward(ThreadAction::Answer { ix, option }) { | |
| 1113 | + return; | |
| 1114 | + } | |
| 1022 | 1115 | let Some(process) = &self.process else { return }; |
| 1023 | 1116 | let Some(ChatItem::Question(question)) = self.items.get_mut(ix) else { return }; |
| 1024 | 1117 | let Some(answer) = question.options.get(option).cloned() else { return }; |
| @@ -1910,7 +2003,7 @@ pub struct SavedThread { | ||
| 1910 | 2003 | pub turn_changes: Vec<(usize, TurnChanges)>, |
| 1911 | 2004 | } |
| 1912 | 2005 | |
| 1913 | - #[derive(Serialize, Deserialize)] | |
| 2006 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 1914 | 2007 | #[serde(tag = "type", rename_all = "snake_case")] |
| 1915 | 2008 | pub enum SavedItem { |
| 1916 | 2009 | User { text: String, attachments: Vec<String>, at: u64 }, |
| @@ -1920,14 +2013,26 @@ pub enum SavedItem { | ||
| 1920 | 2013 | name: String, |
| 1921 | 2014 | input: Value, |
| 1922 | 2015 | result: Option<String>, |
| 1923 | - /// `done` or `error`; a tool still running when saved counts as failed. | |
| 2016 | + /// `done`, `running` or `error`; a restored thread counts a running | |
| 2017 | + /// tool as failed. | |
| 1924 | 2018 | state: String, |
| 1925 | - /// `allowed`, `denied` or `cancelled`, if it asked for permission. | |
| 2019 | + /// `pending`, `allowed`, `denied` or `cancelled`, if it asked for | |
| 2020 | + /// permission. A restored thread treats `pending` as cancelled. | |
| 1926 | 2021 | permission: Option<String>, |
| 2022 | + /// The rules a pending prompt offers to remember. | |
| 2023 | + #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 2024 | + suggestions: Vec<Value>, | |
| 1927 | 2025 | subagent_tool_count: usize, |
| 1928 | 2026 | }, |
| 1929 | 2027 | Notice { error: bool, text: String }, |
| 1930 | - Question { title: String, message: Option<String>, options: Vec<String>, answer: Option<String> }, | |
| 2028 | + Question { | |
| 2029 | + title: String, | |
| 2030 | + message: Option<String>, | |
| 2031 | + options: Vec<String>, | |
| 2032 | + #[serde(default)] | |
| 2033 | + confirm: bool, | |
| 2034 | + answer: Option<String>, | |
| 2035 | + }, | |
| 1931 | 2036 | TurnEnd { duration_ms: u64, cost: Option<f64>, at: u64 }, |
| 1932 | 2037 | } |
| 1933 | 2038 | |
| @@ -1941,9 +2046,15 @@ pub fn from_millis(millis: u64) -> SystemTime { | ||
| 1941 | 2046 | |
| 1942 | 2047 | impl Session { |
| 1943 | 2048 | pub fn save(&self) -> SavedThread { |
| 1944 | - let items = self | |
| 1945 | - .items | |
| 2049 | + let items = self.saved_items(0); | |
| 2050 | + self.save_with(items) | |
| 2051 | + } | |
| 2052 | + | |
| 2053 | + /// The transcript from item `from` on, as it's saved and sent. | |
| 2054 | + pub fn saved_items(&self, from: usize) -> Vec<SavedItem> { | |
| 2055 | + self.items | |
| 1946 | 2056 | .iter() |
| 2057 | + .skip(from) | |
| 1947 | 2058 | .map(|item| match item { |
| 1948 | 2059 | ChatItem::User { text, attachments, at } => SavedItem::User { |
| 1949 | 2060 | text: text.to_string(), |
| @@ -1956,15 +2067,27 @@ impl Session { | ||
| 1956 | 2067 | name: tool.name.clone(), |
| 1957 | 2068 | input: tool.input.clone(), |
| 1958 | 2069 | result: tool.result.clone(), |
| 1959 | - state: if tool.state == ToolState::Done { "done" } else { "error" }.into(), | |
| 2070 | + state: match tool.state { | |
| 2071 | + ToolState::Done => "done", | |
| 2072 | + ToolState::Running => "running", | |
| 2073 | + ToolState::Error => "error", | |
| 2074 | + } | |
| 2075 | + .into(), | |
| 1960 | 2076 | permission: tool.permission.as_ref().map(|prompt| { |
| 1961 | 2077 | match prompt.state { |
| 2078 | + PermissionState::Pending => "pending", | |
| 1962 | 2079 | PermissionState::Allowed => "allowed", |
| 1963 | 2080 | PermissionState::Denied => "denied", |
| 1964 | - PermissionState::Pending | PermissionState::Cancelled => "cancelled", | |
| 2081 | + PermissionState::Cancelled => "cancelled", | |
| 1965 | 2082 | } |
| 1966 | 2083 | .into() |
| 1967 | 2084 | }), |
| 2085 | + suggestions: tool | |
| 2086 | + .permission | |
| 2087 | + .as_ref() | |
| 2088 | + .filter(|prompt| prompt.state == PermissionState::Pending) | |
| 2089 | + .map(|prompt| prompt.suggestions.clone()) | |
| 2090 | + .unwrap_or_default(), | |
| 1968 | 2091 | subagent_tool_count: tool.subagent_tool_count, |
| 1969 | 2092 | }, |
| 1970 | 2093 | ChatItem::Notice(kind, text) => { |
| @@ -1974,13 +2097,17 @@ impl Session { | ||
| 1974 | 2097 | title: question.title.to_string(), |
| 1975 | 2098 | message: question.message.as_ref().map(ToString::to_string), |
| 1976 | 2099 | options: question.options.iter().map(ToString::to_string).collect(), |
| 2100 | + confirm: question.confirm, | |
| 1977 | 2101 | answer: question.answer.as_ref().map(ToString::to_string), |
| 1978 | 2102 | }, |
| 1979 | 2103 | ChatItem::TurnEnd { duration_ms, cost, at } => { |
| 1980 | 2104 | SavedItem::TurnEnd { duration_ms: *duration_ms, cost: *cost, at: to_millis(*at) } |
| 1981 | 2105 | } |
| 1982 | 2106 | }) |
| 1983 | - .collect(); | |
| 2107 | + .collect() | |
| 2108 | + } | |
| 2109 | + | |
| 2110 | + fn save_with(&self, items: Vec<SavedItem>) -> SavedThread { | |
| 1984 | 2111 | SavedThread { |
| 1985 | 2112 | id: self.id.clone(), |
| 1986 | 2113 | instance: self.instance.clone(), |
| @@ -2052,54 +2179,7 @@ impl Session { | ||
| 2052 | 2179 | session.interrupted = saved.working; |
| 2053 | 2180 | session.turn_changes = saved.turn_changes.into_iter().collect(); |
| 2054 | 2181 | for item in saved.items { |
| 2055 | - let item = match item { | |
| 2056 | - SavedItem::User { text, attachments, at } => ChatItem::User { | |
| 2057 | - text: text.into(), | |
| 2058 | - attachments: attachments.into_iter().map(Into::into).collect(), | |
| 2059 | - at: from_millis(at), | |
| 2060 | - }, | |
| 2061 | - SavedItem::Assistant { text } => { | |
| 2062 | - session.push_assistant_text(&text, cx); | |
| 2063 | - continue; | |
| 2064 | - } | |
| 2065 | - SavedItem::Tool { id, name, input, result, state, permission, subagent_tool_count } => { | |
| 2066 | - ChatItem::Tool(ToolCall { | |
| 2067 | - id, | |
| 2068 | - name, | |
| 2069 | - input, | |
| 2070 | - partial_input: String::new(), | |
| 2071 | - result, | |
| 2072 | - state: if state == "done" { ToolState::Done } else { ToolState::Error }, | |
| 2073 | - permission: permission.map(|state| PermissionPrompt { | |
| 2074 | - request_id: String::new(), | |
| 2075 | - input: Value::Null, | |
| 2076 | - suggestions: Vec::new(), | |
| 2077 | - state: match state.as_str() { | |
| 2078 | - "allowed" => PermissionState::Allowed, | |
| 2079 | - "denied" => PermissionState::Denied, | |
| 2080 | - _ => PermissionState::Cancelled, | |
| 2081 | - }, | |
| 2082 | - }), | |
| 2083 | - expanded: false, | |
| 2084 | - subagent_tool_count, | |
| 2085 | - detached: false, | |
| 2086 | - }) | |
| 2087 | - } | |
| 2088 | - SavedItem::Notice { error, text } => { | |
| 2089 | - ChatItem::Notice(if error { NoticeKind::Error } else { NoticeKind::Info }, text.into()) | |
| 2090 | - } | |
| 2091 | - SavedItem::Question { title, message, options, answer } => ChatItem::Question(Question { | |
| 2092 | - request_id: String::new(), | |
| 2093 | - title: title.into(), | |
| 2094 | - message: message.map(Into::into), | |
| 2095 | - options: options.into_iter().map(Into::into).collect(), | |
| 2096 | - confirm: false, | |
| 2097 | - answer: Some(answer.unwrap_or_else(|| "No answer".into()).into()), | |
| 2098 | - }), | |
| 2099 | - SavedItem::TurnEnd { duration_ms, cost, at } => { | |
| 2100 | - ChatItem::TurnEnd { duration_ms, cost, at: from_millis(at) } | |
| 2101 | - } | |
| 2102 | - }; | |
| 2182 | + let item = session.item_from_saved(item, false, cx); | |
| 2103 | 2183 | session.items.push(item); |
| 2104 | 2184 | } |
| 2105 | 2185 | if session.interrupted { |
| @@ -2107,6 +2187,409 @@ impl Session { | ||
| 2107 | 2187 | } |
| 2108 | 2188 | session |
| 2109 | 2189 | } |
| 2190 | + | |
| 2191 | + /// Turns a saved item back into a chat item. `live` keeps pending | |
| 2192 | + /// prompts and questions pending (a host's transcript); otherwise they | |
| 2193 | + /// count as over (a thread restored from disk). | |
| 2194 | + fn item_from_saved(&mut self, item: SavedItem, live: bool, cx: &mut Context<Self>) -> ChatItem { | |
| 2195 | + match item { | |
| 2196 | + SavedItem::User { text, attachments, at } => ChatItem::User { | |
| 2197 | + text: text.into(), | |
| 2198 | + attachments: attachments.into_iter().map(Into::into).collect(), | |
| 2199 | + at: from_millis(at), | |
| 2200 | + }, | |
| 2201 | + SavedItem::Assistant { text } => { | |
| 2202 | + let view = cx.new(|cx| TextViewState::markdown(&text, cx).selectable(true)); | |
| 2203 | + let shown = text.len(); | |
| 2204 | + ChatItem::Assistant(AssistantText { text, view, shown }) | |
| 2205 | + } | |
| 2206 | + SavedItem::Tool { id, name, input, result, state, permission, suggestions, subagent_tool_count } => { | |
| 2207 | + let pending = live && permission.as_deref() == Some("pending"); | |
| 2208 | + ChatItem::Tool(ToolCall { | |
| 2209 | + id, | |
| 2210 | + name, | |
| 2211 | + input: input.clone(), | |
| 2212 | + partial_input: String::new(), | |
| 2213 | + result, | |
| 2214 | + state: match state.as_str() { | |
| 2215 | + "done" => ToolState::Done, | |
| 2216 | + "running" if live => ToolState::Running, | |
| 2217 | + _ => ToolState::Error, | |
| 2218 | + }, | |
| 2219 | + permission: permission.map(|state| PermissionPrompt { | |
| 2220 | + request_id: String::new(), | |
| 2221 | + input, | |
| 2222 | + suggestions, | |
| 2223 | + state: match state.as_str() { | |
| 2224 | + "allowed" => PermissionState::Allowed, | |
| 2225 | + "denied" => PermissionState::Denied, | |
| 2226 | + _ if pending => PermissionState::Pending, | |
| 2227 | + _ => PermissionState::Cancelled, | |
| 2228 | + }, | |
| 2229 | + }), | |
| 2230 | + expanded: false, | |
| 2231 | + subagent_tool_count, | |
| 2232 | + detached: false, | |
| 2233 | + }) | |
| 2234 | + } | |
| 2235 | + SavedItem::Notice { error, text } => { | |
| 2236 | + ChatItem::Notice(if error { NoticeKind::Error } else { NoticeKind::Info }, text.into()) | |
| 2237 | + } | |
| 2238 | + SavedItem::Question { title, message, options, confirm, answer } => ChatItem::Question(Question { | |
| 2239 | + request_id: String::new(), | |
| 2240 | + title: title.into(), | |
| 2241 | + message: message.map(Into::into), | |
| 2242 | + options: options.into_iter().map(Into::into).collect(), | |
| 2243 | + confirm, | |
| 2244 | + answer: match answer { | |
| 2245 | + Some(answer) => Some(answer.into()), | |
| 2246 | + None if live => None, | |
| 2247 | + None => Some("No answer".into()), | |
| 2248 | + }, | |
| 2249 | + }), | |
| 2250 | + SavedItem::TurnEnd { duration_ms, cost, at } => ChatItem::TurnEnd { duration_ms, cost, at: from_millis(at) }, | |
| 2251 | + } | |
| 2252 | + } | |
| 2253 | + } | |
| 2254 | + | |
| 2255 | + // ---- mirroring threads across devices ------------------------------------- | |
| 2256 | + | |
| 2257 | + fn status_key(status: SessionStatus) -> &'static str { | |
| 2258 | + match status { | |
| 2259 | + SessionStatus::Starting => "starting", | |
| 2260 | + SessionStatus::Idle => "idle", | |
| 2261 | + SessionStatus::Working => "working", | |
| 2262 | + SessionStatus::Exited => "exited", | |
| 2263 | + } | |
| 2264 | + } | |
| 2265 | + | |
| 2266 | + fn status_from_key(key: &str) -> SessionStatus { | |
| 2267 | + match key { | |
| 2268 | + "starting" => SessionStatus::Starting, | |
| 2269 | + "working" => SessionStatus::Working, | |
| 2270 | + "exited" => SessionStatus::Exited, | |
| 2271 | + _ => SessionStatus::Idle, | |
| 2272 | + } | |
| 2273 | + } | |
| 2274 | + | |
| 2275 | + /// Reads a file for sending to a host. | |
| 2276 | + fn upload(path: &Path) -> Result<Upload, String> { | |
| 2277 | + let size = path.metadata().map_err(|err| err.to_string())?.len(); | |
| 2278 | + if size > 20 * 1024 * 1024 { | |
| 2279 | + return Err("it is over the 20 MB limit for sending to another device".into()); | |
| 2280 | + } | |
| 2281 | + let bytes = std::fs::read(path).map_err(|err| err.to_string())?; | |
| 2282 | + Ok(Upload { name: file_name(path), data: BASE64.encode(bytes) }) | |
| 2283 | + } | |
| 2284 | + | |
| 2285 | + /// Writes files a client sent into a folder of their own, returning their paths. | |
| 2286 | + pub fn store_uploads(uploads: Vec<Upload>) -> Vec<PathBuf> { | |
| 2287 | + if uploads.is_empty() { | |
| 2288 | + return Vec::new(); | |
| 2289 | + } | |
| 2290 | + let Some(dir) = settings::data_dir().map(|dir| { | |
| 2291 | + dir.join("uploads").join(format!("{:x}", to_millis(SystemTime::now()) ^ u64::from(std::process::id()))) | |
| 2292 | + }) else { | |
| 2293 | + return Vec::new(); | |
| 2294 | + }; | |
| 2295 | + if std::fs::create_dir_all(&dir).is_err() { | |
| 2296 | + return Vec::new(); | |
| 2297 | + } | |
| 2298 | + uploads | |
| 2299 | + .into_iter() | |
| 2300 | + .filter_map(|upload| { | |
| 2301 | + let name = Path::new(&upload.name).file_name()?.to_owned(); | |
| 2302 | + let path = dir.join(name); | |
| 2303 | + let bytes = BASE64.decode(upload.data).ok()?; | |
| 2304 | + std::fs::write(&path, bytes).ok()?; | |
| 2305 | + Some(path) | |
| 2306 | + }) | |
| 2307 | + .collect() | |
| 2308 | + } | |
| 2309 | + | |
| 2310 | + impl Session { | |
| 2311 | + /// A thread on another device, as the host's thread list describes it. | |
| 2312 | + pub fn remote(summary: &ThreadSummary, link: RemoteLink, cx: &mut Context<Self>) -> Self { | |
| 2313 | + let _ = cx; | |
| 2314 | + let new = NewSession { | |
| 2315 | + instance: summary.instance.clone(), | |
| 2316 | + provider: summary.provider, | |
| 2317 | + project: summary.project.clone(), | |
| 2318 | + model: summary.model.clone(), | |
| 2319 | + permission_mode: PermissionMode::default(), | |
| 2320 | + reasoning: Reasoning::default(), | |
| 2321 | + fast_mode: false, | |
| 2322 | + worktree: None, | |
| 2323 | + }; | |
| 2324 | + let id = summary.id.clone(); | |
| 2325 | + let mut session = Self::empty(id, new, from_millis(summary.created)); | |
| 2326 | + session.remote = Some(link); | |
| 2327 | + session.apply_summary(summary); | |
| 2328 | + session | |
| 2329 | + } | |
| 2330 | + | |
| 2331 | + /// A new, still empty thread meant for one of a host's projects. | |
| 2332 | + pub fn new_remote(new: NewSession, link: RemoteLink) -> Self { | |
| 2333 | + let now = SystemTime::now(); | |
| 2334 | + let id = format!("remote-{:x}-{}", to_millis(now), NEXT_SESSION.load(Ordering::Relaxed)); | |
| 2335 | + let mut session = Self::empty(id, new, now); | |
| 2336 | + session.status = SessionStatus::Idle; | |
| 2337 | + session.remote = Some(link); | |
| 2338 | + session | |
| 2339 | + } | |
| 2340 | + | |
| 2341 | + pub fn is_remote(&self) -> bool { | |
| 2342 | + self.remote.is_some() | |
| 2343 | + } | |
| 2344 | + | |
| 2345 | + /// Whether nothing was sent in it: not even a remote thread that exists. | |
| 2346 | + pub fn is_blank(&self) -> bool { | |
| 2347 | + self.items.is_empty() && self.remote.as_ref().is_none_or(|remote| remote.thread.is_none() && remote.creating.is_none()) | |
| 2348 | + } | |
| 2349 | + | |
| 2350 | + /// Hands an action to the host, for a mirrored thread. Returns whether | |
| 2351 | + /// the thread is mirrored (then the action is the host's to carry out). | |
| 2352 | + fn forward(&self, action: ThreadAction) -> bool { | |
| 2353 | + let Some(remote) = &self.remote else { return false }; | |
| 2354 | + if let Some(thread) = &remote.thread { | |
| 2355 | + remote.client.send(&ClientMessage::Action { thread: thread.clone(), action }); | |
| 2356 | + } | |
| 2357 | + true | |
| 2358 | + } | |
| 2359 | + | |
| 2360 | + /// Sends (or queues) a message to a mirrored thread, creating the | |
| 2361 | + /// thread on the host with its first message. | |
| 2362 | + fn send_remote(&mut self, text: String, attachments: Vec<PathBuf>, queue: bool, cx: &mut Context<Self>) { | |
| 2363 | + if text.trim().is_empty() && attachments.is_empty() { | |
| 2364 | + return; | |
| 2365 | + } | |
| 2366 | + let mut uploads = Vec::new(); | |
| 2367 | + for path in &attachments { | |
| 2368 | + match upload(path) { | |
| 2369 | + Ok(file) => uploads.push(file), | |
| 2370 | + Err(err) => self.push_notice(NoticeKind::Error, format!("Couldn't attach {}: {err}", file_name(path))), | |
| 2371 | + } | |
| 2372 | + } | |
| 2373 | + let Some(remote) = self.remote.as_mut() else { return }; | |
| 2374 | + match remote.thread.clone() { | |
| 2375 | + Some(thread) => { | |
| 2376 | + let action = if queue { | |
| 2377 | + ThreadAction::Queue { text: text.clone(), attachments: uploads } | |
| 2378 | + } else { | |
| 2379 | + ThreadAction::Send { text: text.clone(), attachments: uploads } | |
| 2380 | + }; | |
| 2381 | + remote.client.send(&ClientMessage::Action { thread, action }); | |
| 2382 | + if queue { | |
| 2383 | + self.queue.push(QueuedMessage { text, attachments }); | |
| 2384 | + cx.notify(); | |
| 2385 | + return; | |
| 2386 | + } | |
| 2387 | + } | |
| 2388 | + None if remote.creating.is_some() => { | |
| 2389 | + // The thread is on its way; this waits for it. | |
| 2390 | + self.queue.push(QueuedMessage { text, attachments }); | |
| 2391 | + cx.notify(); | |
| 2392 | + return; | |
| 2393 | + } | |
| 2394 | + None => { | |
| 2395 | + static NEXT_REFERENCE: AtomicU64 = AtomicU64::new(1); | |
| 2396 | + let reference = NEXT_REFERENCE.fetch_add(1, Ordering::Relaxed); | |
| 2397 | + remote.creating = Some(reference); | |
| 2398 | + let plan = self.pending_worktree.clone(); | |
| 2399 | + remote.client.send(&ClientMessage::Create { | |
| 2400 | + reference, | |
| 2401 | + project: self.project.clone(), | |
| 2402 | + instance: self.instance.clone(), | |
| 2403 | + provider: self.provider, | |
| 2404 | + model: self.model.clone(), | |
| 2405 | + permission_mode: self.permission_mode.as_cli().into(), | |
| 2406 | + reasoning: self.reasoning.key().into(), | |
| 2407 | + fast_mode: self.fast_mode, | |
| 2408 | + worktree: plan.is_some(), | |
| 2409 | + base: plan.and_then(|plan| plan.base), | |
| 2410 | + text: text.clone(), | |
| 2411 | + attachments: uploads, | |
| 2412 | + }); | |
| 2413 | + } | |
| 2414 | + } | |
| 2415 | + // Show the message right away; the host's transcript confirms it. | |
| 2416 | + let now = SystemTime::now(); | |
| 2417 | + let names = attachments.iter().map(|path| file_name(path).into()).collect(); | |
| 2418 | + if self.title.as_ref() == DEFAULT_TITLE { | |
| 2419 | + self.title = title_from(&text).into(); | |
| 2420 | + } | |
| 2421 | + self.items.push(ChatItem::User { text: text.into(), attachments: names, at: now }); | |
| 2422 | + self.status = SessionStatus::Working; | |
| 2423 | + self.updated = now; | |
| 2424 | + self.last_message = now; | |
| 2425 | + cx.notify(); | |
| 2426 | + } | |
| 2427 | + | |
| 2428 | + /// Learns the host's id for a thread created from here, and sends what | |
| 2429 | + /// was typed meanwhile. | |
| 2430 | + pub fn remote_created(&mut self, thread: String, cx: &mut Context<Self>) { | |
| 2431 | + let Some(remote) = self.remote.as_mut() else { return }; | |
| 2432 | + remote.creating = None; | |
| 2433 | + remote.thread = Some(thread.clone()); | |
| 2434 | + remote.subscribed = true; | |
| 2435 | + remote.client.send(&ClientMessage::Subscribe { thread: thread.clone() }); | |
| 2436 | + self.id = thread; | |
| 2437 | + for message in std::mem::take(&mut self.queue) { | |
| 2438 | + self.send_remote(message.text, message.attachments, true, cx); | |
| 2439 | + } | |
| 2440 | + cx.notify(); | |
| 2441 | + } | |
| 2442 | + | |
| 2443 | + /// Asks the host for the transcript, once. | |
| 2444 | + pub fn subscribe_remote(&mut self) { | |
| 2445 | + let Some(remote) = self.remote.as_mut() else { return }; | |
| 2446 | + if let (Some(thread), false) = (&remote.thread, remote.subscribed) { | |
| 2447 | + remote.client.send(&ClientMessage::Subscribe { thread: thread.clone() }); | |
| 2448 | + remote.subscribed = true; | |
| 2449 | + } | |
| 2450 | + } | |
| 2451 | + | |
| 2452 | + pub fn apply_summary(&mut self, summary: &ThreadSummary) { | |
| 2453 | + self.title = summary.title.clone().into(); | |
| 2454 | + self.provider = summary.provider; | |
| 2455 | + self.instance = summary.instance.clone(); | |
| 2456 | + self.model = summary.model.clone(); | |
| 2457 | + self.permission_mode = PermissionMode::from_cli(&summary.permission_mode).unwrap_or_default(); | |
| 2458 | + self.reasoning = Reasoning::from_key(&summary.reasoning).unwrap_or_default(); | |
| 2459 | + self.fast_mode = summary.fast_mode; | |
| 2460 | + self.status = status_from_key(&summary.status); | |
| 2461 | + self.settled = summary.settled; | |
| 2462 | + self.pinned = summary.pinned; | |
| 2463 | + self.snoozed_until = summary.snoozed_until.map(from_millis); | |
| 2464 | + self.updated = from_millis(summary.updated); | |
| 2465 | + self.last_message = from_millis(summary.last_message); | |
| 2466 | + self.total_cost = summary.total_cost; | |
| 2467 | + self.project = summary.project.clone(); | |
| 2468 | + self.worktree = summary.branch.clone().map(|branch| Worktree { | |
| 2469 | + root: self.cwd.clone(), | |
| 2470 | + branch, | |
| 2471 | + repo: summary.project.clone(), | |
| 2472 | + }); | |
| 2473 | + if let Some(remote) = self.remote.as_mut() { | |
| 2474 | + remote.attention = summary.attention; | |
| 2475 | + } | |
| 2476 | + } | |
| 2477 | + | |
| 2478 | + /// Takes a host's update of a subscribed thread: its state, and its | |
| 2479 | + /// transcript from item `from` on. | |
| 2480 | + pub fn apply_remote( | |
| 2481 | + &mut self, | |
| 2482 | + summary: &ThreadSummary, | |
| 2483 | + detail: ThreadDetail, | |
| 2484 | + from: usize, | |
| 2485 | + len: usize, | |
| 2486 | + items: Vec<SavedItem>, | |
| 2487 | + cx: &mut Context<Self>, | |
| 2488 | + ) { | |
| 2489 | + self.apply_summary(summary); | |
| 2490 | + self.cwd = detail.cwd; | |
| 2491 | + if let Some(worktree) = self.worktree.as_mut() { | |
| 2492 | + worktree.root = self.cwd.clone(); | |
| 2493 | + } | |
| 2494 | + self.active_model = detail.active_model; | |
| 2495 | + self.thinking = detail.thinking; | |
| 2496 | + self.preparing = detail.preparing; | |
| 2497 | + self.turn_started = detail.turn_elapsed_ms.map(|elapsed| Instant::now() - Duration::from_millis(elapsed)); | |
| 2498 | + self.context_tokens = detail.context_tokens; | |
| 2499 | + self.context_window = detail.context_window; | |
| 2500 | + self.queue = detail.queue; | |
| 2501 | + self.limit_resets = detail.limit_resets.map(from_millis); | |
| 2502 | + self.resume_at = detail.resume_at.map(from_millis); | |
| 2503 | + self.pull_request = detail.pull_request; | |
| 2504 | + if !detail.pending_worktree { | |
| 2505 | + self.pending_worktree = None; | |
| 2506 | + } | |
| 2507 | + self.turn_changes = detail.turn_changes.into_iter().collect(); | |
| 2508 | + | |
| 2509 | + let from = from.min(self.items.len()); | |
| 2510 | + for (offset, saved) in items.into_iter().enumerate() { | |
| 2511 | + let ix = from + offset; | |
| 2512 | + // A streaming answer grows; feed it the new text instead of | |
| 2513 | + // rebuilding its view. | |
| 2514 | + if let (Some(ChatItem::Assistant(answer)), SavedItem::Assistant { text }) = (self.items.get_mut(ix), &saved) | |
| 2515 | + && text.starts_with(answer.text.as_str()) | |
| 2516 | + { | |
| 2517 | + let new = text[answer.text.len()..].to_string(); | |
| 2518 | + answer.text.push_str(&new); | |
| 2519 | + let end = answer.text.len(); | |
| 2520 | + answer.show(end, cx); | |
| 2521 | + continue; | |
| 2522 | + } | |
| 2523 | + let mut item = self.item_from_saved(saved, true, cx); | |
| 2524 | + // Keep tool cards open across updates. | |
| 2525 | + if let (Some(ChatItem::Tool(old)), ChatItem::Tool(new)) = (self.items.get(ix), &mut item) | |
| 2526 | + && old.id == new.id | |
| 2527 | + { | |
| 2528 | + new.expanded = old.expanded; | |
| 2529 | + } | |
| 2530 | + match self.items.get_mut(ix) { | |
| 2531 | + Some(slot) => *slot = item, | |
| 2532 | + None => self.items.push(item), | |
| 2533 | + } | |
| 2534 | + } | |
| 2535 | + self.items.truncate(len); | |
| 2536 | + if let Some(remote) = self.remote.as_mut() { | |
| 2537 | + remote.loaded = true; | |
| 2538 | + } | |
| 2539 | + cx.notify(); | |
| 2540 | + } | |
| 2541 | + | |
| 2542 | + /// The thread as a client's sidebar needs it. | |
| 2543 | + pub fn summary(&self) -> ThreadSummary { | |
| 2544 | + ThreadSummary { | |
| 2545 | + id: self.id.clone(), | |
| 2546 | + project: self.project.clone(), | |
| 2547 | + title: self.title.to_string(), | |
| 2548 | + provider: self.provider, | |
| 2549 | + instance: self.instance.clone(), | |
| 2550 | + model: self.model.clone(), | |
| 2551 | + permission_mode: self.permission_mode.as_cli().into(), | |
| 2552 | + reasoning: self.reasoning.key().into(), | |
| 2553 | + fast_mode: self.fast_mode, | |
| 2554 | + status: status_key(self.status).into(), | |
| 2555 | + attention: self.needs_attention(), | |
| 2556 | + settled: self.settled, | |
| 2557 | + pinned: self.pinned, | |
| 2558 | + snoozed_until: self.snoozed_until.filter(|_| self.is_snoozed()).map(to_millis), | |
| 2559 | + created: to_millis(self.created), | |
| 2560 | + updated: to_millis(self.updated), | |
| 2561 | + last_message: to_millis(self.last_message), | |
| 2562 | + branch: self.worktree.as_ref().map(|worktree| worktree.branch.clone()), | |
| 2563 | + total_cost: self.total_cost, | |
| 2564 | + has_items: !self.items.is_empty(), | |
| 2565 | + } | |
| 2566 | + } | |
| 2567 | + | |
| 2568 | + /// The rest of the thread's state, for a client that has it open. | |
| 2569 | + pub fn detail(&self) -> ThreadDetail { | |
| 2570 | + ThreadDetail { | |
| 2571 | + cwd: self.cwd.clone(), | |
| 2572 | + active_model: self.active_model.clone(), | |
| 2573 | + thinking: self.thinking, | |
| 2574 | + preparing: self.preparing, | |
| 2575 | + turn_elapsed_ms: self.turn_started.map(|start| start.elapsed().as_millis() as u64), | |
| 2576 | + context_tokens: self.context_tokens, | |
| 2577 | + context_window: self.context_window, | |
| 2578 | + queue: self.queue.clone(), | |
| 2579 | + limit_resets: self.limit_resets.map(to_millis), | |
| 2580 | + resume_at: self.resume_at.map(to_millis), | |
| 2581 | + pending_worktree: self.pending_worktree.is_some(), | |
| 2582 | + pull_request: self.pull_request.clone(), | |
| 2583 | + turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(), | |
| 2584 | + } | |
| 2585 | + } | |
| 2586 | + | |
| 2587 | + /// Schedules (or reschedules) continuing after a usage limit. | |
| 2588 | + pub fn schedule_resume(&mut self, at: SystemTime, cx: &mut Context<Self>) { | |
| 2589 | + self.forward(ThreadAction::ScheduleResume { at: to_millis(at) }); | |
| 2590 | + self.resume_at = Some(at); | |
| 2591 | + cx.notify(); | |
| 2592 | + } | |
| 2110 | 2593 | } |
| 2111 | 2594 | |
| 2112 | 2595 | impl Drop for Session { |
| @@ -2,12 +2,13 @@ | ||
| 2 | 2 | //! diff view it opens. |
| 3 | 3 | |
| 4 | 4 | use std::collections::{BTreeMap, HashSet}; |
| 5 | + use std::rc::Rc; | |
| 5 | 6 | |
| 6 | - use gpui_kit::component::{Icon, IconName, h_flex, v_flex}; | |
| 7 | + use gpui_kit::component::{Icon, IconName, Selectable as _, h_flex, v_flex}; | |
| 7 | 8 | use gpui_kit::prelude::FluentBuilder as _; |
| 8 | 9 | use gpui_kit::*; |
| 9 | 10 | |
| 10 | - use crate::git::{FileChange, FileDiff, LineKind, TurnChanges}; | |
| 11 | + use crate::git::{DiffLine, FileChange, FileDiff, LineKind, TurnChanges}; | |
| 11 | 12 | use crate::session::Session; |
| 12 | 13 | use crate::ui::icons::Lucide; |
| 13 | 14 | use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette}; |
| @@ -250,15 +251,170 @@ pub fn render_changes( | ||
| 250 | 251 | .into_any_element() |
| 251 | 252 | } |
| 252 | 253 | |
| 253 | - /// The diff view's state: which turn and file it shows, and the diff once | |
| 254 | - /// git has produced it. | |
| 254 | + /// The diff view's state: which turn and file it shows, how, and the diff | |
| 255 | + /// once git has produced it. | |
| 256 | + #[derive(Default)] | |
| 255 | 257 | pub struct DiffView { |
| 256 | 258 | pub title: SharedString, |
| 259 | + /// The turn it shows, by the index of its end in the transcript. | |
| 260 | + pub end: usize, | |
| 261 | + /// The one file it was opened for; `None` for the whole turn. | |
| 262 | + pub path: Option<String>, | |
| 257 | 263 | pub files: Option<Result<Vec<FileDiff>, String>>, |
| 264 | + pub ignore_whitespace: bool, | |
| 265 | + pub side_by_side: bool, | |
| 266 | + /// Paths of the files folded down to their header. | |
| 267 | + pub collapsed: HashSet<String>, | |
| 268 | + /// Fold every file once the first diff arrives. | |
| 269 | + pub start_collapsed: bool, | |
| 270 | + } | |
| 271 | + | |
| 272 | + impl DiffView { | |
| 273 | + pub fn set_files(&mut self, files: Result<Vec<FileDiff>, String>) { | |
| 274 | + if let Ok(files) = &files | |
| 275 | + && std::mem::take(&mut self.start_collapsed) | |
| 276 | + { | |
| 277 | + self.collapsed = files.iter().map(|file| file.path.clone()).collect(); | |
| 278 | + } | |
| 279 | + self.files = Some(files); | |
| 280 | + } | |
| 281 | + | |
| 282 | + pub fn toggle_file(&mut self, path: String) { | |
| 283 | + if !self.collapsed.remove(&path) { | |
| 284 | + self.collapsed.insert(path); | |
| 285 | + } | |
| 286 | + } | |
| 287 | + | |
| 288 | + pub fn set_all_collapsed(&mut self, collapsed: bool) { | |
| 289 | + self.collapsed = match &self.files { | |
| 290 | + Some(Ok(files)) if collapsed => files.iter().map(|file| file.path.clone()).collect(), | |
| 291 | + _ => HashSet::new(), | |
| 292 | + }; | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + /// What the diff view's controls ask of whoever holds the view. | |
| 297 | + #[derive(Clone, Debug, PartialEq)] | |
| 298 | + pub enum DiffAction { | |
| 299 | + Close, | |
| 300 | + ToggleWhitespace, | |
| 301 | + SetSideBySide(bool), | |
| 302 | + /// Fold or unfold one file, by path. | |
| 303 | + ToggleFile(String), | |
| 304 | + SetAllCollapsed(bool), | |
| 305 | + } | |
| 306 | + | |
| 307 | + /// One row of the side-by-side layout. | |
| 308 | + #[derive(Debug)] | |
| 309 | + enum SplitRow<'a> { | |
| 310 | + /// A hunk header, across both columns. | |
| 311 | + Hunk(&'a DiffLine), | |
| 312 | + /// The old line on the left, the new one on the right; either may be | |
| 313 | + /// missing where one side of a change runs longer. | |
| 314 | + Pair(Option<&'a DiffLine>, Option<&'a DiffLine>), | |
| 315 | + } | |
| 316 | + | |
| 317 | + /// Pairs a file's lines up for the side-by-side layout: context on both | |
| 318 | + /// sides, and each run of removed lines next to the added run that follows it. | |
| 319 | + fn split_rows(lines: &[DiffLine]) -> Vec<SplitRow<'_>> { | |
| 320 | + let mut rows = Vec::new(); | |
| 321 | + let mut ix = 0; | |
| 322 | + while ix < lines.len() { | |
| 323 | + let line = &lines[ix]; | |
| 324 | + match line.kind { | |
| 325 | + LineKind::Hunk => rows.push(SplitRow::Hunk(line)), | |
| 326 | + LineKind::Context => rows.push(SplitRow::Pair(Some(line), Some(line))), | |
| 327 | + LineKind::Removed | LineKind::Added => { | |
| 328 | + let run = |from: usize, kind: LineKind| { | |
| 329 | + lines[from..].iter().take_while(|line| line.kind == kind).count() | |
| 330 | + }; | |
| 331 | + let removed = run(ix, LineKind::Removed); | |
| 332 | + let added = run(ix + removed, LineKind::Added); | |
| 333 | + let (old, new) = (&lines[ix..ix + removed], &lines[ix + removed..ix + removed + added]); | |
| 334 | + rows.extend((0..removed.max(added)).map(|row| SplitRow::Pair(old.get(row), new.get(row)))); | |
| 335 | + ix += removed + added; | |
| 336 | + continue; | |
| 337 | + } | |
| 338 | + } | |
| 339 | + ix += 1; | |
| 340 | + } | |
| 341 | + rows | |
| 342 | + } | |
| 343 | + | |
| 344 | + /// A line's sign, background and ink. | |
| 345 | + fn line_style(kind: LineKind, p: &Palette) -> (&'static str, Option<Hsla>, Hsla) { | |
| 346 | + match kind { | |
| 347 | + LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink), | |
| 348 | + LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink), | |
| 349 | + LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3), | |
| 350 | + LineKind::Context => (" ", None, p.ink2), | |
| 351 | + } | |
| 352 | + } | |
| 353 | + | |
| 354 | + const DIFF_ROW: Pixels = px(18.); | |
| 355 | + | |
| 356 | + /// A hunk header, spanning the whole width. | |
| 357 | + fn hunk_row(line: &DiffLine, p: &Palette) -> Div { | |
| 358 | + let (_, bg, color) = line_style(line.kind, p); | |
| 359 | + h_flex() | |
| 360 | + .h(DIFF_ROW) | |
| 361 | + .px(px(12.)) | |
| 362 | + .when_some(bg, |this, bg| this.bg(bg)) | |
| 363 | + .text_color(color) | |
| 364 | + .child(div().flex_1().min_w_0().truncate().child(line.text.clone())) | |
| 365 | + } | |
| 366 | + | |
| 367 | + /// A line of code behind its numbers in the gutter, clipped at the edge | |
| 368 | + /// rather than wrapped. | |
| 369 | + fn code_row(line: &DiffLine, numbers: &[Option<u32>], gutter: Pixels, p: &Palette) -> Div { | |
| 370 | + let (sign, bg, color) = line_style(line.kind, p); | |
| 371 | + h_flex() | |
| 372 | + .h(DIFF_ROW) | |
| 373 | + .min_w_0() | |
| 374 | + .px(px(12.)) | |
| 375 | + .gap(px(10.)) | |
| 376 | + .when_some(bg, |this, bg| this.bg(bg)) | |
| 377 | + .text_color(color) | |
| 378 | + .children(numbers.iter().map(|number| { | |
| 379 | + div() | |
| 380 | + .flex_none() | |
| 381 | + .w(gutter) | |
| 382 | + .text_right() | |
| 383 | + .text_color(p.ink3.opacity(0.7)) | |
| 384 | + .when_some(*number, |this, number| this.child(number.to_string())) | |
| 385 | + })) | |
| 386 | + .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign)) | |
| 387 | + .child(div().flex_1().min_w_0().truncate().child(line.text.clone())) | |
| 258 | 388 | } |
| 259 | 389 | |
| 260 | - pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + 'static, cx: &App) -> AnyElement { | |
| 390 | + /// One column of a side-by-side row: a line, or a blank where the other | |
| 391 | + /// side runs longer. | |
| 392 | + fn split_cell(line: Option<&DiffLine>, number: impl Fn(&DiffLine) -> Option<u32>, gutter: Pixels, p: &Palette) -> Div { | |
| 393 | + match line { | |
| 394 | + Some(line) => code_row(line, &[number(line)], gutter, p), | |
| 395 | + None => div().h(DIFF_ROW).bg(p.ink.opacity(0.025)), | |
| 396 | + } | |
| 397 | + .flex_1() | |
| 398 | + .overflow_hidden() | |
| 399 | + } | |
| 400 | + | |
| 401 | + pub fn render_diff( | |
| 402 | + view: &DiffView, | |
| 403 | + on_action: impl Fn(DiffAction, &mut Window, &mut App) + 'static, | |
| 404 | + cx: &App, | |
| 405 | + ) -> AnyElement { | |
| 261 | 406 | let p = metal::palette(cx); |
| 407 | + let on_action = Rc::new(on_action); | |
| 408 | + let on_click = |action: DiffAction| { | |
| 409 | + let on_action = on_action.clone(); | |
| 410 | + move |_: &ClickEvent, window: &mut Window, cx: &mut App| on_action(action.clone(), window, cx) | |
| 411 | + }; | |
| 412 | + let files = match &view.files { | |
| 413 | + Some(Ok(files)) if !files.is_empty() => Some(files), | |
| 414 | + _ => None, | |
| 415 | + }; | |
| 416 | + let any_open = files.is_some_and(|files| files.iter().any(|file| !view.collapsed.contains(&file.path))); | |
| 417 | + | |
| 262 | 418 | let header = h_flex() |
| 263 | 419 | .flex_none() |
| 264 | 420 | .h(px(46.)) |
| @@ -275,6 +431,30 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + ' | ||
| 275 | 431 | .text_color(p.ink) |
| 276 | 432 | .child(view.title.clone()), |
| 277 | 433 | ) |
| 434 | + .when(files.is_some(), |this| { | |
| 435 | + this.child( | |
| 436 | + MetalButton::new("diff-toggle-all") | |
| 437 | + .ghost() | |
| 438 | + .compact() | |
| 439 | + .icon(if any_open { Lucide::ChevronsDownUp } else { Lucide::ChevronsUpDown }) | |
| 440 | + .label(if any_open { "Collapse all" } else { "Expand all" }) | |
| 441 | + .on_click(on_click(DiffAction::SetAllCollapsed(any_open))), | |
| 442 | + ) | |
| 443 | + }) | |
| 444 | + .child( | |
| 445 | + MetalButton::new("diff-whitespace") | |
| 446 | + .ghost() | |
| 447 | + .mini() | |
| 448 | + .icon(Lucide::Space) | |
| 449 | + .selected(view.ignore_whitespace) | |
| 450 | + .tooltip(if view.ignore_whitespace { "Show whitespace changes" } else { "Hide whitespace changes" }) | |
| 451 | + .on_click(on_click(DiffAction::ToggleWhitespace)), | |
| 452 | + ) | |
| 453 | + .child(metal::segmented("diff-layout", &[(false, "Stacked"), (true, "Side by side")], view.side_by_side, p, { | |
| 454 | + let on_action = on_action.clone(); | |
| 455 | + move |side_by_side, window, cx| on_action(DiffAction::SetSideBySide(side_by_side), window, cx) | |
| 456 | + })) | |
| 457 | + .child(metal::vrule(p)) | |
| 278 | 458 | .child(metal::keycap("Esc", p)) |
| 279 | 459 | .child( |
| 280 | 460 | MetalButton::new("close-diff") |
| @@ -282,33 +462,52 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + ' | ||
| 282 | 462 | .mini() |
| 283 | 463 | .icon(IconName::Close) |
| 284 | 464 | .tooltip("Close diff") |
| 285 | - .on_click(move |_, window, cx| on_close(window, cx)), | |
| 465 | + .on_click(on_click(DiffAction::Close)), | |
| 286 | 466 | ); |
| 287 | 467 | |
| 468 | + let message = |text: SharedString, color: Hsla| { | |
| 469 | + div().p(px(20.)).text_size(px(13.)).text_color(color).child(text).into_any_element() | |
| 470 | + }; | |
| 288 | 471 | let body: AnyElement = match &view.files { |
| 289 | - None => div().p(px(20.)).text_size(px(13.)).text_color(p.ink3).child("Reading the diff…").into_any_element(), | |
| 290 | - Some(Err(error)) => div() | |
| 291 | - .p(px(20.)) | |
| 292 | - .text_size(px(13.)) | |
| 293 | - .text_color(p.red) | |
| 294 | - .child(format!("Couldn't read the diff: {error}")) | |
| 295 | - .into_any_element(), | |
| 472 | + None => message("Reading the diff…".into(), p.ink3), | |
| 473 | + Some(Err(error)) => message(format!("Couldn't read the diff: {error}").into(), p.red), | |
| 474 | + Some(Ok(files)) if files.is_empty() => message( | |
| 475 | + if view.ignore_whitespace { "Only whitespace changed." } else { "Nothing changed." }.into(), | |
| 476 | + p.ink3, | |
| 477 | + ), | |
| 296 | 478 | Some(Ok(files)) => { |
| 297 | 479 | let mut budget = MAX_DIFF_LINES; |
| 298 | 480 | v_flex() |
| 299 | 481 | .gap(px(14.)) |
| 300 | 482 | .p(px(16.)) |
| 301 | - .children(files.iter().map(|file| { | |
| 302 | - let shown = file.lines.len().min(budget); | |
| 483 | + .children(files.iter().enumerate().map(|(ix, file)| { | |
| 484 | + let open = !view.collapsed.contains(&file.path); | |
| 485 | + let shown = if open { file.lines.len().min(budget) } else { 0 }; | |
| 303 | 486 | budget -= shown; |
| 304 | 487 | let hidden = file.lines.len() - shown; |
| 488 | + let lines = &file.lines[..shown]; | |
| 305 | 489 | let added = file.lines.iter().filter(|line| line.kind == LineKind::Added).count() as u64; |
| 306 | 490 | let removed = file.lines.iter().filter(|line| line.kind == LineKind::Removed).count() as u64; |
| 491 | + // Wide enough for the longest number, in Geist Mono's 12px digits. | |
| 492 | + let digits = lines.iter().filter_map(|line| line.old_line.max(line.new_line)).max().unwrap_or(0); | |
| 493 | + let gutter = px(digits.to_string().len() as f32 * 7.5); | |
| 307 | 494 | v_flex() |
| 308 | 495 | .gap(px(6.)) |
| 309 | 496 | .child( |
| 310 | 497 | h_flex() |
| 311 | - .gap(px(10.)) | |
| 498 | + .id(("diff-file", ix)) | |
| 499 | + .h(px(28.)) | |
| 500 | + .px(px(8.)) | |
| 501 | + .gap(px(8.)) | |
| 502 | + .rounded(px(9.)) | |
| 503 | + .cursor_pointer() | |
| 504 | + .hover(|style| style.bg(p.row_hover)) | |
| 505 | + .on_click(on_click(DiffAction::ToggleFile(file.path.clone()))) | |
| 506 | + .child( | |
| 507 | + Icon::new(if open { IconName::ChevronDown } else { IconName::ChevronRight }) | |
| 508 | + .size(px(12.)) | |
| 509 | + .text_color(p.ink3), | |
| 510 | + ) | |
| 312 | 511 | .child(file_icon(&file.path).size(px(14.)).text_color(p.ink3)) |
| 313 | 512 | .child( |
| 314 | 513 | div() |
| @@ -326,42 +525,49 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + ' | ||
| 326 | 525 | p, |
| 327 | 526 | )), |
| 328 | 527 | ) |
| 329 | - .child( | |
| 330 | - v_flex() | |
| 331 | - .rounded(px(12.)) | |
| 332 | - .well(p) | |
| 333 | - .py(px(6.)) | |
| 334 | - .font_family(metal::MONO) | |
| 335 | - .text_size(px(12.)) | |
| 336 | - .line_height(px(18.)) | |
| 337 | - .when(file.binary, |this| { | |
| 338 | - this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file")) | |
| 339 | - }) | |
| 340 | - .children(file.lines.iter().take(shown).map(|line| { | |
| 341 | - let (sign, bg, color) = match line.kind { | |
| 342 | - LineKind::Added => ("+", Some(p.green_deep.opacity(0.14)), p.ink), | |
| 343 | - LineKind::Removed => ("−", Some(p.red.opacity(0.12)), p.ink), | |
| 344 | - LineKind::Hunk => ("", Some(p.ink.opacity(0.04)), p.ink3), | |
| 345 | - LineKind::Context => (" ", None, p.ink2), | |
| 346 | - }; | |
| 347 | - h_flex() | |
| 348 | - .px(px(12.)) | |
| 349 | - .gap(px(10.)) | |
| 350 | - .when_some(bg, |this, bg| this.bg(bg)) | |
| 351 | - .text_color(color) | |
| 352 | - .child(div().w(px(10.)).flex_none().text_color(p.ink3).child(sign)) | |
| 353 | - .child(div().flex_1().min_w_0().whitespace_nowrap().child(line.text.clone())) | |
| 354 | - })) | |
| 355 | - .when(hidden > 0, |this| { | |
| 356 | - this.child( | |
| 357 | - div() | |
| 358 | - .px(px(12.)) | |
| 359 | - .pt(px(4.)) | |
| 360 | - .text_color(p.ink3) | |
| 361 | - .child(format!("… {hidden} more lines")), | |
| 362 | - ) | |
| 363 | - }), | |
| 364 | - ) | |
| 528 | + .when(open, |this| { | |
| 529 | + this.child( | |
| 530 | + v_flex() | |
| 531 | + .rounded(px(12.)) | |
| 532 | + .well(p) | |
| 533 | + .py(px(6.)) | |
| 534 | + .overflow_hidden() | |
| 535 | + .font_family(metal::MONO) | |
| 536 | + .text_size(px(12.)) | |
| 537 | + .line_height(DIFF_ROW) | |
| 538 | + .when(file.binary, |this| { | |
| 539 | + this.child(div().px(px(12.)).text_color(p.ink3).child("Binary file")) | |
| 540 | + }) | |
| 541 | + .map(|this| { | |
| 542 | + if view.side_by_side { | |
| 543 | + this.children(split_rows(lines).into_iter().map(|row| match row { | |
| 544 | + SplitRow::Hunk(line) => hunk_row(line, p), | |
| 545 | + SplitRow::Pair(old, new) => h_flex() | |
| 546 | + .child( | |
| 547 | + split_cell(old, |line| line.old_line, gutter, p) | |
| 548 | + .border_r_1() | |
| 549 | + .border_color(p.rule), | |
| 550 | + ) | |
| 551 | + .child(split_cell(new, |line| line.new_line, gutter, p)), | |
| 552 | + })) | |
| 553 | + } else { | |
| 554 | + this.children(lines.iter().map(|line| match line.kind { | |
| 555 | + LineKind::Hunk => hunk_row(line, p), | |
| 556 | + _ => code_row(line, &[line.old_line, line.new_line], gutter, p), | |
| 557 | + })) | |
| 558 | + } | |
| 559 | + }) | |
| 560 | + .when(hidden > 0, |this| { | |
| 561 | + this.child( | |
| 562 | + div() | |
| 563 | + .px(px(12.)) | |
| 564 | + .pt(px(4.)) | |
| 565 | + .text_color(p.ink3) | |
| 566 | + .child(format!("… {hidden} more lines")), | |
| 567 | + ) | |
| 568 | + }), | |
| 569 | + ) | |
| 570 | + }) | |
| 365 | 571 | })) |
| 366 | 572 | .into_any_element() |
| 367 | 573 | } |
| @@ -374,3 +580,65 @@ pub fn render_diff(view: &DiffView, on_close: impl Fn(&mut Window, &mut App) + ' | ||
| 374 | 580 | .child(div().id("diff-scroll").flex_1().min_h_0().overflow_y_scroll().child(body)) |
| 375 | 581 | .into_any_element() |
| 376 | 582 | } |
| 583 | + | |
| 584 | + #[cfg(test)] | |
| 585 | + mod tests { | |
| 586 | + use super::{SplitRow, split_rows}; | |
| 587 | + use crate::git::{DiffLine, LineKind}; | |
| 588 | + | |
| 589 | + fn line(kind: LineKind, text: &str) -> DiffLine { | |
| 590 | + DiffLine { kind, text: text.to_string(), old_line: None, new_line: None } | |
| 591 | + } | |
| 592 | + | |
| 593 | + /// The rows as `(left, right)` texts; hunk headers show on both sides. | |
| 594 | + fn texts<'a>(rows: &[SplitRow<'a>]) -> Vec<(Option<&'a str>, Option<&'a str>)> { | |
| 595 | + rows.iter() | |
| 596 | + .map(|row| match row { | |
| 597 | + SplitRow::Hunk(line) => (Some(line.text.as_str()), Some(line.text.as_str())), | |
| 598 | + SplitRow::Pair(old, new) => (old.map(|line| line.text.as_str()), new.map(|line| line.text.as_str())), | |
| 599 | + }) | |
| 600 | + .collect() | |
| 601 | + } | |
| 602 | + | |
| 603 | + #[::core::prelude::v1::test] | |
| 604 | + fn pairs_changes_side_by_side() { | |
| 605 | + use LineKind::*; | |
| 606 | + let lines = [ | |
| 607 | + line(Hunk, "@@"), | |
| 608 | + line(Context, "a"), | |
| 609 | + line(Removed, "b"), | |
| 610 | + line(Removed, "c"), | |
| 611 | + line(Added, "B"), | |
| 612 | + line(Context, "d"), | |
| 613 | + line(Removed, "e"), | |
| 614 | + line(Added, "E"), | |
| 615 | + line(Added, "F"), | |
| 616 | + line(Added, "G"), | |
| 617 | + line(Added, "H"), | |
| 618 | + line(Context, "i"), | |
| 619 | + line(Removed, "j"), | |
| 620 | + ]; | |
| 621 | + assert_eq!( | |
| 622 | + texts(&split_rows(&lines)), | |
| 623 | + [ | |
| 624 | + (Some("@@"), Some("@@")), | |
| 625 | + (Some("a"), Some("a")), | |
| 626 | + (Some("b"), Some("B")), | |
| 627 | + (Some("c"), None), | |
| 628 | + (Some("d"), Some("d")), | |
| 629 | + (Some("e"), Some("E")), | |
| 630 | + (None, Some("F")), | |
| 631 | + (None, Some("G")), | |
| 632 | + (None, Some("H")), | |
| 633 | + (Some("i"), Some("i")), | |
| 634 | + (Some("j"), None), | |
| 635 | + ] | |
| 636 | + ); | |
| 637 | + } | |
| 638 | + | |
| 639 | + #[::core::prelude::v1::test] | |
| 640 | + fn pairs_additions_alone() { | |
| 641 | + let lines = [line(LineKind::Added, "x"), line(LineKind::Removed, "y")]; | |
| 642 | + assert_eq!(texts(&split_rows(&lines)), [(None, Some("x")), (Some("y"), None)]); | |
| 643 | + } | |
| 644 | + } | |
| @@ -30,7 +30,7 @@ use crate::session::{ | ||
| 30 | 30 | SessionEvent, ToolCall, ToolState, tool_field, tool_summary, |
| 31 | 31 | }; |
| 32 | 32 | use crate::settings::{self, FollowUp, WorkspaceMode}; |
| 33 | - use crate::ui::changes::{self, DiffView}; | |
| 33 | + use crate::ui::changes::{self, DiffAction, DiffView}; | |
| 34 | 34 | use crate::ui::icons::Lucide; |
| 35 | 35 | use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; |
| 36 | 36 | use crate::ui::pickers::{self, ModelPicker, ModelPickerEvent}; |
| @@ -408,28 +408,77 @@ impl ChatView { | ||
| 408 | 408 | |
| 409 | 409 | /// Shows what a finished turn changed, all files or one. |
| 410 | 410 | fn open_diff(&mut self, end: usize, path: Option<String>, window: &mut Window, cx: &mut Context<Self>) { |
| 411 | - let session = self.session.read(cx); | |
| 412 | - let Some(changes) = session.turn_changes(end) else { return }; | |
| 413 | - let (base, head) = (changes.base.clone(), changes.head.clone()); | |
| 414 | - let cwd = session.cwd.clone(); | |
| 411 | + let Some(changes) = self.session.read(cx).turn_changes(end) else { return }; | |
| 415 | 412 | let title = match &path { |
| 416 | 413 | Some(path) => path.clone(), |
| 417 | 414 | None => format!("{} changed files", changes.files.len()), |
| 418 | 415 | }; |
| 419 | - self.diff = Some(DiffView { title: title.into(), files: None }); | |
| 420 | - let task = cx | |
| 421 | - .background_executor() | |
| 422 | - .spawn(async move { git::diff_between(&cwd, &base, &head, path.as_deref()) }); | |
| 416 | + let (ignore_whitespace, side_by_side, collapsed) = settings::get(|settings| { | |
| 417 | + (settings.hide_whitespace, settings.diff_side_by_side, settings.diff_collapsed) | |
| 418 | + }); | |
| 419 | + self.diff = Some(DiffView { | |
| 420 | + title: title.into(), | |
| 421 | + end, | |
| 422 | + path, | |
| 423 | + ignore_whitespace, | |
| 424 | + side_by_side, | |
| 425 | + start_collapsed: collapsed, | |
| 426 | + ..Default::default() | |
| 427 | + }); | |
| 428 | + self.load_diff(window, cx); | |
| 429 | + cx.notify(); | |
| 430 | + } | |
| 431 | + | |
| 432 | + /// Reads the open diff in the background, as its whitespace toggle asks. | |
| 433 | + fn load_diff(&mut self, window: &mut Window, cx: &mut Context<Self>) { | |
| 434 | + let Some(diff) = &self.diff else { return }; | |
| 435 | + let session = self.session.read(cx); | |
| 436 | + let Some(changes) = session.turn_changes(diff.end) else { return }; | |
| 437 | + if session.is_remote() { | |
| 438 | + // The snapshots live in the host's repository. | |
| 439 | + let note = Err("This thread runs on another device; its diffs open there.".to_string()); | |
| 440 | + if let Some(diff) = self.diff.as_mut() { | |
| 441 | + diff.set_files(note); | |
| 442 | + } | |
| 443 | + return; | |
| 444 | + } | |
| 445 | + let (base, head) = (changes.base.clone(), changes.head.clone()); | |
| 446 | + let cwd = session.cwd.clone(); | |
| 447 | + let (end, path, ignore_whitespace) = (diff.end, diff.path.clone(), diff.ignore_whitespace); | |
| 448 | + let task = cx.background_executor().spawn({ | |
| 449 | + let path = path.clone(); | |
| 450 | + async move { git::diff_between(&cwd, &base, &head, path.as_deref(), ignore_whitespace) } | |
| 451 | + }); | |
| 423 | 452 | cx.spawn_in(window, async move |this, cx| { |
| 424 | 453 | let files = task.await; |
| 425 | 454 | let _ = this.update(cx, |this, cx| { |
| 426 | - if let Some(diff) = this.diff.as_mut() { | |
| 427 | - diff.files = Some(files); | |
| 455 | + // Unless the diff was closed, replaced or toggled meanwhile. | |
| 456 | + if let Some(diff) = this.diff.as_mut() | |
| 457 | + && (diff.end, &diff.path, diff.ignore_whitespace) == (end, &path, ignore_whitespace) | |
| 458 | + { | |
| 459 | + diff.set_files(files); | |
| 428 | 460 | cx.notify(); |
| 429 | 461 | } |
| 430 | 462 | }); |
| 431 | 463 | }) |
| 432 | 464 | .detach(); |
| 465 | + } | |
| 466 | + | |
| 467 | + fn diff_action(&mut self, action: DiffAction, window: &mut Window, cx: &mut Context<Self>) { | |
| 468 | + let Some(diff) = self.diff.as_mut() else { return }; | |
| 469 | + match action { | |
| 470 | + DiffAction::Close => self.diff = None, | |
| 471 | + DiffAction::ToggleWhitespace => { | |
| 472 | + diff.ignore_whitespace = !diff.ignore_whitespace; | |
| 473 | + self.load_diff(window, cx); | |
| 474 | + } | |
| 475 | + DiffAction::SetSideBySide(side_by_side) => { | |
| 476 | + diff.side_by_side = side_by_side; | |
| 477 | + settings::update(|settings| settings.diff_side_by_side = side_by_side); | |
| 478 | + } | |
| 479 | + DiffAction::ToggleFile(path) => diff.toggle_file(path), | |
| 480 | + DiffAction::SetAllCollapsed(collapsed) => diff.set_all_collapsed(collapsed), | |
| 481 | + } | |
| 433 | 482 | cx.notify(); |
| 434 | 483 | } |
| 435 | 484 | |
| @@ -634,8 +683,7 @@ impl ChatView { | ||
| 634 | 683 | .label("Continue then") |
| 635 | 684 | .on_click(move |_, _, cx| { |
| 636 | 685 | session.update(cx, |session, cx| { |
| 637 | - session.resume_at = Some(resets + Duration::from_secs(60)); | |
| 638 | - cx.notify(); | |
| 686 | + session.schedule_resume(resets + Duration::from_secs(60), cx) | |
| 639 | 687 | }) |
| 640 | 688 | }), |
| 641 | 689 | }) |
| @@ -1114,13 +1162,10 @@ impl ChatView { | ||
| 1114 | 1162 | impl Render for ChatView { |
| 1115 | 1163 | fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { |
| 1116 | 1164 | let is_empty = self.session.read(cx).items.is_empty(); |
| 1117 | - let close_diff = { | |
| 1165 | + let on_diff = { | |
| 1118 | 1166 | let chat = cx.entity().downgrade(); |
| 1119 | - move |_: &mut Window, cx: &mut App| { | |
| 1120 | - let _ = chat.update(cx, |this, cx| { | |
| 1121 | - this.diff = None; | |
| 1122 | - cx.notify(); | |
| 1123 | - }); | |
| 1167 | + move |action: DiffAction, window: &mut Window, cx: &mut App| { | |
| 1168 | + let _ = chat.update(cx, |this, cx| this.diff_action(action, window, cx)); | |
| 1124 | 1169 | } |
| 1125 | 1170 | }; |
| 1126 | 1171 | v_flex() |
| @@ -1129,7 +1174,7 @@ impl Render for ChatView { | ||
| 1129 | 1174 | .on_action(cx.listener(|this, _: &Interrupt, _, cx| this.escape(cx))) |
| 1130 | 1175 | .on_action(cx.listener(|this, _: &input::Escape, _, cx| this.escape(cx))) |
| 1131 | 1176 | .map(|this| match &self.diff { |
| 1132 | - Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, close_diff, cx))), | |
| 1177 | + Some(diff) => this.child(div().flex_1().min_h_0().child(changes::render_diff(diff, on_diff, cx))), | |
| 1133 | 1178 | None if is_empty => this.child(self.render_start(window, cx)), |
| 1134 | 1179 | None => this.child(self.render_transcript(cx)), |
| 1135 | 1180 | }) |
| @@ -14,6 +14,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 14 | 14 | ArchiveRestore, |
| 15 | 15 | Bell, |
| 16 | 16 | Brain, |
| 17 | + ChevronsDownUp, | |
| 17 | 18 | ChevronsUpDown, |
| 18 | 19 | Code, |
| 19 | 20 | FileCode, |
| @@ -46,6 +47,7 @@ gpui_kit::assets::icon_assets!(ExtraIcons, [ | ||
| 46 | 47 | ShieldCheck, |
| 47 | 48 | SquareSlash, |
| 48 | 49 | SlidersHorizontal, |
| 50 | + Space, | |
| 49 | 51 | Sparkles, |
| 50 | 52 | SquarePen, |
| 51 | 53 | Star, |