a very good jj gui
0
fork

Configure Feed

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

feat(desktop): add bookmark drag-and-drop between revisions

+145 -21
+3 -2
apps/desktop/src-tauri/tauri.conf.json
··· 5 5 "identifier": "com.tatami.desktop", 6 6 "build": { 7 7 "beforeDevCommand": "bun run dev", 8 - "devUrl": "http://localhost:5173", 8 + "devUrl": "http://localhost:4545", 9 9 "beforeBuildCommand": "bun run build", 10 10 "frontendDist": "../dist" 11 11 }, ··· 21 21 "height": 800, 22 22 "resizable": true, 23 23 "fullscreen": false, 24 - "zoomHotkeysEnabled": true 24 + "zoomHotkeysEnabled": true, 25 + "dragDropEnabled": false 25 26 } 26 27 ], 27 28 "security": {
+7 -4
apps/desktop/src/atoms.ts
··· 8 8 // View mode: 1 = overview (only revisions), 2 = split (revisions + diff panel) 9 9 export type ViewMode = 1 | 2; 10 10 export const viewModeAtom = Atom.make<ViewMode>(1); 11 - // Panel focus tracking for split view (viewMode=2) 12 - // "revisions" = left panel (revision graph), "diff" = right panel (diff viewer) 13 - export type FocusPanel = "revisions" | "diff"; 14 - export const focusPanelAtom = Atom.make<FocusPanel>("revisions"); 15 11 // Tracks which revision stacks are expanded (by stack ID) 16 12 export const expandedStacksAtom = Atom.make(new Set<string>()); 17 13 // Tracks which stack is currently hovered (for coordinated edge highlighting) 18 14 export const hoveredStackIdAtom = Atom.make<string | null>(null); 15 + 16 + // Bookmark drag state - tracks which bookmark is being dragged and from which revision 17 + export type DraggingBookmark = { 18 + bookmark: string; 19 + fromChangeId: string; 20 + } | null; 21 + export const draggingBookmarkAtom = Atom.make<DraggingBookmark>(null); 19 22 20 23 // DEBUG STATE 21 24 /** Debug overlay visibility (Ctrl+Shift+D) */
+47
apps/desktop/src/components/revision-graph/BookmarkTag.tsx
··· 1 + import { useAtom } from "@effect-atom/atom-react"; 2 + import { draggingBookmarkAtom } from "@/atoms"; 3 + 4 + interface BookmarkTagProps { 5 + bookmark: string; 6 + changeId: string; 7 + } 8 + 9 + /** 10 + * BookmarkTag - A draggable bookmark label 11 + * Can be dragged to another revision to move the bookmark 12 + */ 13 + export function BookmarkTag({ bookmark, changeId }: BookmarkTagProps) { 14 + const [draggingBookmark, setDraggingBookmark] = useAtom(draggingBookmarkAtom); 15 + 16 + // Derive isDragging from global state instead of local useState 17 + const isDragging = 18 + draggingBookmark?.bookmark === bookmark && 19 + draggingBookmark?.fromChangeId === changeId; 20 + 21 + return ( 22 + // biome-ignore lint/a11y/noStaticElementInteractions: Draggable element needs drag handlers 23 + <span 24 + draggable 25 + onDragStart={(e) => { 26 + const dragData = JSON.stringify({ bookmark, changeId }); 27 + e.dataTransfer.setData("text/plain", dragData); 28 + e.dataTransfer.setData("application/x-bookmark", dragData); 29 + e.dataTransfer.effectAllowed = "move"; 30 + 31 + // Delay state update to avoid re-render cancelling drag 32 + requestAnimationFrame(() => { 33 + setDraggingBookmark({ bookmark, fromChangeId: changeId }); 34 + }); 35 + }} 36 + onDragEnd={() => { 37 + setDraggingBookmark(null); 38 + }} 39 + className={`text-xs text-primary font-medium whitespace-nowrap cursor-grab active:cursor-grabbing px-1.5 py-0.5 rounded-sm hover:bg-primary/10 transition-opacity ${ 40 + isDragging ? "opacity-50" : "" 41 + }`} 42 + title={`Drag to move "${bookmark}" to another revision`} 43 + > 44 + {bookmark} 45 + </span> 46 + ); 47 + }
+86 -13
apps/desktop/src/components/revision-graph/RevisionRow.tsx
··· 1 1 import { useAtom } from "@effect-atom/atom-react"; 2 2 import { useLiveQuery } from "@tanstack/react-db"; 3 3 import { useNavigate, useSearch } from "@tanstack/react-router"; 4 + import { useRef, useState } from "react"; 4 5 import { Route } from "@/routes/project.$projectId"; 5 6 import { draggingBookmarkAtom, viewModeAtom } from "@/atoms"; 6 7 import { ChangedFilesList } from "@/components/ChangedFilesList"; 7 8 import { emptyChangesCollection, getRevisionChangesCollection } from "@/db"; 8 9 import type { Revision } from "@/tauri-commands"; 10 + import { BookmarkTag } from "./BookmarkTag"; 9 11 import { ROW_HEIGHT, LANE_PADDING, LANE_WIDTH, NODE_RADIUS, laneToX, laneColor } from "./constants"; 10 12 import { GraphNode } from "./GraphNode"; 11 13 ··· 25 27 jumpModeActive: boolean; 26 28 jumpQuery: string; 27 29 jumpHint: string | null; 30 + onMoveBookmark?: (bookmark: string, fromChangeId: string, toChangeId: string) => void; 28 31 } 29 32 30 33 /** ··· 47 50 jumpModeActive, 48 51 jumpQuery, 49 52 jumpHint, 53 + onMoveBookmark, 50 54 }: RevisionRowProps) { 51 55 const firstLine = revision.description.split("\n")[0] || "(no description)"; 52 56 const fullDescription = revision.description || "(no description)"; 57 + const [isDragOver, setIsDragOver] = useState(false); 58 + const [showDropPlaceholder, setShowDropPlaceholder] = useState(false); 59 + const dragOverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); 60 + const dragEnterCountRef = useRef(0); 61 + const [draggingBookmark] = useAtom(draggingBookmarkAtom); 53 62 54 63 // Calculate the node position area - leaves space for graph edges on the left 55 64 const nodeAreaWidth = LANE_PADDING + (maxLaneOnRow + 1) * LANE_WIDTH; ··· 112 121 onSelect(revision.change_id, { shift: e.shiftKey, meta: e.metaKey || e.ctrlKey }); 113 122 } 114 123 }} 124 + onDragEnter={(e) => { 125 + if (e.dataTransfer.types.includes("application/x-bookmark")) { 126 + e.preventDefault(); 127 + dragEnterCountRef.current++; 128 + if (!isDragOver) { 129 + setIsDragOver(true); 130 + // Start timer to show placeholder after delay 131 + if (!dragOverTimerRef.current) { 132 + dragOverTimerRef.current = setTimeout(() => { 133 + setShowDropPlaceholder(true); 134 + }, 150); 135 + } 136 + } 137 + } 138 + }} 139 + onDragOver={(e) => { 140 + // Check if this is a bookmark drag 141 + if (e.dataTransfer.types.includes("application/x-bookmark")) { 142 + e.preventDefault(); 143 + e.dataTransfer.dropEffect = "move"; 144 + } 145 + }} 146 + onDragLeave={(e) => { 147 + if (e.dataTransfer.types.includes("application/x-bookmark")) { 148 + dragEnterCountRef.current--; 149 + if (dragEnterCountRef.current === 0) { 150 + setIsDragOver(false); 151 + setShowDropPlaceholder(false); 152 + if (dragOverTimerRef.current) { 153 + clearTimeout(dragOverTimerRef.current); 154 + dragOverTimerRef.current = null; 155 + } 156 + } 157 + } 158 + }} 159 + onDrop={(e) => { 160 + e.preventDefault(); 161 + dragEnterCountRef.current = 0; 162 + setIsDragOver(false); 163 + setShowDropPlaceholder(false); 164 + if (dragOverTimerRef.current) { 165 + clearTimeout(dragOverTimerRef.current); 166 + dragOverTimerRef.current = null; 167 + } 168 + const data = e.dataTransfer.getData("application/x-bookmark"); 169 + if (data && onMoveBookmark) { 170 + try { 171 + const { bookmark, changeId: fromChangeId } = JSON.parse(data); 172 + if (fromChangeId !== revision.change_id) { 173 + onMoveBookmark(bookmark, fromChangeId, revision.change_id); 174 + } 175 + } catch { 176 + // Invalid drag data, ignore 177 + } 178 + } 179 + }} 115 180 > 116 181 {/* Graph node - absolutely positioned to align with edge layer */} 117 182 <div ··· 127 192 <div className="shrink-0" style={{ width: nodeAreaWidth + 8 }} /> 128 193 {/* Content area with visual styling - full row height */} 129 194 <div 130 - className={`relative flex-1 mr-2 min-w-0 overflow-hidden text-card-foreground flex flex-col justify-center py-1 border-b ${ 131 - isChecked || isFocused ? "bg-accent/40 rounded-md border-transparent" : "border-border/30" 195 + className={`relative flex-1 mr-2 min-w-0 overflow-hidden text-card-foreground flex flex-col justify-center py-1 border-b transition-colors ${ 196 + isDragOver 197 + ? "bg-primary/20 border-primary/50 rounded-md" 198 + : isChecked || isFocused 199 + ? "bg-accent/40 rounded-md border-transparent" 200 + : "border-border/30" 132 201 }`} 133 202 > 134 203 <div className={`px-3 py-1.5 min-w-0 ${isPendingAbandon ? "blur-sm" : ""}`}> 135 - <div className="flex items-center gap-2 flex-nowrap min-w-0"> 204 + {/* Grid: [change_id] [bookmarks] [author/date] - fixed height row */} 205 + <div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 min-w-0 h-5"> 136 206 <code 137 - className={`text-xs font-mono rounded px-0.5 shrink-0 ${ 207 + className={`text-xs font-mono rounded px-0.5 ${ 138 208 isFlashing ? "bg-primary/40 animate-pulse" : "" 139 209 } text-muted-foreground`} 140 210 > ··· 157 227 revision.change_id_short 158 228 )} 159 229 </code> 160 - {revision.bookmarks.length > 0 && ( 161 - <span 162 - className="text-xs text-primary font-medium truncate min-w-0 whitespace-nowrap" 163 - title={revision.bookmarks.join(", ")} 164 - > 165 - {revision.bookmarks.join(", ")} 166 - </span> 167 - )} 168 - <span className="text-xs text-muted-foreground truncate min-w-0 shrink-0"> 230 + {/* Bookmarks - middle column */} 231 + <div className="flex items-center gap-1 min-w-0 overflow-hidden"> 232 + {revision.bookmarks.map((bookmark) => ( 233 + <BookmarkTag key={bookmark} bookmark={bookmark} changeId={revision.change_id} /> 234 + ))} 235 + {showDropPlaceholder && draggingBookmark && draggingBookmark.fromChangeId !== revision.change_id && ( 236 + <span className="text-xs text-primary/60 font-medium whitespace-nowrap px-1 rounded-sm border border-dashed border-primary/40 bg-primary/5 pointer-events-none"> 237 + {draggingBookmark.bookmark} 238 + </span> 239 + )} 240 + </div> 241 + <span className="text-xs text-muted-foreground truncate whitespace-nowrap"> 169 242 {revision.author.split("@")[0]} · {revision.timestamp} 170 243 </span> 171 244 </div>
+2 -2
apps/desktop/vite.config.ts
··· 27 27 }, 28 28 clearScreen: false, 29 29 server: { 30 - port: 5173, 30 + port: 4545, 31 31 strictPort: true, 32 32 host: host || false, 33 33 hmr: host 34 34 ? { 35 35 protocol: "ws", 36 36 host, 37 - port: 5173, 37 + port: 4545, 38 38 } 39 39 : undefined, 40 40 watch: {