a very good jj gui
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add working copy status view

- Add right panel showing current working copy info
- Display change ID, commit ID, and description
- List modified/added/deleted files with status indicators
- Show parent commit description
- Update status bar with changed file count

+238 -21
+3
.fp/activity.jsonl
··· 25 25 {"at":"2025-12-12T23:53:02.478Z","author":"top-earthworm","issue":"TAT-4","action":"issue_updated","data":{"changes":["status: todo → in-progress","range: base set to zzyqryzx"]}} 26 26 {"at":"2025-12-13T00:00:03.306Z","author":"top-earthworm","issue":"TAT-4","action":"status_changed","data":{"from":"in-progress","to":"done"}} 27 27 {"at":"2025-12-13T00:00:03.311Z","author":"top-earthworm","issue":"TAT-4","action":"issue_updated","data":{"changes":["status: in-progress → done","range: tip set to zzyqryzx"]}} 28 + {"at":"2025-12-13T00:02:15.416Z","author":"top-earthworm","issue":"TAT-5","action":"status_changed","data":{"from":"todo","to":"in-progress"}} 29 + {"at":"2025-12-13T00:02:15.422Z","author":"top-earthworm","issue":"TAT-5","action":"issue_updated","data":{"changes":["status: todo → in-progress","range: base set to zzyqryzx"]}} 30 + {"at":"2025-12-13T00:02:31.274Z","action":"session_started","data":{"agentName":"warm-newt"}}
+7 -3
.fp/issues/TAT-5.md
··· 2 2 id: 164a234a-bf6f-43ca-b959-9634ddcd6ead 3 3 short_id: TAT-5 4 4 title: Working copy status view 5 - status: todo 5 + status: in-progress 6 6 parent: TAT-1 7 7 branch: "" 8 - range: null 8 + range: 9 + base: &a1 10 + _tag: jj 11 + changeId: zzyqryzxvuxszunslpsulqwtsrowtvop 12 + tip: *a1 9 13 created_at: 2025-12-12T22:56:37.954Z 10 - updated_at: 2025-12-12T22:56:37.954Z 14 + updated_at: 2025-12-13T00:02:15.418Z 11 15 --- 12 16 13 17 Display current working copy status and changed files.
+1 -1
.fp/workspace.toml
··· 1 1 # FP Workspace State (local only) 2 2 3 - current_issue = "" 3 + current_issue = "TAT-5"
+44 -17
src/app.rs
··· 5 5 6 6 use crate::repo::RepoState; 7 7 use crate::ui::log_view::render_log_view; 8 + use crate::ui::status_view::render_status_view; 8 9 9 10 pub struct Tatami { 10 11 repo: RepoState, ··· 22 23 23 24 impl Render for Tatami { 24 25 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { 25 - let main_content = match &self.repo { 26 - RepoState::NotFound { path } => { 26 + let (main_content, right_panel) = match &self.repo { 27 + RepoState::NotFound { path } => ( 28 + div() 29 + .flex_1() 30 + .h_full() 31 + .p_4() 32 + .child(format!("No jj repository at {}", path.display())), 33 + div().w(px(300.0)).h_full(), 34 + ), 35 + RepoState::Loaded { 36 + revisions, status, .. 37 + } => ( 38 + div() 39 + .flex_1() 40 + .h_full() 41 + .child(render_log_view(revisions, self.selected_revision)), 42 + div() 43 + .w(px(300.0)) 44 + .h_full() 45 + .bg(rgb(0x252525)) 46 + .border_l_1() 47 + .border_color(rgb(0x3d3d3d)) 48 + .children(status.as_ref().map(render_status_view)), 49 + ), 50 + RepoState::Error { message } => ( 27 51 div() 28 52 .flex_1() 29 53 .h_full() 30 54 .p_4() 31 - .child(format!("No jj repository at {}", path.display())) 32 - } 33 - RepoState::Loaded { revisions, .. } => div() 34 - .flex_1() 35 - .h_full() 36 - .child(render_log_view(revisions, self.selected_revision)), 37 - RepoState::Error { message } => { 38 - div().flex_1().h_full().p_4().child(format!("Error: {}", message)) 39 - } 55 + .child(format!("Error: {}", message)), 56 + div().w(px(300.0)).h_full(), 57 + ), 40 58 }; 41 59 42 60 let status_text = match &self.repo { ··· 44 62 RepoState::Loaded { 45 63 workspace_root, 46 64 revisions, 47 - } => format!( 48 - "{} • {} revisions", 49 - workspace_root.file_name().unwrap_or_default().to_string_lossy(), 50 - revisions.len() 51 - ), 65 + status, 66 + } => { 67 + let file_count = status.as_ref().map(|s| s.files.len()).unwrap_or(0); 68 + format!( 69 + "{} • {} revisions • {} changed files", 70 + workspace_root 71 + .file_name() 72 + .unwrap_or_default() 73 + .to_string_lossy(), 74 + revisions.len(), 75 + file_count 76 + ) 77 + } 52 78 RepoState::Error { .. } => "Error".to_string(), 53 79 }; 54 80 ··· 85 111 .p_2() 86 112 .child("Bookmarks"), 87 113 ) 88 - .child(main_content), 114 + .child(main_content) 115 + .child(right_panel), 89 116 ) 90 117 .child( 91 118 div()
+5
src/repo.rs
··· 1 1 pub mod log; 2 + pub mod status; 2 3 3 4 use std::path::PathBuf; 4 5 5 6 use crate::repo::log::Revision; 7 + use crate::repo::status::WorkingCopyStatus; 6 8 7 9 #[derive(Clone)] 8 10 pub enum RepoState { ··· 10 12 Loaded { 11 13 workspace_root: PathBuf, 12 14 revisions: Vec<Revision>, 15 + status: Option<WorkingCopyStatus>, 13 16 }, 14 17 Error { message: String }, 15 18 } ··· 33 36 match find_jj_repo(path) { 34 37 Some(workspace_root) => { 35 38 let revisions = log::fetch_log(&workspace_root, 50).unwrap_or_default(); 39 + let status = status::fetch_status(&workspace_root).ok(); 36 40 RepoState::Loaded { 37 41 workspace_root, 38 42 revisions, 43 + status, 39 44 } 40 45 } 41 46 None => RepoState::NotFound {
+89
src/repo/status.rs
··· 1 + use std::path::Path; 2 + use std::process::Command; 3 + 4 + #[derive(Clone, Debug, PartialEq)] 5 + pub enum FileStatus { 6 + Added, 7 + Modified, 8 + Deleted, 9 + } 10 + 11 + #[derive(Clone, Debug)] 12 + pub struct ChangedFile { 13 + pub path: String, 14 + pub status: FileStatus, 15 + } 16 + 17 + #[derive(Clone, Debug)] 18 + pub struct WorkingCopyStatus { 19 + pub change_id: String, 20 + pub commit_id: String, 21 + pub description: String, 22 + pub parent_description: String, 23 + pub files: Vec<ChangedFile>, 24 + } 25 + 26 + pub fn fetch_status(repo_path: &Path) -> Result<WorkingCopyStatus, String> { 27 + let output = Command::new("jj") 28 + .args(["status", "--no-pager"]) 29 + .current_dir(repo_path) 30 + .output() 31 + .map_err(|e| format!("Failed to run jj status: {}", e))?; 32 + 33 + if !output.status.success() { 34 + let stderr = String::from_utf8_lossy(&output.stderr); 35 + return Err(format!("jj status failed: {}", stderr)); 36 + } 37 + 38 + let stdout = String::from_utf8_lossy(&output.stdout); 39 + let mut files = Vec::new(); 40 + let mut change_id = String::new(); 41 + let mut commit_id = String::new(); 42 + let mut description = String::new(); 43 + let mut parent_description = String::new(); 44 + 45 + for line in stdout.lines() { 46 + if line.starts_with("Working copy") && line.contains("(@)") { 47 + if let Some(rest) = line.strip_prefix("Working copy (@) :") { 48 + let parts: Vec<&str> = rest.trim().splitn(3, ' ').collect(); 49 + if parts.len() >= 2 { 50 + change_id = parts[0].to_string(); 51 + commit_id = parts[1].to_string(); 52 + if parts.len() >= 3 { 53 + description = parts[2].to_string(); 54 + } 55 + } 56 + } 57 + } else if line.starts_with("Parent commit") { 58 + if let Some(rest) = line.strip_prefix("Parent commit (@-):") { 59 + let parts: Vec<&str> = rest.trim().splitn(3, ' ').collect(); 60 + if parts.len() >= 3 { 61 + parent_description = parts[2].to_string(); 62 + } 63 + } 64 + } else if line.starts_with("A ") { 65 + files.push(ChangedFile { 66 + path: line[2..].to_string(), 67 + status: FileStatus::Added, 68 + }); 69 + } else if line.starts_with("M ") { 70 + files.push(ChangedFile { 71 + path: line[2..].to_string(), 72 + status: FileStatus::Modified, 73 + }); 74 + } else if line.starts_with("D ") { 75 + files.push(ChangedFile { 76 + path: line[2..].to_string(), 77 + status: FileStatus::Deleted, 78 + }); 79 + } 80 + } 81 + 82 + Ok(WorkingCopyStatus { 83 + change_id, 84 + commit_id, 85 + description, 86 + parent_description, 87 + files, 88 + }) 89 + }
+1
src/ui/mod.rs
··· 1 1 pub mod log_view; 2 + pub mod status_view;
+88
src/ui/status_view.rs
··· 1 + use gpui::{div, px, rgb, IntoElement, ParentElement, Styled}; 2 + 3 + use crate::repo::status::{ChangedFile, FileStatus, WorkingCopyStatus}; 4 + 5 + pub fn render_status_view(status: &WorkingCopyStatus) -> impl IntoElement { 6 + div() 7 + .flex() 8 + .flex_col() 9 + .size_full() 10 + .p_2() 11 + .gap_2() 12 + .child(render_working_copy_info(status)) 13 + .child(render_file_list(&status.files)) 14 + } 15 + 16 + fn render_working_copy_info(status: &WorkingCopyStatus) -> impl IntoElement { 17 + div() 18 + .flex() 19 + .flex_col() 20 + .gap_1() 21 + .pb_2() 22 + .border_b_1() 23 + .border_color(rgb(0x3d3d3d)) 24 + .child( 25 + div() 26 + .flex() 27 + .gap_2() 28 + .child( 29 + div() 30 + .text_color(rgb(0x4ec9b0)) 31 + .child(format!("@ {}", status.change_id)), 32 + ) 33 + .child( 34 + div() 35 + .text_color(rgb(0x808080)) 36 + .child(status.commit_id.clone()), 37 + ), 38 + ) 39 + .child( 40 + div() 41 + .text_color(rgb(0xcccccc)) 42 + .child(if status.description.is_empty() { 43 + "(no description)".to_string() 44 + } else { 45 + status.description.clone() 46 + }), 47 + ) 48 + .child( 49 + div() 50 + .text_color(rgb(0x606060)) 51 + .text_sm() 52 + .child(format!("Parent: {}", status.parent_description)), 53 + ) 54 + } 55 + 56 + fn render_file_list(files: &[ChangedFile]) -> impl IntoElement { 57 + div() 58 + .flex() 59 + .flex_col() 60 + .gap_1() 61 + .child( 62 + div() 63 + .text_color(rgb(0x808080)) 64 + .text_sm() 65 + .child(format!("Changes ({} files)", files.len())), 66 + ) 67 + .children(files.iter().map(render_file_row).collect::<Vec<_>>()) 68 + } 69 + 70 + fn render_file_row(file: &ChangedFile) -> impl IntoElement { 71 + let (status_char, status_color) = match file.status { 72 + FileStatus::Added => ("A", rgb(0x4ec9b0)), 73 + FileStatus::Modified => ("M", rgb(0xdcdcaa)), 74 + FileStatus::Deleted => ("D", rgb(0xf14c4c)), 75 + }; 76 + 77 + div() 78 + .flex() 79 + .gap_2() 80 + .py(px(2.0)) 81 + .child( 82 + div() 83 + .w(px(16.0)) 84 + .text_color(status_color) 85 + .child(status_char), 86 + ) 87 + .child(div().text_color(rgb(0xcccccc)).child(file.path.clone())) 88 + }