github.com/treeverse/lakefs@v1.24.1-0.20240520134607-95648127bfb0/webui/src/pages/repositories/repository/compare.jsx (about) 1 import React, {useCallback, useEffect, useState, useRef} from "react"; 2 import { useOutletContext } from "react-router-dom"; 3 import {ActionGroup, ActionsBar, AlertError, Loading, RefreshButton} from "../../../lib/components/controls"; 4 import {useRefs} from "../../../lib/hooks/repo"; 5 import RefDropdown from "../../../lib/components/repository/refDropdown"; 6 import {ArrowLeftIcon, ArrowSwitchIcon, GitMergeIcon} from "@primer/octicons-react"; 7 import {useAPIWithPagination} from "../../../lib/hooks/api"; 8 import {refs} from "../../../lib/api"; 9 import Alert from "react-bootstrap/Alert"; 10 import {ChangesTreeContainer, defaultGetMoreChanges, MetadataFields} from "../../../lib/components/repository/changes"; 11 import {useRouter} from "../../../lib/hooks/router"; 12 import {URINavigator} from "../../../lib/components/repository/tree"; 13 import {appendMoreResults} from "./changes"; 14 import {RefTypeBranch, RefTypeCommit} from "../../../constants"; 15 import Button from "react-bootstrap/Button"; 16 import {FormControl, FormHelperText, InputLabel, MenuItem, Select} from "@mui/material"; 17 import Modal from "react-bootstrap/Modal"; 18 import {RepoError} from "./error"; 19 import OverlayTrigger from "react-bootstrap/OverlayTrigger"; 20 import Tooltip from "react-bootstrap/Tooltip"; 21 import Form from "react-bootstrap/Form"; 22 23 const CompareList = ({ repo, reference, compareReference, prefix, onSelectRef, onSelectCompare, onNavigate }) => { 24 const [internalRefresh, setInternalRefresh] = useState(true); 25 const [afterUpdated, setAfterUpdated] = useState(""); // state of pagination of the item's children 26 const [resultsState, setResultsState] = useState({prefix: prefix, results:[], pagination:{}}); // current retrieved children of the item 27 28 const router = useRouter(); 29 const handleSwitchRefs = useCallback( 30 (e) => { 31 e.preventDefault(); 32 router.push({pathname: `/repositories/:repoId/compare`, params: {repoId: repo.id}, 33 query: {ref: compareReference.id, compare: reference.id}}); 34 },[] 35 ); 36 37 const refresh = () => { 38 setResultsState({prefix: prefix, results:[], pagination:{}}) 39 setInternalRefresh(!internalRefresh) 40 } 41 42 const delimiter = "/" 43 44 const { error, loading, nextPage } = useAPIWithPagination(async () => { 45 if (!repo) return 46 if (compareReference.id === reference.id) 47 return {pagination: {has_more: false}, results: []}; // nothing to compare here. 48 49 return await appendMoreResults(resultsState, prefix, afterUpdated, setAfterUpdated, setResultsState, 50 () => refs.diff(repo.id, reference.id, compareReference.id, afterUpdated, prefix, delimiter)); 51 }, [repo.id, reference.id, internalRefresh, afterUpdated, delimiter, prefix]) 52 53 let results = resultsState.results 54 let content; 55 56 const relativeTitle = (from, to) => { 57 let fromId = from.id; 58 let toId = to.id; 59 if (from.type === RefTypeCommit) { 60 fromId = fromId.substr(0, 12); 61 } 62 if (to.type === RefTypeCommit) { 63 toId = toId.substr(0, 12); 64 } 65 66 return `${fromId}...${toId}` 67 } 68 const uriNavigator = <URINavigator 69 path={prefix} 70 reference={reference} 71 relativeTo={relativeTitle(reference, compareReference)} 72 repo={repo} 73 pathURLBuilder={(params, query) => { 74 const q = { 75 delimiter: "/", 76 prefix: query.path, 77 }; 78 if (compareReference) 79 q.compare = compareReference.id; 80 if (reference) 81 q.ref = reference.id; 82 return { 83 pathname: '/repositories/:repoId/compare', 84 params: {repoId: repo.id}, 85 query: q 86 }; 87 }}/> 88 89 const changesTreeMessage = <p>Showing changes between <strong>{reference.id}</strong> and <strong>{compareReference.id}</strong></p> 90 let leftCommittedRef = reference.id; 91 let rightCommittedRef = compareReference.id; 92 if (reference.type === RefTypeBranch) { 93 leftCommittedRef += "@"; 94 } 95 if (compareReference.type === RefTypeBranch) { 96 rightCommittedRef += "@"; 97 } 98 99 if (loading) { 100 content = <Loading/> 101 } 102 else if (error) content = <AlertError error={error}/> 103 else if (compareReference.id === reference.id) { 104 content = ( 105 <Alert variant="warning"> 106 <Alert.Heading>There isn’t anything to compare.</Alert.Heading> 107 You’ll need to use two different sources to get a valid comparison. 108 </Alert> 109 ) 110 } 111 else { 112 content = <ChangesTreeContainer results={results} delimiter={delimiter} 113 uriNavigator={uriNavigator} leftDiffRefID={leftCommittedRef} rightDiffRefID={rightCommittedRef} 114 repo={repo} reference={reference} internalReferesh={internalRefresh} prefix={prefix} 115 getMore={defaultGetMoreChanges(repo, reference.id, compareReference.id, delimiter)} 116 loading={loading} nextPage={nextPage} setAfterUpdated={setAfterUpdated} onNavigate={onNavigate} 117 changesTreeMessage={changesTreeMessage}/> 118 } 119 120 const emptyDiff = (!loading && !error && !!results && results.length === 0); 121 122 return ( 123 <> 124 <ActionsBar> 125 <ActionGroup orientation="left"> 126 <RefDropdown 127 prefix={'Base '} 128 repo={repo} 129 selected={(reference) ? reference : null} 130 withCommits={true} 131 withWorkspace={false} 132 selectRef={onSelectRef}/> 133 134 <ArrowLeftIcon className="me-2 mt-2" size="small" verticalAlign="middle"/> 135 136 <RefDropdown 137 prefix={'Compared to '} 138 emptyText={'Compare with...'} 139 repo={repo} 140 selected={(compareReference) ? compareReference : null} 141 withCommits={true} 142 withWorkspace={false} 143 selectRef={onSelectCompare}/> 144 145 <OverlayTrigger placement="bottom" overlay={ 146 <Tooltip>Switch directions</Tooltip> 147 }> 148 <span> 149 <Button variant={"link"} 150 onClick={handleSwitchRefs}> 151 <ArrowSwitchIcon className="me-2 mt-2" size="small" verticalAlign="middle"/> 152 </Button> 153 </span> 154 </OverlayTrigger>   155 </ActionGroup> 156 157 <ActionGroup orientation="right"> 158 159 <RefreshButton onClick={refresh}/> 160 161 {(compareReference.type === RefTypeBranch && reference.type === RefTypeBranch) && 162 <MergeButton 163 repo={repo} 164 disabled={((compareReference.id === reference.id) || emptyDiff || repo?.read_only)} 165 source={compareReference.id} 166 dest={reference.id} 167 onDone={refresh} 168 /> 169 } 170 </ActionGroup> 171 </ActionsBar> 172 {content} 173 </> 174 ); 175 }; 176 177 const MergeButton = ({repo, onDone, source, dest, disabled = false}) => { 178 const textRef = useRef(null); 179 const [metadataFields, setMetadataFields] = useState([]) 180 const initialMerge = { 181 merging: false, 182 show: false, 183 err: null, 184 strategy: "none", 185 } 186 const [mergeState, setMergeState] = useState(initialMerge); 187 188 const onClickMerge = useCallback(() => { 189 setMergeState({merging: mergeState.merging, err: mergeState.err, show: true, strategy: mergeState.strategy})} 190 ); 191 192 const onStrategyChange = (event) => { 193 setMergeState({merging: mergeState.merging, err: mergeState.err, show: mergeState.show, strategy: event.target.value}); 194 } 195 const hide = () => { 196 if (mergeState.merging) return; 197 setMergeState(initialMerge); 198 setMetadataFields([]) 199 } 200 201 const onSubmit = async () => { 202 const message = textRef.current.value; 203 const metadata = {}; 204 metadataFields.forEach(pair => metadata[pair.key] = pair.value) 205 206 let strategy = mergeState.strategy; 207 if (strategy === "none") { 208 strategy = ""; 209 } 210 setMergeState({merging: true, show: mergeState.show, err: mergeState.err, strategy: mergeState.strategy}) 211 try { 212 await refs.merge(repo.id, source, dest, strategy, message, metadata); 213 setMergeState({merging: mergeState.merging, show: mergeState.show, err: null, strategy: mergeState.strategy}) 214 onDone(); 215 hide(); 216 } catch (err) { 217 setMergeState({merging: mergeState.merging, show: mergeState.show, err: err, strategy: mergeState.strategy}) 218 } 219 } 220 221 return ( 222 <> 223 <Modal show={mergeState.show} onHide={hide} size="lg"> 224 <Modal.Header closeButton> 225 <Modal.Title>Merge branch {source} into {dest}</Modal.Title> 226 </Modal.Header> 227 <Modal.Body> 228 <Form className="mb-2"> 229 <Form.Group controlId="message" className="mb-3"> 230 <Form.Control type="text" placeholder="Commit Message (Optional)" ref={textRef}/> 231 </Form.Group> 232 233 <MetadataFields metadataFields={metadataFields} setMetadataFields={setMetadataFields}/> 234 </Form> 235 <FormControl sx={{ m: 1, minWidth: 120 }}> 236 <InputLabel id="demo-select-small">Strategy</InputLabel> 237 <Select 238 labelId="demo-select-small" 239 id="demo-simple-select-helper" 240 value={mergeState.strategy} 241 label="Strategy" 242 onChange={onStrategyChange} 243 > 244 <MenuItem value={"none"}>Default</MenuItem> 245 <MenuItem value={"source-wins"}>source-wins</MenuItem> 246 <MenuItem value={"dest-wins"}>dest-wins</MenuItem> 247 </Select> 248 </FormControl> 249 <FormHelperText>In case of a merge conflict, this option will force the merge process 250 to automatically favor changes from <b>{dest}</b> (”dest-wins”) or 251 from <b>{source}</b> (”source-wins”). In case no selection is made, 252 the merge process will fail in case of a conflict.</FormHelperText> 253 {(mergeState.err) ? (<AlertError error={mergeState.err}/>) : (<></>)} 254 </Modal.Body> 255 <Modal.Footer> 256 <Button variant="secondary" disabled={mergeState.merging} onClick={hide}> 257 Cancel 258 </Button> 259 <Button variant="success" disabled={mergeState.merging} onClick={onSubmit}> 260 {(mergeState.merging) ? 'Merging...' : 'Merge'} 261 </Button> 262 </Modal.Footer> 263 </Modal> 264 <Button variant="success" disabled={disabled} onClick={() => onClickMerge()}> 265 <GitMergeIcon/> {"Merge"} 266 </Button> 267 </> 268 ); 269 } 270 271 const CompareContainer = () => { 272 const router = useRouter(); 273 const { loading, error, repo, reference, compare } = useRefs(); 274 275 const { prefix } = router.query; 276 277 if (loading) return <Loading/>; 278 if (error) return <RepoError error={error}/>; 279 280 const route = query => router.push({pathname: `/repositories/:repoId/compare`, params: {repoId: repo.id}, query: { 281 ...query, 282 }}); 283 284 return ( 285 <CompareList 286 repo={repo} 287 prefix={(prefix) ? prefix : ""} 288 reference={reference} 289 onSelectRef={reference => route(compare ? {ref: reference.id, compare: compare.id} : {ref: reference.id})} 290 compareReference={compare} 291 onSelectCompare={compare => route(reference ? {ref: reference.id, compare: compare.id} : {compare: compare.id})} 292 onNavigate={entry => { 293 return { 294 pathname: `/repositories/:repoId/compare`, 295 params: {repoId: repo.id}, 296 query: { 297 ref: reference.id, 298 compare: compare.id, 299 prefix: entry.path, 300 } 301 } 302 }} 303 /> 304 ); 305 }; 306 307 const RepositoryComparePage = () => { 308 const [setActivePage] = useOutletContext(); 309 useEffect(() => setActivePage("compare"), [setActivePage]); 310 return <CompareContainer />; 311 }; 312 313 export default RepositoryComparePage;