├── .compilerc ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── css │ └── index.css ├── html │ └── index.html ├── less │ └── index.less └── themes │ ├── dark.json │ └── light.json ├── package.json ├── src ├── main │ ├── main.ts │ └── menu.ts ├── renderer │ ├── components │ │ ├── app.tsx │ │ ├── binary-patch-viewer.tsx │ │ ├── clone-repo-dialog.tsx │ │ ├── commit-item.tsx │ │ ├── commit-list.tsx │ │ ├── commit-viewer.tsx │ │ ├── conflict-viewer.tsx │ │ ├── create-branch-dialog.tsx │ │ ├── graph-canvas.tsx │ │ ├── graph-viewer.tsx │ │ ├── image-patch-viewer.tsx │ │ ├── index-item.tsx │ │ ├── index-viewer.tsx │ │ ├── init-repo-dialog.tsx │ │ ├── input-dialog.tsx │ │ ├── loading-screen.tsx │ │ ├── make-modal.tsx │ │ ├── notification-item.tsx │ │ ├── notification-queue.tsx │ │ ├── patch-item.tsx │ │ ├── patch-list.tsx │ │ ├── patch-viewer.tsx │ │ ├── preferences-dialog.tsx │ │ ├── reference-badge.tsx │ │ ├── reference-explorer.tsx │ │ ├── reference-item.tsx │ │ ├── reference-list.tsx │ │ ├── repo-dashboard.tsx │ │ ├── spinner-button.tsx │ │ ├── splitter.tsx │ │ ├── stash-item.tsx │ │ ├── stash-list.tsx │ │ ├── text-patch-viewer.tsx │ │ ├── toolbar.tsx │ │ └── welcome-dashboard.tsx │ ├── helpers │ │ ├── commit-context-menu.ts │ │ ├── commit-graph.ts │ │ ├── conflict-parser.ts │ │ ├── make-cancellable.ts │ │ ├── open-create-branch-window.ts │ │ ├── open-in-editor.ts │ │ ├── patch-comparison.ts │ │ ├── reference-context-menu.ts │ │ ├── repo-wrapper.ts │ │ └── stash-context-menu.ts │ └── index.tsx ├── resize_observer.d.ts └── shared │ ├── settings.ts │ └── theme-manager.ts ├── tsconfig.json └── tslint.json /.compilerc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "text/typescript": { 5 | "removeComments": false, 6 | "preserveConstEnums": true, 7 | "declaration": true, 8 | "noImplicitAny": true, 9 | "noImplicitReturns": true, 10 | "suppressImplicitAnyIndexErrors": true, 11 | "strictNullChecks": true, 12 | "noUnusedLocals": true, 13 | "noImplicitThis": true, 14 | "noUnusedParameters": true, 15 | "inlineSourceMap": true, 16 | "inlineSources": true, 17 | "importHelpers": true, 18 | "noEmitHelpers": true, 19 | "experimentalDecorators": true, 20 | "target": "es2015", 21 | "module": "commonjs", 22 | "jsx": "react" 23 | } 24 | }, 25 | "production": { 26 | "text/typescript": { 27 | "removeComments": false, 28 | "preserveConstEnums": true, 29 | "declaration": true, 30 | "noImplicitAny": true, 31 | "noImplicitReturns": true, 32 | "suppressImplicitAnyIndexErrors": true, 33 | "strictNullChecks": true, 34 | "noUnusedLocals": true, 35 | "noImplicitThis": true, 36 | "noUnusedParameters": true, 37 | "sourceMap": false, 38 | "importHelpers": true, 39 | "noEmitHelpers": true, 40 | "experimentalDecorators": true, 41 | "target": "es2015", 42 | "jsx": "react" 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # npm package-lock 64 | package-lock\.json 65 | 66 | # Electron-forge 67 | out/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gitamine 2 | 3 | gitamine is a modern graphical user interface for Git. It is open-source, multiplatform and easy to use. 4 | 5 | The main features of gitamine are: 6 | 7 | * Beautiful and readable commit graph 8 | * Syntax highlighting 9 | * Themeable (light and dark modes available) 10 | * Automatic refresh 11 | * No account required and no telemetry 12 | 13 | gitamine is powered by awesome technologies including: 14 | 15 | * [TypeScript](https://www.typescriptlang.org/) 16 | * [Node.js](https://nodejs.org/) 17 | * [Electron](https://electronjs.org/) 18 | * [React](https://reactjs.org/) 19 | * [Monaco](https://github.com/Microsoft/monaco-editor) 20 | * [libgit2](https://libgit2.org/) and [NodeGit](https://www.nodegit.org/) 21 | * [chokidar](https://github.com/paulmillr/chokidar) 22 | 23 | If you want to know more about the commit graph drawing algorithm, you can read this [article](https://pvigier.github.io/2019/05/06/commit-graph-drawing-algorithms.html). 24 | 25 | ## Downloads 26 | 27 | You can download the latest version of gitamine [here](https://github.com/pvigier/gitamine/releases/latest). 28 | 29 | See this [page](https://github.com/pvigier/gitamine/wiki/How-to-install) for detailed instruction on how to install gitamine. 30 | 31 | ## Contributing 32 | 33 | Here are some ways, you can contribute to gitamine: 34 | 35 | * Found a bug, please create an issue, I will try to fix it as quickly as possible. 36 | * Want to fix an issue or add a feature yourself, do a pull request. 37 | * If you are a designer, gitamine needs an application icon and some icons for the user interface, you are more than welcome. 38 | * If you want to become an active maintainer, please contact me. 39 | 40 | ## Screenshots 41 | 42 | ![Screenshot of graph viewer](https://user-images.githubusercontent.com/934316/53720250-8b845980-3e60-11e9-88fd-24a957062dc4.png) 43 | ![Screenshot of patch viewer](https://user-images.githubusercontent.com/934316/53720255-8c1cf000-3e60-11e9-9caa-86333eb22575.png) 44 | 45 | ## License 46 | 47 | gitamine is distributed under the [GNU GENERAL PUBLIC LICENSE version 3](https://www.gnu.org/licenses/gpl-3.0.en.html). 48 | 49 | You are free to use gitamine for non-commercial and commercial projects without any restriction. 50 | -------------------------------------------------------------------------------- /assets/css/index.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | :root { 3 | --canvas-width: 32px; 4 | /* Theme's variables (default value should correspond to the default theme) */ 5 | --background-color: #ffffff; 6 | --toolbar-color: #f5f5f5; 7 | --list-background-color: #ebebeb; 8 | --hover-color: #e1e1e1; 9 | --selected-color: #d7d7d7; 10 | --splitter-color: #b9b9b9; 11 | --border-color: #e6e6e6; 12 | --font-color: #000000; 13 | --font-famliy: Arial, Helvetica, sans-serif; 14 | --font-size: 14px; 15 | --link-color: #0000ee; 16 | --button-font-color: #ffffff; 17 | --green-button-border-color: #006400; 18 | --green-button-background-color: #008000; 19 | --red-button-border-color: #8b0000; 20 | --red-button-background-color: #a70000; 21 | --yellow-button-border-color: #b8860b; 22 | --yellow-button-background-color: #daa520; 23 | --notification-font-color: #ffffff; 24 | --info-notification-border-color: #006400; 25 | --info-notification-background-color: #008000; 26 | --error-notification-border-color: #8b0000; 27 | --error-notification-background-color: #a70000; 28 | --reference-section-background-color: #c8c8c8; 29 | --spinner-color: #1e90ff; 30 | --spinner-border: #f3f3f3; 31 | } 32 | /* Body */ 33 | html, 34 | body { 35 | margin: 0; 36 | overflow: hidden; 37 | background-color: var(--background-color); 38 | color: var(--font-color); 39 | font-family: var(--font-famliy); 40 | font-size: var(--font-size); 41 | user-select: none; 42 | } 43 | div#container { 44 | height: 100vh; 45 | width: 100vw; 46 | } 47 | div#app { 48 | height: 100%; 49 | } 50 | /* General */ 51 | h1 { 52 | font-size: 20px; 53 | } 54 | h2 { 55 | font-size: 16px; 56 | } 57 | h3 { 58 | font-size: 15px; 59 | } 60 | button { 61 | margin: 4px 8px; 62 | } 63 | a { 64 | color: var(--link-color); 65 | } 66 | /* Repo dashboard */ 67 | div.repo-dashboard { 68 | height: 100%; 69 | display: flex; 70 | flex-direction: column; 71 | } 72 | /* Repo toolbar */ 73 | div.repo-toolbar { 74 | height: 50px; 75 | display: flex; 76 | text-align: center; 77 | background-color: var(--toolbar-color); 78 | box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.2); 79 | z-index: 3; 80 | } 81 | div.repo-toolbar div.repo-title { 82 | display: table; 83 | height: 100%; 84 | margin-right: 25px; 85 | background-color: var(--list-background-color); 86 | padding: 0px 16px; 87 | } 88 | div.repo-toolbar div.repo-title h1 { 89 | display: table-cell; 90 | vertical-align: middle; 91 | margin: 0px; 92 | font-size: 16px; 93 | } 94 | div.repo-toolbar div.separator { 95 | border-width: 25px 0px 25px 20px; 96 | border-style: solid; 97 | border-color: transparent transparent transparent var(--list-background-color); 98 | margin-left: -25px; 99 | } 100 | div.repo-toolbar div.toolbar-buttons { 101 | flex: 1; 102 | display: flex; 103 | justify-content: center; 104 | padding: 4px 0px; 105 | } 106 | div.repo-toolbar div.toolbar-buttons button { 107 | margin: 0px 2px; 108 | padding: 0px; 109 | width: 48px; 110 | } 111 | div.repo-toolbar div.toolbar-buttons button div.spinner { 112 | border: 3px solid var(--spinner-border); 113 | border-top: 3px solid var(--spinner-color); 114 | border-radius: 50%; 115 | width: 15px; 116 | height: 15px; 117 | animation: spin 2s linear infinite; 118 | margin: auto; 119 | } 120 | @keyframes spin { 121 | 0% { 122 | transform: rotate(0deg); 123 | } 124 | 100% { 125 | transform: rotate(360deg); 126 | } 127 | } 128 | /* Repo content */ 129 | div.repo-content { 130 | flex: 1; 131 | display: flex; 132 | } 133 | /* Loading screen */ 134 | div.loading-screen { 135 | flex: 1; 136 | min-width: 300px; 137 | display: flex; 138 | flex-direction: column; 139 | justify-content: center; 140 | align-items: center; 141 | } 142 | div.loading-screen div.spinner { 143 | border: 16px solid var(--spinner-border); 144 | border-top: 16px solid var(--spinner-color); 145 | border-radius: 50%; 146 | width: 80px; 147 | height: 80px; 148 | animation: spin 2s linear infinite; 149 | } 150 | @keyframes spin { 151 | 0% { 152 | transform: rotate(0deg); 153 | } 154 | 100% { 155 | transform: rotate(360deg); 156 | } 157 | } 158 | /* Reference explorer */ 159 | div.reference-explorer { 160 | width: 200px; 161 | padding-top: 8px; 162 | } 163 | div.reference-explorer > div { 164 | overflow-y: auto; 165 | height: 100%; 166 | } 167 | div.reference-explorer li { 168 | list-style: none; 169 | text-overflow: ellipsis; 170 | overflow: hidden; 171 | white-space: nowrap; 172 | background: var(--list-background-color); 173 | padding: 2px 4px 2px 16px; 174 | font-size: 13px; 175 | } 176 | div.reference-explorer li:hover { 177 | background: var(--hover-color); 178 | } 179 | div.reference-explorer li.selected { 180 | background: var(--selected-color); 181 | } 182 | div.reference-explorer ul { 183 | margin: 0px; 184 | padding: 0px; 185 | } 186 | div.reference-explorer ul.hidden { 187 | display: none; 188 | } 189 | div.reference-explorer h3 { 190 | margin: 0px; 191 | padding: 4px; 192 | background: var(--reference-section-background-color); 193 | display: flex; 194 | justify-content: space-between; 195 | white-space: nowrap; 196 | } 197 | div.reference-explorer h3 span { 198 | padding: 0px 4px; 199 | } 200 | /* Graph viewer */ 201 | div.graph-viewer { 202 | flex: 1; 203 | min-width: 300px; 204 | display: flex; 205 | } 206 | div.graph-viewer div.graph-container { 207 | position: relative; 208 | flex: 1; 209 | min-width: calc(var(--canvas-width) + 100px); 210 | } 211 | /* Commit graph */ 212 | div.commit-graph { 213 | min-width: 32px; 214 | width: var(--canvas-width); 215 | height: 100%; 216 | overflow-x: scroll; 217 | overflow-y: hidden; 218 | } 219 | div.commit-graph canvas { 220 | position: sticky; 221 | margin-left: auto; 222 | padding: 0px 10px; 223 | display: block; 224 | z-index: 2; 225 | pointer-events: none; 226 | } 227 | div.commit-graph .splitter { 228 | position: absolute; 229 | top: 0px; 230 | left: calc(var(--canvas-width)); 231 | height: 100%; 232 | z-index: 3; 233 | border: 1px dashed var(--splitter-color); 234 | } 235 | /* Commit list */ 236 | div.commit-list { 237 | width: 100%; 238 | height: calc(100% - 15px); 239 | position: absolute; 240 | top: 0px; 241 | z-index: 1; 242 | overflow-y: scroll; 243 | text-align: left; 244 | } 245 | div.commit-list ul { 246 | margin: 0px; 247 | padding-left: 0px; 248 | list-style: none; 249 | line-height: 22px; 250 | background-color: var(--list-background-color); 251 | min-height: 100%; 252 | } 253 | div.commit-list ul li { 254 | white-space: nowrap; 255 | overflow: hidden; 256 | text-overflow: ellipsis; 257 | height: 22px; 258 | padding-left: calc(var(--canvas-width) + 16px); 259 | padding-top: 3px; 260 | padding-bottom: 3px; 261 | font-size: 13px; 262 | } 263 | div.commit-list ul li:hover { 264 | background: var(--hover-color); 265 | } 266 | div.commit-list ul li.selected-commit { 267 | background: var(--selected-color); 268 | } 269 | /* Reference badge */ 270 | div.commit-list li span.reference { 271 | border-radius: 5px; 272 | padding: 2px; 273 | margin-right: 2px; 274 | background-color: var(--branch-color); 275 | color: white; 276 | user-select: none; 277 | filter: brightness(0.8); 278 | } 279 | div.commit-list li span.reference.tag { 280 | border-radius: 0px; 281 | } 282 | div.commit-list li span.reference:hover { 283 | filter: brightness(1); 284 | } 285 | div.commit-list li span.reference.selected { 286 | filter: brightness(1); 287 | } 288 | /* Patch viewer */ 289 | div.patch-viewer { 290 | flex: 1; 291 | min-width: 300px; 292 | z-index: 1; 293 | background: var(--background-color); 294 | display: flex; 295 | flex-direction: column; 296 | } 297 | div.patch-viewer div.patch-header { 298 | height: 30px; 299 | display: flex; 300 | justify-content: space-between; 301 | align-items: center; 302 | flex-shrink: 0; 303 | } 304 | div.patch-viewer div.patch-header h2 { 305 | margin: 0px; 306 | padding: 6px 8px; 307 | user-select: text; 308 | } 309 | div.patch-viewer div.patch-toolbar { 310 | height: 30px; 311 | display: flex; 312 | justify-content: space-between; 313 | align-items: center; 314 | } 315 | div.patch-viewer div.patch-toolbar button { 316 | margin: 4px; 317 | } 318 | div.patch-viewer div.patch-editor { 319 | height: calc(100% - 30px - 30px); 320 | align-items: center; 321 | } 322 | div.patch-viewer div.patch-editor.hidden { 323 | visibility: hidden; 324 | } 325 | div.patch-viewer div.image-viewer { 326 | overflow: auto; 327 | padding: 0px 8px; 328 | } 329 | div.patch-viewer div.binary-viewer { 330 | padding: 0px 8px; 331 | } 332 | /* Hunk widget */ 333 | div.hunk-widget { 334 | width: 100%; 335 | } 336 | div.hunk-widget > div { 337 | display: flex; 338 | position: absolute; 339 | bottom: 0px; 340 | width: 100%; 341 | border-bottom: 1px solid; 342 | } 343 | div.hunk-widget > div > div { 344 | position: absolute; 345 | right: 18px; 346 | bottom: 2px; 347 | } 348 | div.hunk-widget p { 349 | margin: 0px; 350 | } 351 | div.hunk-widget button { 352 | margin: 0px 2px; 353 | } 354 | /* Conflict widget */ 355 | div.conflict-widget { 356 | width: 100%; 357 | } 358 | div.conflict-widget > div { 359 | display: flex; 360 | position: absolute; 361 | bottom: 0px; 362 | width: 100%; 363 | border-bottom: 1px solid; 364 | } 365 | div.conflict-widget > div > div { 366 | position: absolute; 367 | right: 18px; 368 | bottom: 2px; 369 | } 370 | div.conflict-widget p { 371 | margin: 0px 8px; 372 | } 373 | div.conflict-widget button { 374 | margin: 0px 2px; 375 | } 376 | /* Margin buttons */ 377 | .stage-line-button { 378 | width: 19px; 379 | height: 19px; 380 | text-align: center; 381 | color: white; 382 | font-family: 'Courier New', Courier, monospace; 383 | font-weight: bold; 384 | border-radius: 5px; 385 | } 386 | .stage-line-button * { 387 | font-family: var(--font-famliy); 388 | font-weight: normal; 389 | } 390 | .stage-line-button:hover { 391 | background: green; 392 | } 393 | .stage-line-button:hover::before { 394 | content: '+'; 395 | } 396 | .unstage-line-button { 397 | width: 19px; 398 | height: 19px; 399 | text-align: center; 400 | color: white; 401 | font-family: 'Courier New', Courier, monospace; 402 | font-weight: bold; 403 | border-radius: 5px; 404 | } 405 | .unstage-line-button * { 406 | font-family: var(--font-famliy); 407 | font-weight: normal; 408 | } 409 | .unstage-line-button:hover { 410 | background: red; 411 | } 412 | .unstage-line-button:hover::before { 413 | content: '-'; 414 | } 415 | /* Inline decorations */ 416 | .conflict-start { 417 | background: lightgreen; 418 | } 419 | .conflict-current { 420 | background: #bcf5bc; 421 | } 422 | .conflict-end { 423 | background: lightblue; 424 | } 425 | .conflict-incoming { 426 | background: #d4ebf2; 427 | } 428 | /* Splitter */ 429 | div.splitter { 430 | border: 1.5px solid var(--splitter-color); 431 | cursor: col-resize; 432 | z-index: 1; 433 | } 434 | /* Commit viewer */ 435 | .commit-viewer { 436 | width: 300px; 437 | min-width: 300px; 438 | display: flex; 439 | flex-direction: column; 440 | background: var(--background-color); 441 | z-index: 4; 442 | user-select: text; 443 | } 444 | .commit-viewer h3 { 445 | margin: 4px 0; 446 | text-align: center; 447 | font-size: var(--font-size); 448 | font-weight: normal; 449 | } 450 | .commit-viewer div.commit-message { 451 | max-height: 100px; 452 | overflow-y: auto; 453 | background: var(--list-background-color); 454 | border: 1px solid var(--border-color); 455 | margin: 8px; 456 | padding: 8px; 457 | } 458 | .commit-viewer div.commit-message h2 { 459 | margin: 0px; 460 | } 461 | .commit-viewer div.commit-message pre { 462 | font-size: 13px; 463 | font-family: var(--font-famliy); 464 | margin: 8px 0px 0px 0px; 465 | } 466 | .commit-viewer p { 467 | margin: 4px 8px; 468 | } 469 | .commit-viewer .sha-button { 470 | border-bottom: 1px dotted var(--font-color); 471 | } 472 | .commit-viewer .navigate-button { 473 | cursor: pointer; 474 | } 475 | .commit-viewer div.section-header { 476 | display: flex; 477 | position: relative; 478 | } 479 | .commit-viewer div.section-header button { 480 | position: absolute; 481 | right: 0px; 482 | } 483 | .commit-viewer div.section-header .amend-container { 484 | display: flex; 485 | position: absolute; 486 | right: 0px; 487 | margin: 4px 8px; 488 | } 489 | .commit-viewer > input { 490 | margin: 4px 8px; 491 | } 492 | .commit-viewer > textarea { 493 | margin: 4px 8px; 494 | resize: none; 495 | font-family: var(--font-famliy); 496 | } 497 | /* Index Viewer */ 498 | .index-viewer { 499 | user-select: none; 500 | } 501 | /* Patch list */ 502 | ul.patch-list { 503 | flex: 1; 504 | margin: 8px; 505 | border: 1px solid var(--border-color); 506 | padding-left: 0px; 507 | list-style: none; 508 | overflow-y: auto; 509 | background-color: var(--list-background-color); 510 | } 511 | ul.patch-list li { 512 | padding: 4px; 513 | display: flex; 514 | position: relative; 515 | user-select: none; 516 | } 517 | ul.patch-list li:hover { 518 | background: var(--hover-color); 519 | } 520 | ul.patch-list li.selected-patch { 521 | background: var(--selected-color); 522 | } 523 | ul.patch-list li span.icon { 524 | padding-right: 4px; 525 | } 526 | ul.patch-list li div.buttons { 527 | position: absolute; 528 | width: 100%; 529 | height: 100%; 530 | left: 0; 531 | top: 0; 532 | text-align: right; 533 | line-height: 25px; 534 | } 535 | ul.patch-list li div.buttons button { 536 | display: none; 537 | margin-right: 2px; 538 | padding: 0px 2px; 539 | } 540 | ul.patch-list li div.buttons:hover button { 541 | display: inline; 542 | } 543 | /* Patch icons */ 544 | span.patch-add::before { 545 | font-family: 'Courier New', Courier, monospace; 546 | font-weight: bold; 547 | content: '+'; 548 | color: green; 549 | } 550 | span.patch-delete::before { 551 | font-family: 'Courier New', Courier, monospace; 552 | font-weight: bold; 553 | content: '-'; 554 | color: red; 555 | } 556 | span.patch-modify::before { 557 | font-family: 'Courier New', Courier, monospace; 558 | font-weight: bold; 559 | content: '\2026'; 560 | color: orange; 561 | } 562 | span.patch-rename::before { 563 | font-family: 'Courier New', Courier, monospace; 564 | font-weight: bold; 565 | content: '/'; 566 | color: dodgerblue; 567 | } 568 | span.patch-conflict::before { 569 | font-family: 'Courier New', Courier, monospace; 570 | font-weight: bold; 571 | content: '!'; 572 | color: orange; 573 | } 574 | /* Ellipsis middle */ 575 | .ellipsis-middle { 576 | display: flex; 577 | overflow: hidden; 578 | } 579 | .ellipsis-middle .left { 580 | text-overflow: ellipsis; 581 | overflow: hidden; 582 | white-space: nowrap; 583 | } 584 | .ellipsis-middle .right { 585 | max-width: 100%; 586 | flex-shrink: 0; 587 | text-overflow: ellipsis; 588 | overflow: hidden; 589 | white-space: nowrap; 590 | } 591 | /* Notifications */ 592 | ul.notification-queue { 593 | position: absolute; 594 | top: 0; 595 | left: calc(100% - 400px); 596 | width: 800px; 597 | z-index: 5; 598 | list-style: none; 599 | margin: 16px 0px; 600 | padding: 0px; 601 | } 602 | ul.notification-queue li { 603 | width: calc(50% - 2 * (16px + 1px)); 604 | padding: 16px; 605 | margin: 8px 0px; 606 | margin-left: 50%; 607 | transition: margin-left 1s 0s; 608 | border: 1px solid var(--border-color); 609 | background: var(--background-color); 610 | box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.2); 611 | } 612 | ul.notification-queue li.shown { 613 | margin-left: 0px; 614 | } 615 | ul.notification-queue li:hover { 616 | background: var(--hover-color); 617 | } 618 | ul.notification-queue li.information { 619 | color: var(--notification-font-color); 620 | background-color: var(--info-notification-background-color); 621 | border-color: var(--info-notification-border-color); 622 | } 623 | ul.notification-queue li.information:hover { 624 | background-color: var(--info-notification-background-color); 625 | border-color: var(--info-notification-background-color); 626 | } 627 | ul.notification-queue li.information:active { 628 | background-color: var(--info-notification-border-color); 629 | border-color: var(--info-notification-background-color); 630 | } 631 | ul.notification-queue li.error { 632 | color: var(--notification-font-color); 633 | background-color: var(--error-notification-background-color); 634 | border-color: var(--error-notification-border-color); 635 | } 636 | ul.notification-queue li.error:hover { 637 | background-color: var(--error-notification-background-color); 638 | border-color: var(--error-notification-background-color); 639 | } 640 | ul.notification-queue li.error:active { 641 | background-color: var(--error-notification-border-color); 642 | border-color: var(--error-notification-background-color); 643 | } 644 | /* Welcome dashboard */ 645 | div.welcome-dashboard { 646 | margin-top: 100px; 647 | text-align: center; 648 | } 649 | div.welcome-dashboard h1 { 650 | margin-bottom: 20px; 651 | } 652 | div.welcome-dashboard div.actions { 653 | width: 600px; 654 | margin: auto; 655 | display: flex; 656 | } 657 | div.welcome-dashboard div.actions div.action { 658 | width: 174px; 659 | background-color: var(--list-background-color); 660 | margin: 8px; 661 | padding: 4px; 662 | border: 1px solid var(--border-color); 663 | border-radius: 3px; 664 | overflow-wrap: break-word; 665 | } 666 | div.welcome-dashboard div.actions div.action p { 667 | text-align: left; 668 | font-size: 12px; 669 | } 670 | div.welcome-dashboard div.actions div.action:hover { 671 | background-color: var(--hover-color); 672 | } 673 | div.welcome-dashboard div.actions div.action:active { 674 | background-color: var(--selected-color); 675 | } 676 | /* Tooltips */ 677 | .tooltip-bottom { 678 | position: relative; 679 | } 680 | .tooltip-bottom .tooltip-text { 681 | visibility: hidden; 682 | background-color: black; 683 | color: #fff; 684 | border-radius: 5px; 685 | padding: 5px; 686 | position: absolute; 687 | z-index: 1; 688 | left: 50%; 689 | top: calc(100% + 8px); 690 | transform: translateX(-50%); 691 | } 692 | .tooltip-bottom .tooltip-text::after { 693 | content: ""; 694 | position: absolute; 695 | bottom: 100%; 696 | left: 50%; 697 | margin-left: -5px; 698 | border-width: 5px; 699 | border-style: solid; 700 | border-color: transparent transparent black transparent; 701 | } 702 | .tooltip-bottom:hover .tooltip-text { 703 | visibility: visible; 704 | } 705 | .tooltip-right { 706 | position: relative; 707 | } 708 | .tooltip-right .tooltip-text { 709 | visibility: hidden; 710 | background-color: black; 711 | color: #fff; 712 | border-radius: 5px; 713 | padding: 5px; 714 | position: absolute; 715 | z-index: 1; 716 | top: 50%; 717 | left: calc(100% + 8px); 718 | transform: translateY(-50%); 719 | white-space: nowrap; 720 | } 721 | .tooltip-right .tooltip-text::after { 722 | content: ""; 723 | position: absolute; 724 | right: 100%; 725 | top: 50%; 726 | margin-top: -5px; 727 | border-width: 5px; 728 | border-style: solid; 729 | border-color: transparent black transparent transparent; 730 | } 731 | .tooltip-right:hover .tooltip-text { 732 | visibility: visible; 733 | } 734 | /* Buttons */ 735 | button { 736 | background-color: var(--list-background-color); 737 | border: 1px solid var(--border-color); 738 | border-radius: 3px; 739 | color: var(--font-color); 740 | outline: 0px; 741 | } 742 | button:hover { 743 | background-color: var(--hover-color); 744 | } 745 | button:active { 746 | background-color: var(--selected-color); 747 | } 748 | button:disabled { 749 | cursor: not-allowed; 750 | } 751 | .green-button { 752 | color: var(--button-font-color); 753 | background-color: var(--green-button-background-color); 754 | border-color: var(--green-button-border-color); 755 | } 756 | .green-button:hover { 757 | background-color: var(--green-button-background-color); 758 | border-color: var(--green-button-background-color); 759 | } 760 | .green-button:active { 761 | background-color: var(--green-button-border-color); 762 | border-color: var(--green-button-background-color); 763 | } 764 | .red-button { 765 | color: var(--button-font-color); 766 | background-color: var(--red-button-background-color); 767 | border-color: var(--red-button-border-color); 768 | } 769 | .red-button:hover { 770 | background-color: var(--red-button-background-color); 771 | border-color: var(--red-button-background-color); 772 | } 773 | .red-button:active { 774 | background-color: var(--red-button-border-color); 775 | border-color: var(--red-button-background-color); 776 | } 777 | .yellow-button { 778 | color: var(--button-font-color); 779 | background-color: var(--yellow-button-background-color); 780 | border-color: var(--yellow-button-border-color); 781 | } 782 | .yellow-button:hover { 783 | background-color: var(--yellow-button-background-color); 784 | border-color: var(--yellow-button-background-color); 785 | } 786 | .yellow-button:active { 787 | background-color: var(--yellow-button-border-color); 788 | border-color: var(--yellow-button-background-color); 789 | } 790 | /* Modal windows */ 791 | div.modal-container { 792 | position: absolute; 793 | top: 0px; 794 | left: 0px; 795 | width: 100%; 796 | height: 100%; 797 | z-index: 4; 798 | display: flex; 799 | justify-content: center; 800 | align-items: center; 801 | background-color: rgba(0, 0, 0, 0.5); 802 | } 803 | div.modal-container div.modal-background { 804 | background-color: var(--background-color); 805 | } 806 | div.modal-container div.modal-background div.modal-header { 807 | position: relative; 808 | text-align: center; 809 | border-bottom: 1px solid var(--border-color); 810 | margin: 0px 8px; 811 | padding: 4px 0px; 812 | } 813 | div.modal-container div.modal-background div.modal-header h2 { 814 | margin: 0px; 815 | } 816 | div.modal-container div.modal-background div.modal-header button { 817 | position: absolute; 818 | top: 0px; 819 | right: 0px; 820 | margin: 4px 0px; 821 | } 822 | /* Modal forms */ 823 | form.modal-form { 824 | display: flex; 825 | flex-direction: column; 826 | margin: 10px; 827 | } 828 | form.modal-form div.field-container { 829 | display: flex; 830 | margin: 4px 0px; 831 | } 832 | form.modal-form div.field-container label { 833 | min-width: 140px; 834 | display: inline-block; 835 | text-align: right; 836 | margin-right: 10px; 837 | } 838 | form.modal-form div.field-container input[type="text"] { 839 | flex: 1; 840 | } 841 | form.modal-form div.field-container span#prefix { 842 | overflow: hidden; 843 | text-overflow: ellipsis; 844 | } 845 | form.modal-form div.button-container { 846 | width: 100%; 847 | margin: 4px 0px; 848 | } 849 | form.modal-form div.button-container button { 850 | width: 100%; 851 | margin: 0px; 852 | } 853 | /* Preferences */ 854 | div.preferences { 855 | width: 600px; 856 | height: 400px; 857 | display: flex; 858 | } 859 | div.preferences nav { 860 | width: 200px; 861 | } 862 | div.preferences nav ul { 863 | list-style: none; 864 | margin: 16px 0px; 865 | padding: 0px; 866 | } 867 | div.preferences nav ul li { 868 | padding: 5px 16px; 869 | } 870 | div.preferences nav ul li:hover { 871 | background-color: var(--hover-color); 872 | } 873 | div.preferences nav ul li.selected { 874 | background-color: var(--selected-color); 875 | } 876 | div.preferences main { 877 | flex-grow: 1; 878 | } 879 | div.preferences section { 880 | display: none; 881 | padding: 16px 8px; 882 | } 883 | div.preferences section.shown { 884 | display: block; 885 | } 886 | div.preferences section h1 { 887 | margin: 0px; 888 | border-bottom: 1px solid var(--border-color); 889 | margin-bottom: 16px; 890 | } 891 | div.preferences section div { 892 | margin: 4px 0px; 893 | } 894 | div.preferences section div label { 895 | margin-right: 4px; 896 | } 897 | -------------------------------------------------------------------------------- /assets/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | gitamine 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /assets/themes/dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "css": { 3 | "background-color": "#323232", 4 | "toolbar-color": "#3c3c3c", 5 | "list-background-color": "#141414", 6 | "hover-color": "#1e1e1e", 7 | "selected-color": "#282828", 8 | "splitter-color": "#464646", 9 | "border-color": "#191919", 10 | "font-color": "#dcdcdc", 11 | "font-famliy": "Arial, Helvetica, sans-serif", 12 | "font-size": "14px", 13 | "link-color": "#3399ff", 14 | "button-font-color": "#dcdcdc", 15 | "green-button-border-color": "#008000", 16 | "green-button-background-color": "#006400", 17 | "red-button-border-color": "#a70000", 18 | "red-button-background-color": "#8b0000", 19 | "yellow-button-border-color": "#daa520", 20 | "yellow-button-background-color": "#b8860b", 21 | "notification-font-color": "#dcdcdc", 22 | "info-notification-border-color": "#008000", 23 | "info-notification-background-color": "#006400", 24 | "error-notification-border-color": "#a70000", 25 | "error-notification-background-color": "#8b0000", 26 | "reference-section-background-color": "#373737", 27 | "spinner-color": "#1e90ff", 28 | "spinner-border": "#f3f3f3" 29 | }, 30 | "monaco": { 31 | "theme": "vs-dark" 32 | } 33 | } -------------------------------------------------------------------------------- /assets/themes/light.json: -------------------------------------------------------------------------------- 1 | { 2 | "css": { 3 | "background-color": "#ffffff", 4 | "toolbar-color": "#f5f5f5", 5 | "list-background-color": "#ebebeb", 6 | "hover-color": "#e1e1e1", 7 | "selected-color": "#d7d7d7", 8 | "splitter-color": "#b9b9b9", 9 | "border-color": "#e6e6e6", 10 | "font-color": "#000000", 11 | "font-famliy": "Arial, Helvetica, sans-serif", 12 | "font-size": "14px", 13 | "link-color": "#0000ee", 14 | "button-font-color": "#ffffff", 15 | "green-button-border-color": "#006400", 16 | "green-button-background-color": "#008000", 17 | "red-button-border-color": "#8b0000", 18 | "red-button-background-color": "#a70000", 19 | "yellow-button-border-color": "#b8860b", 20 | "yellow-button-background-color": "#daa520", 21 | "notification-font-color": "#ffffff", 22 | "info-notification-border-color": "#006400", 23 | "info-notification-background-color": "#008000", 24 | "error-notification-border-color": "#8b0000", 25 | "error-notification-background-color": "#a70000", 26 | "reference-section-background-color": "#c8c8c8", 27 | "spinner-color": "#1e90ff", 28 | "spinner-border": "#f3f3f3" 29 | }, 30 | "monaco": { 31 | "theme": "vs-light" 32 | } 33 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitamine", 3 | "productName": "gitamine", 4 | "version": "0.0.4", 5 | "description": "gitamine is a graphical user interface for git", 6 | "main": "src/main/main.ts", 7 | "scripts": { 8 | "start": "electron-forge start", 9 | "package": "electron-forge package", 10 | "make": "electron-forge make", 11 | "publish": "electron-forge publish", 12 | "lint": "tslint --project tsconfig.json --type-check --force" 13 | }, 14 | "keywords": [ 15 | "Node", 16 | "Electron", 17 | "git" 18 | ], 19 | "author": "Pierre Vigier", 20 | "license": "GPL-3.0-or-later", 21 | "config": { 22 | "forge": { 23 | "make_targets": { 24 | "win32": [ 25 | "zip", 26 | "squirrel", 27 | "wix" 28 | ], 29 | "darwin": [ 30 | "zip" 31 | ], 32 | "linux": [ 33 | "zip", 34 | "deb", 35 | "rpm" 36 | ] 37 | }, 38 | "electronPackagerConfig": { 39 | "packageManager": "npm" 40 | }, 41 | "electronWinstallerConfig": { 42 | "name": "gitamine" 43 | }, 44 | "electronInstallerDebian": {}, 45 | "electronInstallerRedhat": {}, 46 | "github_repository": { 47 | "owner": "", 48 | "name": "" 49 | }, 50 | "windowsStoreConfig": { 51 | "packageName": "", 52 | "name": "gitamine" 53 | } 54 | } 55 | }, 56 | "dependencies": { 57 | "chokidar": "^2.1.2", 58 | "electron-compile": "^6.4.4", 59 | "electron-devtools-installer": "^2.2.4", 60 | "electron-settings": "^3.2.0", 61 | "electron-squirrel-startup": "^1.0.0", 62 | "electron-unhandled": "^2.1.0", 63 | "fastpriorityqueue": "^0.6.1", 64 | "file-type": "^10.9.0", 65 | "ignore": "^5.0.5", 66 | "isbinaryfile": "^4.0.0", 67 | "launch-editor": "^2.2.1", 68 | "monaco-loader": "^0.15.0", 69 | "node-interval-tree": "^1.3.3", 70 | "nodegit": "^0.24.1", 71 | "react": "^16.8.4", 72 | "react-dom": "^16.8.4", 73 | "tslib": "^1.9.3" 74 | }, 75 | "devDependencies": { 76 | "@types/chokidar": "^2.1.3", 77 | "@types/electron-devtools-installer": "^2.2.0", 78 | "@types/file-type": "^10.6.0", 79 | "@types/nodegit": "^0.24.4", 80 | "@types/react": "^16.8.7", 81 | "@types/react-dom": "^16.8.2", 82 | "babel-plugin-transform-async-to-generator": "^6.24.1", 83 | "babel-preset-env": "^1.7.0", 84 | "babel-preset-react": "^6.24.1", 85 | "electron": "^4.0.7", 86 | "electron-forge": "^5.2.4", 87 | "electron-prebuilt-compile": "4.0.0", 88 | "electron-wix-msi": "^2.1.1", 89 | "tslint": "^5.13.1", 90 | "typescript": "^3.3.3" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/main/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron'; 2 | import { setMenu } from './menu'; 3 | import { ThemeManager } from '../shared/theme-manager'; 4 | 5 | // Dev mode 6 | const isDevMode = process.execPath.match(/[\\/]electron/); 7 | 8 | async function initDevTools() { 9 | const installExtension = await import('electron-devtools-installer'); 10 | mainWindow!.webContents.openDevTools(); 11 | await installExtension.default(installExtension.REACT_DEVELOPER_TOOLS); 12 | } 13 | 14 | let mainWindow: BrowserWindow | null = null; 15 | 16 | const createWindow = async () => { 17 | const themeManager = new ThemeManager(); 18 | await themeManager.loadTheme(); 19 | 20 | // Create the browser window 21 | mainWindow = new BrowserWindow({ 22 | show: false, 23 | minWidth: 800, 24 | minHeight: 600, 25 | width: 800, 26 | height: 600, 27 | backgroundColor: themeManager.getBackgroundColor() 28 | }); 29 | 30 | // Set the menu 31 | setMenu(mainWindow); 32 | 33 | // Load the index.html of the app 34 | mainWindow.loadURL(`file://${__dirname}/../../assets/html/index.html`); 35 | 36 | // Open the DevTools 37 | if (isDevMode) { 38 | await initDevTools(); 39 | } 40 | 41 | // Only show when the DOM is ready 42 | mainWindow.once('ready-to-show', () => { 43 | mainWindow!.show(); 44 | }); 45 | 46 | mainWindow.on('closed', () => { 47 | mainWindow = null; 48 | }); 49 | }; 50 | 51 | app.on('ready', createWindow); 52 | 53 | app.on('window-all-closed', () => { 54 | // On OS X it is common for applications and their menu bar 55 | // to stay active until the user quits explicitly with Cmd + Q 56 | if (process.platform !== 'darwin') { 57 | app.quit(); 58 | } 59 | }); 60 | 61 | app.on('activate', () => { 62 | // On OS X it's common to re-create a window in the app when the 63 | // dock icon is clicked and there are no other windows open 64 | if (mainWindow === null) { 65 | createWindow(); 66 | } 67 | }); -------------------------------------------------------------------------------- /src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from 'electron'; 2 | 3 | export function setMenu(mainWindow: Electron.BrowserWindow) { 4 | const template = [ 5 | { 6 | label: 'File', 7 | submenu: [ 8 | { 9 | label: 'Clone repo', 10 | accelerator: 'CmdOrCtrl+N', 11 | click: () => mainWindow.webContents.send('clone-repo') 12 | }, 13 | { 14 | label: 'Init repo', 15 | accelerator: 'CmdOrCtrl+I', 16 | click: () => mainWindow.webContents.send('init-repo') 17 | }, 18 | { 19 | label: 'Open repo', 20 | accelerator: 'CmdOrCtrl+O', 21 | click: () => mainWindow.webContents.send('open-repo') 22 | }, 23 | { 24 | type: 'separator' 25 | }, 26 | { 27 | label: 'Preferences', 28 | accelerator: 'CmdOrCtrl+,', 29 | click: () => mainWindow.webContents.send('preferences') 30 | }, 31 | { 32 | type: 'separator' 33 | }, 34 | { 35 | role: 'quit' 36 | } 37 | ] 38 | }, 39 | { 40 | label: 'View', 41 | submenu: [ 42 | {role: 'reload'}, 43 | {role: 'forcereload'}, 44 | {role: 'toggledevtools'}, 45 | ] 46 | }, 47 | ]; 48 | 49 | const menu = Menu.buildFromTemplate(template); 50 | mainWindow.setMenu(menu); 51 | } -------------------------------------------------------------------------------- /src/renderer/components/app.tsx: -------------------------------------------------------------------------------- 1 | import { remote } from 'electron'; 2 | import * as React from 'react'; 3 | import * as Git from 'nodegit'; 4 | import { RepoDashboard } from './repo-dashboard'; 5 | import { RepoWrapper } from '../helpers/repo-wrapper'; 6 | import { NotificationQueue } from './notification-queue'; 7 | import { WelcomeDashboard } from './welcome-dashboard'; 8 | import { NotificationType } from './notification-item'; 9 | import { CreateBranchDialog } from './create-branch-dialog'; 10 | import { CloneRepoDialog } from './clone-repo-dialog'; 11 | import { InitRepoDialog } from './init-repo-dialog'; 12 | import { TextPatchViewerOptions } from './text-patch-viewer'; 13 | import { PreferencesDialog } from './preferences-dialog'; 14 | import { Field, Settings } from '../../shared/settings'; 15 | import { ThemeManager } from '../../shared/theme-manager'; 16 | import { InputDialog } from './input-dialog'; 17 | 18 | export interface AppState { 19 | repos: RepoWrapper[]; 20 | editorTheme: string; 21 | patchViewerOptions: TextPatchViewerOptions; 22 | modalWindow: JSX.Element | null; 23 | } 24 | 25 | export class App extends React.PureComponent<{}, AppState> { 26 | notificationQueue: React.RefObject; 27 | themeManager: ThemeManager; 28 | 29 | constructor(props: {}) { 30 | super(props); 31 | this.notificationQueue = React.createRef(); 32 | this.themeManager = new ThemeManager(); 33 | this.state = { 34 | repos: [], 35 | editorTheme: 'vs-light', 36 | patchViewerOptions: { 37 | fontSize: 14 38 | }, 39 | modalWindow: null 40 | }; 41 | this.updateTheme = this.updateTheme.bind(this); 42 | this.updatePatchViewer = this.updatePatchViewer.bind(this); 43 | this.cloneRepo = this.cloneRepo.bind(this); 44 | this.initRepo = this.initRepo.bind(this); 45 | this.openRepo = this.openRepo.bind(this); 46 | this.openCloneRepoDialog = this.openCloneRepoDialog.bind(this); 47 | this.openInitRepoDialog = this.openInitRepoDialog.bind(this); 48 | this.openOpenRepoDialog = this.openOpenRepoDialog.bind(this); 49 | this.openCreateBranchDialog = this.openCreateBranchDialog.bind(this); 50 | this.openInputDialog = this.openInputDialog.bind(this); 51 | this.showNotification = this.showNotification.bind(this); 52 | this.closeModalWindow = this.closeModalWindow.bind(this); 53 | } 54 | 55 | componentDidMount() { 56 | this.updateTheme(); 57 | this.updatePatchViewer(); 58 | } 59 | 60 | async updateTheme(name?: string) { 61 | await this.themeManager.loadTheme(name); 62 | this.themeManager.updateCssVariables(); 63 | this.setState({ 64 | editorTheme: this.themeManager.getEditorTheme() 65 | }); 66 | } 67 | 68 | async updatePatchViewer(options?: TextPatchViewerOptions) { 69 | options = options || {fontSize: Settings.get(Field.FontSize)}; 70 | this.setState({ 71 | patchViewerOptions: options! 72 | }); 73 | } 74 | 75 | async cloneRepo(url: string, path: string) { 76 | try { 77 | this.addRepo(await RepoWrapper.clone(url, path)); 78 | } catch (e) { 79 | this.showNotification(`Unable to clone repo: ${e.message}`, NotificationType.Error); 80 | } 81 | } 82 | 83 | async initRepo(path: string) { 84 | try { 85 | this.addRepo(await RepoWrapper.init(path)); 86 | } catch (e) { 87 | this.showNotification(`Unable to init repo: ${e.message}`, NotificationType.Error); 88 | } 89 | } 90 | 91 | async openRepo(path: string) { 92 | try { 93 | this.addRepo(await RepoWrapper.open(path)); 94 | } catch (e) { 95 | this.showNotification(`Unable to open repo: ${e.message}`, NotificationType.Error); 96 | } 97 | } 98 | 99 | addRepo(repo: Git.Repository) { 100 | const repoState = new RepoWrapper(repo, this.showNotification); 101 | this.setState({ 102 | repos: [repoState] 103 | }); 104 | // Update settings 105 | const recentlyOpened: string[] = Settings.get(Field.RecentlyOpened, []); 106 | // Make sure that there is no duplicate 107 | const iPath = recentlyOpened.indexOf(repoState.path); 108 | if (iPath !== -1) { 109 | recentlyOpened.splice(iPath, 1); 110 | } 111 | Settings.set(Field.RecentlyOpened, [repoState.path, ...recentlyOpened.slice(0, 2)]); 112 | } 113 | 114 | closeRepo(i: number) { 115 | this.setState((prevState) => { 116 | const repos = prevState.repos.slice(); 117 | repos.splice(i, 1); 118 | return { 119 | repos: repos 120 | }; 121 | }); 122 | } 123 | 124 | getCurrentRepo() { 125 | return this.state.repos[0]; 126 | } 127 | 128 | showNotification(message: string, type: NotificationType) { 129 | if (this.notificationQueue.current) { 130 | this.notificationQueue.current.addNotification(message, type); 131 | } 132 | } 133 | 134 | // Modal components 135 | 136 | openCloneRepoDialog() { 137 | const element = 140 | this.setState({ 141 | modalWindow: element 142 | }); 143 | } 144 | 145 | openInitRepoDialog() { 146 | const element = 149 | this.setState({ 150 | modalWindow: element 151 | }); 152 | } 153 | 154 | openOpenRepoDialog() { 155 | remote.dialog.showOpenDialog(remote.getCurrentWindow(), 156 | {properties: ['openDirectory']}, 157 | (paths) => { 158 | if (paths) { 159 | this.openRepo(paths[0]); 160 | } 161 | } 162 | ); 163 | } 164 | 165 | openPreferencesDialog() { 166 | const element = 170 | this.setState({ 171 | modalWindow: element 172 | }); 173 | } 174 | 175 | openCreateBranchDialog(commit: Git.Commit) { 176 | const element = ; 180 | this.setState({ 181 | modalWindow: element 182 | }); 183 | } 184 | 185 | openInputDialog(title: string, label: string, button: string, onSubmit: (value: string) => void, defaultValue = '') { 186 | const element = 192 | this.setState({ 193 | modalWindow: element 194 | }); 195 | } 196 | 197 | closeModalWindow() { 198 | this.setState({ 199 | modalWindow: null 200 | }); 201 | } 202 | 203 | render() { 204 | const repoDashboards = this.state.repos.length === 0 ? 205 | : 209 | this.state.repos.map((repo, i) => this.closeRepo(i)} 214 | onCreateBranch={this.openCreateBranchDialog} 215 | onOpenInputDialog={this.openInputDialog} 216 | key={repo.path} />); 217 | return ( 218 |
219 | {repoDashboards} 220 | {this.state.modalWindow} 221 | 222 |
223 | ); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/renderer/components/binary-patch-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export class BinaryPatchViewer extends React.PureComponent<{}, {}> { 4 | render() { 5 | return ( 6 |
7 |

Binary file

8 |
9 | ); 10 | } 11 | } -------------------------------------------------------------------------------- /src/renderer/components/clone-repo-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as Path from 'path'; 2 | import { remote } from 'electron'; 3 | import * as React from 'react'; 4 | import { makeModal } from './make-modal'; 5 | 6 | export class CloneRepoFormProps { 7 | onClose: () => void; 8 | onCloneRepo: (url: string, path: string) => void; 9 | } 10 | 11 | export class CloneRepoFormState { 12 | repoPath: string; 13 | repoUrl: string; 14 | repoFolder: string; 15 | } 16 | 17 | class CloneRepoForm extends React.PureComponent { 18 | constructor(props: CloneRepoFormProps) { 19 | super(props); 20 | this.state = { 21 | repoPath: '', 22 | repoUrl: '', 23 | repoFolder: '' 24 | } 25 | this.handleRepoPathChange = this.handleRepoPathChange.bind(this); 26 | this.handleBrowseClick = this.handleBrowseClick.bind(this); 27 | this.handleRepoUrlChange = this.handleRepoUrlChange.bind(this); 28 | this.handleRepoFolderChange = this.handleRepoFolderChange.bind(this); 29 | this.handleSubmit = this.handleSubmit.bind(this); 30 | } 31 | 32 | handleRepoPathChange(event: React.ChangeEvent) { 33 | this.setState({repoPath: event.target.value}); 34 | } 35 | 36 | handleBrowseClick() { 37 | remote.dialog.showOpenDialog(remote.getCurrentWindow(), 38 | {properties: ['openDirectory']}, 39 | (paths) => { 40 | if (paths) { 41 | this.setState({repoPath: paths[0]}); 42 | } 43 | } 44 | ); 45 | } 46 | 47 | handleRepoUrlChange(event: React.ChangeEvent) { 48 | this.setState({repoUrl: event.target.value}); 49 | } 50 | 51 | handleRepoFolderChange(event: React.ChangeEvent) { 52 | this.setState({repoFolder: event.target.value}); 53 | } 54 | 55 | handleSubmit(event: React.FormEvent) { 56 | event.preventDefault(); 57 | if (this.isPathValid()) { 58 | const folderName = this.state.repoFolder || Path.parse(this.state.repoUrl).name; 59 | const fullPath = Path.join(this.state.repoPath, folderName); 60 | this.props.onCloneRepo(this.state.repoUrl, fullPath); 61 | this.props.onClose(); 62 | } 63 | } 64 | 65 | isPathValid() { 66 | const folderName = this.state.repoFolder || Path.parse(this.state.repoUrl).name; 67 | return this.state.repoPath.length > 0 && this.state.repoUrl && folderName.length > 0; 68 | } 69 | 70 | render() { 71 | return ( 72 |
73 |
74 | 75 | 81 | 85 |
86 |
87 | 88 | 93 |
94 |
95 | 96 | {this.state.repoPath}{Path.sep} 97 | 103 |
104 |
105 | 110 |
111 |
112 | ); 113 | } 114 | } 115 | 116 | export const CloneRepoDialog = makeModal(CloneRepoForm); -------------------------------------------------------------------------------- /src/renderer/components/commit-item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { ReferenceBadge } from './reference-badge'; 4 | import { InputDialogHandler } from './input-dialog'; 5 | import { RepoWrapper, Stash } from '../helpers/repo-wrapper'; 6 | import { createStashContextMenu } from '../helpers/stash-context-menu'; 7 | import { createCommitContextMenu } from '../helpers/commit-context-menu'; 8 | 9 | export interface CommitItemProps { 10 | repo: RepoWrapper; 11 | commit: Git.Commit; 12 | head: Git.Reference | null; 13 | references: string[]; 14 | selected: boolean; 15 | color: string; 16 | stash?: Stash; 17 | onCommitSelect: (commit: Git.Commit) => void; 18 | onCreateBranch: (commit: Git.Commit) => void; 19 | onOpenInputDialog: InputDialogHandler; 20 | } 21 | 22 | export class CommitItem extends React.PureComponent { 23 | constructor(props: CommitItemProps) { 24 | super(props); 25 | this.handleClick = this.handleClick.bind(this); 26 | this.handleContextMenu = this.handleContextMenu.bind(this); 27 | } 28 | 29 | handleClick(event: React.MouseEvent) { 30 | this.props.onCommitSelect(this.props.commit); 31 | } 32 | 33 | handleContextMenu(event: React.MouseEvent) { 34 | event.preventDefault(); 35 | let menu: Electron.Menu; 36 | if (!this.props.stash) { 37 | menu = createCommitContextMenu(this.props.repo, 38 | this.props.commit, 39 | this.props.onCreateBranch, 40 | this.props.onOpenInputDialog); 41 | } else { 42 | menu = createStashContextMenu(this.props.repo, this.props.stash.index); 43 | } 44 | menu.popup({}); 45 | } 46 | 47 | render() { 48 | const badges = this.props.references.map((name) => ( 49 | 55 | )); 56 | return ( 57 |
  • 60 | {badges}{this.props.commit.summary()} 61 |
  • 62 | ); 63 | } 64 | } -------------------------------------------------------------------------------- /src/renderer/components/commit-list.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { CommitItem } from './commit-item'; 4 | import { IndexItem } from './index-item'; 5 | import { InputDialogHandler } from './input-dialog'; 6 | import { RepoWrapper, RepoState } from '../helpers/repo-wrapper'; 7 | import { getBranchColor } from '../helpers/commit-graph'; 8 | 9 | const ITEM_HEIGHT = 28; 10 | 11 | export interface CommitListProps { 12 | repo: RepoWrapper; 13 | repoState: RepoState; 14 | selectedCommit: Git.Commit | null; 15 | onCommitSelect: (commit: Git.Commit) => void; 16 | onIndexSelect: () => void; 17 | onCreateBranch: (commit: Git.Commit) => void; 18 | onOpenInputDialog: InputDialogHandler; 19 | onScroll: (height: number, start: number, end: number) => void; 20 | onResize: (offset: number, start: number, end: number) => void; 21 | onStateUpdate: (start: number, end: number) => void; 22 | } 23 | 24 | export interface CommitListState { 25 | start: number; 26 | end: number; 27 | } 28 | 29 | export class CommitList extends React.PureComponent { 30 | div: React.RefObject; 31 | resizeObserver: ResizeObserver; 32 | 33 | constructor(props: CommitListProps) { 34 | super(props); 35 | this.div = React.createRef(); 36 | this.state = { 37 | start: 0, 38 | end: 0 39 | } 40 | this.handleScroll = this.handleScroll.bind(this); 41 | this.handleResize = this.handleResize.bind(this); 42 | this.handleKeyUp = this.handleKeyUp.bind(this); 43 | } 44 | 45 | componentDidMount() { 46 | if (this.div.current) { 47 | this.resizeObserver = new ResizeObserver(this.handleResize); 48 | this.resizeObserver.observe(this.div.current); 49 | } 50 | } 51 | 52 | componentDidUpdate(prevProps: CommitListProps) { 53 | if (this.props.selectedCommit !== prevProps.selectedCommit) { 54 | this.scrollToItem(); 55 | } 56 | } 57 | 58 | componentWillUnmount() { 59 | this.resizeObserver.disconnect(); 60 | } 61 | 62 | handleScroll() { 63 | if (this.div.current) { 64 | const newState = this.computeState()!; 65 | this.props.onScroll(this.div.current.scrollTop, newState.start, newState.end); 66 | this.setState(newState); 67 | } 68 | } 69 | 70 | handleResize() { 71 | if (this.div.current) { 72 | const newState = this.computeState()!; 73 | this.props.onResize(this.div.current.clientHeight, newState.start, newState.end); 74 | this.setState(newState); 75 | } 76 | } 77 | 78 | handleKeyUp(event: React.KeyboardEvent) { 79 | event.preventDefault(); 80 | if (event.keyCode === 38) { 81 | let i = this.getIndexOfSelectedCommit(); 82 | i = Math.max(i - 1, -1); 83 | if (i >= 0) { 84 | this.props.onCommitSelect(this.props.repo.commits[i]) 85 | } else { 86 | this.props.onIndexSelect(); 87 | } 88 | } else if (event.keyCode === 40) { 89 | let i = this.props.selectedCommit ? 90 | this.props.repo.shaToIndex.get(this.props.selectedCommit.sha())! : -1; 91 | i = Math.min(i + 1, this.props.repo.commits.length - 1); 92 | this.props.onCommitSelect(this.props.repo.commits[i]) 93 | } 94 | } 95 | 96 | scrollToItem() { 97 | // Scroll so that the commit is visible 98 | const i = this.getIndexOfSelectedCommit(); 99 | if (i <= this.state.start && this.div.current) { 100 | this.div.current.scrollTo(0, (i + 1) * ITEM_HEIGHT); 101 | } 102 | if (i >= this.state.end - 1 && this.div.current) { 103 | this.div.current.scrollTo(0, (i + 2) * ITEM_HEIGHT - this.div.current.clientHeight); 104 | } 105 | } 106 | 107 | centerOnItem(i: number) { 108 | if (this.div.current) { 109 | this.div.current.scrollTo(0, (i + 1.5) * ITEM_HEIGHT - this.div.current.clientHeight / 2); 110 | } 111 | } 112 | 113 | updateState() { 114 | if (this.div.current) { 115 | const newState = this.computeState()!; 116 | this.props.onStateUpdate(newState.start, newState.end); 117 | this.setState(newState); 118 | } 119 | } 120 | 121 | computeState() { 122 | const div = this.div.current!; 123 | // Take account of the index 124 | const top = div.scrollTop - ITEM_HEIGHT; 125 | const start = Math.floor(top / ITEM_HEIGHT); 126 | const end = Math.min(Math.ceil((top + div.clientHeight) / ITEM_HEIGHT), 127 | this.props.repo.commits.length); 128 | return { 129 | start: start, 130 | end: end 131 | }; 132 | } 133 | 134 | getIndexOfSelectedCommit() { 135 | return this.props.selectedCommit ? 136 | this.props.repo.shaToIndex.get(this.props.selectedCommit.sha())! : 137 | -1; 138 | } 139 | 140 | render() { 141 | // Select visible commits and add index if necessary 142 | const items: JSX.Element[] = []; 143 | if (this.state.start === -1) { 144 | items.push(); 147 | } 148 | const commits = this.props.repo.commits.slice(Math.max(this.state.start, 0), this.state.end); 149 | items.push(...commits.map((commit: Git.Commit) => { 150 | const commitSha = commit.sha(); 151 | const color = getBranchColor(this.props.repo.graph.positions.get(commitSha)![1]); 152 | return ( 153 | 165 | ); 166 | })); 167 | 168 | // Compute height of the divs 169 | const paddingTop = (this.state.start + 1) * ITEM_HEIGHT; 170 | const paddingBottom = (this.props.repo.commits.length - this.state.end) * ITEM_HEIGHT; 171 | const style = { 172 | paddingTop: paddingTop, 173 | paddingBottom: paddingBottom 174 | }; 175 | 176 | return ( 177 |
    178 |
      {event.preventDefault(); event.stopPropagation();}}>{items}
    179 |
    180 | ); 181 | } 182 | } 183 | 184 | -------------------------------------------------------------------------------- /src/renderer/components/commit-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { clipboard } from 'electron'; 2 | import * as React from 'react'; 3 | import * as Git from 'nodegit'; 4 | import { PatchList } from './patch-list'; 5 | import { PatchType, RepoWrapper, shortenSha } from '../helpers/repo-wrapper'; 6 | import { CancellablePromise, makeCancellable } from '../helpers/make-cancellable'; 7 | 8 | function formatDate(date: Date) { 9 | return `${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`; 10 | } 11 | 12 | export interface CommitViewerProps { 13 | repo: RepoWrapper; 14 | commit: Git.Commit; 15 | selectedPatch: Git.ConvenientPatch | null; 16 | onCommitSelect: (commit: Git.Commit) => void; 17 | onPatchSelect: (patch: Git.ConvenientPatch, type: PatchType) => void; 18 | } 19 | 20 | export interface CommitViewerState { 21 | patches: Git.ConvenientPatch[]; 22 | } 23 | 24 | export class CommitViewer extends React.PureComponent { 25 | div: React.RefObject; 26 | patchesPromise: CancellablePromise; 27 | 28 | constructor(props: CommitViewerProps) { 29 | super(props); 30 | this.div = React.createRef(); 31 | this.state = { 32 | patches: [] 33 | } 34 | this.handlePatchSelect = this.handlePatchSelect.bind(this); 35 | } 36 | 37 | componentDidMount() { 38 | this.updatePatches(); 39 | } 40 | 41 | componentWillUnmount() { 42 | if (this.patchesPromise) { 43 | this.patchesPromise.cancel(); 44 | } 45 | } 46 | 47 | componentDidUpdate(prevProps: CommitViewerProps) { 48 | if (this.props.commit !== prevProps.commit) { 49 | if (this.patchesPromise) { 50 | this.patchesPromise.cancel(); 51 | } 52 | this.updatePatches(); 53 | } 54 | } 55 | 56 | handlePatchSelect(patch: Git.ConvenientPatch) { 57 | this.props.onPatchSelect(patch, PatchType.Committed); 58 | } 59 | 60 | resize(offset: number) { 61 | if (this.div.current) { 62 | this.div.current.style.width = `${this.div.current.clientWidth - offset}px`; 63 | } 64 | } 65 | 66 | async updatePatches() { 67 | this.patchesPromise = makeCancellable(this.props.repo.getPatches(this.props.commit)); 68 | try { 69 | this.setState({ 70 | patches: await this.patchesPromise.promise 71 | }); 72 | } catch (e) { 73 | } 74 | } 75 | 76 | static createShaButton(sha: string) { 77 | function handleHover(event: React.MouseEvent) { 78 | const element = event.target as HTMLDivElement; 79 | const tooltipSpan = element.getElementsByClassName('tooltip-text')[0]; 80 | tooltipSpan.textContent = 'Copy'; 81 | } 82 | function handleClick(event: React.MouseEvent) { 83 | clipboard.writeText(sha); 84 | const element = event.target as HTMLDivElement; 85 | const tooltipSpan = element.getElementsByClassName('tooltip-text')[0]; 86 | tooltipSpan.textContent = 'Copied'; 87 | } 88 | return ( 89 | 90 | {shortenSha(sha)} 91 | Copy 92 | 93 | ); 94 | } 95 | 96 | createNavigationButton(commit: Git.Commit) { 97 | return ( 98 | this.props.onCommitSelect(commit)}> 99 | {shortenSha(commit.sha())} 100 | Navigate 101 | 102 | ); 103 | } 104 | 105 | createNavigationButtons(shas: string[]) { 106 | const buttons = []; 107 | for (let i = 0; i < shas.length; ++i) { 108 | buttons.push(this.createNavigationButton(this.props.repo.shaToCommit.get(shas[i])!)); 109 | if (i < shas.length - 1) { 110 | buttons.push(', '); 111 | } 112 | } 113 | return buttons; 114 | } 115 | 116 | render() { 117 | const commit = this.props.commit; 118 | const author = commit.author(); 119 | const authoredDate = new Date(author.when().time() * 1000); 120 | const body = commit.body(); 121 | return ( 122 |
    123 |

    Commit: {CommitViewer.createShaButton(commit.sha())}

    124 |
    125 |

    {commit.summary()}

    126 | {body ?
    {body}
    : null} 127 |
    128 |

    By {author.name()} <{author.email()}>

    129 |

    Authored {formatDate(authoredDate)}

    130 |

    Last modified {formatDate(commit.date())}

    131 |

    Parents: {this.createNavigationButtons(this.props.repo.parents.get(commit.sha())!)}

    132 | 136 |
    137 | ); 138 | } 139 | } -------------------------------------------------------------------------------- /src/renderer/components/conflict-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as Path from 'path'; 3 | import * as React from 'react'; 4 | import * as Git from 'nodegit'; 5 | import { RepoWrapper } from '../helpers/repo-wrapper' 6 | import { openInEditor } from '../helpers/open-in-editor'; 7 | import { arePatchesSimilar } from '../helpers/patch-comparison'; 8 | import { ConflictHunk, findConflictHunks } from '../helpers/conflict-parser'; 9 | 10 | // Load Monaco 11 | 12 | const loadMonaco = require('monaco-loader') 13 | let monaco: any; 14 | loadMonaco().then((m: any) => { 15 | monaco = m; 16 | }); 17 | 18 | // ConflictViewer 19 | 20 | enum Version { 21 | Current = 1, 22 | Incoming = 2, 23 | Both = 3 24 | } 25 | export interface ConflictViewerOptions { 26 | fontSize: number; 27 | } 28 | 29 | export interface ConflictViewerProps { 30 | repo: RepoWrapper; 31 | patch: Git.ConvenientPatch; 32 | content: string; 33 | editorTheme: string; 34 | options: ConflictViewerOptions; 35 | } 36 | 37 | export class ConflictViewer extends React.PureComponent { 38 | divEditor: React.RefObject; 39 | editor: any; 40 | viewZoneIds: number[]; 41 | overlayWidgets: any[]; 42 | decorations: string[]; 43 | conflicts: ConflictHunk[]; 44 | 45 | constructor(props: ConflictViewerProps) { 46 | super(props); 47 | this.divEditor = React.createRef(); 48 | this.editor = null; 49 | this.viewZoneIds = []; 50 | this.overlayWidgets = []; 51 | this.decorations = []; 52 | this.setUpEditor = this.setUpEditor.bind(this); 53 | this.handleOpenInEditor = this.handleOpenInEditor.bind(this); 54 | this.handleResolve = this.handleResolve.bind(this); 55 | } 56 | 57 | componentDidMount() { 58 | this.conflicts = findConflictHunks(this.props.content.toString()); 59 | console.log(this.conflicts); 60 | this.setUpEditor(); 61 | } 62 | 63 | componentDidUpdate(prevProps: ConflictViewerProps) { 64 | // Only load if the patch has changed 65 | if (this.props.patch !== prevProps.patch || this.props.content !== prevProps.content) { 66 | const scrollTop = arePatchesSimilar(prevProps.patch, this.props.patch) ? this.editor.getScrollTop() : 0; 67 | this.conflicts = findConflictHunks(this.props.content.toString()); 68 | console.log(this.conflicts); 69 | this.updateEditor(scrollTop); 70 | } 71 | if (this.props.editorTheme !== prevProps.editorTheme) { 72 | monaco.editor.setTheme(this.props.editorTheme); 73 | } 74 | if (this.props.options !== prevProps.options) { 75 | this.editor.updateOptions(this.props.options); 76 | } 77 | } 78 | 79 | componentWillUnmount() { 80 | if (this.editor) { 81 | this.editor.dispose(); 82 | } 83 | } 84 | 85 | handleOpenInEditor() { 86 | openInEditor(this.props.patch.newFile().path()); 87 | } 88 | 89 | handleResolve() { 90 | this.props.repo.stagePatch(this.props.patch); 91 | } 92 | 93 | hide() { 94 | if (this.divEditor.current) { 95 | this.divEditor.current.classList.add('hidden'); 96 | } 97 | } 98 | 99 | show() { 100 | if (this.divEditor.current) { 101 | this.divEditor.current.classList.remove('hidden'); 102 | } 103 | } 104 | 105 | setUpEditor() { 106 | if (this.divEditor.current) { 107 | const options = { 108 | theme: this.props.editorTheme, 109 | automaticLayout: true, 110 | readOnly: true, 111 | minimap: { 112 | enabled: false 113 | }, 114 | ...this.props.options 115 | } 116 | this.editor = monaco.editor.create(this.divEditor.current, options) 117 | this.updateEditor(0); 118 | } 119 | } 120 | 121 | updateEditor(scrollTop: number) { 122 | // Hide 123 | this.hide(); 124 | // Reset editor 125 | this.resetEditor(); 126 | // Update models 127 | this.editor.setValue(this.props.content); 128 | this.createConflictWidgets(); 129 | // Scroll 130 | this.editor.setScrollTop(scrollTop); 131 | // Show 132 | this.show(); 133 | } 134 | 135 | resetEditor() { 136 | // Reset view zones 137 | this.editor.changeViewZones((changeAccessor: any) => { 138 | for (let viewZoneId of this.viewZoneIds) { 139 | changeAccessor.removeZone(viewZoneId); 140 | } 141 | }); 142 | this.viewZoneIds = []; 143 | // Reset overlay widgets 144 | for (let overlayWidget of this.overlayWidgets) { 145 | this.editor.removeOverlayWidget(overlayWidget); 146 | } 147 | this.overlayWidgets = []; 148 | } 149 | 150 | createConflictWidgets() { 151 | // Add decorations 152 | const decorations: any[] = []; 153 | for (let i = 0; i < this.conflicts.length; ++i) { 154 | const conflict = this.conflicts[i]; 155 | decorations.push({ 156 | range: new monaco.Range(conflict.start, 1, conflict.start, 1), 157 | options: { 158 | isWholeLine: true, 159 | className: 'conflict-start' 160 | } 161 | }); 162 | const currentEnd = (conflict.ancestors.length > 0 ? conflict.ancestors[0] : conflict.separator) - 1; 163 | if (conflict.start + 1 <= currentEnd) { 164 | decorations.push({ 165 | range: new monaco.Range(conflict.start + 1, 1, currentEnd, 1), 166 | options: { 167 | isWholeLine: true, 168 | className: 'conflict-current' 169 | } 170 | }); 171 | } 172 | if (conflict.separator + 1 <= conflict.end - 1) { 173 | decorations.push({ 174 | range: new monaco.Range(conflict.separator + 1, 1, conflict.end - 1, 1), 175 | options: { 176 | isWholeLine: true, 177 | className: 'conflict-incoming' 178 | } 179 | }); 180 | } 181 | decorations.push({ 182 | range: new monaco.Range(conflict.end, 1, conflict.end, 1), 183 | options: { 184 | isWholeLine: true, 185 | className: 'conflict-end' 186 | } 187 | }); 188 | } 189 | this.decorations= this.editor.deltaDecorations(this.decorations, decorations); 190 | // Create widgets 191 | const overlayWidgets: any[] = []; 192 | for (let i = 0; i < this.conflicts.length; ++i) { 193 | const id = `conflict.${this.overlayWidgets.length}`; 194 | const overlayNode = document.createElement('div'); 195 | overlayNode.classList.add('conflict-widget'); 196 | 197 | const contentNode = document.createElement('div'); 198 | const textNode = document.createElement('p'); 199 | textNode.textContent = `Conflict`; 200 | contentNode.appendChild(textNode); 201 | 202 | const buttonsNode = document.createElement('div'); 203 | // Current change button 204 | const currentChangeButton = document.createElement('button'); 205 | currentChangeButton.textContent = 'Accept current change'; 206 | currentChangeButton.addEventListener('click', () => this.acceptChange(this.conflicts[i], Version.Current)); 207 | buttonsNode.appendChild(currentChangeButton); 208 | // Incoming change button 209 | const incomingChangeButton = document.createElement('button'); 210 | incomingChangeButton.textContent = 'Accept incoming change'; 211 | incomingChangeButton.addEventListener('click', () => this.acceptChange(this.conflicts[i], Version.Incoming)); 212 | buttonsNode.appendChild(incomingChangeButton); 213 | // Incoming change button 214 | const bothChangesButton = document.createElement('button'); 215 | bothChangesButton.textContent = 'Accept both changes'; 216 | bothChangesButton.addEventListener('click', () => this.acceptChange(this.conflicts[i], Version.Both)); 217 | buttonsNode.appendChild(bothChangesButton); 218 | 219 | contentNode.appendChild(buttonsNode); 220 | overlayNode.appendChild(contentNode); 221 | overlayWidgets.push({ 222 | getId: () => id, 223 | getDomNode: () => overlayNode, 224 | getPosition: () => null 225 | }); 226 | this.overlayWidgets.push(overlayWidgets[overlayWidgets.length - 1]); 227 | } 228 | // Add the widgets (we batch the calls) 229 | this.editor.changeViewZones((changeAccessor: any) => { 230 | for (let i = 0; i < this.conflicts.length; ++i) { 231 | const overlayWidget = overlayWidgets[i]; 232 | this.editor.addOverlayWidget(overlayWidget); 233 | 234 | // Used only to compute the position. 235 | this.viewZoneIds.push(changeAccessor.addZone({ 236 | afterLineNumber: this.conflicts[i].start - 1, 237 | heightInLines: 2, 238 | domNode: document.createElement('div'), 239 | onDomNodeTop: (top: number) => { 240 | overlayWidget.getDomNode().style.top = top + "px"; 241 | }, 242 | onComputedHeight: (height: number) => { 243 | overlayWidget.getDomNode().style.height = height + "px"; 244 | } 245 | })); 246 | } 247 | }); 248 | } 249 | 250 | removeLines(linesToRemove: Set) { 251 | const lines = this.props.content.split('\n'); 252 | const value = lines.filter((line, i) => !linesToRemove.has(i + 1)).join('\n'); 253 | const path = Path.join(this.props.repo.repo.workdir(), this.props.patch.newFile().path()); 254 | return new Promise((resolve, reject) => { 255 | fs.writeFile(path, value, (error) => { 256 | if (!error) { 257 | resolve(); 258 | } else { 259 | reject(error); 260 | } 261 | }); 262 | }); 263 | } 264 | 265 | addConflictChange(linesToRemove: Set, conflict: ConflictHunk, version: Version) { 266 | function addRange(start: number, end: number) { 267 | for (let i = start; i < end; ++i) { 268 | linesToRemove.add(i); 269 | } 270 | } 271 | 272 | // Start line 273 | linesToRemove.add(conflict.start); 274 | // Incoming changes 275 | const currentEnd = conflict.ancestors.length > 0 ? conflict.ancestors[0] : conflict.separator; 276 | if ((version & Version.Current) === 0) { 277 | addRange(conflict.start + 1, currentEnd); 278 | } 279 | // Ancestors and separator 280 | addRange(currentEnd, conflict.separator + 1); 281 | // Current changes 282 | if ((version & Version.Incoming) === 0) { 283 | addRange(conflict.separator + 1, conflict.end); 284 | } 285 | // End line 286 | linesToRemove.add(conflict.end); 287 | } 288 | 289 | acceptChange(conflict: ConflictHunk, version: Version) { 290 | const linesToRemove = new Set(); 291 | this.addConflictChange(linesToRemove, conflict, version); 292 | this.removeLines(linesToRemove); 293 | } 294 | 295 | render() { 296 | const rightButtons = [ 297 | , 298 | , 299 | 300 | ]; 301 | return ( 302 | <> 303 |
    304 |
    305 | 306 |
    307 |
    308 | {rightButtons} 309 |
    310 |
    311 |
    312 | 313 | ); 314 | } 315 | } -------------------------------------------------------------------------------- /src/renderer/components/create-branch-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RepoWrapper } from '../helpers/repo-wrapper'; 3 | import { makeModal } from './make-modal'; 4 | import * as Git from 'nodegit'; 5 | 6 | export class CreateBranchFormProps { 7 | repo: RepoWrapper; 8 | commit: Git.Commit; 9 | onClose: () => void; 10 | } 11 | 12 | export class CreateBranchFormState { 13 | branchName: string; 14 | checkout: boolean; 15 | } 16 | 17 | class CreateBranchForm extends React.PureComponent { 18 | constructor(props: CreateBranchFormProps) { 19 | super(props); 20 | this.state = { 21 | branchName: '', 22 | checkout: true 23 | } 24 | this.handleBranchNameChange = this.handleBranchNameChange.bind(this); 25 | this.handleCheckoutChange = this.handleCheckoutChange.bind(this); 26 | this.handleSubmit = this.handleSubmit.bind(this); 27 | } 28 | 29 | handleBranchNameChange(event: React.ChangeEvent) { 30 | this.setState({branchName: event.target.value}); 31 | } 32 | 33 | handleCheckoutChange(event: React.ChangeEvent) { 34 | this.setState({checkout: event.target.checked}); 35 | } 36 | 37 | async handleSubmit(event: React.FormEvent) { 38 | event.preventDefault(); 39 | if (this.state.branchName) { 40 | const reference = await this.props.repo.createBranch(this.state.branchName, this.props.commit); 41 | if (reference && this.state.checkout) { 42 | this.props.repo.checkoutReference(reference); 43 | } 44 | this.props.onClose(); 45 | } 46 | } 47 | 48 | render() { 49 | return ( 50 |
    51 |
    52 | 53 | 59 |
    60 |
    61 | 62 | 67 |
    68 |
    69 | 74 |
    75 |
    76 | ); 77 | } 78 | } 79 | 80 | export const CreateBranchDialog = makeModal(CreateBranchForm); -------------------------------------------------------------------------------- /src/renderer/components/graph-canvas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RepoWrapper } from '../helpers/repo-wrapper'; 3 | import { getBranchColor, EdgeType, NodeType } from '../helpers/commit-graph'; 4 | 5 | const RADIUS = 11; 6 | export const OFFSET_X = 2 * RADIUS; 7 | const OFFSET_Y = 28; 8 | const LINE_WIDTH = 2; 9 | const INNER_RADIUS = RADIUS - LINE_WIDTH / 2; 10 | const DASH_LENGTH = 2 * Math.PI * INNER_RADIUS / 32; 11 | 12 | export interface GraphCanvasProps { 13 | repo: RepoWrapper; 14 | } 15 | 16 | export class GraphCanvas extends React.PureComponent { 17 | canvas: React.RefObject; 18 | offset: number; 19 | start: number; 20 | end: number; 21 | 22 | constructor(props: GraphCanvasProps) { 23 | super(props); 24 | this.canvas = React.createRef(); 25 | this.offset = 0; 26 | } 27 | 28 | handleScroll(offset: number, start: number, end: number) { 29 | this.offset = offset; 30 | this.handleRangeUpdate(start, end); 31 | } 32 | 33 | handleResize(height: number, start: number, end: number) { 34 | if (this.canvas.current) { 35 | const canvas = this.canvas.current; 36 | if (canvas.height != height) { 37 | canvas.height = height; 38 | this.handleRangeUpdate(start, end); 39 | } 40 | } 41 | } 42 | 43 | handleRangeUpdate(start: number , end: number) { 44 | this.start = start; 45 | this.end = end; 46 | this.drawGraph(); 47 | } 48 | 49 | drawGraph() { 50 | if (this.canvas.current) { 51 | this.canvas.current.width = this.props.repo.graph.width * OFFSET_X; 52 | const ctx = this.canvas.current.getContext('2d'); 53 | if (ctx) { 54 | this.drawIndexEdge(ctx); 55 | this.drawEdges(ctx); 56 | if (this.start === -1) { 57 | this.drawIndexNode(ctx); 58 | } 59 | this.drawNodes(ctx); 60 | } 61 | } 62 | } 63 | 64 | // Index 65 | 66 | drawIndexNode(ctx: CanvasRenderingContext2D) { 67 | // Set the style 68 | ctx.lineWidth = LINE_WIDTH; 69 | ctx.setLineDash([DASH_LENGTH]); 70 | ctx.strokeStyle = getBranchColor(0); 71 | 72 | // Draw the node 73 | const [x, y] = this.computeNodeCenterCoordinates(0, 0); 74 | ctx.beginPath(); 75 | ctx.arc(x, y, INNER_RADIUS, 0, 2 * Math.PI, true); 76 | ctx.stroke(); 77 | } 78 | 79 | drawIndexEdge(ctx: CanvasRenderingContext2D) { 80 | const repo = this.props.repo; 81 | const positions = repo.graph.positions; 82 | // Draw the edge between the index and the head commit 83 | if (repo.headCommit) { 84 | let [x0, y0] = this.computeNodeCenterCoordinates(0, 0); 85 | y0 += RADIUS; 86 | const node = positions.get(repo.headCommit.sha())!; 87 | if (!node) { 88 | console.error(`Unable to draw index edge: position of head is undefined.\n` + 89 | `Head\'s commit: ${repo.headCommit.sha()}.\n` + 90 | `Heads's branch: ${repo.head ? repo.head.name() : ''}`); 91 | return; 92 | } 93 | const [x1, y1] = this.computeNodeCenterCoordinates(node[0], node[1]); 94 | 95 | // Set the style 96 | ctx.lineWidth = LINE_WIDTH; 97 | ctx.setLineDash([DASH_LENGTH]); 98 | ctx.strokeStyle = getBranchColor(0); 99 | 100 | // Draw the edge 101 | ctx.beginPath(); 102 | ctx.moveTo(x0, y0); 103 | ctx.lineTo(x1, y1); 104 | ctx.stroke(); 105 | } 106 | } 107 | 108 | // Commits 109 | 110 | drawNodes(ctx: CanvasRenderingContext2D) { 111 | // Draw only visible nodes 112 | const positions = this.props.repo.commits.slice(Math.max(this.start, 0), this.end).map((commit) => 113 | this.props.repo.graph.positions.get(commit.sha())! 114 | ); 115 | for (let [i, j, type] of positions) { 116 | const [x, y] = this.computeNodeCenterCoordinates(i, j); 117 | ctx.fillStyle = getBranchColor(j); 118 | ctx.beginPath(); 119 | if (type === NodeType.Commit) { 120 | ctx.arc(x, y, RADIUS, 0, 2 * Math.PI, true); 121 | } else { 122 | ctx.fillRect(x - RADIUS, y - RADIUS, 2 * RADIUS, 2 * RADIUS); 123 | } 124 | ctx.fill(); 125 | } 126 | } 127 | 128 | drawEdges(ctx: CanvasRenderingContext2D) { 129 | const repo = this.props.repo; 130 | const edges = repo.graph.edges; 131 | ctx.lineWidth = LINE_WIDTH; 132 | ctx.setLineDash([]); 133 | for (let [[i0, j0], [i1, j1], type] of edges.search(this.start, this.end)) { 134 | const [x0, y0] = this.computeNodeCenterCoordinates(i0, j0); 135 | const [x1, y1] = this.computeNodeCenterCoordinates(i1, j1); 136 | ctx.beginPath(); 137 | if (j0 !== j1 && type === EdgeType.Merge) { 138 | ctx.strokeStyle = getBranchColor(j1); 139 | } else { 140 | ctx.strokeStyle = getBranchColor(j0); 141 | } 142 | ctx.moveTo(x0, y0); 143 | if (j0 !== j1) { 144 | if (type === EdgeType.Merge) { 145 | if (x0 < x1) { 146 | ctx.lineTo(x1 - RADIUS, y0); 147 | ctx.quadraticCurveTo(x1, y0, x1, y0 + RADIUS); 148 | } else { 149 | ctx.lineTo(x1 + RADIUS, y0); 150 | ctx.quadraticCurveTo(x1, y0, x1, y0 + RADIUS); 151 | } 152 | } else { 153 | if (x0 < x1) { 154 | ctx.lineTo(x0, y1 - RADIUS); 155 | ctx.quadraticCurveTo(x0, y1, x0 + RADIUS, y1); 156 | } else { 157 | ctx.lineTo(x0, y1 - RADIUS); 158 | ctx.quadraticCurveTo(x0, y1, x0 - RADIUS, y1); 159 | } 160 | } 161 | } 162 | ctx.lineTo(x1, y1); 163 | ctx.stroke(); 164 | } 165 | } 166 | 167 | computeNodeCenterCoordinates(i: number, j: number) { 168 | return [j * OFFSET_X + RADIUS, 3 + i * OFFSET_Y + RADIUS - this.offset] 169 | } 170 | 171 | render() { 172 | return ( 173 | 174 | ); 175 | } 176 | } -------------------------------------------------------------------------------- /src/renderer/components/graph-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { GraphCanvas, OFFSET_X } from './graph-canvas'; 4 | import { CommitList } from './commit-list'; 5 | import { Splitter } from './splitter'; 6 | import { ReferenceExplorer } from './reference-explorer' 7 | import { InputDialogHandler } from './input-dialog'; 8 | import { RepoWrapper, RepoState } from "../helpers/repo-wrapper"; 9 | 10 | export interface GraphViewerProps { 11 | repo: RepoWrapper; 12 | repoState: RepoState; 13 | selectedCommit: Git.Commit | null; 14 | onCommitSelect: (commit: Git.Commit) => void; 15 | onIndexSelect: () => void; 16 | onCreateBranch: (commit: Git.Commit) => void; 17 | onOpenInputDialog: InputDialogHandler; 18 | } 19 | 20 | export class GraphViewer extends React.PureComponent { 21 | referenceExplorer: React.RefObject; 22 | div: React.RefObject; 23 | canvas: React.RefObject; 24 | commitList: React.RefObject; 25 | 26 | constructor(props: GraphViewerProps) { 27 | super(props); 28 | this.referenceExplorer = React.createRef(); 29 | this.div = React.createRef(); 30 | this.canvas = React.createRef(); 31 | this.commitList = React.createRef(); 32 | this.handleListScroll = this.handleListScroll.bind(this); 33 | this.handleListResize = this.handleListResize.bind(this); 34 | this.handleListStateUpdate = this.handleListStateUpdate.bind(this); 35 | this.handleCanvasResize = this.handleCanvasResize.bind(this); 36 | this.handleLeftPanelResize = this.handleLeftPanelResize.bind(this); 37 | } 38 | 39 | handleListScroll(offset: number, start: number, end: number) { 40 | if (this.canvas.current) { 41 | this.canvas.current.handleScroll(offset, start, end); 42 | } 43 | } 44 | 45 | handleListResize(height: number, start: number, end: number) { 46 | if (this.canvas.current) { 47 | this.canvas.current.handleResize(height, start, end); 48 | } 49 | } 50 | 51 | handleListStateUpdate(start: number, end: number) { 52 | if (this.canvas.current) { 53 | this.canvas.current.handleRangeUpdate(start, end); 54 | } 55 | } 56 | 57 | handleCanvasResize(offset: number) { 58 | if (this.div.current) { 59 | const parentWidth = this.div.current.parentElement!.clientWidth; 60 | // 32px is the min width of the graph canvas 61 | // 100px is the min width of the commit list 62 | const newWidth = Math.max(32, Math.min(this.div.current.clientWidth + offset, parentWidth - 100)); 63 | this.setCanvasWidth(newWidth); 64 | } 65 | } 66 | 67 | handleLeftPanelResize(offset: number) { 68 | if (this.referenceExplorer.current) { 69 | this.referenceExplorer.current.resize(offset); 70 | } 71 | } 72 | 73 | updateGraph() { 74 | if (this.commitList.current) { 75 | this.commitList.current.updateState(); 76 | this.commitList.current.forceUpdate(); 77 | } 78 | if (this.referenceExplorer.current) { 79 | this.referenceExplorer.current.forceUpdate(); 80 | } 81 | } 82 | 83 | shrinkCanvas() { 84 | if (this.canvas.current) { 85 | const canvasWidth = this.props.repo.graph.width * OFFSET_X; 86 | // 32px is the min width of the graph canvas 87 | // 20px is the padding around the canvas 88 | // 98px is the width to display 4 branches 89 | this.setCanvasWidth(Math.max(32, Math.min(canvasWidth + 20, 98))); 90 | } 91 | } 92 | 93 | setCanvasWidth(width: number) { 94 | document.body.style.setProperty('--canvas-width', `${width}px`); 95 | } 96 | 97 | render() { 98 | return ( 99 |
    100 | { 102 | await this.props.onCommitSelect(commit); 103 | if (this.commitList.current) { 104 | this.commitList.current.centerOnItem(this.props.repo.shaToIndex.get(commit.sha())!); 105 | } 106 | }} 107 | onIndexSelect={async () => { 108 | await this.props.onIndexSelect(); 109 | if (this.commitList.current) { 110 | this.commitList.current.centerOnItem(-1); 111 | } 112 | }} 113 | onOpenInputDialog={this.props.onOpenInputDialog} 114 | ref={this.referenceExplorer} /> 115 | 116 |
    117 |
    118 | 119 | 120 | 125 |
    126 |
    127 |
    128 | ); 129 | } 130 | } -------------------------------------------------------------------------------- /src/renderer/components/image-patch-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { RepoWrapper } from '../helpers/repo-wrapper'; 4 | 5 | export interface ImagePatchViewerProps { 6 | repo: RepoWrapper; 7 | patch: Git.ConvenientPatch; 8 | oldBuffer: Buffer; 9 | newBuffer: Buffer; 10 | } 11 | 12 | export class ImagePatchViewer extends React.PureComponent { 13 | oldUrl: string; 14 | newUrl: string; 15 | 16 | render() { 17 | // Free urls 18 | if (this.oldUrl) { 19 | URL.revokeObjectURL(this.oldUrl); 20 | } 21 | if (this.newUrl) { 22 | URL.revokeObjectURL(this.newUrl); 23 | } 24 | // Create new urls 25 | this.oldUrl = URL.createObjectURL(new Blob([this.props.oldBuffer])); 26 | this.newUrl = URL.createObjectURL(new Blob([this.props.newBuffer])); 27 | return ( 28 |
    29 |

    Old file:

    30 | {this.props.patch.oldFile().flags() & Git.Diff.FLAG.EXISTS ? 31 | : 32 | null} 33 |

    New file:

    34 | {this.props.patch.newFile().flags() & Git.Diff.FLAG.EXISTS ? 35 | : 36 | null} 37 |
    38 | ); 39 | } 40 | } -------------------------------------------------------------------------------- /src/renderer/components/index-item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { RepoState } from '../helpers/repo-wrapper'; 4 | 5 | export interface IndexItemProps { 6 | repoState: RepoState; 7 | selected: boolean; 8 | onIndexSelect: () => void; 9 | } 10 | 11 | export class IndexItem extends React.PureComponent { 12 | constructor(props: IndexItemProps) { 13 | super(props); 14 | this.handleClick = this.handleClick.bind(this); 15 | } 16 | 17 | handleClick() { 18 | this.props.onIndexSelect(); 19 | } 20 | 21 | render() { 22 | //console.log(this.props.state); 23 | let suffix = '' 24 | switch (this.props.repoState) { 25 | case RepoState.Cherrypick: 26 | suffix = 'cherrypicking' 27 | break; 28 | case RepoState.Merge: 29 | suffix = 'merging'; 30 | break; 31 | case RepoState.Rebase: 32 | suffix = 'rebasing'; 33 | break; 34 | case RepoState.Revert: 35 | suffix = 'reverting'; 36 | break; 37 | } 38 | return ( 39 |
  • 40 | Index{suffix ? ` (${suffix})` : null} 41 |
  • 42 | ); 43 | } 44 | } -------------------------------------------------------------------------------- /src/renderer/components/index-viewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Git from 'nodegit'; 3 | import { PatchList } from './patch-list'; 4 | import { RepoWrapper, PatchType, RepoState } from '../helpers/repo-wrapper'; 5 | 6 | export interface IndexViewerProps { 7 | repo: RepoWrapper; 8 | repoState: RepoState; 9 | selectedPatch: Git.ConvenientPatch | null; 10 | onPatchSelect: (patch: Git.ConvenientPatch | null, type: PatchType) => void; 11 | } 12 | 13 | export interface IndexViewerState { 14 | amend: boolean; 15 | summary: string; 16 | description: string; 17 | selectedUnstagedPatches: Set; 18 | selectedStagedPatches: Set; 19 | } 20 | 21 | export class IndexViewer extends React.PureComponent { 22 | form: React.RefObject; 23 | iSelectedPatch: number; 24 | 25 | constructor(props: IndexViewerProps) { 26 | super(props); 27 | this.form = React.createRef(); 28 | this.state = { 29 | amend: false, 30 | summary: '', 31 | description: '', 32 | selectedUnstagedPatches: new Set(), 33 | selectedStagedPatches: new Set() 34 | } 35 | this.handleUnstagedPatchSelect = this.handleUnstagedPatchSelect.bind(this); 36 | this.handleSelectedUnstagedPatchesChange = this.handleSelectedUnstagedPatchesChange.bind(this); 37 | this.handleStagedPatchSelect = this.handleStagedPatchSelect.bind(this); 38 | this.handleSelectedStagedPatchesChange = this.handleSelectedStagedPatchesChange.bind(this); 39 | this.handlePatchesStage = this.handlePatchesStage.bind(this); 40 | this.handlePatchesUnstage = this.handlePatchesUnstage.bind(this); 41 | this.handleAmendChange = this.handleAmendChange.bind(this); 42 | this.handleSummaryChange = this.handleSummaryChange.bind(this); 43 | this.handleDescriptionChange = this.handleDescriptionChange.bind(this); 44 | this.handleSubmit = this.handleSubmit.bind(this); 45 | } 46 | 47 | async componentDidMount() { 48 | if (this.props.repoState === RepoState.Merge) { 49 | this.setState({ 50 | summary: await this.props.repo.getMergeMessage() 51 | }); 52 | } 53 | } 54 | 55 | async componentDidUpdate(prevProps: IndexViewerProps) { 56 | if (prevProps.repoState !== this.props.repoState) { 57 | this.setState({ 58 | summary: this.props.repoState === RepoState.Merge ? await this.props.repo.getMergeMessage() : '' 59 | }); 60 | } 61 | } 62 | 63 | handleUnstagedPatchSelect(patch: Git.ConvenientPatch) { 64 | this.setState({ 65 | selectedUnstagedPatches: new Set([patch]), 66 | selectedStagedPatches: new Set() 67 | }); 68 | this.iSelectedPatch = this.props.repo.unstagedPatches.indexOf(patch); 69 | this.props.onPatchSelect(patch, PatchType.Unstaged); 70 | } 71 | 72 | handleSelectedUnstagedPatchesChange(selectedPatches: Set) { 73 | this.setState({ 74 | selectedUnstagedPatches: selectedPatches, 75 | selectedStagedPatches: new Set() 76 | }); 77 | this.props.onPatchSelect(null, PatchType.Staged); 78 | } 79 | 80 | handleStagedPatchSelect(patch: Git.ConvenientPatch) { 81 | this.setState({ 82 | selectedUnstagedPatches: new Set(), 83 | selectedStagedPatches: new Set([patch]) 84 | }); 85 | this.iSelectedPatch = this.props.repo.stagedPatches.indexOf(patch); 86 | this.props.onPatchSelect(patch, PatchType.Staged); 87 | } 88 | 89 | handleSelectedStagedPatchesChange(selectedPatches: Set) { 90 | this.setState({ 91 | selectedUnstagedPatches: new Set(), 92 | selectedStagedPatches: selectedPatches 93 | }); 94 | this.props.onPatchSelect(null, PatchType.Staged); 95 | } 96 | 97 | handlePatchesStage() { 98 | if (this.props.selectedPatch || this.state.selectedUnstagedPatches.size === 0) { 99 | this.props.repo.stageAll(this.props.repo.unstagedPatches) 100 | } else if (this.state.selectedUnstagedPatches.size > 0) { 101 | this.props.repo.stageAll([...this.state.selectedUnstagedPatches]) 102 | } 103 | this.setState({ 104 | selectedUnstagedPatches: new Set() 105 | }); 106 | } 107 | 108 | handlePatchesUnstage() { 109 | if (this.props.selectedPatch || this.state.selectedStagedPatches.size === 0) { 110 | this.props.repo.unstageAll(this.props.repo.stagedPatches) 111 | } else if (this.state.selectedStagedPatches.size > 0) { 112 | this.props.repo.unstageAll([...this.state.selectedStagedPatches]) 113 | } 114 | this.setState({ 115 | selectedStagedPatches: new Set() 116 | }); 117 | } 118 | 119 | handleAmendChange(event: React.ChangeEvent) { 120 | if (this.props.repo.headCommit) { 121 | const newState = { 122 | amend: event.target.checked 123 | } as IndexViewerState 124 | if (newState.amend && !this.state.summary) { 125 | newState.summary = this.props.repo.headCommit.summary(); 126 | } 127 | this.setState(newState); 128 | } 129 | } 130 | 131 | handleSummaryChange(event: React.ChangeEvent) { 132 | this.setState({summary: event.target.value}); 133 | } 134 | 135 | handleDescriptionChange(event: React.ChangeEvent) { 136 | this.setState({description: event.target.value}); 137 | } 138 | 139 | refreshSelectedPatch(unstagedPatch: boolean) { 140 | const patches = unstagedPatch ? 141 | [this.props.repo.unstagedPatches, this.props.repo.stagedPatches] : 142 | [this.props.repo.stagedPatches, this.props.repo.unstagedPatches]; 143 | const handler = unstagedPatch ? 144 | [this.handleUnstagedPatchSelect, this.handleStagedPatchSelect] : 145 | [this.handleStagedPatchSelect, this.handleUnstagedPatchSelect]; 146 | const path = this.props.selectedPatch!.newFile().path(); 147 | // Try to find the find in the same list 148 | let patch = patches[0].find((patch) => path === patch.newFile().path()); 149 | if (patch) { 150 | handler[0].call(this, patch); 151 | return; 152 | } 153 | // If there is another patch in this list, select it 154 | if (patches[0].length > 0) { 155 | const i = this.iSelectedPatch < patches[0].length ? 156 | this.iSelectedPatch : 157 | Math.max(patches[0].length - 1, this.iSelectedPatch - 1); 158 | handler[0].call(this, patches[0][i]); 159 | return; 160 | } 161 | // Otherwise, try to find the file in the other list 162 | patch = patches[1].find((patch) => path === patch.newFile().path()); 163 | if (patch) { 164 | handler[1].call(this, patch); 165 | return; 166 | } 167 | // Finally, if there nothing succeeded reset the selected patch 168 | this.props.onPatchSelect(null, PatchType.Committed); 169 | } 170 | 171 | resize(offset: number) { 172 | if (this.form.current) { 173 | this.form.current.style.width = `${this.form.current.clientWidth - offset}px`; 174 | } 175 | } 176 | 177 | async handleSubmit(event: React.FormEvent) { 178 | event.preventDefault(); 179 | if (this.state.summary) { 180 | const message = this.state.description ? 181 | `${this.state.summary}\n\n${this.state.description}` : 182 | this.state.summary; 183 | if (this.props.repoState === RepoState.Merge) { 184 | await this.props.repo.finishMerge(message); 185 | } else { 186 | if (this.state.amend) { 187 | await this.props.repo.amend(message); 188 | } else { 189 | await this.props.repo.commit(message); 190 | } 191 | } 192 | // Close path viewer if it is open 193 | this.props.onPatchSelect(null, PatchType.Committed); 194 | // Reset the state 195 | this.setState({ 196 | amend: false, 197 | summary: '', 198 | description: '', 199 | selectedUnstagedPatches: new Set(), 200 | selectedStagedPatches: new Set() 201 | }); 202 | } 203 | } 204 | 205 | formatButtonString(prefix: string, patches: Set) { 206 | if (this.props.selectedPatch || patches.size === 0) { 207 | return `${prefix} all changes` 208 | } else if (patches.size === 1) { 209 | return `${prefix} 1 file` 210 | } else { 211 | return `${prefix} ${patches.size} files` 212 | } 213 | } 214 | 215 | getSubmitButton() { 216 | if (this.props.repoState === RepoState.Merge) { 217 | return ( 218 | <> 219 | 224 | 229 | 230 | ); 231 | } else { 232 | return ( 233 | 238 | ); 239 | } 240 | } 241 | 242 | render() { 243 | return ( 244 |
    245 |
    246 |

    Index

    247 |
    248 |
    249 |

    Unstaged files ({this.props.repo.unstagedPatches.length})

    250 | 256 |
    257 | 264 |
    265 |

    Staged files ({this.props.repo.stagedPatches.length})

    266 | 272 |
    273 | 280 |
    281 |

    Commit message

    282 | {this.props.repo.headCommit && this.props.repoState !== RepoState.Merge ? 283 |
    284 | 285 | 286 |
    : null} 287 |
    288 | 291 |