a very good jj gui
0
fork

Configure Feed

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

feat: add jj new and jj edit commands

Add Tauri commands for creating new revisions and editing existing ones:
- new_revision(): creates empty commit on parent(s), sets as working copy
- edit_revision(): switches working copy to existing revision

Keyboard shortcuts:
- n: create new revision on selected
- e: edit selected revision

Issue: TAT-52

+202 -42
+20
apps/desktop/src-tauri/src/lib.rs
··· 166 166 watcher_manager.unwatch(&PathBuf::from(repo_path)) 167 167 } 168 168 169 + #[tauri::command] 170 + async fn jj_new(repo_path: String, parent_change_ids: Vec<String>) -> Result<(), String> { 171 + let path = Path::new(&repo_path); 172 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 173 + jj_repo 174 + .new_revision(parent_change_ids) 175 + .map_err(|e| format!("Failed to create new revision: {}", e)) 176 + } 177 + 178 + #[tauri::command] 179 + async fn jj_edit(repo_path: String, change_id: String) -> Result<(), String> { 180 + let path = Path::new(&repo_path); 181 + let mut jj_repo = JjRepo::open(path).map_err(|e| format!("Failed to open repo: {}", e))?; 182 + jj_repo 183 + .edit_revision(change_id) 184 + .map_err(|e| format!("Failed to edit revision: {}", e)) 185 + } 186 + 169 187 #[cfg_attr(mobile, tauri::mobile_entry_point)] 170 188 pub fn run() { 171 189 tauri::Builder::default() ··· 202 220 update_layout, 203 221 watch_repository, 204 222 unwatch_repository, 223 + jj_new, 224 + jj_edit, 205 225 ]) 206 226 .run(tauri::generate_context!()) 207 227 .expect("error while running tauri application");
+97 -1
apps/desktop/src-tauri/src/repo/jj.rs
··· 6 6 use jj_lib::object_id::{HexPrefix, PrefixResolution}; 7 7 use jj_lib::repo::{Repo, StoreFactories}; 8 8 use jj_lib::repo_path::RepoPath; 9 + use jj_lib::settings::UserSettings; 9 10 use jj_lib::workspace::{Workspace, default_working_copy_factories}; 10 11 use std::path::Path; 11 12 use tokio::io::AsyncReadExt; 12 13 13 14 pub struct JjRepo { 14 15 workspace: Workspace, 16 + #[allow(dead_code)] // Used by jj-lib internals via workspace 17 + user_settings: UserSettings, 15 18 } 16 19 17 20 impl JjRepo { ··· 30 33 ) 31 34 .context("Failed to load jj workspace")?; 32 35 33 - Ok(Self { workspace }) 36 + Ok(Self { 37 + workspace, 38 + user_settings, 39 + }) 34 40 } 35 41 36 42 fn load_config() -> Result<jj_lib::config::StackedConfig> { ··· 75 81 Ok(repo.store().get_commit(&commit_id)?) 76 82 } 77 83 84 + #[allow(dead_code)] // May be used in future features 78 85 pub fn get_parent_tree(&self, commit: &Commit) -> Result<MergedTree> { 79 86 let repo = self.workspace.repo_loader().load_at_head()?; 80 87 let parents = commit.parents(); ··· 159 166 160 167 pub fn repo_loader(&self) -> &jj_lib::repo::RepoLoader { 161 168 self.workspace.repo_loader() 169 + } 170 + 171 + pub fn new_revision(&mut self, parent_change_ids: Vec<String>) -> Result<()> { 172 + let repo = self.workspace.repo_loader().load_at_head()?; 173 + let mut tx = repo.start_transaction(); 174 + 175 + // Resolve parent change IDs to commit IDs and get commits 176 + let mut parent_commits = Vec::new(); 177 + for change_id in parent_change_ids { 178 + let commit_id = self.resolve_change_id(repo.as_ref(), &change_id)?; 179 + let commit = repo.store().get_commit(&commit_id) 180 + .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 181 + parent_commits.push(commit); 182 + } 183 + 184 + // Get the tree from the first parent (empty commit uses parent's tree) 185 + let tree_id = parent_commits 186 + .first() 187 + .context("No parent commits provided")? 188 + .tree_id() 189 + .clone(); 190 + 191 + // Create new commit with parent commits and their tree (no changes) 192 + let parent_commit_ids: Vec<_> = parent_commits.iter().map(|c| c.id().clone()).collect(); 193 + let new_commit = tx 194 + .repo_mut() 195 + .new_commit(parent_commit_ids, tree_id) 196 + .write() 197 + .map_err(|e| anyhow::anyhow!("Failed to write commit: {}", e))?; 198 + 199 + // Set as working copy 200 + let workspace_name = self.workspace.workspace_name().to_owned(); 201 + tx.repo_mut() 202 + .set_wc_commit(workspace_name, new_commit.id().clone()) 203 + .context("Failed to set working copy commit")?; 204 + 205 + // Get old tree for checkout 206 + let old_commit = repo 207 + .store() 208 + .get_commit(repo.view().get_wc_commit_id(self.workspace.workspace_name()) 209 + .context("No working copy commit")?) 210 + .map_err(|e| anyhow::anyhow!("Failed to get old commit: {}", e))?; 211 + let old_tree_id = old_commit.tree_id().clone(); 212 + 213 + // Finalize transaction 214 + let new_repo = tx.commit("new")?; 215 + let operation_id = new_repo.operation().id().clone(); 216 + 217 + // Check out the new commit in the working copy 218 + self.workspace 219 + .check_out(operation_id, Some(&old_tree_id), &new_commit) 220 + .context("Failed to check out new commit")?; 221 + 222 + Ok(()) 223 + } 224 + 225 + pub fn edit_revision(&mut self, change_id: String) -> Result<()> { 226 + let repo = self.workspace.repo_loader().load_at_head()?; 227 + let mut tx = repo.start_transaction(); 228 + 229 + // Resolve change ID to commit 230 + let commit_id = self.resolve_change_id(repo.as_ref(), &change_id)?; 231 + let commit = repo.store().get_commit(&commit_id) 232 + .map_err(|e| anyhow::anyhow!("Failed to get commit: {}", e))?; 233 + 234 + // Set as working copy 235 + let workspace_name = self.workspace.workspace_name().to_owned(); 236 + tx.repo_mut() 237 + .set_wc_commit(workspace_name, commit.id().clone()) 238 + .context("Failed to set working copy commit")?; 239 + 240 + // Get old tree for checkout 241 + let old_commit = repo 242 + .store() 243 + .get_commit(repo.view().get_wc_commit_id(self.workspace.workspace_name()) 244 + .context("No working copy commit")?) 245 + .map_err(|e| anyhow::anyhow!("Failed to get old commit: {}", e))?; 246 + let old_tree_id = old_commit.tree_id().clone(); 247 + 248 + // Finalize transaction 249 + let new_repo = tx.commit("edit")?; 250 + let operation_id = new_repo.operation().id().clone(); 251 + 252 + // Check out the commit in the working copy 253 + self.workspace 254 + .check_out(operation_id, Some(&old_tree_id), &commit) 255 + .context("Failed to check out commit")?; 256 + 257 + Ok(()) 162 258 } 163 259 }
+2 -2
apps/desktop/src-tauri/src/repo/log.rs
··· 26 26 let jj_repo = JjRepo::open(repo_path)?; 27 27 let repo = jj_repo.repo_loader().load_at_head()?; 28 28 29 - // Use working copy ancestors - excludes orphaned descendants from rebases 29 + // Show all commits reachable from heads (includes WC descendants) 30 30 let wc_id = repo 31 31 .view() 32 32 .wc_commit_ids() 33 33 .values() 34 34 .next() 35 35 .context("No working copy")?; 36 - let revset_expression = RevsetExpression::commit(wc_id.clone()).ancestors(); 36 + let revset_expression = RevsetExpression::visible_heads().ancestors(); 37 37 38 38 let revset = revset_expression 39 39 .evaluate(repo.as_ref())
+24
apps/desktop/src/components/AppShell.tsx
··· 19 19 type Revision, 20 20 upsertProject, 21 21 } from "@/tauri-commands"; 22 + import { editRevision, newRevision } from "@/db"; 22 23 23 24 const openDirectoryDialogEffect = Effect.gen(function* () { 24 25 const home = yield* Effect.tryPromise({ ··· 180 181 sequence: "yY", 181 182 onTrigger: handleYankLink, 182 183 enabled: !!selectedRevision && !!projectId, 184 + }); 185 + 186 + const handleNew = useCallback(() => { 187 + if (!activeProject || !selectedRevision) return; 188 + newRevision(activeProject.path, [selectedRevision.change_id]); 189 + }, [activeProject, selectedRevision]); 190 + 191 + const handleEdit = useCallback(() => { 192 + if (!activeProject || !selectedRevision) return; 193 + const currentWC = revisions.find((r) => r.is_working_copy); 194 + editRevision(activeProject.path, selectedRevision.change_id, currentWC?.change_id ?? null); 195 + }, [activeProject, selectedRevision, revisions]); 196 + 197 + useKeyboardShortcut({ 198 + key: "n", 199 + onPress: handleNew, 200 + enabled: !!activeProject && !!selectedRevision, 201 + }); 202 + 203 + useKeyboardShortcut({ 204 + key: "e", 205 + onPress: handleEdit, 206 + enabled: !!activeProject && !!selectedRevision, 183 207 }); 184 208 185 209 const closestBookmark = useMemo(() => {
+7
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 19 19 ], 20 20 }, 21 21 { 22 + category: "Actions", 23 + items: [ 24 + { keys: ["n"], description: "New revision on selected" }, 25 + { keys: ["e"], description: "Edit selected revision" }, 26 + ], 27 + }, 28 + { 22 29 category: "Yank", 23 30 items: [ 24 31 { keys: ["y y"], description: "Copy revision ID" },
+1 -37
apps/desktop/src/components/RevisionGraph.tsx
··· 79 79 } 80 80 } 81 81 82 - // Find which heads have working copy in their ancestry 83 - const workingCopy = revisions.find((r) => r.is_working_copy); 84 - const headsWithWorkingCopy = new Set<string>(); 85 - 86 - if (workingCopy) { 87 - for (const head of heads) { 88 - // Check if working copy is reachable from this head 89 - const visited = new Set<string>(); 90 - const stack = [head.commit_id]; 91 - while (stack.length > 0) { 92 - const id = stack.pop()!; 93 - if (visited.has(id)) continue; 94 - visited.add(id); 95 - if (id === workingCopy.commit_id) { 96 - headsWithWorkingCopy.add(head.commit_id); 97 - break; 98 - } 99 - const rev = commitMap.get(id); 100 - if (rev) { 101 - stack.push(...rev.parent_ids.filter((pid) => commitMap.has(pid))); 102 - } 103 - } 104 - } 105 - } 106 - 107 - // Sort heads: working copy itself first, then heads containing it, then others 108 - const sortedHeads = [...heads].sort((a, b) => { 109 - if (a.is_working_copy) return -1; 110 - if (b.is_working_copy) return 1; 111 - const aHasWc = headsWithWorkingCopy.has(a.commit_id); 112 - const bHasWc = headsWithWorkingCopy.has(b.commit_id); 113 - if (aHasWc && !bHasWc) return -1; 114 - if (!aHasWc && bHasWc) return 1; 115 - return 0; 116 - }); 117 - 118 - for (const head of sortedHeads) { 82 + for (const head of heads) { 119 83 visit(head); 120 84 } 121 85
+43 -2
apps/desktop/src/db.ts
··· 1 - import { createCollection } from "@tanstack/db"; 1 + import { createCollection, createTransaction } from "@tanstack/db"; 2 2 import { QueryClient } from "@tanstack/query-core"; 3 3 import { queryCollectionOptions } from "@tanstack/query-db-collection"; 4 4 import { listen } from "@tauri-apps/api/event"; 5 5 import type { Project, Revision } from "@/tauri-commands"; 6 - import { getProjects, getRevisions, watchRepository } from "@/tauri-commands"; 6 + import { getProjects, getRevisions, jjEdit, jjNew, watchRepository } from "@/tauri-commands"; 7 7 8 8 export const queryClient = new QueryClient(); 9 9 ··· 76 76 } 77 77 return collection; 78 78 } 79 + 80 + export function editRevision( 81 + repoPath: string, 82 + targetChangeId: string, 83 + currentWcChangeId: string | null, 84 + ) { 85 + const collection = getRevisionsCollection(repoPath); 86 + const tx = createTransaction({ 87 + mutationFn: async () => { 88 + await jjEdit(repoPath, targetChangeId); 89 + }, 90 + }); 91 + 92 + tx.mutate(() => { 93 + if (currentWcChangeId && currentWcChangeId !== targetChangeId) { 94 + collection.update(currentWcChangeId, (draft) => { 95 + draft.is_working_copy = false; 96 + }); 97 + } 98 + collection.update(targetChangeId, (draft) => { 99 + draft.is_working_copy = true; 100 + }); 101 + }); 102 + 103 + return tx; 104 + } 105 + 106 + export function newRevision(repoPath: string, parentChangeIds: string[]) { 107 + const tx = createTransaction({ 108 + mutationFn: async () => { 109 + await jjNew(repoPath, parentChangeIds); 110 + }, 111 + }); 112 + 113 + tx.mutate(() => { 114 + // No optimistic update - we don't know the new revision's ID 115 + // File watcher will add it to the collection 116 + }); 117 + 118 + return tx; 119 + }
+8
apps/desktop/src/tauri-commands.ts
··· 90 90 export async function unwatchRepository(repoPath: string): Promise<void> { 91 91 return invoke("unwatch_repository", { repoPath }); 92 92 } 93 + 94 + export async function jjNew(repoPath: string, parentChangeIds: string[]): Promise<void> { 95 + return invoke("jj_new", { repoPath, parentChangeIds }); 96 + } 97 + 98 + export async function jjEdit(repoPath: string, changeId: string): Promise<void> { 99 + return invoke("jj_edit", { repoPath, changeId }); 100 + }