a very good jj gui
0
fork

Configure Feed

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

Sprint 2: frontend squash and rebase mutation flows in UI

+269 -4
+120 -4
apps/desktop/src/components/AppShell.tsx
··· 43 43 getRevisionKey, 44 44 getRevisionsCollection, 45 45 newRevision, 46 + rebaseRevision, 46 47 repositoriesCollection, 48 + squashRevision, 47 49 syncRepository, 48 50 } from "@/db"; 49 51 import { useAddRepository } from "@/hooks/useAddRepository"; ··· 120 122 const [, setSearchOpen] = useAtom(searchOpenAtom); 121 123 const [pendingAbandon, setPendingAbandon] = useState<Revision | null>(null); 122 124 const [editingChangeId, setEditingChangeId] = useState<string | null>(null); 125 + const [rebaseSourceKey, setRebaseSourceKey] = useState<string | null>(null); 123 126 const [projectPickerOpen, setProjectPickerOpen] = useState(false); 124 127 const [isSyncing, setIsSyncing] = useState(false); 125 128 const revisionGraphRef = useRef<RevisionGraphHandle>(null); ··· 190 193 }, [revisions, orderedRevisions, expandedStacks]); 191 194 192 195 const selectedRevision = useSelectedRevision(revisions, rev); 196 + const rebaseSourceRevision = rebaseSourceKey 197 + ? (revisions.find((r) => getRevisionKey(r) === rebaseSourceKey) ?? null) 198 + : null; 199 + const isPickingRebaseDestination = !!rebaseSourceRevision; 193 200 const revisionsErrorMessage = 194 201 revisionsStatus === "error" || revisionsLoadFailed 195 202 ? "Could not fetch revisions from jj." ··· 206 213 } 207 214 }, [editingChangeId, selectedRevision]); 208 215 216 + useEffect(() => { 217 + if (!rebaseSourceKey) return; 218 + const stillExists = revisions.some((revision) => getRevisionKey(revision) === rebaseSourceKey); 219 + if (!stillExists) { 220 + setRebaseSourceKey(null); 221 + } 222 + }, [rebaseSourceKey, revisions]); 223 + 209 224 // Debounce the changeId passed to DiffPanel to avoid expensive re-renders during rapid navigation 210 225 // DiffPanel only updates when navigation settles (200ms without movement) 211 226 const selectedChangeId = selectedRevision?.change_id ?? null; ··· 303 318 editRevision(revisionsCollection, activeProject.path, selectedRevision, currentWC ?? null); 304 319 } 305 320 321 + function handleSquash() { 322 + if (!activeProject || !selectedRevision) return; 323 + squashRevision(revisionsCollection, activeProject.path, selectedRevision); 324 + } 325 + 326 + function handleStartRebase() { 327 + if (!selectedRevision || selectedRevision.is_immutable) return; 328 + setPendingAbandon(null); 329 + setEditingChangeId(null); 330 + setRebaseSourceKey(getRevisionKey(selectedRevision)); 331 + } 332 + 333 + function handleCancelRebaseDestinationPick() { 334 + setRebaseSourceKey(null); 335 + } 336 + 337 + function handlePickRebaseDestination(destinationRevision: Revision) { 338 + if (!activeProject || !rebaseSourceRevision) return; 339 + rebaseRevision( 340 + revisionsCollection, 341 + activeProject.path, 342 + rebaseSourceRevision, 343 + destinationRevision, 344 + ); 345 + setRebaseSourceKey(null); 346 + } 347 + 306 348 useKeyboardShortcut({ 307 349 key: "n", 308 350 onPress: handleNew, 309 - enabled: !!activeProject && !!selectedRevision, 351 + enabled: 352 + !!activeProject && 353 + !!selectedRevision && 354 + !pendingAbandon && 355 + !isPickingRebaseDestination && 356 + !editingChangeId, 310 357 }); 311 358 312 359 useKeyboardShortcut({ 313 360 key: "e", 314 361 onPress: handleEdit, 315 - enabled: !!activeProject && !!selectedRevision, 362 + enabled: 363 + !!activeProject && 364 + !!selectedRevision && 365 + !pendingAbandon && 366 + !isPickingRebaseDestination && 367 + !editingChangeId, 368 + }); 369 + 370 + useKeyboardShortcut({ 371 + key: "s", 372 + onPress: handleSquash, 373 + enabled: 374 + !!activeProject && 375 + !!selectedRevision && 376 + !selectedRevision.is_immutable && 377 + !pendingAbandon && 378 + !isPickingRebaseDestination && 379 + !editingChangeId, 380 + }); 381 + 382 + useKeyboardShortcut({ 383 + key: "r", 384 + onPress: () => { 385 + if (isPickingRebaseDestination) { 386 + handleCancelRebaseDestinationPick(); 387 + return; 388 + } 389 + handleStartRebase(); 390 + }, 391 + enabled: 392 + isPickingRebaseDestination || 393 + (!!activeProject && 394 + !!selectedRevision && 395 + !selectedRevision.is_immutable && 396 + !pendingAbandon && 397 + !editingChangeId), 398 + }); 399 + 400 + useKeyboardShortcut({ 401 + key: "Enter", 402 + onPress: () => { 403 + if (!selectedRevision) return; 404 + handlePickRebaseDestination(selectedRevision); 405 + }, 406 + enabled: 407 + isPickingRebaseDestination && 408 + !!selectedRevision && 409 + getRevisionKey(selectedRevision) !== rebaseSourceKey, 410 + }); 411 + 412 + useKeyboardShortcut({ 413 + key: "Escape", 414 + onPress: handleCancelRebaseDestinationPick, 415 + enabled: isPickingRebaseDestination, 316 416 }); 317 417 318 418 useKeyboardShortcut({ 319 419 key: "d", 320 420 onPress: handleStartDescribe, 321 - enabled: !!selectedRevision && !selectedRevision.is_immutable, 421 + enabled: 422 + !!selectedRevision && 423 + !selectedRevision.is_immutable && 424 + !pendingAbandon && 425 + !isPickingRebaseDestination, 322 426 }); 323 427 324 428 function handleDescribe(changeId: string, description: string) { ··· 331 435 332 436 function handleStartDescribe() { 333 437 if (!selectedRevision || selectedRevision.is_immutable) return; 438 + setRebaseSourceKey(null); 334 439 setEditingChangeId(getRevisionKey(selectedRevision)); 335 440 } 336 441 ··· 342 447 if (!activeProject || !selectedRevision) return; 343 448 // Don't abandon immutable revisions (trunk ancestors) 344 449 if (selectedRevision.is_immutable) return; 450 + setRebaseSourceKey(null); 451 + setEditingChangeId(null); 345 452 // Show confirmation 346 453 setPendingAbandon(selectedRevision); 347 454 } ··· 359 466 useKeyboardShortcut({ 360 467 key: "a", 361 468 onPress: handleAbandon, 362 - enabled: !!activeProject && !!selectedRevision && !pendingAbandon, 469 + enabled: 470 + !!activeProject && 471 + !!selectedRevision && 472 + !pendingAbandon && 473 + !isPickingRebaseDestination && 474 + !editingChangeId, 363 475 }); 364 476 365 477 // Confirmation shortcuts ··· 500 612 editingChangeId={editingChangeId} 501 613 onDescribe={handleDescribe} 502 614 onCancelDescribe={handleCancelDescribe} 615 + rebaseSourceChangeId={rebaseSourceRevision?.change_id ?? null} 616 + onPickRebaseDestination={handlePickRebaseDestination} 503 617 diffPanelRef={diffPanelRef} 504 618 /> 505 619 </Profiler> ··· 529 643 editingChangeId={editingChangeId} 530 644 onDescribe={handleDescribe} 531 645 onCancelDescribe={handleCancelDescribe} 646 + rebaseSourceChangeId={rebaseSourceRevision?.change_id ?? null} 647 + onPickRebaseDestination={handlePickRebaseDestination} 532 648 diffPanelRef={diffPanelRef} 533 649 /> 534 650 </Profiler>
+3
apps/desktop/src/components/KeyboardShortcutsHelp.tsx
··· 24 24 items: [ 25 25 { keys: ["n"], description: "New revision on selected" }, 26 26 { keys: ["e"], description: "Edit selected revision" }, 27 + { keys: ["s"], description: "Squash selected revision into parent" }, 28 + { keys: ["r"], description: "Start/cancel rebase destination pick" }, 29 + { keys: ["Enter"], description: "Confirm rebase destination (pick mode)" }, 27 30 ], 28 31 }, 29 32 {
+24
apps/desktop/src/components/revision-graph/index.tsx
··· 72 72 editingChangeId: string | null; 73 73 onDescribe: (changeId: string, description: string) => void; 74 74 onCancelDescribe: () => void; 75 + rebaseSourceChangeId: string | null; 76 + onPickRebaseDestination: (revision: Revision) => void; 75 77 diffPanelRef: RefObject<HTMLElement | null>; 76 78 } 77 79 ··· 341 343 editingChangeId, 342 344 onDescribe, 343 345 onCancelDescribe, 346 + rebaseSourceChangeId, 347 + onPickRebaseDestination, 344 348 diffPanelRef, 345 349 }, 346 350 ref, ··· 371 375 const navigate = useNavigate({ from: Route.fullPath }); 372 376 const [aceJumpQuery, setAceJumpQuery] = useAtom(aceJumpQueryAtom); 373 377 const aceJumpMode = aceJumpQuery !== null; 378 + const isRebaseDestinationPickMode = rebaseSourceChangeId !== null; 379 + const rebaseSourceRevision = isRebaseDestinationPickMode 380 + ? (stableRevisions.find((revision) => revision.change_id === rebaseSourceChangeId) ?? null) 381 + : null; 374 382 375 383 // Detect collapsible stacks 376 384 const stacks = useMemo(() => detectStacks(stableRevisions), [stableRevisions]); ··· 826 834 const revision = revisionMapByKey.get(revisionKey); 827 835 if (!revision) return; 828 836 837 + if (isRebaseDestinationPickMode && !modifiers.meta && !modifiers.shift) { 838 + if (revision.change_id !== rebaseSourceChangeId) { 839 + onPickRebaseDestination(revision); 840 + } 841 + return; 842 + } 843 + 829 844 // Cmd/Ctrl+click: toggle selection 830 845 if (modifiers.meta) { 831 846 toggleRevisionCheck(revisionKey); ··· 1154 1169 className="h-full overflow-auto ascii-bg pt-4" 1155 1170 style={{ overflowAnchor: "none" }} 1156 1171 > 1172 + {isRebaseDestinationPickMode && rebaseSourceRevision ? ( 1173 + <div className="sticky top-0 z-30 border-y border-primary/30 bg-primary/10 px-3 py-1 text-xs text-primary"> 1174 + Rebase mode: choose destination for 1175 + <code className="mx-1 rounded bg-primary/20 px-1 py-0.5 font-mono text-[10px]"> 1176 + {rebaseSourceRevision.change_id_short} 1177 + </code> 1178 + (Enter or click revision, Esc to cancel) 1179 + </div> 1180 + ) : null} 1157 1181 <div 1158 1182 className="relative" 1159 1183 style={{
+122
apps/desktop/src/db.ts
··· 18 18 jjGitFetch, 19 19 jjGitPush, 20 20 jjNew, 21 + jjRebase, 22 + jjSquash, 21 23 removeRepository, 22 24 undoOperation, 23 25 upsertRepository, ··· 170 172 await queryClient.invalidateQueries({ queryKey: ["commit-recency", repoPath] }); 171 173 await queryClient.invalidateQueries({ queryKey: ["status", repoPath] }); 172 174 await queryClient.invalidateQueries({ queryKey: ["conflict-paths", repoPath] }); 175 + } 176 + 177 + function mutationSuccessWithUndo( 178 + repoPath: string, 179 + operationId: string, 180 + title: string, 181 + description?: string, 182 + ) { 183 + toast.success(title, { 184 + description, 185 + action: { 186 + label: "Undo", 187 + onClick: () => { 188 + undoOperation(repoPath, operationId) 189 + .then(() => { 190 + void invalidateRepositoryQueries(repoPath); 191 + toast.success("Undo successful"); 192 + }) 193 + .catch((error) => { 194 + toast.error(`Undo failed: ${error}`, { duration: Number.POSITIVE_INFINITY }); 195 + }); 196 + }, 197 + }, 198 + }); 173 199 } 174 200 175 201 export async function syncRepository(repoPath: string, preset?: string): Promise<void> { ··· 521 547 // Revert optimistic update on failure 522 548 collection.utils.writeUpsert([{ ...revision, description: previousDescription }]); 523 549 toast.error(`Failed to update description: ${error}`, { 550 + duration: Number.POSITIVE_INFINITY, 551 + }); 552 + }); 553 + } 554 + 555 + export function squashRevision( 556 + collection: RevisionsCollection, 557 + repoPath: string, 558 + revision: Revision, 559 + ) { 560 + if (revision.is_immutable) { 561 + toast.error("Cannot squash immutable revision", { duration: Number.POSITIVE_INFINITY }); 562 + return; 563 + } 564 + if (revision.parent_edges.length === 0) { 565 + toast.error("Cannot squash root revision", { duration: Number.POSITIVE_INFINITY }); 566 + return; 567 + } 568 + if (revision.parent_edges.length > 1) { 569 + toast.error("Cannot squash merge revision with multiple parents", { 570 + duration: Number.POSITIVE_INFINITY, 571 + }); 572 + return; 573 + } 574 + 575 + const mutationId = `squash-${Date.now()}-${Math.random()}`; 576 + const shouldOptimisticallyDelete = !revision.is_working_copy; 577 + 578 + if (shouldOptimisticallyDelete) { 579 + collection.utils.writeDelete(getRevisionKey(revision)); 580 + } 581 + 582 + trackMutation(mutationId, jjSquash(repoPath, revision.change_id)) 583 + .then((result) => { 584 + void invalidateRepositoryQueries(repoPath); 585 + mutationSuccessWithUndo( 586 + repoPath, 587 + result.operation_id, 588 + `Squashed ${revision.change_id_short} into parent`, 589 + ); 590 + }) 591 + .catch((error) => { 592 + if (shouldOptimisticallyDelete) { 593 + collection.utils.writeUpsert([revision]); 594 + } 595 + toast.error(`Failed to squash revision: ${error}`, { 596 + duration: Number.POSITIVE_INFINITY, 597 + }); 598 + }); 599 + } 600 + 601 + export function rebaseRevision( 602 + collection: RevisionsCollection, 603 + repoPath: string, 604 + sourceRevision: Revision, 605 + destinationRevision: Revision, 606 + ) { 607 + if (sourceRevision.is_immutable) { 608 + toast.error("Cannot rebase immutable revision", { duration: Number.POSITIVE_INFINITY }); 609 + return; 610 + } 611 + if (sourceRevision.change_id === destinationRevision.change_id) { 612 + toast.error("Cannot rebase revision onto itself", { duration: Number.POSITIVE_INFINITY }); 613 + return; 614 + } 615 + 616 + const mutationId = `rebase-${Date.now()}-${Math.random()}`; 617 + const previousParentEdges = sourceRevision.parent_edges; 618 + 619 + collection.utils.writeUpsert([ 620 + { 621 + ...sourceRevision, 622 + parent_edges: [{ parent_id: destinationRevision.commit_id, edge_type: "direct" as const }], 623 + }, 624 + ]); 625 + 626 + trackMutation( 627 + mutationId, 628 + jjRebase(repoPath, sourceRevision.change_id, destinationRevision.change_id), 629 + ) 630 + .then((result) => { 631 + void invalidateRepositoryQueries(repoPath); 632 + mutationSuccessWithUndo( 633 + repoPath, 634 + result.operation_id, 635 + `Rebased ${sourceRevision.change_id_short} onto ${destinationRevision.change_id_short}`, 636 + ); 637 + }) 638 + .catch((error) => { 639 + collection.utils.writeUpsert([ 640 + { 641 + ...sourceRevision, 642 + parent_edges: previousParentEdges, 643 + }, 644 + ]); 645 + toast.error(`Failed to rebase revision: ${error}`, { 524 646 duration: Number.POSITIVE_INFINITY, 525 647 }); 526 648 });