From dd113991e3d9aff0ea60f9486f3b518662646e43 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 4 Jun 2026 15:57:28 +0800 Subject: [PATCH 1/2] feat(desktop): populate file panel Diff/History from session snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop file panel's Diff/History tabs were always empty: the renderer runs core's runAgent without a SessionManager (the webview has no node:fs), so core's snapshot capture (agent.ts) never fired for desktop sessions. Capture snapshots on the Rust side instead, mirroring core's on-disk format (~/.deepcode/sessions//snapshots/ — manifest.jsonl + blobs) so /rewind interops: - snapshots.rs: capture_file_snapshot + session_snapshots(sessionId, filePath) command. sha2 for the hash; ISO/blob timestamps hand-rolled (no chrono). - tool_write/tool_edit take an optional session_id and capture a pre+post pair per mutation, best-effort (a snapshot hiccup never fails the edit). The post is stamped +1ms so history timestamps never collide. - mac-session.ts publishes the active session id (mac-agent -> mac-tools + panel) so tools snapshot under it and the panel reads them back. Render it: - diff.ts: pure LCS line differ -> DiffLine[]. - file-history.ts: snapshots -> History timeline (consecutive identical states collapsed) + Diff baseline (oldest snapshot). - useFilePanel.open fetches a file's snapshots -> Diff (current vs session baseline) + History; selectHistory(ts) recomputes the diff against that revision and jumps to the Diff view. Diff defaults to the NET session change (oldest snapshot -> current) — the "what did this conversation do to this file" view. Tests: +5 Rust (capture<->list roundtrip, capture-pair seq/ms, time fmt), +15 TS (differ, history mapping), +2 reducer. Verified the full hook flow (Diff, History, selectHistory) in the mock-Tauri preview. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src-tauri/Cargo.lock | 1 + apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/lib.rs | 5 +- apps/desktop/src-tauri/src/snapshots.rs | 326 ++++++++++++++++++ apps/desktop/src-tauri/src/tools.rs | 156 ++++++++- apps/desktop/src/App.tsx | 2 +- apps/desktop/src/lib/diff.test.ts | 62 ++++ apps/desktop/src/lib/diff.ts | 75 ++++ apps/desktop/src/lib/file-history.test.ts | 60 ++++ apps/desktop/src/lib/file-history.ts | 58 ++++ .../src/lib/file-panel-reducer.test.ts | 17 + apps/desktop/src/lib/file-panel-reducer.ts | 13 +- apps/desktop/src/lib/mac-agent.ts | 6 + apps/desktop/src/lib/mac-session.ts | 17 + apps/desktop/src/lib/mac-tools.ts | 10 +- apps/desktop/src/lib/tauri-api.ts | 25 ++ apps/desktop/src/lib/use-file-panel.ts | 62 +++- apps/desktop/src/preview-app.tsx | 106 ++++-- 18 files changed, 961 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src-tauri/src/snapshots.rs create mode 100644 apps/desktop/src/lib/diff.test.ts create mode 100644 apps/desktop/src/lib/diff.ts create mode 100644 apps/desktop/src/lib/file-history.test.ts create mode 100644 apps/desktop/src/lib/file-history.ts create mode 100644 apps/desktop/src/lib/mac-session.ts diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 5012637..d25f9f5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -677,6 +677,7 @@ dependencies = [ "dirs 5.0.1", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6cef93e..703e41d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" thiserror = "1" tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] } dirs = "5" diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 331d998..ca09a52 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod commands; mod credentials; mod settings; +mod snapshots; mod tools; use commands::{ @@ -20,8 +21,9 @@ use commands::{ save_credentials, save_keybindings, save_settings_file, session_append, session_archive, session_create, session_delete, session_read, session_set_title, }; -use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; +use snapshots::session_snapshots; use tauri::Manager; +use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -59,6 +61,7 @@ pub fn run() { tool_bash, tool_glob, tool_grep, + session_snapshots, ]) .setup(|app| { // macOS: hide window menu items we don't use. diff --git a/apps/desktop/src-tauri/src/snapshots.rs b/apps/desktop/src-tauri/src/snapshots.rs new file mode 100644 index 0000000..a97ccbe --- /dev/null +++ b/apps/desktop/src-tauri/src/snapshots.rs @@ -0,0 +1,326 @@ +// File snapshots — captured before & after each Edit/Write so the right-side +// file panel's Diff/History tabs (and the CLI's `/rewind`) share one data source. +// +// The desktop runs @deepcode/core's `runAgent` IN THE RENDERER, which (by design) +// has no node:fs and so passes no SessionManager — meaning core's own snapshot +// capture (packages/core/src/agent.ts) never fires for desktop sessions. We +// therefore mirror it here on the Rust side: tool_write / tool_edit call +// `capture_file_snapshot` for the pre- and post-mutation states. +// +// On-disk layout MATCHES core (packages/core/src/sessions/{storage,snapshots}.ts) +// so the two interoperate: +// ~/.deepcode/sessions//snapshots/ +// manifest.jsonl — one JSON Snapshot per line +// --.blob — the captured file bytes +// +// Each manifest line is the core `Snapshot` shape: { filePath, capturedAt, +// reason, hash, size, seq, blobPath, kind } plus a `capturedAtMs` convenience +// field (core ignores unknown keys) so the renderer needn't parse ISO strings. + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +/// `~/.deepcode/sessions//snapshots` — the per-session snapshot directory. +pub fn snapshots_dir(home: &Path, session_id: &str) -> PathBuf { + home.join(".deepcode") + .join("sessions") + .join(session_id) + .join("snapshots") +} + +/// Next sequence number for a session = count of existing manifest lines. +/// Snapshots are append-only and the desktop captures them one tool-call at a +/// time, so a line count is a sufficient monotonic counter (mirrors core's +/// per-session `snapshotSeq`). +pub fn next_seq(dir: &Path) -> u64 { + let manifest = dir.join("manifest.jsonl"); + match std::fs::read_to_string(&manifest) { + Ok(t) => t.lines().filter(|l| !l.trim().is_empty()).count() as u64, + Err(_) => 0, + } +} + +/// Milliseconds since the Unix epoch (0 if the clock is before 1970). +pub fn now_ms() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Capture one file snapshot: write the blob and append a manifest line. +/// Best-effort by contract — callers ignore the error so a snapshot hiccup never +/// fails the user's edit. `content` is the exact file bytes for this revision. +pub fn capture_file_snapshot( + home: &Path, + session_id: &str, + file_path: &str, + content: &[u8], + reason: &str, + seq: u64, + captured_ms: u128, +) -> std::io::Result<()> { + let dir = snapshots_dir(home, session_id); + std::fs::create_dir_all(&dir)?; + + let mut hasher = Sha256::new(); + hasher.update(content); + // core: sha256 hex truncated to 16 chars == the first 8 bytes. + let hash16: String = hasher + .finalize() + .iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect(); + + let blob_name = format!("{:05}-{}-{}.blob", seq, fmt_blob_ts(captured_ms), hash16); + let blob_path = dir.join(&blob_name); + std::fs::write(&blob_path, content)?; + + let entry = serde_json::json!({ + "filePath": file_path, + "capturedAt": fmt_iso(captured_ms), + "capturedAtMs": captured_ms as u64, + "reason": reason, + "hash": hash16, + "size": content.len(), + "seq": seq, + "blobPath": blob_path.to_string_lossy(), + "kind": "file", + }); + + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("manifest.jsonl"))?; + writeln!(f, "{entry}") +} + +// ── session_snapshots command ─────────────────────────────────────────────── + +/// One snapshot returned to the renderer for a single file. `content` is the +/// full blob text; the panel computes its own diff (current vs baseline) from +/// these, so we hand back everything it needs in one call. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotEntry { + pub seq: u64, + pub captured_at_ms: u64, + pub reason: String, + pub hash: String, + pub content: String, +} + +/// List a file's snapshots for a session (seq-ascending). Reads the session +/// manifest, keeps file-kind rows whose `filePath` matches `file_path` (exact +/// string, or same canonicalized path), and loads each blob's text. Returns an +/// empty list (not an error) when the session has no snapshots yet. +#[tauri::command] +pub fn session_snapshots( + session_id: String, + file_path: String, +) -> Result, String> { + if session_id.is_empty() || session_id.contains('/') || session_id.contains("..") { + return Ok(vec![]); + } + let Some(home) = dirs::home_dir() else { + return Ok(vec![]); + }; + list_file_snapshots(&snapshots_dir(&home, &session_id), &file_path) +} + +/// The dir-parameterized body of `session_snapshots` (testable without the real +/// home dir). Reads `/manifest.jsonl` and returns the matching file rows. +pub fn list_file_snapshots(dir: &Path, file_path: &str) -> Result, String> { + let manifest = dir.join("manifest.jsonl"); + let text = match std::fs::read_to_string(&manifest) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(format!("read {}: {}", manifest.display(), e)), + }; + + let requested_canon = std::fs::canonicalize(file_path).ok(); + let mut out: Vec = Vec::new(); + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let Ok(v) = serde_json::from_str::(line) else { + continue; // tolerate a partial trailing line + }; + // git-checkpoint rows are whole-tree, not per-file — skip them here. + if v.get("kind").and_then(|k| k.as_str()) == Some("git") { + continue; + } + let stored = v.get("filePath").and_then(|x| x.as_str()).unwrap_or(""); + if !paths_match(stored, file_path, requested_canon.as_deref()) { + continue; + } + let blob = v.get("blobPath").and_then(|x| x.as_str()).unwrap_or(""); + let content = std::fs::read_to_string(blob).unwrap_or_default(); + out.push(SnapshotEntry { + seq: v.get("seq").and_then(|x| x.as_u64()).unwrap_or(0), + captured_at_ms: v.get("capturedAtMs").and_then(|x| x.as_u64()).unwrap_or(0), + reason: v + .get("reason") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + hash: v + .get("hash") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + content, + }); + } + out.sort_by_key(|e| e.seq); + Ok(out) +} + +/// True when a stored snapshot path refers to the requested file: exact string +/// match, or both canonicalize to the same path (handles symlinks / `..`). +fn paths_match(stored: &str, requested: &str, requested_canon: Option<&Path>) -> bool { + if stored == requested { + return true; + } + if let Some(rc) = requested_canon { + if let Ok(sc) = std::fs::canonicalize(stored) { + return sc == rc; + } + } + false +} + +// ── time formatting (no chrono dep) ───────────────────────────────────────── + +/// (year, month, day) from days-since-Unix-epoch. Howard Hinnant's +/// civil_from_days — same algorithm as commands.rs::format_date. +fn civil_from_days(days: i64) -> (i64, u64, u64) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +/// ISO-8601 UTC with millis, e.g. "2026-06-04T12:30:45.123Z" (mirrors JS +/// `new Date(ms).toISOString()`). +fn fmt_iso(ms: u128) -> String { + let total_secs = (ms / 1000) as i64; + let millis = (ms % 1000) as u64; + let days = total_secs.div_euclid(86_400); + let tod = total_secs.rem_euclid(86_400) as u64; + let (y, mo, d) = civil_from_days(days); + let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z") +} + +/// Compact timestamp for blob filenames: core's +/// `toISOString().replace(/[-:.]/g,'').slice(0,15)` → "YYYYMMDDtHHMMSS". +fn fmt_blob_ts(ms: u128) -> String { + fmt_iso(ms) + .chars() + .filter(|c| *c != '-' && *c != ':' && *c != '.') + .take(15) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keys(v: &serde_json::Value) -> Vec { + v.as_object().unwrap().keys().cloned().collect() + } + + #[test] + fn fmt_iso_known_values() { + assert_eq!(fmt_iso(0), "1970-01-01T00:00:00.000Z"); + assert_eq!(fmt_iso(86_400_000), "1970-01-02T00:00:00.000Z"); + // 2023-11-14T22:13:20.123Z + assert_eq!(fmt_iso(1_700_000_000_123), "2023-11-14T22:13:20.123Z"); + } + + #[test] + fn fmt_blob_ts_is_15_chars_with_t_separator() { + let ts = fmt_blob_ts(0); + assert_eq!(ts, "19700101T000000"); + assert_eq!(ts.chars().count(), 15); + assert_eq!(ts.as_bytes()[8], b'T'); + } + + #[test] + fn snapshot_entry_serializes_camel_case() { + let v = serde_json::to_value(SnapshotEntry { + seq: 1, + captured_at_ms: 42, + reason: "pre-Edit".into(), + hash: "abc".into(), + content: "x".into(), + }) + .unwrap(); + let k = keys(&v); + assert!(k.contains(&"capturedAtMs".to_string()), "got {k:?}"); + assert!( + !k.contains(&"captured_at_ms".to_string()), + "snake leaked: {k:?}" + ); + } + + #[test] + fn capture_then_list_roundtrips_and_filters() { + let home = std::env::temp_dir().join(format!("dc-snap-{}", std::process::id())); + let sid = "2026-06-04-test01"; + let file = "/tmp/example/app.ts"; + let _ = std::fs::remove_dir_all(&home); + + // Two edits → 4 snapshots (pre/post each), distinct ms so ordering holds. + let dir = snapshots_dir(&home, sid); + std::fs::create_dir_all(&dir).unwrap(); + let base0 = next_seq(&dir); + capture_file_snapshot(&home, sid, file, b"v0\n", "pre-Edit", base0, 1000).unwrap(); + capture_file_snapshot(&home, sid, file, b"v1\n", "post-Edit", base0 + 1, 1001).unwrap(); + let base1 = next_seq(&dir); + assert_eq!(base1, 2, "seq advances with manifest lines"); + capture_file_snapshot(&home, sid, file, b"v1\n", "pre-Edit", base1, 2000).unwrap(); + capture_file_snapshot(&home, sid, file, b"v2\n", "post-Edit", base1 + 1, 2001).unwrap(); + + // A snapshot for a DIFFERENT file must be filtered out. + capture_file_snapshot(&home, sid, "/tmp/other.ts", b"z\n", "pre-Write", 99, 3000).unwrap(); + + let rows = list_file_snapshots(&dir, file).unwrap(); + let _ = std::fs::remove_dir_all(&home); + + assert_eq!(rows.len(), 4, "only the 4 snapshots for `file`"); + assert_eq!(rows[0].seq, 0); + assert_eq!(rows[0].content, "v0\n"); + assert_eq!(rows[0].reason, "pre-Edit"); + assert_eq!(rows[0].captured_at_ms, 1000); + assert_eq!(rows[3].content, "v2\n"); + // ascending by seq + assert!(rows.windows(2).all(|w| w[0].seq < w[1].seq)); + } + + #[test] + fn list_missing_session_is_empty() { + let dir = std::env::temp_dir().join("dc-snap-nope-xyz/snapshots"); + let rows = list_file_snapshots(&dir, "/tmp/x").unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn list_rejects_unsafe_session_id() { + assert!(session_snapshots("../escape".into(), "/tmp/x".into()) + .unwrap() + .is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs index 1a9c220..3f43c07 100644 --- a/apps/desktop/src-tauri/src/tools.rs +++ b/apps/desktop/src-tauri/src/tools.rs @@ -3,12 +3,69 @@ // these Tauri commands for actual fs / subprocess work (the webview can't // do node:fs / node:child_process itself). +use crate::snapshots; use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Stdio; use tokio::io::AsyncReadExt; use tokio::process::Command; +// ────────────────────────────────────────────────────────────────────────── +// Snapshot capture +// ────────────────────────────────────────────────────────────────────────── +// Edit/Write record a pre- and post-mutation snapshot so the desktop file +// panel's Diff/History tabs (and `/rewind`) have data — mirroring core's +// agent.ts, which never runs for desktop sessions (no SessionManager in the +// renderer). Best-effort: capture failures are logged and ignored so a +// snapshot hiccup never fails the user's edit. + +/// Capture the pre + post pair for one file mutation under the user's home dir. +fn capture_pair(session_id: &str, file_path: &str, pre: &[u8], post: &[u8], tool: &str) { + let Some(home) = dirs::home_dir() else { + return; + }; + capture_pair_in(&home, session_id, file_path, pre, post, tool); +} + +/// home-parameterized body of `capture_pair` (testable without the real home). +/// `pre`/`post` are the file bytes before/after the change. The post snapshot is +/// stamped 1ms after the pre so the two never collide on a millisecond timeline +/// (the renderer keys history entries by timestamp). +fn capture_pair_in( + home: &Path, + session_id: &str, + file_path: &str, + pre: &[u8], + post: &[u8], + tool: &str, +) { + let dir = snapshots::snapshots_dir(home, session_id); + let base = snapshots::next_seq(&dir); + let t = snapshots::now_ms(); + if let Err(e) = snapshots::capture_file_snapshot( + home, + session_id, + file_path, + pre, + &format!("pre-{tool}"), + base, + t, + ) { + eprintln!("snapshot pre-{tool} {file_path}: {e}"); + } + if let Err(e) = snapshots::capture_file_snapshot( + home, + session_id, + file_path, + post, + &format!("post-{tool}"), + base + 1, + t + 1, + ) { + eprintln!("snapshot post-{tool} {file_path}: {e}"); + } +} + // ────────────────────────────────────────────────────────────────────────── // Read // ────────────────────────────────────────────────────────────────────────── @@ -75,7 +132,14 @@ pub async fn tool_read( // ────────────────────────────────────────────────────────────────────────── #[tauri::command] -pub async fn tool_write(file_path: String, content: String) -> Result<(), String> { +pub async fn tool_write( + file_path: String, + content: String, + session_id: Option, +) -> Result<(), String> { + // Pre-state: the existing file bytes (empty when the file is new) — read + // before the overwrite so the post-Write diff has a baseline. + let pre = tokio::fs::read(&file_path).await.unwrap_or_default(); if let Some(parent) = Path::new(&file_path).parent() { if !parent.as_os_str().is_empty() { tokio::fs::create_dir_all(parent) @@ -83,9 +147,13 @@ pub async fn tool_write(file_path: String, content: String) -> Result<(), String .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?; } } - tokio::fs::write(&file_path, content) + tokio::fs::write(&file_path, &content) .await - .map_err(|e| format!("write {}: {}", file_path, e)) + .map_err(|e| format!("write {}: {}", file_path, e))?; + if let Some(sid) = session_id.as_deref() { + capture_pair(sid, &file_path, &pre, content.as_bytes(), "Write"); + } + Ok(()) } // ────────────────────────────────────────────────────────────────────────── @@ -109,7 +177,7 @@ pub struct EditOk { } #[tauri::command] -pub async fn tool_edit(input: EditInput) -> Result { +pub async fn tool_edit(input: EditInput, session_id: Option) -> Result { let raw = tokio::fs::read_to_string(&input.file_path) .await .map_err(|e| format!("read {}: {}", input.file_path, e))?; @@ -133,6 +201,15 @@ pub async fn tool_edit(input: EditInput) -> Result { tokio::fs::write(&input.file_path, &new_content) .await .map_err(|e| format!("write {}: {}", input.file_path, e))?; + if let Some(sid) = session_id.as_deref() { + capture_pair( + sid, + &input.file_path, + raw.as_bytes(), + new_content.as_bytes(), + "Edit", + ); + } let diff_preview = format!( "- {}\n+ {}", input.old_string.lines().next().unwrap_or(""), @@ -323,7 +400,6 @@ pub async fn tool_grep(input: GrepInput) -> Result { Ok(GrepOk { matches, truncated }) } - // ── Serde casing contract ────────────────────────────────────────────── // Regression guard for HANDOFF §8a: Tauri's serde does NOT auto-convert case // between Rust and JS. Every multi-word *output* field must serialize as @@ -350,7 +426,10 @@ mod casing_tests { let k = keys(&v); assert!(k.contains(&"linesTotal".to_string()), "got {k:?}"); assert!(k.contains(&"linesShown".to_string()), "got {k:?}"); - assert!(!k.contains(&"lines_total".to_string()), "snake_case leaked: {k:?}"); + assert!( + !k.contains(&"lines_total".to_string()), + "snake_case leaked: {k:?}" + ); } #[test] @@ -362,7 +441,10 @@ mod casing_tests { .unwrap(); let k = keys(&v); assert!(k.contains(&"diffPreview".to_string()), "got {k:?}"); - assert!(!k.contains(&"diff_preview".to_string()), "snake_case leaked: {k:?}"); + assert!( + !k.contains(&"diff_preview".to_string()), + "snake_case leaked: {k:?}" + ); } #[test] @@ -378,6 +460,64 @@ mod casing_tests { // The exit-code badge bug: renderer compares r.exitCode !== 0. assert!(k.contains(&"exitCode".to_string()), "got {k:?}"); assert!(k.contains(&"timedOut".to_string()), "got {k:?}"); - assert!(!k.contains(&"exit_code".to_string()), "snake_case leaked: {k:?}"); + assert!( + !k.contains(&"exit_code".to_string()), + "snake_case leaked: {k:?}" + ); + } +} + +// ── snapshot capture path ─────────────────────────────────────────────── +// End-to-end coverage of the Edit/Write → manifest path (against a real temp +// fs) via the home-injectable `capture_pair_in`. +#[cfg(test)] +mod snapshot_capture_tests { + use super::*; + + #[test] + fn capture_pair_writes_pre_then_post_with_distinct_ms() { + let home = std::env::temp_dir().join(format!("dc-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + let sid = "2026-06-04-cap01"; + let file = "/tmp/example/app.ts"; + + capture_pair_in(&home, sid, file, b"old\n", b"new\n", "Edit"); + + let dir = snapshots::snapshots_dir(&home, sid); + let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); + let _ = std::fs::remove_dir_all(&home); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].reason, "pre-Edit"); + assert_eq!(rows[0].content, "old\n"); + assert_eq!(rows[0].seq, 0); + assert_eq!(rows[1].reason, "post-Edit"); + assert_eq!(rows[1].content, "new\n"); + assert_eq!(rows[1].seq, 1); + // Distinct timestamps so the renderer's history keys never collide. + assert_eq!(rows[1].captured_at_ms, rows[0].captured_at_ms + 1); + } + + #[test] + fn capture_pair_appends_across_calls_with_monotonic_seq() { + let home = std::env::temp_dir().join(format!("dc-cap2-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + let sid = "2026-06-04-cap02"; + let file = "/tmp/x.ts"; + + capture_pair_in(&home, sid, file, b"a", b"b", "Write"); + capture_pair_in(&home, sid, file, b"b", b"c", "Edit"); + + let dir = snapshots::snapshots_dir(&home, sid); + let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); + let _ = std::fs::remove_dir_all(&home); + + assert_eq!( + rows.iter().map(|r| r.seq).collect::>(), + vec![0, 1, 2, 3] + ); + assert_eq!(rows[0].reason, "pre-Write"); + assert_eq!(rows[3].reason, "post-Edit"); + assert_eq!(rows[3].content, "c"); } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8306447..0a986af 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -251,7 +251,7 @@ export function App(): JSX.Element { onCloseTab={fp.close} onSelectView={fp.setView} onToggleDiffMode={fp.toggleDiffMode} - onSelectHistory={() => {}} + onSelectHistory={fp.selectHistory} onResizeStart={onFilePanelResizeStart} /> ) : inspectorShowing ? ( diff --git a/apps/desktop/src/lib/diff.test.ts b/apps/desktop/src/lib/diff.test.ts new file mode 100644 index 0000000..2631b7c --- /dev/null +++ b/apps/desktop/src/lib/diff.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { computeLineDiff, hasChanges } from './diff.js'; +import type { DiffLine } from '../types/file-panel.js'; + +/** Compact a diff to "" rows for readable assertions. */ +function sigs(lines: DiffLine[]): string[] { + const sign = (k: DiffLine['kind']): string => (k === 'add' ? '+' : k === 'del' ? '-' : ' '); + return lines.map((l) => `${sign(l.kind)}${l.text}`); +} + +describe('computeLineDiff', () => { + it('returns all-context for identical input (and hasChanges is false)', () => { + const d = computeLineDiff('a\nb\nc', 'a\nb\nc'); + expect(sigs(d)).toEqual([' a', ' b', ' c']); + expect(hasChanges(d)).toBe(false); + }); + + it('detects a single inserted line with correct line numbers', () => { + const d = computeLineDiff('a\nc', 'a\nb\nc'); + expect(sigs(d)).toEqual([' a', '+b', ' c']); + // context line numbers advance on both sides; the add has no oldNo. + expect(d[0]).toMatchObject({ kind: 'ctx', oldNo: 1, newNo: 1 }); + expect(d[1]).toMatchObject({ kind: 'add', oldNo: null, newNo: 2 }); + expect(d[2]).toMatchObject({ kind: 'ctx', oldNo: 2, newNo: 3 }); + }); + + it('detects a deleted line', () => { + const d = computeLineDiff('a\nb\nc', 'a\nc'); + expect(sigs(d)).toEqual([' a', '-b', ' c']); + expect(d[1]).toMatchObject({ kind: 'del', oldNo: 2, newNo: null }); + }); + + it('represents a changed line as a delete + add pair', () => { + const d = computeLineDiff('x = 1', 'x = 2'); + expect(sigs(d)).toEqual(['-x = 1', '+x = 2']); + expect(hasChanges(d)).toBe(true); + }); + + it('handles empty → content as all adds (new file)', () => { + const d = computeLineDiff('', 'line1\nline2'); + expect(sigs(d)).toEqual(['+line1', '+line2']); + expect(d.every((l) => l.kind === 'add' && l.oldNo === null)).toBe(true); + }); + + it('handles content → empty as all deletes', () => { + const d = computeLineDiff('line1\nline2', ''); + expect(sigs(d)).toEqual(['-line1', '-line2']); + }); + + it('keeps newNo monotonic across mixed adds/deletes', () => { + const d = computeLineDiff('a\nb\nc\nd', 'a\nB\nc\nD\ne'); + expect(sigs(d)).toEqual([' a', '-b', '+B', ' c', '-d', '+D', '+e']); + const newNos = d.filter((l) => l.newNo !== null).map((l) => l.newNo); + expect(newNos).toEqual([1, 2, 3, 4, 5]); + const oldNos = d.filter((l) => l.oldNo !== null).map((l) => l.oldNo); + expect(oldNos).toEqual([1, 2, 3, 4]); + }); + + it('returns [] for two empty strings', () => { + expect(computeLineDiff('', '')).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/diff.ts b/apps/desktop/src/lib/diff.ts new file mode 100644 index 0000000..08cb366 --- /dev/null +++ b/apps/desktop/src/lib/diff.ts @@ -0,0 +1,75 @@ +// Pure line differ for the file panel's Diff view. Produces a full (un-hunked) +// unified diff as DiffLine[] — the FilePanel renders inline or split from the +// same rows. Kept dependency-free + side-effect-free so it's trivially testable. +// +// Algorithm: classic longest-common-subsequence over lines, then a backtrack +// that emits ctx / del / add rows with running old/new line numbers. Lines are +// split on '\n' to match how SourceView numbers the file, so a diff row's +// oldNo/newNo line up with the Source view. + +import type { DiffLine } from '../types/file-panel.js'; + +// LCS builds an (n+1)×(m+1) table. Cap the cell count so a pathologically large +// pair can't blow up memory; beyond it we fall back to a naive replace-all diff +// (every old line deleted, every new line added). Real source files are far +// under this (a 4000×4000 diff = 16M cells ≈ 64MB Int32Array, transient). +const MAX_CELLS = 16_000_000; + +export function computeLineDiff(oldText: string, newText: string): DiffLine[] { + // An empty string is zero lines, not one empty line — otherwise diffing an + // empty baseline (e.g. a brand-new file's pre-snapshot) emits a phantom + // "delete empty line" row before the real additions. + const a = oldText === '' ? [] : oldText.split('\n'); + const b = newText === '' ? [] : newText.split('\n'); + const n = a.length; + const m = b.length; + if (n === 0 && m === 0) return []; + if ((n + 1) * (m + 1) > MAX_CELLS) return naiveDiff(a, b); + + // dp[i*w + j] = LCS length of a[i..] and b[j..]; filled bottom-up. + const w = m + 1; + const dp = new Int32Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i * w + j] = + a[i] === b[j] + ? dp[(i + 1) * w + (j + 1)] + 1 + : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]); + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + let oldNo = 1; + let newNo = 1; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ kind: 'ctx', oldNo: oldNo++, newNo: newNo++, text: a[i] }); + i++; + j++; + } else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) { + out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i] }); + i++; + } else { + out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j] }); + j++; + } + } + while (i < n) out.push({ kind: 'del', oldNo: oldNo++, newNo: null, text: a[i++] }); + while (j < m) out.push({ kind: 'add', oldNo: null, newNo: newNo++, text: b[j++] }); + return out; +} + +/** Whole-file replacement diff — fallback for oversized inputs. */ +function naiveDiff(a: string[], b: string[]): DiffLine[] { + const out: DiffLine[] = []; + a.forEach((text, i) => out.push({ kind: 'del', oldNo: i + 1, newNo: null, text })); + b.forEach((text, i) => out.push({ kind: 'add', oldNo: null, newNo: i + 1, text })); + return out; +} + +/** True when a diff has at least one add/del (i.e. the two revisions differ). */ +export function hasChanges(lines: DiffLine[]): boolean { + return lines.some((l) => l.kind !== 'ctx'); +} diff --git a/apps/desktop/src/lib/file-history.test.ts b/apps/desktop/src/lib/file-history.test.ts new file mode 100644 index 0000000..2f89956 --- /dev/null +++ b/apps/desktop/src/lib/file-history.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + baselineContent, + buildHistory, + contentAt, + labelFromReason, + toolFromReason, +} from './file-history.js'; +import type { SessionSnapshot } from './tauri-api.js'; + +// Two edits: original A → B → C. Core captures pre+post per edit, so the +// post-of-edit-1 (B) and pre-of-edit-2 (B) share a hash/content. +const SNAPS: SessionSnapshot[] = [ + { seq: 0, capturedAtMs: 1000, reason: 'pre-Edit', hash: 'hA', content: 'A\n' }, + { seq: 1, capturedAtMs: 1001, reason: 'post-Edit', hash: 'hB', content: 'B\n' }, + { seq: 2, capturedAtMs: 2000, reason: 'pre-Edit', hash: 'hB', content: 'B\n' }, + { seq: 3, capturedAtMs: 2001, reason: 'post-Edit', hash: 'hC', content: 'C\n' }, +]; + +describe('reason parsing', () => { + it('extracts tool + label', () => { + expect(toolFromReason('pre-Edit')).toBe('Edit'); + expect(toolFromReason('post-Write')).toBe('Write'); + expect(labelFromReason('pre-Edit')).toBe('before Edit'); + expect(labelFromReason('post-Write')).toBe('after Write'); + }); +}); + +describe('buildHistory', () => { + it('collapses the duplicate B state and lists newest-first', () => { + const h = buildHistory(SNAPS); + // A(pre), B(post), [B(pre) collapsed], C(post) → reversed + expect(h.map((e) => e.label)).toEqual(['after Edit', 'after Edit', 'before Edit']); + expect(h.map((e) => e.ts)).toEqual([2001, 1001, 1000]); + expect(h.every((e) => e.tool === 'Edit')).toBe(true); + }); + + it('is empty for no snapshots', () => { + expect(buildHistory([])).toEqual([]); + }); +}); + +describe('baselineContent', () => { + it('is the oldest snapshot content', () => { + expect(baselineContent(SNAPS)).toBe('A\n'); + }); + it('is null when there are no snapshots', () => { + expect(baselineContent([])).toBeNull(); + }); +}); + +describe('contentAt', () => { + it('finds a snapshot content by its ms timestamp', () => { + expect(contentAt(SNAPS, 2001)).toBe('C\n'); + expect(contentAt(SNAPS, 1000)).toBe('A\n'); + }); + it('is null for an unknown timestamp', () => { + expect(contentAt(SNAPS, 999)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/file-history.ts b/apps/desktop/src/lib/file-history.ts new file mode 100644 index 0000000..dbbe110 --- /dev/null +++ b/apps/desktop/src/lib/file-history.ts @@ -0,0 +1,58 @@ +// Pure helpers that turn a file's session snapshots (from the `session_snapshots` +// Tauri command) into the file panel's History timeline + Diff baseline. Kept +// free of React/Tauri so the mapping is unit-testable in isolation. + +import type { FileHistoryEntry } from '../types/file-panel.js'; +import type { SessionSnapshot } from './tauri-api.js'; + +/** "pre-Edit" / "post-Write" → the tool name ("Edit" / "Write"). */ +export function toolFromReason(reason: string): string { + const dash = reason.indexOf('-'); + return dash >= 0 ? reason.slice(dash + 1) : reason; +} + +/** "pre-Edit" → "before Edit", "post-Write" → "after Write". */ +export function labelFromReason(reason: string): string { + const tool = toolFromReason(reason); + if (reason.startsWith('pre')) return `before ${tool}`; + if (reason.startsWith('post')) return `after ${tool}`; + return reason; +} + +/** + * Build the newest-first version timeline. Snapshots arrive seq-ascending; we + * collapse consecutive identical-content rows — a mutation's post-blob equals + * the next mutation's pre-blob, so the same file state would otherwise appear + * twice in a row — then reverse to newest-first for display. + */ +export function buildHistory(snaps: SessionSnapshot[]): FileHistoryEntry[] { + const deduped: SessionSnapshot[] = []; + for (const s of snaps) { + const prev = deduped[deduped.length - 1]; + if (prev && prev.hash === s.hash) continue; + deduped.push(s); + } + return deduped + .map((s) => ({ + ts: s.capturedAtMs, + tool: toolFromReason(s.reason), + label: labelFromReason(s.reason), + })) + .reverse(); +} + +/** + * The Diff view's baseline = the OLDEST captured content for this file (the + * pre-state of the first edit this session). Diffing it against the current + * file shows the conversation's NET change to the file. Returns null when the + * file has no snapshots (never edited this session). + */ +export function baselineContent(snaps: SessionSnapshot[]): string | null { + return snaps.length > 0 ? snaps[0].content : null; +} + +/** A snapshot's content by its capturedAt ms (a History row's identity). */ +export function contentAt(snaps: SessionSnapshot[], ts: number): string | null { + const s = snaps.find((x) => x.capturedAtMs === ts); + return s ? s.content : null; +} diff --git a/apps/desktop/src/lib/file-panel-reducer.test.ts b/apps/desktop/src/lib/file-panel-reducer.test.ts index 81740a5..e577dd6 100644 --- a/apps/desktop/src/lib/file-panel-reducer.test.ts +++ b/apps/desktop/src/lib/file-panel-reducer.test.ts @@ -90,4 +90,21 @@ describe('filePanelReducer', () => { expect(filePanelReducer(s, { type: 'nextTab' })).toBe(s); expect(filePanelReducer(s, { type: 'prevTab' })).toBe(s); }); + + it('setDiff replaces the matching tab’s diff and leaves others intact', () => { + let s = initialFilePanelState(); + for (const p of ['/a.ts', '/b.ts']) s = filePanelReducer(s, { type: 'open', tab: tab(p) }); + const diff = [{ kind: 'add' as const, oldNo: null, newNo: 1, text: 'x' }]; + s = filePanelReducer(s, { type: 'setDiff', path: '/a.ts', diff }); + expect(s.tabs[0]?.diff).toEqual(diff); + expect(s.tabs[1]?.diff).toBeNull(); + }); + + it('setDiff is a no-op for an unopened path', () => { + let s = initialFilePanelState(); + s = filePanelReducer(s, { type: 'open', tab: tab('/a.ts') }); + const before = s; + s = filePanelReducer(s, { type: 'setDiff', path: '/missing.ts', diff: [] }); + expect(s).toBe(before); + }); }); diff --git a/apps/desktop/src/lib/file-panel-reducer.ts b/apps/desktop/src/lib/file-panel-reducer.ts index 90b75dd..dd9c054 100644 --- a/apps/desktop/src/lib/file-panel-reducer.ts +++ b/apps/desktop/src/lib/file-panel-reducer.ts @@ -5,6 +5,7 @@ import { clampPanelWidth, + type DiffLine, FILE_PANEL_DEFAULT_WIDTH, type FilePanelState, type FileTab, @@ -19,7 +20,10 @@ export type FilePanelAction = | { type: 'toggleDiffMode' } | { type: 'width'; width: number } | { type: 'nextTab' } - | { type: 'prevTab' }; + | { type: 'prevTab' } + // Replace a tab's precomputed diff (selecting a History entry recomputes it + // against the chosen revision). No-op when the path isn't open. + | { type: 'setDiff'; path: string; diff: DiffLine[] | null }; export function initialFilePanelState(width = FILE_PANEL_DEFAULT_WIDTH): FilePanelState { return { @@ -61,6 +65,13 @@ export function filePanelReducer(state: FilePanelState, action: FilePanelAction) return { ...state, diffMode: state.diffMode === 'split' ? 'inline' : 'split' }; case 'width': return { ...state, width: clampPanelWidth(action.width) }; + case 'setDiff': { + const idx = state.tabs.findIndex((t) => t.path === action.path); + if (idx < 0) return state; + const tabs = state.tabs.slice(); + tabs[idx] = { ...tabs[idx], diff: action.diff }; + return { ...state, tabs }; + } case 'nextTab': if (state.tabs.length === 0) return state; return { ...state, activeIndex: (state.activeIndex + 1) % state.tabs.length }; diff --git a/apps/desktop/src/lib/mac-agent.ts b/apps/desktop/src/lib/mac-agent.ts index e3ad426..a0c6cda 100644 --- a/apps/desktop/src/lib/mac-agent.ts +++ b/apps/desktop/src/lib/mac-agent.ts @@ -15,6 +15,7 @@ import { runAgent } from '@deepcode/core/dist/agent.js'; import { DeepSeekProvider, EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; import type { AgentEvent, Effort, Mode, ToolHandler } from '@deepcode/core/dist/types.js'; import { MAC_TOOLS } from './mac-tools.js'; +import { setActiveSessionId } from './mac-session.js'; import { readCredentials, sessionAppend, sessionCreate, sessionSetTitle } from './tauri-api.js'; /** First non-empty line of the user message, trimmed to a sidebar-friendly length. */ @@ -73,6 +74,7 @@ let currentSessionId: string | null = null; export function clearSession(): void { currentSessionId = null; + setActiveSessionId(null); history = []; } @@ -86,6 +88,7 @@ export function resumeSession( loadedHistory: import('@deepcode/core/dist/types.js').StoredMessage[], ): void { currentSessionId = sessionId; + setActiveSessionId(sessionId); history = loadedHistory; } @@ -146,6 +149,8 @@ export async function startAgentTurn(args: StartTurnArgs): Promise { const { openUrl: openerOpen } = await import('@tauri-apps/plugin-opener'); await openerOpen(url); } + +/** One file snapshot from a session — the `session_snapshots` command's row. */ +export interface SessionSnapshot { + /** Sequence within the session (ascending = chronological). */ + seq: number; + /** Capture time, unix ms. */ + capturedAtMs: number; + /** "pre-Edit" / "post-Write" / … */ + reason: string; + /** sha256[:16] of the blob (used to collapse duplicate states). */ + hash: string; + /** The captured file contents. */ + content: string; +} + +/** + * List a file's session snapshots for the Diff/History tabs. Returns the rows + * seq-ascending; an empty array when the session has none (file untouched). + */ +export async function sessionSnapshots( + sessionId: string, + filePath: string, +): Promise { + return (await invoke('session_snapshots', { sessionId, filePath })) as SessionSnapshot[]; +} diff --git a/apps/desktop/src/lib/use-file-panel.ts b/apps/desktop/src/lib/use-file-panel.ts index 6a394a3..ae1f7e2 100644 --- a/apps/desktop/src/lib/use-file-panel.ts +++ b/apps/desktop/src/lib/use-file-panel.ts @@ -1,16 +1,23 @@ // React hook wiring the pure file-panel reducer to side effects: reading file -// contents (Source view), persisting the panel width, and the ⌘O / ⌘[ / ⌘] -// keybindings. The ⌘\ split/inline toggle is owned by App (it shares the chord -// with the inspector toggle and resolves contextually). +// contents (Source view), fetching session snapshots for the Diff/History tabs, +// persisting the panel width, and the ⌘O / ⌘[ / ⌘] keybindings. The ⌘\ +// split/inline toggle is owned by App (it shares the chord with the inspector +// toggle and resolves contextually). // -// Diff/History data is left empty here — those are backed by session snapshots, -// wired in a follow-up. The component shows honest empty states meanwhile. +// Diff/History come from session snapshots captured on the Rust side for every +// Edit/Write (see src-tauri/src/snapshots.rs). On open() we fetch a file's +// snapshots and derive: the History timeline, and a Diff of the current file +// vs the session baseline (its oldest snapshot). Selecting a History entry +// recomputes the Diff against that revision. -import { useCallback, useEffect, useReducer } from 'react'; -import { pickFile, toolRead } from './tauri-api.js'; +import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { pickFile, sessionSnapshots, toolRead, type SessionSnapshot } from './tauri-api.js'; +import { getActiveSessionId } from './mac-session.js'; import { registerShortcut } from './keyboard.js'; import { filePanelReducer, initialFilePanelState } from './file-panel-reducer.js'; -import type { FileView } from '../types/file-panel.js'; +import { computeLineDiff } from './diff.js'; +import { baselineContent, buildHistory, contentAt } from './file-history.js'; +import type { DiffLine, FileHistoryEntry, FileView } from '../types/file-panel.js'; const WIDTH_KEY = 'deepcode.filePanel.width'; @@ -33,11 +40,16 @@ export interface UseFilePanel { select: (index: number) => void; setView: (view: FileView) => void; toggleDiffMode: () => void; + /** Show the diff of the active file vs the snapshot captured at `ts`. */ + selectHistory: (ts: number) => void; setWidth: (width: number) => void; } export function useFilePanel(): UseFilePanel { const [state, dispatch] = useReducer(filePanelReducer, loadWidth(), initialFilePanelState); + // Per-path snapshot cache (with blob contents) so selectHistory can recompute + // a diff without re-hitting Tauri. Refreshed on every open(). + const snapsByPath = useRef(new Map()); const open = useCallback(async (path: string): Promise => { let source: string; @@ -46,9 +58,40 @@ export function useFilePanel(): UseFilePanel { } catch (e) { source = `// Could not read ${path}\n// ${String(e)}`; } - dispatch({ type: 'open', tab: { path, source, diff: null, history: [] } }); + // Pull this file's session snapshots → History timeline + a Diff of the + // current content vs the session baseline. All best-effort: no session yet, + // or no snapshots, leaves the honest empty states in place. + let history: FileHistoryEntry[] = []; + let diff: DiffLine[] | null = null; + const sessionId = getActiveSessionId(); + if (sessionId) { + try { + const snaps = await sessionSnapshots(sessionId, path); + snapsByPath.current.set(path, snaps); + history = buildHistory(snaps); + const base = baselineContent(snaps); + if (base !== null) diff = computeLineDiff(base, source); + } catch { + /* snapshots unavailable — leave empty states */ + } + } + dispatch({ type: 'open', tab: { path, source, diff, history } }); }, []); + const selectHistory = useCallback( + (ts: number): void => { + const tab = state.tabs[state.activeIndex]; + if (!tab) return; + const snaps = snapsByPath.current.get(tab.path); + const revision = snaps ? contentAt(snaps, ts) : null; + if (revision === null) return; + // Diff the chosen revision → current file, then jump to the Diff view. + dispatch({ type: 'setDiff', path: tab.path, diff: computeLineDiff(revision, tab.source) }); + dispatch({ type: 'view', view: 'diff' }); + }, + [state.tabs, state.activeIndex], + ); + const openViaPicker = useCallback(async (): Promise => { try { const p = await pickFile(); @@ -100,6 +143,7 @@ export function useFilePanel(): UseFilePanel { select, setView, toggleDiffMode, + selectHistory, setWidth, }; } diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index e34886e..0b90f81 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client'; import { App } from './App.js'; import { installTauriShim } from './lib/window-shim.js'; +import { setActiveSessionId } from './lib/mac-session.js'; import './index.css'; const now = Math.floor(Date.now() / 1000); @@ -39,6 +40,85 @@ const MOCK_SESSIONS = [ title: 'hi', }, ]; +// The file the panel opens (⌘O / dialog mock below) and its session-snapshot +// history, so the preview exercises the Diff + History tabs. CURRENT_HTML is +// what tool_read returns (the live file); the snapshots are earlier revisions. +const CURRENT_HTML = [ + '', + '', + ' ', + ' ', + ' 打飞机', + ' ', + ' ', + ' ', + ' ', + ' ', + '', +].join('\n'); + +const ORIGINAL_HTML = [ + '', + '', + ' ', + ' 打飞机', + ' ', + ' ', + ' ', + ' ', + '', +].join('\n'); + +const INTERMEDIATE_HTML = [ + '', + '', + ' ', + ' ', + ' 打飞机', + ' ', + ' ', + ' ', + ' ', + '', +].join('\n'); + +const snapTime = Date.now(); +const MOCK_SNAPSHOTS = [ + { + seq: 0, + capturedAtMs: snapTime - 600_000, + reason: 'pre-Write', + hash: 'h0', + content: ORIGINAL_HTML, + }, + { + seq: 1, + capturedAtMs: snapTime - 599_999, + reason: 'post-Write', + hash: 'h1', + content: INTERMEDIATE_HTML, + }, + { + seq: 2, + capturedAtMs: snapTime - 120_000, + reason: 'pre-Edit', + hash: 'h1', + content: INTERMEDIATE_HTML, + }, + { + seq: 3, + capturedAtMs: snapTime - 119_999, + reason: 'post-Edit', + hash: 'h3', + content: CURRENT_HTML, + }, +]; + const MOCK_MESSAGES = [ { type: 'message', role: 'user', content: [{ type: 'text', text: '制作一个打飞机的小游戏' }] }, { @@ -86,26 +166,10 @@ const MOCK_MESSAGES = [ return '/Users/oratis/Projects/DeepCode/test/打飞机.html'; // toolRead unwraps `.content` (see lib/tauri-api.ts). case 'tool_read': - return { - content: [ - '', - '', - ' ', - ' ', - ' 打飞机', - ' ', - ' ', - ' ', - ' ', - ' ', - '', - ].join('\n'), - }; + return { content: CURRENT_HTML }; + // Session snapshots back the file panel's Diff + History tabs. + case 'session_snapshots': + return MOCK_SNAPSHOTS; default: console.warn('[preview] unmocked invoke:', cmd); return null; @@ -115,5 +179,7 @@ const MOCK_MESSAGES = [ }; installTauriShim(); +// Pretend a session is active so the file panel fetches the mock snapshots above. +setActiveSessionId('preview-session'); const rootEl = document.getElementById('root'); if (rootEl) createRoot(rootEl).render(); From a8d369a853c309c677c722a47820ad122faaff57 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 4 Jun 2026 18:51:29 +0800 Subject: [PATCH 2/2] feat(desktop): label inspector-rail icons + show active panel name in titlebar The right activity rail was icon-only (tooltips on hover only), so the Inspector / Files / Settings toggles were not self-explanatory. Add a text label under each icon (rail 48->64px) and surface the active right-panel's name centered in the otherwise-empty macOS titlebar strip (pointer-events:none keeps it OS-draggable, matching the shell's no-webkit-drag approach). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/App.tsx | 10 +++- apps/desktop/src/components/InspectorRail.tsx | 3 ++ apps/desktop/src/index.css | 50 +++++++++++++++---- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0a986af..109d763 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -176,14 +176,22 @@ export function App(): JSX.Element { const usedTokens = inspector.usage.inputTokens + inspector.usage.outputTokens; const contextFill = usedTokens > 0 ? usedTokens / contextWindowFor(inspector.model) : undefined; - // The rail is always the last 48px column. A panel (file OR inspector) opens + // The rail is always the last 64px column. A panel (file OR inspector) opens // to its left, widening the grid so it squeezes chat rather than overlaying. const inspectorShowing = inspectorOpen && !filesVisible; const shellClass = 'app-shell' + (filesVisible ? ' file-open' : inspectorShowing ? ' inspector-open' : ''); + // Name of the right-side panel currently showing — surfaced in the otherwise + // empty macOS titlebar strip so the rail toggles gain a visible, labeled echo. + const activePanelName = filesVisible ? 'Files' : inspectorShowing ? 'Inspector' : null; return (
+ {activePanelName && ( + + )} {update && } + Inspector {planCount !== undefined && planCount > 0 && {planCount}} @@ -64,6 +65,7 @@ export function InspectorRail({ onClick={onToggleFiles} > + Files @@ -74,6 +76,7 @@ export function InspectorRail({ onClick={onSettings} > + Settings ); diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 420a762..90252d1 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -252,7 +252,7 @@ select { .app-shell { display: grid; - grid-template-columns: 240px 1fr 48px; + grid-template-columns: 240px 1fr 64px; grid-template-rows: 1fr; height: 100vh; background: var(--bg-1); @@ -265,6 +265,27 @@ select { padding-top: 30px; } +/* Active right-panel name, centered in the transparent macOS titlebar strip + (otherwise empty). pointer-events:none keeps the native title region + OS-draggable — same reason the shell avoids -webkit-app-region:drag (above). */ +.titlebar-status { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + color: var(--text-2); + pointer-events: none; + user-select: none; + z-index: 5; +} + /* ──────────────────────────────────────────────────────────────────── */ /* Sidebar (Sessions) */ /* ──────────────────────────────────────────────────────────────────── */ @@ -496,26 +517,35 @@ select { .inspector-rail { background: var(--bg-1); border-left: 1px solid var(--line); - padding: 6px 0; - gap: 10px; + padding: 8px 0; + gap: 8px; display: flex; flex-direction: column; align-items: center; } .rail-btn { - width: 30px; - height: 30px; - border-radius: 7px; - display: inline-flex; + width: 54px; + min-height: 44px; + padding: 5px 2px 4px; + border-radius: 8px; + display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 3px; color: var(--text-2); background: var(--bg-1); border: 1px solid var(--line-soft); cursor: pointer; - font-size: 13px; position: relative; } +.rail-btn .rail-label { + font-size: 9px; + line-height: 1; + letter-spacing: 0.2px; + color: inherit; + white-space: nowrap; +} .rail-btn:hover { color: var(--text-0); border-color: var(--line); @@ -557,7 +587,7 @@ select { /* ──────────────────────────────────────────────────────────────────── */ .app-shell.inspector-open { - grid-template-columns: 240px 1fr 320px 48px; + grid-template-columns: 240px 1fr 320px 64px; } .inspector { @@ -1315,7 +1345,7 @@ select { /* ──────────────────────────────────────────────────────────────────── */ .app-shell.file-open { - grid-template-columns: 240px 1fr auto 48px; + grid-template-columns: 240px 1fr auto 64px; } .file-panel {