diff --git a/apps/identra-desktop/frontend/src/App.tsx b/apps/identra-desktop/frontend/src/App.tsx index c9e8db4..f2a9221 100644 --- a/apps/identra-desktop/frontend/src/App.tsx +++ b/apps/identra-desktop/frontend/src/App.tsx @@ -12,6 +12,7 @@ import SettingsPanel from "./SettingsPanel"; import WorkPanel from "./WorkPanel"; import WorkspaceMenu from "./WorkspaceMenu"; import CommandBar, { MOD_LABEL, type DispatchState } from "./CommandBar"; +import ChangesPanel from "./ChangesPanel"; import ConnectionsPanel from "./ConnectionsPanel"; import WallpaperPicker from "./WallpaperPicker"; import { AgentIcon } from "./icons"; @@ -100,7 +101,7 @@ const DEFAULT_H = 320; // is the change. Connections is new, and it is not a convenience: it is the only place a grant of // agent-to-agent access can now be seen. Changes and Review are named in the plan and are not // built, and an empty tab that says "coming soon" is worse than a column with three honest ones. -type RightMode = "work" | "files" | "connections"; +type RightMode = "work" | "files" | "changes" | "connections"; // Whether this workspace has been told its canvas is gone. Kept in the browser's own storage rather // than in the engine, because it is a fact about what this person has read and not about the @@ -1288,6 +1289,15 @@ export default function App() { > Files + + + + {changes !== null && ( + // Which branch, and whose. An agent given its own checkout is working somewhere else + // entirely, and a diff read as your branch when it is a helper's is the kind of wrong that + // ends with someone committing the wrong thing. +
+ {changes.branch ?? "detached HEAD"} + {changes.worktree && ( + + isolated worktree + + )} +
+ )} + +
+ {error !== null && ( +

+ {error} +

+ )} + {error === null && loaded && groups.size === 0 && ( +

+ Nothing has changed in the working tree. +

+ )} + {[...groups.entries()].map(([dir, files]) => ( +
+
{dir === "" ? "." : dir}
+ {files.map((f) => ( +
+ {MARK[f.state]} + + {f.path.slice(f.path.lastIndexOf("/") + 1)} + + {f.staged && ( + + staged + + )} + {/* A binary file gets no numbers rather than a pair of zeroes: git will not diff + it, and "+0 -0" on a 4MB image an agent just wrote reads as nothing happened. */} + {f.added === null || f.removed === null ? ( + binary + ) : ( + + +{f.added} + −{f.removed} + + )} +
+ ))} +
+ ))} +
+ + ); +} diff --git a/apps/identra-desktop/frontend/src/api.ts b/apps/identra-desktop/frontend/src/api.ts index d0a3888..ad94804 100644 --- a/apps/identra-desktop/frontend/src/api.ts +++ b/apps/identra-desktop/frontend/src/api.ts @@ -239,6 +239,28 @@ export type Handshake = { facts: number; at: number }; export const busHandshakes = () => invoke>("bus_handshakes"); +// What the agents have done to the working tree. Mirrors `changes.rs` in identra-core. +// +// `added`/`removed` are null for a binary file, which is git saying it will not diff this, and a +// zero there would read as "nothing happened" on a file that entirely changed. +export type FileChange = { + path: string; + added: number | null; + removed: number | null; + state: "added" | "modified" | "deleted" | "renamed" | "untracked"; + staged: boolean; +}; + +export type Changes = { + /// Null on a detached HEAD, which is a state worth showing rather than an error. + branch: string | null; + /// True when this is one of Identra's isolated worktrees rather than the user's own checkout. + worktree: boolean; + files: FileChange[]; +}; + +export const workspaceChanges = () => invoke("workspace_changes"); + export const memorySearch = (query: string, limit?: number) => invoke("memory_search", { query, limit: limit ?? null }); diff --git a/apps/identra-desktop/frontend/src/styles.css b/apps/identra-desktop/frontend/src/styles.css index b8a1f4d..59dc3ec 100644 --- a/apps/identra-desktop/frontend/src/styles.css +++ b/apps/identra-desktop/frontend/src/styles.css @@ -2242,3 +2242,98 @@ body { .identra-side__handshake-when { color: var(--state-missing); } + +/* Changes: the branch line, then files grouped by directory. Denser than the file browser, because + this is a list you scan for a shape — six files under src/memory, one under docs — rather than + one you navigate. */ +.identra-changes__branch { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + border-bottom: 1px solid var(--panel-edge); + font-size: 12px; + color: var(--text); +} +/* An agent given its own checkout is working somewhere else entirely, and a diff read as your + branch when it is a helper's is how someone commits the wrong thing. */ +.identra-changes__worktree { + padding: 1px 6px; + border-radius: 5px; + background: var(--header); + color: var(--state-setup); + font-size: 10px; + cursor: help; +} +.identra-changes__group { + padding: 4px 0; +} +.identra-changes__dir { + padding: 3px 12px; + font-size: 11px; + color: var(--state-missing); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: left; +} +.identra-changes__row { + display: flex; + align-items: center; + gap: 7px; + padding: 3px 12px 3px 20px; + font-size: 12px; +} +.identra-changes__row:hover { + background: var(--panel); +} +.identra-changes__mark { + width: 10px; + flex: none; + font-family: "Ubuntu Mono", Menlo, Consolas, monospace; + color: var(--state-missing); +} +/* Untracked is the most interesting row in the list — a file an agent made that git would not have + shown you — and deleted is the only one where work is leaving rather than arriving. */ +.identra-changes__row[data-state="untracked"] .identra-changes__mark { + color: var(--state-running); +} +.identra-changes__row[data-state="deleted"] .identra-changes__mark { + color: #c01c28; +} +.identra-changes__row[data-state="deleted"] .identra-changes__name { + text-decoration: line-through; + opacity: 0.7; +} +.identra-changes__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.identra-changes__staged { + flex: none; + font-size: 10px; + color: var(--state-missing); + cursor: help; +} +.identra-changes__stat { + flex: none; + display: flex; + gap: 5px; + font-family: "Ubuntu Mono", Menlo, Consolas, monospace; + font-size: 11px; +} +.identra-changes__plus { + color: var(--state-running); +} +.identra-changes__minus { + color: #c01c28; +} +.identra-changes__binary { + flex: none; + font-size: 10px; + color: var(--state-missing); +} diff --git a/apps/identra-desktop/src-tauri/src/lib.rs b/apps/identra-desktop/src-tauri/src/lib.rs index 88699d3..0f46384 100644 --- a/apps/identra-desktop/src-tauri/src/lib.rs +++ b/apps/identra-desktop/src-tauri/src/lib.rs @@ -425,6 +425,20 @@ fn bus_handshakes( state.bus.handshakes() } +/// What the agents have done to this workspace's working tree. +/// +/// The one question Identra could not answer about itself. Agents run here editing files, and until +/// now the only way to see what they changed was to open a terminal and run `git status` — inside +/// the app whose whole purpose is watching agents work. The board says what they claimed and the +/// memory says what they decided; this is what they did. +/// +/// A workspace that is not a repository is an ordinary state, since Identra makes empty ones, so +/// the error is a sentence the panel can print rather than something it has to treat as a fault. +#[tauri::command] +fn workspace_changes(state: State) -> Result { + identra_core::changes::changes(&state.dir()).map_err(|e| e.to_string()) +} + /// Search what the project has learned. Same ranking the agents get: with a model, by meaning; /// without one, by words. This is why it goes through the bus opener rather than a bare store. #[tauri::command] @@ -922,6 +936,7 @@ pub fn run() { board_list, memory_list, bus_handshakes, + workspace_changes, memory_restated, memory_superseded, memory_search, diff --git a/crates/identra-core/src/changes.rs b/crates/identra-core/src/changes.rs new file mode 100644 index 0000000..ddb81fd --- /dev/null +++ b/crates/identra-core/src/changes.rs @@ -0,0 +1,337 @@ +//! What the agents have done to the working tree, as a thing a person can read. +//! +//! This is the one question Identra could not answer about itself. Four agents run in a workspace, +//! every one of them editing files, and the only way to find out what they changed was to open a +//! terminal and run `git status` yourself — inside the app whose entire purpose is watching agents +//! work. The task board says what they *claimed*, the memory says what they *decided*, and nothing +//! said what they *did*. +//! +//! Read-only, deliberately, and see `changes()`'s note on why staging and revert are not here. +//! +//! Shelling out to git rather than linking a library. `worktree.rs` already does, so this adds no +//! dependency and no second idea of what a repository is, and the answers are the ones the user +//! would get typing the same commands in the terminal one pane over. A library would be faster and +//! would introduce a way for the panel and the terminal beside it to disagree. + +use std::path::Path; +use std::process::Command; + +use serde::Serialize; + +/// One file the working tree has changed, relative to the repository root. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct FileChange { + /// Repository-relative, forward slashes, as git reports it. + pub path: String, + /// Lines added and removed. Both are `None` for a binary file, which git reports as `-`, and a + /// zero would be a lie there rather than a smaller truth. + pub added: Option, + pub removed: Option, + /// What happened to it: added, modified, deleted, renamed, or untracked. + pub state: State, + /// Whether the change is in the index. Shown, not acted on: it is part of describing the tree + /// honestly, and someone who staged files in their terminal should not see the panel claim + /// otherwise. + pub staged: bool, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum State { + Added, + Modified, + Deleted, + Renamed, + /// Not tracked by git at all. Kept rather than filtered: a file an agent created is the single + /// most interesting row in this list, and it is exactly the one `git diff` will not show you. + Untracked, +} + +/// The working tree, summarised. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct Changes { + /// The branch checked out here, or `None` on a detached HEAD. `None` is not an error: an agent + /// left on a detached head is a state worth seeing rather than hiding behind a failure. + pub branch: Option, + /// True when this checkout is one of Identra's isolated worktrees rather than the user's own. + /// The person reading needs to know whether they are looking at their branch or a helper's. + pub worktree: bool, + pub files: Vec, +} + +#[derive(Debug)] +pub enum Error { + NotARepo, + Git(String), + Io(std::io::Error), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotARepo => write!(f, "this workspace is not a git repository"), + Error::Git(e) => write!(f, "git said: {e}"), + Error::Io(e) => write!(f, "could not run git: {e}"), + } + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +fn git(dir: &Path, args: &[&str]) -> Result { + let out = Command::new("git").current_dir(dir).args(args).output()?; + if !out.status.success() { + return Err(Error::Git( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + // Not trimmed. Porcelain output is parsed line by line and a trailing newline is the record + // separator; trimming it here would be invisible until a path with trailing whitespace, which + // is legal, quietly lost a character. + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// What has changed in `dir`'s working tree. +/// +/// # Why this is read-only +/// +/// Superset's equivalent column stages and reverts, and this one does neither. Staging without a +/// commit control is half a gesture, and the terminal that can finish it is one pane away. Revert +/// is the real reason: discarding uncommitted work is the only operation in this app that destroys +/// something no undo can bring back, and it would be sitting one click from a list a person scans +/// quickly, describing files an agent wrote while they were not watching. Seeing what changed is +/// the whole of the gap; being able to throw it away is a separate decision that should be made on +/// its own and not smuggled in behind a panel. +pub fn changes(dir: &Path) -> Result { + if !dir.exists() { + return Err(Error::NotARepo); + } + let root = git(dir, &["rev-parse", "--show-toplevel"]) + .map(|s| std::path::PathBuf::from(s.trim())) + .map_err(|_| Error::NotARepo)?; + + // `--symbolic-full-name` rather than `--abbrev-ref`, because the latter answers "HEAD" on a + // detached head, which is indistinguishable from a branch actually called HEAD. + let branch = git(&root, &["symbolic-ref", "--quiet", "--short", "HEAD"]) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let mut files = statuses(&root)?; + let counts = numstat(&root)?; + for f in &mut files { + if let Some(&(added, removed)) = counts.get(f.path.as_str()) { + f.added = added; + f.removed = removed; + } + } + // Directory order, so the panel can group without sorting twice and two reads of the same tree + // never come back in a different order. + files.sort_by(|a, b| a.path.cmp(&b.path)); + + Ok(Changes { + branch, + worktree: root.join(".git").is_file(), + files, + }) +} + +/// Every changed path with its index and worktree status letters. +/// +/// `-z` and NUL separation, not lines. Git quotes and escapes paths containing spaces or newlines +/// in its default output, which means a naive line parser both mangles ordinary filenames and can +/// be made to see rows that are not there by a file with a newline in its name — a file an agent +/// could create. NUL separation has no escaping, so there is nothing to get wrong. +fn statuses(root: &Path) -> Result, Error> { + let out = git( + root, + &["status", "--porcelain=v1", "-z", "--untracked-files=all"], + )?; + let mut fields = out.split('\0'); + let mut files = Vec::new(); + while let Some(entry) = fields.next() { + if entry.len() < 3 { + continue; + } + let bytes = entry.as_bytes(); + let (index, tree) = (bytes[0] as char, bytes[1] as char); + let path = entry[3..].to_string(); + // A rename is two NUL-separated fields: the new path in this entry and the old one in the + // next. The old path is consumed here so it is never mistaken for its own change. + if index == 'R' || tree == 'R' { + let _ = fields.next(); + } + let state = match (index, tree) { + ('?', _) => State::Untracked, + ('R', _) | (_, 'R') => State::Renamed, + ('A', _) => State::Added, + ('D', _) | (_, 'D') => State::Deleted, + _ => State::Modified, + }; + files.push(FileChange { + path, + added: None, + removed: None, + state, + staged: index != ' ' && index != '?', + }); + } + Ok(files) +} + +/// Added and removed line counts per path, staged and unstaged together. +/// +/// Two calls, because a file can be partly staged and the panel shows one row per file: what a +/// person wants from that row is how far the file has moved from HEAD, which is both halves added +/// up. `git diff HEAD` would answer it in one call and reports nothing at all for a path that was +/// staged as a rename, so it is two. +type Counts = std::collections::HashMap, Option)>; + +fn numstat(root: &Path) -> Result { + let mut counts: Counts = std::collections::HashMap::new(); + for args in [ + &["diff", "--numstat", "-z"][..], + &["diff", "--numstat", "-z", "--cached"][..], + ] { + for (path, added, removed) in parse_numstat(&git(root, args)?) { + let slot = counts.entry(path).or_insert((Some(0), Some(0))); + // Binary anywhere wins. Half a count on a file git will not diff is worse than saying + // plainly that there is no line count to give. + slot.0 = match (slot.0, added) { + (Some(a), Some(b)) => Some(a + b), + _ => None, + }; + slot.1 = match (slot.1, removed) { + (Some(a), Some(b)) => Some(a + b), + _ => None, + }; + } + } + Ok(counts) +} + +/// `--numstat -z` rows: `added \t removed \t path NUL`, except a rename, which is +/// `added \t removed \t NUL old NUL new`. Pure, so the shape is testable without a repository. +fn parse_numstat(out: &str) -> Vec<(String, Option, Option)> { + let mut fields = out.split('\0').peekable(); + let mut rows = Vec::new(); + while let Some(field) = fields.next() { + if field.is_empty() { + continue; + } + let mut parts = field.splitn(3, '\t'); + let (Some(added), Some(removed), Some(path)) = (parts.next(), parts.next(), parts.next()) + else { + continue; + }; + // A dash is git saying this file is binary, which is a different answer from zero lines. + let num = |s: &str| if s == "-" { None } else { s.parse().ok() }; + let path = if path.is_empty() { + // Rename: the path field is empty and the old and new paths follow as their own + // fields. The new one is what the tree has now, so that is the row. + let _old = fields.next(); + match fields.next() { + Some(new) => new.to_string(), + None => continue, + } + } else { + path.to_string() + }; + rows.push((path, num(added), num(removed))); + } + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Renames are two paths in one row, and getting it wrong shows the user a file at a path that + /// no longer exists while the one that does exist is missing its counts. + #[test] + fn a_rename_is_counted_against_the_path_the_file_has_now() { + let out = "3\t1\t\0src/old.rs\0src/new.rs\0"; + assert_eq!( + parse_numstat(out), + vec![("src/new.rs".to_string(), Some(3), Some(1))] + ); + } + + /// Git says `-` for a file it will not diff. Parsing that as zero would put "+0 -0" on a 4MB + /// PNG an agent just committed, which reads as "nothing happened here". + #[test] + fn a_binary_file_has_no_line_count_rather_than_a_count_of_zero() { + let rows = parse_numstat("-\t-\tassets/logo.png\0"); + assert_eq!(rows, vec![("assets/logo.png".to_string(), None, None)]); + } + + #[test] + fn ordinary_rows_parse_and_a_trailing_separator_adds_nothing() { + let rows = parse_numstat("10\t2\tsrc/lib.rs\0\0"); + assert_eq!(rows, vec![("src/lib.rs".to_string(), Some(10), Some(2))]); + } + + /// The end-to-end shape, against a real repository, because the porcelain parsing is the part + /// that has to agree with git rather than with my reading of the manual. + #[test] + fn a_real_tree_reports_what_changed_including_files_git_diff_will_not_show() { + let dir = std::env::temp_dir().join(format!("identra-changes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let run = |args: &[&str]| { + Command::new("git") + .current_dir(&dir) + .args(args) + .output() + .unwrap(); + }; + run(&["init", "-q", "-b", "work"]); + run(&["config", "user.email", "t@t"]); + run(&["config", "user.name", "t"]); + std::fs::write(dir.join("kept.txt"), "one\ntwo\n").unwrap(); + run(&["add", "."]); + run(&["commit", "-qm", "first"]); + + // Three shapes at once: an edit, a file an agent created and never added, and a deletion. + std::fs::write(dir.join("kept.txt"), "one\ntwo\nthree\n").unwrap(); + std::fs::write(dir.join("made-by-agent.txt"), "new\n").unwrap(); + std::fs::remove_file(dir.join("kept.txt")).ok(); + std::fs::write(dir.join("kept.txt"), "one\ntwo\nthree\n").unwrap(); + + let c = changes(&dir).unwrap(); + assert_eq!(c.branch.as_deref(), Some("work")); + assert!(!c.worktree); + + let edited = c.files.iter().find(|f| f.path == "kept.txt").unwrap(); + assert_eq!(edited.state, State::Modified); + assert_eq!(edited.added, Some(1)); + + // The row that matters most and the one `git diff` alone would have missed entirely. + let made = c + .files + .iter() + .find(|f| f.path == "made-by-agent.txt") + .unwrap(); + assert_eq!(made.state, State::Untracked); + assert!(!made.staged); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// A workspace that is not a repository is an ordinary state — Identra makes empty workspaces — + /// so it has to be a nameable answer rather than a panel that errors. + #[test] + fn a_folder_that_is_not_a_repository_says_so() { + let dir = std::env::temp_dir().join(format!("identra-norepo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert!(matches!(changes(&dir), Err(Error::NotARepo))); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/identra-core/src/lib.rs b/crates/identra-core/src/lib.rs index 54b006f..ac4fe02 100644 --- a/crates/identra-core/src/lib.rs +++ b/crates/identra-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod agents; pub mod canvas; +pub mod changes; pub mod devserver; pub mod files; pub mod fileview;