Compare commits

...

422 Commits
v0.25 ... v0.29

Author SHA1 Message Date
Mark Kopenga
2eeff1257b Merge pull request #1432 from black-desk/moved-submodule
fix moved submodule
2021-08-17 10:10:22 +02:00
Mark Kopenga
c878f34ff1 Merge pull request #1438 from Ryooooooga/feature/rename-files-with-modification
Fix staged renamed file with unstaged in file pane #1408
2021-08-16 17:29:08 +02:00
Mark Kopenga
f8db3592e3 Merge pull request #1440 from Ryooooooga/feature/quote-git-C-path
Fix stash submodule #1436
2021-08-16 16:43:12 +02:00
Ryooooooga
d073932cec Fix stash submodule #1436 2021-08-16 23:36:16 +09:00
Ryooooooga
a2f7fcd730 Remove unused constant 2021-08-16 23:21:46 +09:00
Ryooooooga
f96674b24b Fix error when filename contains -> 2021-08-16 23:15:37 +09:00
Ryooooooga
a553f7fb77 Fix staged renamed file with unstaged changes displays incorrectly in Files view #1408 2021-08-16 20:05:59 +09:00
Mark Kopenga
6c415d1341 Merge pull request #1434 from Ryooooooga/feature/fix-quote-in-filename
the patch panel would crash if the filename contained an odd number of double quotes
2021-08-16 09:52:06 +02:00
Mark Kopenga
617e8a05ee Merge pull request #1437 from Ryooooooga/feature/fix-submodule-update
Fix submodule command escaping #1436
2021-08-16 09:48:50 +02:00
Ryooooooga
b21ac990ea fix submodule command escaping #1436 2021-08-16 12:34:52 +09:00
Ryooooooga
0740409f43 fix test 2021-08-13 22:15:06 +09:00
Ryooooooga
37700908cc fix checkout file command 2021-08-13 21:49:40 +09:00
Ryooooooga
488c43aaa2 fix crash when double quotes in filename #1433 2021-08-13 21:39:38 +09:00
black_desk
bb4fe2653b fix moved submodule 2021-08-13 17:03:37 +08:00
Mark Kopenga
a2ee52142c Merge pull request #1433 from black-desk/fix-space-in-filename
fix empty patch panel when spaces in filename
2021-08-13 10:47:40 +02:00
black_desk
66d735acb5 Update pkg/commands/files.go
Co-authored-by: Mark Kopenga <mkopenga@gmail.com>
2021-08-13 16:41:23 +08:00
black_desk
d51b065f2a fix empty patch panel when spaces in filename 2021-08-13 14:02:11 +08:00
Mark Kopenga
a3a14e9ff4 Merge pull request #1423 from FoamScience/feature/colorsInMenuFromCommand
Support match colors in `labelFormat` entry in menuFromCommand prompts
2021-08-09 21:13:32 +02:00
mjarkk
e58376f9f7 add tests for TemplateFuncMapAddColors 2021-08-09 21:09:52 +02:00
Elwardi
e8e4fa5957 Add color functions to templates funcMaps 2021-08-09 11:52:00 +01:00
Elwardi
b5d8849c06 Support match colors in labelFormat entry in menuFromCommand prompts 2021-08-07 16:06:36 +01:00
Mark Kopenga
5d1a9639b6 Merge pull request #1416 from FoamScience/feature_menuOptions 2021-08-07 15:24:42 +02:00
mjarkk
ea136e4e77 Improve code quality
- Make CommandMenuEntry private
- create candidates only once we really need it
- Use only 1 buffer
- Clearify CommandMenuEntry creation fields
2021-08-06 21:50:58 +02:00
Elwardi
dcd3b7c058 Show only labels in menuFromCommand prompts 2021-08-06 18:38:26 +01:00
Mark Kopenga
fd8cb6e6d7 Merge pull request #1419 from mrgarelli/1418_bug_tests_ForceSetColorLevel
1418 Fixing test failure due to ColorLevel in test renderers
2021-08-06 14:30:26 +02:00
Elwardi
906ec30cac Minor changes to menuFromCommand prompts 2021-08-06 10:53:32 +01:00
Matthew Garelli
46c146a8c1 fixed test failing due to ForceSetColorLevel in pkg/gui/style/style_test.go 2021-08-06 02:18:04 -07:00
Elwardi
a8ec044f0e Make menuFromCommand format menu items and their description 2021-08-05 15:45:18 +01:00
Jesse Duffield
d626bcac00 color fixups 2021-08-01 16:14:56 +10:00
Jesse Duffield
123d624141 make import explicit 2021-08-01 13:23:59 +10:00
Jesse Duffield
e798aa4b15 more color tests 2021-08-01 13:21:06 +10:00
Mark Kopenga
04e474aa66 Merge pull request #1401 from jesseduffield/switch-text-color-library
Switch to gookit/color for terminal text styling
2021-07-31 20:58:40 +02:00
mjarkk
0662733ad9 add tests for color changes 2021-07-31 20:53:49 +02:00
Mark Kopenga
3c78ba7ed3 Merge pull request #1409 from jesseduffield/jesse-switch-text-color-library 2021-07-31 11:02:16 +02:00
Jesse Duffield
550c0fd4dc refactor 2021-07-31 17:56:47 +10:00
Jesse Duffield
0bc0e4ac88 more efficient 2021-07-31 17:33:20 +10:00
Jesse Duffield
117c0bd4f7 simplify code a bit 2021-07-31 17:33:13 +10:00
mjarkk
79848087bc Switch to github.com/gookit/color for terminal colors 2021-07-30 15:14:46 +02:00
mjarkk
a3b820fb5f add missing universal keybindings to doc 2021-07-29 14:15:26 +02:00
Mark Kopenga
de5133ff90 Merge pull request #1402 from jesseduffield/ci-build-less-binaries
PRs CI Build less types of go binaries
2021-07-28 15:45:51 +02:00
mjarkk
1183de151a revert changes from bfc9881 2021-07-28 15:42:37 +02:00
mjarkk
bfc9881213 added changes that should fail the ci on windows 2021-07-28 15:41:23 +02:00
mjarkk
3db40a79fe Sperate gh action build step 2021-07-28 15:40:06 +02:00
Jesse Duffield
62393cf28a more treeish files 2021-07-27 21:52:42 +10:00
Jesse Duffield
ec82f8099c update keybindings 2021-07-27 21:30:08 +10:00
Jesse Duffield
b81bac3d65 more i18n 2021-07-27 21:30:08 +10:00
Jesse Duffield
58ddbae4d1 Minor refactor 2021-07-27 21:30:08 +10:00
Denis Palashevskii
3802b563b0 Add error message if target branch not found with prompt 2021-07-27 21:30:08 +10:00
Denis Palashevskii
d1134daa53 review fixes: PR URL refactoring, target branch selection prompt 2021-07-27 21:30:08 +10:00
Denis Palashevskii
63cb304a82 Fix translations, make formatter happy 2021-07-27 21:30:08 +10:00
Denis Palashevskii
6e579dc6e4 Update localized Keybinding file 2021-07-27 21:30:08 +10:00
Denis Palashevskii
d5ec0fdcd1 Remove doubled string formatting in pull request URL generation 2021-07-27 21:30:08 +10:00
Denis Palashevskii
0a63f701e5 Apply suggestions from code review
Co-authored-by: Mark Kopenga <mkopenga@gmail.com>
2021-07-27 21:30:08 +10:00
Denis Palashevskii
bccf203a18 Fix menu item color 2021-07-27 21:30:08 +10:00
Denis Palashevskii
b590397dce Update docs 2021-07-27 21:30:08 +10:00
Denis Palashevskii
755cc9f8d8 Add tests 2021-07-27 21:30:08 +10:00
Denis Palashevskii
0e6598adbd Implement pull request options menu 2021-07-27 21:30:08 +10:00
Denis Palashevskii
f2645da16a Extract git service URL formatting to a separate method 2021-07-27 21:30:08 +10:00
Francisco Miamoto
f8f596d097 add tests for open file cmd on linux 2021-07-27 20:28:00 +10:00
Francisco Miamoto
028cb2be2f add extra quoting for shell cmd string on linux
This solves an issue where we could not open files with names that contained
spaces and single quotes.
It also  solves an issue of variable expansion for files with some kind
of environment variables on the name e.g. '$USER.txt'
2021-07-27 20:28:00 +10:00
Evan Boehs
fb69bfd20d Update english.go 2021-07-27 14:57:41 +10:00
Mark Kopenga
f4874bbb74 Merge pull request #1396 from mjarkk/fix-1385
branches check for split parts length
2021-07-26 11:22:33 +02:00
Mark Kopenga
eec20b845d Merge pull request #1392 from mjarkk/parcally-fix-1385
Change the way file statuses are loaded
2021-07-26 11:22:14 +02:00
mjarkk
3a0a9ec33b branches check for split parts length 2021-07-26 11:07:42 +02:00
Mark Kopenga
9b57b73f41 Merge pull request #1395 from mjarkk/allow-hex-theme-colors
Allow hex theme colors
2021-07-26 10:48:25 +02:00
mjarkk
4fca89bc52 Allow hex theme colors 2021-07-26 10:38:45 +02:00
mjarkk
fc76b44b45 correctly show files with special chars in commit 2021-07-23 12:04:23 +02:00
mjarkk
9a087d04eb Change the way file statuses are loaded
This makes it so file statuses recived from git no longer get joined
before spliting them again.
2021-07-22 22:12:43 +02:00
Mark Kopenga
c005b0d92b Merge pull request #1390 from FoamScience/menu_from_cmd
Generate menu options from a Git Command with a filter
2021-07-22 19:54:14 +02:00
mjarkk
713fae3e32 format code 2021-07-22 19:45:43 +02:00
Elwardi
148bf2c070 Add test for GenerateMenuCandidates from Custom Commands 2021-07-22 15:44:16 +01:00
Elwardi
edfb0a26b2 Refactor code around handleCustomCommandKeybinding 2021-07-20 20:59:03 +01:00
Elwardi
f70435a20f Better format error catching in menuFromCommand prompts 2021-07-19 13:41:42 +01:00
Elwardi
b92ff3ee3f Consider first match only in menuFromCommand prompt 2021-07-19 13:06:00 +01:00
Elwardi
f1ced5539a Add option to format filter matches to menuFromCommand prompts 2021-07-19 11:46:29 +01:00
Elwardi
77e9ee64a4 Apply suggestions from @mjarkk for menyFromCommands 2021-07-18 18:42:42 +01:00
Elwardi
9daa47fb2d Add docs for menuFromCommand prompts 2021-07-18 10:36:01 +01:00
Elwardi
d18c8c8dc3 Add prompt type: menuFromCommand 2021-07-18 10:36:00 +01:00
Mark Kopenga
1573a449f8 Merge pull request #1389 from mjarkk/parse-emoji-update-docs
Add parse github emoji to config docs
2021-07-16 21:14:21 +02:00
mjarkk
7b19c5ad95 add parse github emoji to docs 2021-07-16 21:13:01 +02:00
Mark Kopenga
b363b75534 Merge pull request #1387 from mjarkk/parse-emoji-2
parse github emoji config option
2021-07-16 21:01:14 +02:00
mjarkk
fc066d2f2e parse github emoji config option 2021-07-16 14:06:01 +02:00
Davyd McColl
53ea7df655 🚚 move only the platform-specific part of log tailing into platform-specific files 2021-07-01 17:13:14 +10:00
Davyd McColl
533817bda3 🐛 should be TailLogs 2021-07-01 17:13:14 +10:00
Davyd McColl
35f1ccdb1b ♻️ temporarily bypass ignore whitespace for diff view instead of turning the toggle off completely 2021-07-01 17:13:14 +10:00
Davyd McColl
3dc3174d85 🔥 remove erroneous user_config fields 2021-07-01 17:13:14 +10:00
Davyd McColl
ae2496cf80 🎨 prefer the long switch over the short one for easier reading 2021-07-01 17:13:14 +10:00
Davyd McColl
2ac33bb83d 🎨 split out platform-dependent logging for compile-time selection 2021-07-01 17:13:14 +10:00
Davyd McColl
2b4048ebff 🐛 shouldn't hammer the file continually, have a nap instead 2021-07-01 17:13:14 +10:00
Davyd McColl
31bcd632c7 🎨 observe the error, if there is one 2021-07-01 17:13:14 +10:00
Davyd McColl
aa9ef12d43 make log-watching work on windows 2021-07-01 17:13:14 +10:00
Davyd McColl
b80fafef02 🎨 properly ignore the result 2021-07-01 17:13:14 +10:00
Davyd McColl
130480555f always show whitespace in diffs when entering line-by-line staging 2021-07-01 17:13:14 +10:00
Davyd McColl
92cc6e883d 🚚 move whitespace toggle out of quitting.go 2021-07-01 17:13:14 +10:00
Davyd McColl
107503c903 🎨 alternative syntax 2021-07-01 17:13:14 +10:00
Davyd McColl
7ae106d4df 🎨 run formatter 2021-07-01 17:13:14 +10:00
Davyd McColl
16dcc8f4db implement feedback when toggling whitespace 2021-07-01 17:13:14 +10:00
Davyd McColl
eb10ddfccc add a test around ignoring whitespace 2021-07-01 17:13:13 +10:00
Davyd McColl
22a6771e51 🎨 run go fmt against the file directly ftw 2021-07-01 17:13:13 +10:00
Davyd McColl
3f96537380 update test to pass in default ignore-whitespace flag (false) 2021-07-01 17:13:13 +10:00
Davyd McColl
a9f04d3925 facilitate toggling whitespace in the diff view with a hotkey (c-w by default) 2021-07-01 17:13:13 +10:00
Mark Kopenga
83834a2c2e Merge pull request #1373 from danielebra/master
Fix typo in random tip
2021-06-28 10:00:25 +02:00
Daniel Ebrahimian
0c3132c6f0 Fix typo in random tip 2021-06-28 11:03:54 +10:00
Cristian Betivu
b28569a593 Fix a format issue 2021-06-16 15:00:17 +10:00
Cristian Betivu
1aa45b0142 Update tests 2021-06-16 15:00:17 +10:00
Cristian Betivu
39c8577074 Use static context 2021-06-16 15:00:17 +10:00
Jesse Duffield
23285eab40 more resilient test 2021-06-16 15:00:17 +10:00
Cristian Betivu
0c2d90a444 Add comment 2021-06-16 15:00:17 +10:00
Cristian Betivu
d65c018875 Add integration test 2021-06-16 15:00:17 +10:00
Cristian Betivu
0c135515a5 Use parent view for tab navigation 2021-06-16 15:00:17 +10:00
Jesse Duffield
2b9df0ea06 fix up cheatsheet 2021-06-15 08:37:56 +10:00
Jesse Duffield
b7b30191f1 update cheatsheet 2021-06-15 08:37:10 +10:00
Stefan Teunissen
7d1b76a349 Update dutch.go 2021-06-15 08:37:03 +10:00
Emiliano Ruiz Carletti
40f10c3388 Update config.md 2021-06-15 08:31:07 +10:00
Emiliano Ruiz Carletti
01e4467d76 Add test cases for pull mode 2021-06-15 08:31:07 +10:00
Emiliano Ruiz Carletti
b4e6850f98 Fix wrong ff-only configuration 2021-06-15 08:31:07 +10:00
Emiliano Ruiz Carletti
c57a0077d0 Read pull mode from gitconfig lazily 2021-06-15 08:31:07 +10:00
Emiliano Ruiz Carletti
46e500dc28 Revert "Read pull mode from git configuration"
This reverts commit e69e240a31.
2021-06-15 08:31:07 +10:00
Emiliano Ruiz Carletti
d7865b3882 Read pull mode from git configuration 2021-06-15 08:31:07 +10:00
Jesse Duffield
0aad68acf0 Merge branch 'btwise-master' 2021-06-15 08:25:24 +10:00
Jesse Duffield
4969e9ce0a gofmt 2021-06-15 08:25:07 +10:00
Jesse Duffield
17770b9f9b go mod vendor 2021-06-15 08:13:45 +10:00
Jesse Duffield
3dd88d6138 bump dependencies 2021-06-15 08:12:38 +10:00
Jesse Duffield
ce7cbe58a0 naming change 2021-06-14 18:17:08 +10:00
Andrei Yangabishev
7588d5290b ShowTotal flag 2021-06-10 12:43:05 +03:00
Jesse Duffield
9fdf92b226 more refactoring
WIP

WIP
2021-06-06 09:12:49 +10:00
Jesse Duffield
93bf691fd6 refactoring 2021-06-06 09:12:49 +10:00
Jesse Duffield
82022615dd bump tcell 2021-06-06 09:12:42 +10:00
Jesse Duffield
fb395bca6e support reverting merge commits 2021-06-05 22:15:51 +10:00
Jesse Duffield
f91adf026b fix lbl scrolling 2021-06-05 13:54:05 +10:00
Jesse Duffield
6d91661d5e prevent closure issue 2021-06-05 13:54:05 +10:00
Jesse Duffield
90983aae65 not importing regexp 2021-06-05 13:53:25 +10:00
Jesse Duffield
f71b23b890 more explicit 2021-06-05 13:53:25 +10:00
Cristian Betivu
05a23f0e1e Discard value after END marker 2021-06-05 13:53:25 +10:00
Cristian Betivu
fd38ad8096 More generic merge conflict detection 2021-06-05 13:53:25 +10:00
Jesse Duffield
d502c43ae8 fix tests 2021-06-05 10:58:36 +10:00
Jesse Duffield
0df02dacc2 minor changes 2021-06-05 10:58:09 +10:00
caojoshua
3258c24fb3 Better english for Configuring File Editing. 2021-06-05 10:58:09 +10:00
caojoshua
e7c657fba0 Docs for EditCommand. 2021-06-05 10:58:09 +10:00
caojoshua
60468d2e17 Edit command as user OS config option 2021-06-05 10:58:09 +10:00
Robert Verst
cb78cf7de4 Simplify sorting of git tags by using git's functions 2021-06-05 10:56:46 +10:00
Robert Verst
94b52af661 Remove config, make default sort order descending 2021-06-05 10:56:46 +10:00
Robert Verst
472288c81b Add user config to change the sort order of tags 2021-06-05 10:56:46 +10:00
Jesse Duffield
258eedb38c refactor 2021-06-02 20:33:52 +10:00
Jérémy Pagé
bc044c64b2 Remove origin prefix when creating local branch based from origin 2021-05-30 15:29:56 +10:00
Harrison Jones
e478c254d4 Handle alternate merge conflict format; add tests 2021-05-30 13:50:42 +10:00
Liberatys
44f7fc6f7c Add global binding to open recent repos 2021-05-30 13:25:44 +10:00
btwise
a13e919d3d add chinese for i18n 2021-05-19 17:55:26 +08:00
Dawid Dziurla
f92fcfbb47 cd: remove ppa job
Deprecated
2021-05-07 00:08:22 +02:00
Dawid Dziurla
6ccf58c224 README: deprecate Ubuntu PPA 2021-05-07 00:06:38 +02:00
Petróczi Zoltán
9190e9beac Fix englishIntroPopupMessage typo in english.go 2021-04-20 21:08:29 +10:00
Jesse Duffield
a99e6ba071 update release notes 2021-04-20 18:34:47 +10:00
Jesse Duffield
604ee02cd9 ignore east asian width setting to avoid broken frame rendering 2021-04-19 23:06:05 +10:00
Jesse Duffield
926a48a65b smarter sizing of command log panel 2021-04-19 18:09:01 +10:00
Jesse Duffield
98375dc902 refactor merge panel 2021-04-18 18:58:09 +10:00
Jesse Duffield
e73de332a1 refactor line by line panel 2021-04-18 16:55:09 +10:00
Jesse Duffield
b28b2d05bd force cursor to be at end of line when opening confirmation panel 2021-04-17 21:15:54 +10:00
Jesse Duffield
9e5f031553 bubble up tracked files in flat file view 2021-04-17 10:04:49 +10:00
Peijun Ma
dab5ba363c Fix typo: scrool -> scroll 2021-04-14 19:11:30 +10:00
Jesse Duffield
e42387d0da update keybindings 2021-04-12 23:40:20 +10:00
Jesse Duffield
730a03a3b2 fix race condition 2021-04-12 23:40:20 +10:00
Jesse Duffield
7d195b97c2 better squash description 2021-04-12 21:57:13 +10:00
Jesse Duffield
4fb2dba587 allow hiding random tip 2021-04-12 21:48:08 +10:00
Jesse Duffield
76697280c9 fix rendering issues caused by resizing 2021-04-12 21:48:08 +10:00
Jesse Duffield
0df6ac6140 bump gocui to fix resizing issue 2021-04-12 21:48:08 +10:00
Jesse Duffield
5453b71fd1 linting 2021-04-12 21:48:08 +10:00
Jesse Duffield
1f3070c882 fix up doc 2021-04-12 21:48:08 +10:00
Jesse Duffield
3b7e7a7f56 add random tip to command log 2021-04-12 21:48:08 +10:00
Jesse Duffield
06a8eb115c make command log size configurable 2021-04-11 23:36:34 +10:00
Jesse Duffield
e4f0a470e9 print header for command log 2021-04-11 23:36:34 +10:00
Jesse Duffield
adee0b8ccb add spans to i18n 2021-04-11 23:36:34 +10:00
Jesse Duffield
0bebfe454e pull out function 2021-04-11 23:36:34 +10:00
Jesse Duffield
84b0c3df4f ask question button 2021-04-11 22:07:29 +10:00
Jesse Duffield
764bd556f3 prettify config.md 2021-04-11 21:42:41 +10:00
Jesse Duffield
069c7c9d35 fix test 2021-04-11 17:07:49 +10:00
Jesse Duffield
393ce05860 allow focusing on command log view 2021-04-11 17:07:49 +10:00
Jesse Duffield
cf78b86cb5 more support for command log and more code reuse for contexts 2021-04-11 17:07:49 +10:00
Jesse Duffield
4f03d7733a allow showing, hiding, and scrolling the extras panel 2021-04-11 17:07:49 +10:00
Jesse Duffield
e3a14d546a support static boxes that go outside the available size 2021-04-11 17:07:49 +10:00
Jesse Duffield
f2007f4d95 support scrolling extras view 2021-04-11 17:07:49 +10:00
Jesse Duffield
8969464b00 log TODO content when interactive rebasing 2021-04-11 17:07:49 +10:00
Jesse Duffield
6137d66914 no need to log this 2021-04-11 17:07:49 +10:00
Jesse Duffield
6fbe660f96 full coverage for logging commands 2021-04-11 17:07:49 +10:00
Jesse Duffield
74320f0075 more logging of commands 2021-04-11 17:07:49 +10:00
Jesse Duffield
bfad972f0c fix bug where mixed reset is actually a soft reset 2021-04-11 17:07:49 +10:00
Jesse Duffield
bb918b579a start adding support for logging of commands 2021-04-11 17:07:49 +10:00
Jesse Duffield
e145090046 add cmdLog panel 2021-04-11 17:07:49 +10:00
Jesse Duffield
70b5c822bb update config.md to explain situation with config paths 2021-04-11 10:25:37 +10:00
tafryn
f2df77a4f1 Fix path for Linux config file
The listed config file directory for Linux is incorrect.
2021-04-11 10:22:26 +10:00
Jesse Duffield
8d416634ba update release notes 2021-04-11 10:21:53 +10:00
Jesse Duffield
9f4433d8b5 allow opening merge tool 2021-04-11 10:21:53 +10:00
Jesse Duffield
2d8f7d2a7b better way of scrolling to a merge conflict 2021-04-11 10:21:53 +10:00
Jesse Duffield
a9fbc9eda1 fix merge conflict panel not rendering 2021-04-11 10:21:53 +10:00
Jesse Duffield
e092da5f78 pause background threads when running subprocess 2021-04-10 12:16:45 +10:00
Jesse Duffield
e42e7e5cbd fix commit amend 2021-04-10 11:54:38 +10:00
Jesse Duffield
93fac1f312 reduce flicker without worrying about carriage returns 2021-04-09 22:50:55 +10:00
Jesse Duffield
d5504fa5d0 potentially fix credentials issue 2021-04-09 00:39:04 +10:00
Jesse Duffield
273aba38d4 stricter CI 2021-04-09 00:15:48 +10:00
Jesse Duffield
cab0aa462c fix crash at start 2021-04-09 00:10:35 +10:00
Jesse Duffield
b03e2270a0 revert no-flicker due to carriage return weirdness 2021-04-08 23:17:27 +10:00
Jesse Duffield
21049be233 support file tree mode on windows 2021-04-08 21:33:17 +10:00
Jesse Duffield
f89c47b83d add test for building tree 2021-04-08 21:33:17 +10:00
Jesse Duffield
44f1f22068 close commit message panel after returning from subprocess 2021-04-08 20:17:16 +10:00
Jesse Duffield
a229547048 fix CI 2021-04-07 22:59:53 +10:00
Jesse Duffield
4f700c23ba fix crash on first open 2021-04-07 22:59:53 +10:00
Emiliano Ruiz Carletti
b69fc19b35 fix broken link to old AUR package 2021-04-07 08:51:07 +10:00
Jesse Duffield
cd1d1996df add 0.27 release notes 2021-04-06 19:34:32 +10:00
Jesse Duffield
963fcc1444 don't kill the index.lock file until I decide whether it's actually a good idea 2021-04-06 19:34:32 +10:00
Jesse Duffield
c6825e3d0d skip some tests that are failing on CI for some reason 2021-04-06 19:34:32 +10:00
Jesse Duffield
20bdba15f6 amend reword test 2021-04-06 19:34:32 +10:00
Jesse Duffield
e636857057 prevent adding staged files when renaming top commit 2021-04-06 19:34:32 +10:00
Jesse Duffield
1ae8523098 restore contents on resume from subprocess
The proper fix will come out of this PR but it's not yet merged:
https://github.com/gdamore/tcell/pull/439/files

In the meantime I'm just going to directly edit this from my vendor
directory. If it ends up stretching a while I'll fork tcell properly
and use the fork.
2021-04-06 19:34:32 +10:00
Jesse Duffield
8eb802d3a0 fix flicker issue in main view 2021-04-06 19:34:32 +10:00
Jesse Duffield
6fc031c523 hide patch panel if we're in the commits panel and we refresh and it's now exited 2021-04-06 19:34:32 +10:00
Jesse Duffield
8c93289a72 reduce chance of deadlock by using a RW mutex on the context stack 2021-04-06 19:34:32 +10:00
Jesse Duffield
b1df0fafa2 remove junk test 2021-04-06 19:34:32 +10:00
Jesse Duffield
15046a0454 more tests for branches 2021-04-06 19:34:32 +10:00
Jesse Duffield
fb9b6314a0 ensure we're passing the right testing struct pointer around 2021-04-06 19:34:32 +10:00
Jesse Duffield
0719a3e36e stop checking out branches when doing a rename. Instead just move the cursor to the new position 2021-04-06 19:34:32 +10:00
Jesse Duffield
a3b0efb82e branch rename test 2021-04-06 19:34:32 +10:00
Jesse Duffield
bde324820d more tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
bbdbbd0b1b more tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
d4f3b292e6 even slower retries for CI 2021-04-06 19:34:32 +10:00
Jesse Duffield
39eb937830 update test descriptions 2021-04-06 19:34:32 +10:00
Jesse Duffield
fbab5bd444 do not refresh patch panel unless commit files panel is the current side panel 2021-04-06 19:34:32 +10:00
Jesse Duffield
12ca922a41 add tests for diffing 2021-04-06 19:34:32 +10:00
Jesse Duffield
f4e552f982 prevent deadlocks.
Hard to choose between the lock with a defer unlock in an anonymous function
vs just having an explicit unlock at the end with additional unlocks before
any early returns. The former is less error prone, but the former is much more
readable, especially if the anonymous function would have needed to return
an error value.
2021-04-06 19:34:32 +10:00
Jesse Duffield
94d26d00ba move suggestions view behind confirmation view 2021-04-06 19:34:32 +10:00
Jesse Duffield
d80d1f8493 more tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
ace4350319 update snapshots to include tags comparison 2021-04-06 19:34:32 +10:00
Jesse Duffield
4441cf1045 fix bug with tags panel 2021-04-06 19:34:32 +10:00
Jesse Duffield
cf99b47ec0 another filter path test 2021-04-06 19:34:32 +10:00
Jesse Duffield
546eb50bac add another filter path test 2021-04-06 19:34:32 +10:00
Jesse Duffield
5e094c8a7c marginally better logic for searching 2021-04-06 19:34:32 +10:00
Jesse Duffield
c683f2c96c allow opening diff menu panel when other popup is open 2021-04-06 19:34:32 +10:00
Jesse Duffield
e5a372fa2d allow opening filter menu panel when other popup is open 2021-04-06 19:34:32 +10:00
Jesse Duffield
02f45b679f do not double-append contexts to the stack 2021-04-06 19:34:32 +10:00
Jesse Duffield
b1cda65dcf show error when user attempts to commit when no files are present 2021-04-06 19:34:32 +10:00
Jesse Duffield
74ce65d9ff update keybindings 2021-04-06 19:34:32 +10:00
Jesse Duffield
ccebe5e069 change language 2021-04-06 19:34:32 +10:00
Jesse Duffield
b6ec667de0 add comment 2021-04-06 19:34:32 +10:00
Jesse Duffield
390b7ddc5e change order of filtering and patch building so that esc key exits patch building mode first 2021-04-06 19:34:32 +10:00
Jesse Duffield
38739b16bc add filter path test 2021-04-06 19:34:32 +10:00
Jesse Duffield
27525f1d42 support passing extra command args in integration tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
43a9dc48e0 default to not quitting when hitting esc at the top level.
I've been using this config option for years now so I don't think much of it,
but newcomers are going to find it annoying that hitting escape gets you out
of filtering/cherry-picking/patch-building mode, but also quits the app. So
if you want to exit all the modes you're in, you need to take care not to
press the key one too many times or the app will close.

We'll see if anybody gets mad about this change, but I think it's reasonable.
The only downside is that you won't be able to always quit by spamming the escape
key. If you're in a prompt panel, you'll need to hit escape to exit that, and
then 'q' at the top level. Or CTRL+C of course.
2021-04-06 19:34:32 +10:00
Jesse Duffield
440eb387d7 much cleaner integration test code 2021-04-06 19:34:32 +10:00
Jesse Duffield
28ffaf9348 tiny refactor 2021-04-06 19:34:32 +10:00
Jesse Duffield
d7da6dde0e allow decimal replay speeds for integration tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
e000620cdf fix windows compilation issue 2021-04-06 19:34:32 +10:00
Jesse Duffield
f09309485a remove time limit 2021-04-06 19:34:32 +10:00
Jesse Duffield
e04e2ebab5 try better logging for CI 2021-04-06 19:34:32 +10:00
Jesse Duffield
91a107eb6f retry flakey tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
5ce9e0193a add retry logic for running git commands to avoid index.lock problems 2021-04-06 19:34:32 +10:00
Jesse Duffield
4c71c26593 speed up test 2021-04-06 19:34:32 +10:00
Jesse Duffield
abdd2455bb allow playing and updating snapshots 2021-04-06 19:34:32 +10:00
Jesse Duffield
c33f8d2790 prevent git from prompting user if program is run directly 2021-04-06 19:34:32 +10:00
Jesse Duffield
8e9d08bc10 minor cleanup of integration code 2021-04-06 19:34:32 +10:00
Jesse Duffield
9593129e6a remove caching of styles in gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
267da3b4db fix issue when switching repos while files refresh 2021-04-06 19:34:32 +10:00
Jesse Duffield
c9ded489c9 bump gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
4c73d070ac ignore clicks on invisible views 2021-04-06 19:34:32 +10:00
Jesse Duffield
121b9d0715 update comment 2021-04-06 19:34:32 +10:00
Jesse Duffield
fbb33b7abc remove code that I'm pretty sure isn't needed 2021-04-06 19:34:32 +10:00
Jesse Duffield
7178bab6b4 only re-use repo state when jumping in and out of submodules 2021-04-06 19:34:32 +10:00
Jesse Duffield
2d7452bfaa Revert "see how CI goes running these tests in parallel"
This reverts commit d271cbc138.
2021-04-06 19:34:32 +10:00
Jesse Duffield
b0f3bfef27 see how CI goes running these tests in parallel 2021-04-06 19:34:32 +10:00
Jesse Duffield
7bc6dc5cf3 show branches context when starting in filtering mode 2021-04-06 19:34:32 +10:00
Jesse Duffield
ee7b634dce how about using pty 2021-04-06 19:34:32 +10:00
Jesse Duffield
b0bd752180 maybe this will fix CI 2021-04-06 19:34:32 +10:00
Jesse Duffield
4d14af5d4b more lint fixes 2021-04-06 19:34:32 +10:00
Jesse Duffield
7953e58c74 try this 2021-04-06 19:34:32 +10:00
Jesse Duffield
549d73a0b1 fix lint issues 2021-04-06 19:34:32 +10:00
Jesse Duffield
8301bba8ad make it more likely for CI to work 2021-04-06 19:34:32 +10:00
Jesse Duffield
78f17aa541 update squash integration test 2021-04-06 19:34:32 +10:00
Jesse Duffield
7578a7466f update searching tests 2021-04-06 19:34:32 +10:00
Jesse Duffield
8681a6b4e2 update patch building with filetree test 2021-04-06 19:34:32 +10:00
Jesse Duffield
efed313721 update patch building 2 test 2021-04-06 19:34:32 +10:00
Jesse Duffield
795cf39ddf update patch building test 2021-04-06 19:34:32 +10:00
Jesse Duffield
f08f248cb7 update merge conflict test 2021-04-06 19:34:32 +10:00
Jesse Duffield
3c20425649 update merge conflict undo test 2021-04-06 19:34:32 +10:00
Jesse Duffield
dfc689411b no need for debug flag because it writes to a different log anyway 2021-04-06 19:34:32 +10:00
Jesse Duffield
2295407a45 update discard file changes test 2021-04-06 19:34:32 +10:00
Jesse Duffield
828a2acd26 update branch autocomplete integration test 2021-04-06 19:34:32 +10:00
Jesse Duffield
843b8ceab0 support tcell simulation screen 2021-04-06 19:34:32 +10:00
Jesse Duffield
011451464f working on integration tests working again 2021-04-06 19:34:32 +10:00
Jesse Duffield
32d170621c remove mutex lock that caused deadlock 2021-04-06 19:34:32 +10:00
Jesse Duffield
464d022a86 minor refactor 2021-04-06 19:34:32 +10:00
Jesse Duffield
6a0066253f move recording code into gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
d627b3bfc8 more refactoring 2021-04-06 19:34:32 +10:00
Jesse Duffield
952c62df37 fix bug where searching through view got stuck if you went over the upper bound 2021-04-06 19:34:32 +10:00
Jesse Duffield
b6cc1c9492 small refactor 2021-04-06 19:34:32 +10:00
Jesse Duffield
39ae122304 more refactoring 2021-04-06 19:34:32 +10:00
Jesse Duffield
c34c6926d5 fix some things up 2021-04-06 19:34:32 +10:00
Jesse Duffield
4fe512ff3a test
type safe view access
2021-04-06 19:34:32 +10:00
Jesse Duffield
4197921465 WIP 2021-04-06 19:34:32 +10:00
Jesse Duffield
4b69ab08c1 WIP 2021-04-06 19:34:32 +10:00
Jesse Duffield
f3a0058eb9 WIP 2021-04-06 19:34:32 +10:00
Jesse Duffield
633b6f596d WIP 2021-04-06 19:34:32 +10:00
Jesse Duffield
e6274c0757 remove sentinel errors 2021-04-06 19:34:32 +10:00
Jesse Duffield
0898a7bb57 refactor 2021-04-06 19:34:32 +10:00
Jesse Duffield
fafd5234bd refactor to get view tab context map into gui state 2021-04-06 19:34:32 +10:00
Jesse Duffield
8cb10f76e4 refresh main panel when switching between tree and flat mode 2021-04-06 19:34:32 +10:00
Jesse Duffield
f1d7f59e49 switching repos without restarting the gui 2021-04-06 19:34:32 +10:00
Jesse Duffield
bc9a99387f refactor of contexts and filtering 2021-04-06 19:34:32 +10:00
Jesse Duffield
5289d49f75 more efficient gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
69e9f6d29d use suspense rather than close the gui when switching to a subprocess 2021-04-06 19:34:32 +10:00
Jesse Duffield
0b42437052 fix comment 2021-04-06 19:34:32 +10:00
Jesse Duffield
ae0f750770 fix bug where you couldn't change tabs 2021-04-06 19:34:32 +10:00
Jesse Duffield
9fe7e0d63d fix bug where we had two sets of contexts with their own state 2021-04-06 19:34:32 +10:00
Jesse Duffield
8935794e28 reset origin when clicking new item 2021-04-06 19:34:32 +10:00
Jesse Duffield
d44ff447bd fix panic 2021-04-06 19:34:32 +10:00
Jesse Duffield
798d3e2d54 get rid of these positively ghastly method signatures 2021-04-06 19:34:32 +10:00
Jesse Duffield
e8f99c3326 better scroll support 2021-04-06 19:34:32 +10:00
Jesse Duffield
1a5f380c00 support alt-enter for inserting newline when typing commit message within the app 2021-04-06 19:34:32 +10:00
Jesse Duffield
b4827a98ca fix commit message panel 2021-04-06 19:34:32 +10:00
Jesse Duffield
3ea5e4d4b2 allow scrolling when staging lines or building patch 2021-04-06 19:34:32 +10:00
Jesse Duffield
5f77ac8d6f bump gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
5d0cf3d919 prioritise keybindings on editors 2021-04-06 19:34:32 +10:00
Jesse Duffield
4b1da0cf3c bump gocui again 2021-04-06 19:34:32 +10:00
Jesse Duffield
862ced3bd0 bump gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
79b256a0fa remove 24 bit color delta arg from docs 2021-04-06 19:34:32 +10:00
Jesse Duffield
0d6ff7d1b7 support backtab key 2021-04-06 19:34:32 +10:00
Jesse Duffield
ecc5fe24a9 get tcell to cleanup the terminal if we panic 2021-04-06 19:34:32 +10:00
Jesse Duffield
1fb2317bac use true output mode 2021-04-06 19:34:32 +10:00
Jesse Duffield
6246eb9717 go mod tidy 2021-04-06 19:34:32 +10:00
Jesse Duffield
8f763c42b6 bum pgocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
6472bda29e bump gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
c0cad91cb6 no more termbox 2021-04-06 19:34:32 +10:00
Jesse Duffield
1149dea4b2 stop referencing termbox 2021-04-06 19:34:32 +10:00
Jesse Duffield
6a6024e38f use tcell via porting over code from awesome-gocui 2021-04-06 19:34:32 +10:00
Jesse Duffield
8901d11674 fix merge conflict cat issue on windows 2021-04-02 13:15:07 +11:00
Jesse Duffield
8b7f7cbc30 linting 2021-04-02 11:09:12 +11:00
Jesse Duffield
b6d0bdfa2d another integration test 2021-04-02 11:09:12 +11:00
Jesse Duffield
44896bcd51 safer code 2021-04-02 11:09:12 +11:00
Jesse Duffield
bdf2b2d5c4 add merge conflict undo integration test 2021-04-02 11:09:12 +11:00
Jesse Duffield
035726f650 add integration UI to make the integration process easier 2021-04-02 11:09:12 +11:00
Jesse Duffield
1abb3cd566 more thorough merge conflict integration test 2021-04-02 11:09:12 +11:00
Jesse Duffield
f7772f00c4 do not jump cursor around when fixing merge conflicts 2021-04-02 11:09:12 +11:00
Jesse Duffield
216b5341ae better handling of scrolling for conflicted files 2021-04-02 11:09:12 +11:00
Jesse Duffield
eeeef9ca86 refactor 2021-04-02 11:09:12 +11:00
Jesse Duffield
cc9293b386 add mutex to prevent crashes with merge conflicts 2021-04-02 11:09:12 +11:00
Jesse Duffield
efe43077bc fix name 2021-04-02 11:00:15 +11:00
Jesse Duffield
949c7726d1 fix bug caused by interface 2021-04-02 11:00:15 +11:00
Jesse Duffield
0b7bda291c remove dead code 2021-04-02 11:00:15 +11:00
Jesse Duffield
872cf0d726 hide commit files view upon losing focus because you probably don't want it lingering anyway 2021-04-02 11:00:15 +11:00
Jesse Duffield
af09223dd5 refactor 2021-04-02 11:00:15 +11:00
Jesse Duffield
7d62f103e4 big refactor to give our enums actual types 2021-04-02 11:00:15 +11:00
Jesse Duffield
9e85d37fb9 refactor to no longer call these things file changes 2021-04-02 11:00:15 +11:00
Jesse Duffield
8dee06f83a allow toggling tree view for commit files panel 2021-04-02 11:00:15 +11:00
Jesse Duffield
82fe4aa6c0 disallow editing commit file directory 2021-04-02 11:00:15 +11:00
Jesse Duffield
50c169e0a3 better colouring for directories for when adding a patch 2021-04-02 11:00:15 +11:00
Jesse Duffield
7364525bf5 do not show commit files of another parent as added to the patch 2021-04-02 11:00:15 +11:00
Jesse Duffield
54910fdb76 refactor 2021-04-02 11:00:15 +11:00
Jesse Duffield
332a3c4cbf file tree for commit files 2021-04-02 11:00:15 +11:00
Jesse Duffield
ac41c41809 refactor to support commit file tree 2021-04-02 11:00:15 +11:00
Jesse Duffield
96a9df04ed Update README.md 2021-04-01 20:56:04 +11:00
Jesse Duffield
b7cc4158d5 Update Config.md 2021-04-01 20:52:56 +11:00
Jesse Duffield
2bbe6269cd Update Config.md 2021-04-01 20:44:27 +11:00
Jesse Duffield
eb54189683 support GIT_EDITOR 2021-04-01 20:40:02 +11:00
Jesse Duffield
e8e59306fc shell out custom commands 2021-04-01 20:25:30 +11:00
Jesse Duffield
8af3fe3b4a faster startup 2021-04-01 09:13:29 +11:00
Jesse Duffield
3103247e8f refactor 2021-03-30 21:57:00 +11:00
Jesse Duffield
1629a7d280 same for renames 2021-03-30 21:57:00 +11:00
Jesse Duffield
b5a5169372 expand to path when switching to tree mode 2021-03-30 21:57:00 +11:00
Jesse Duffield
4b4bfae4f4 fix background colour on selected line 2021-03-30 21:57:00 +11:00
Jesse Duffield
d5639e6e95 refactor 2021-03-30 21:57:00 +11:00
Jesse Duffield
9e67f74ca3 prevent staging directory containing files with inline merge conflicts 2021-03-30 21:57:00 +11:00
Jesse Duffield
e3ddfbf2b8 rename function 2021-03-30 21:57:00 +11:00
Jesse Duffield
1ea78c7ae7 make fields private 2021-03-30 21:57:00 +11:00
Jesse Duffield
e7af3bf55d refactor 2021-03-30 21:57:00 +11:00
Jesse Duffield
e52cec9cdf small refactor 2021-03-30 21:57:00 +11:00
Jesse Duffield
5bb48b51a0 rename 2021-03-30 21:57:00 +11:00
Jesse Duffield
d2e1b35eee small fixes 2021-03-30 21:57:00 +11:00
Jesse Duffield
ef204b0adf remove collapsed field 2021-03-30 21:57:00 +11:00
Jesse Duffield
f742434043 fix spec 2021-03-30 21:57:00 +11:00
Jesse Duffield
d3b34ce323 fix spec 2021-03-30 21:57:00 +11:00
Jesse Duffield
89c2f4f2ff fix spec 2021-03-30 21:57:00 +11:00
Jesse Duffield
5a0f23e6d6 update cheatsheets 2021-03-30 21:57:00 +11:00
Jesse Duffield
5e05e8b62b fix comment 2021-03-30 21:57:00 +11:00
Jesse Duffield
1f7273af23 better way to check if a node is a leaf 2021-03-30 21:57:00 +11:00
Jesse Duffield
2b8302bced refactor 2021-03-30 21:57:00 +11:00
Jesse Duffield
1b94462410 rename some things 2021-03-30 21:57:00 +11:00
Jesse Duffield
120bb443fe small thing 2021-03-30 21:57:00 +11:00
Jesse Duffield
6fc3c03c4b allow configuring to show file tree on startup 2021-03-30 21:57:00 +11:00
Jesse Duffield
46b79c7c61 drop Name field from status line node 2021-03-30 21:57:00 +11:00
Jesse Duffield
4782d8aa1f bring merge conflicts to top 2021-03-30 21:57:00 +11:00
Jesse Duffield
fe4e305410 safer code 2021-03-30 21:57:00 +11:00
Jesse Duffield
040c1fc302 more functional approach 2021-03-30 21:57:00 +11:00
Jesse Duffield
5edea5a8dc better handling of cursor relocation 2021-03-30 21:57:00 +11:00
Jesse Duffield
d2b65537f6 handle nothing selected 2021-03-30 21:57:00 +11:00
Jesse Duffield
1183f68e19 better handling of refreshed files 2021-03-30 21:57:00 +11:00
Jesse Duffield
da6fe01eca allow toggling on/off file tree mode 2021-03-30 21:57:00 +11:00
Jesse Duffield
c27cea6f30 more file tree improvements 2021-03-30 21:57:00 +11:00
Jesse Duffield
cd0532b4d6 allow ignoring directories 2021-03-30 21:57:00 +11:00
Jesse Duffield
c9de6c003b support some more things 2021-03-30 21:57:00 +11:00
Jesse Duffield
418621a9ff support discarding changes in dir 2021-03-30 21:57:00 +11:00
Jesse Duffield
f871724ae6 update wording 2021-03-30 21:57:00 +11:00
Jesse Duffield
def68ddc8f fix bug for combining directories with single child 2021-03-30 21:57:00 +11:00
Jesse Duffield
a31db3df9c support toggling collapsed 2021-03-30 21:57:00 +11:00
Jesse Duffield
64217a8a5b fix spacing 2021-03-30 21:57:00 +11:00
Jesse Duffield
79079b54ea combining nodes when only one child exists 2021-03-30 21:57:00 +11:00
Jesse Duffield
77a7619690 showing changes for directories 2021-03-30 21:57:00 +11:00
Jesse Duffield
9f2d7adb8e more improvements 2021-03-30 21:57:00 +11:00
Jesse Duffield
07dd9c6bc8 better tree formatting 2021-03-30 21:57:00 +11:00
Jesse Duffield
45939171ea WIP
start moving to new interface

WIP

WIP

WIP

WIP

WIP
2021-03-30 21:57:00 +11:00
Jesse Duffield
049849264e defend against race condition in editors 2021-03-30 09:26:06 +11:00
Francisco Miamoto
7e0d48c2a1 fix panic for unprintable key presses
Checking is the associated rune of a key is printable solves our problem
of having panics whenever keys like `Home` or `Ctrl-W` are pressed.

Fixes #1175
2021-03-21 11:24:36 +11:00
Jesse Duffield
ad1468f66f better handling of discarding files 2021-03-20 12:46:27 +11:00
Jesse Duffield
058bcddc53 fix renamed files looking wrong 2021-03-14 13:24:51 +11:00
Jesse Duffield
8288de0c84 update release notes 2021-03-13 11:46:48 +11:00
Ryooooooga
1da2afd450 Fix edit remote name message 2021-03-13 11:04:38 +11:00
Jesse Duffield
03de51747e remove redundant addition 2021-03-13 11:03:34 +11:00
Ryooooooga
3d698cd7c1 Fix tests 2021-03-13 11:02:31 +11:00
Ryooooooga
a48cc245e7 Support multibyte characters in pane 2021-03-13 11:02:31 +11:00
Ryooooooga
9ed3a8ee05 Fix staging/unstaging filenames that starts with - or -- 2021-03-13 11:02:31 +11:00
Ryooooooga
64daf1310d Fix staging/unstaging files containing " in paths 2021-03-13 11:02:31 +11:00
Ryooooooga
e5ba0d9d9c Support multibyte characters in Files pane 2021-03-13 11:02:31 +11:00
Ryooooooga
50e4e9d58d fix command escaping 2021-03-13 10:49:40 +11:00
István Donkó
03b9db5e0a Fix the linux config path (related: #913, #1059) 2021-03-12 12:45:48 +11:00
Jesse Duffield
043cb2ea44 reload config whenever returning to gui 2021-02-24 02:45:05 -08:00
Dawid Dziurla
a62d70fbd5 Merge pull request #1172 from jesseduffield/dawidd6-patch-1
gui: ReplaceAll -> Replace
2021-02-24 00:14:24 +01:00
Dawid Dziurla
053e80a08e gui: ReplaceAll -> Replace 2021-02-24 00:09:05 +01:00
2581 changed files with 88912 additions and 30167 deletions

View File

@@ -29,26 +29,3 @@ jobs:
with:
token: ${{secrets.GITHUB_API_TOKEN}}
formula: lazygit
ppa:
runs-on: ubuntu-20.04
steps:
- name: Checkout PPA repo
uses: actions/checkout@v2
with:
repository: dawidd6/lazygit-debian
token: ${{secrets.GITHUB_API_TOKEN}}
fetch-depth: 0
- name: Setup git
uses: dawidd6/action-git-user-config@v1
- name: Update PPA repo
run: |
version="$(echo "$GITHUB_REF" | sed 's@refs/tags/v@@')"
sudo apt update
sudo apt install -y git-buildpackage
git fetch --tags https://github.com/$GITHUB_REPOSITORY
gbp import-ref -u "$version"
gbp dch -D xenial -N "$version"-1
git add debian/changelog
git commit -m "d/changelog: dch $version"
gbp tag
git push --tags origin master

View File

@@ -22,19 +22,37 @@ jobs:
uses: actions/cache@v1
with:
path: ~/.cache/go-build
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test
restore-keys: |
${{runner.os}}-go-
- name: Format code
run: |
if [ $(find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;|wc -l) -gt 0 ]; then
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
exit 1
fi
- name: Test code
run: |
./test.sh
- name: Build binaries
uses: goreleaser/goreleaser-action@v1
build:
runs-on: ubuntu-latest
env:
GOFLAGS: -mod=vendor
GOARCH: amd64
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v1
with:
args: --skip-publish --snapshot
go-version: 1.16.x
- name: Cache build
uses: actions/cache@v1
with:
path: ~/.cache/go-build
key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-build
restore-keys: |
${{runner.os}}-go-
- name: Build linux binary
run: |
GOOS=linux go build
- name: Build windows binary
run: |
GOOS=windows go build
- name: Build darwin binary
run: |
GOOS=darwin go build

View File

@@ -12,3 +12,9 @@ jobs:
uses: golangci/golangci-lint-action@v2
with:
version: latest
- name: Format code
run: |
if [ $(find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;|wc -l) -gt 0 ]; then
find . ! -path "./vendor/*" -name "*.go" -exec gofmt -s -d {} \;
exit 1
fi

View File

@@ -21,7 +21,6 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is
- [Binary releases](#binary-releases)
- [Homebrew](#homebrew)
- [MacPorts](#macports)
- [Ubuntu](#ubuntu)
- [Void Linux](#void-linux)
- [Scoop (Windows)](#scoop-windows)
- [Arch Linux](#arch-linux)
@@ -82,6 +81,8 @@ sudo port install lazygit
### Ubuntu
**Deprecated**: will no longer receive updates.
Packages for Ubuntu are available via [Launchpad PPA](https://launchpad.net/~lazygit-team).
```sh
@@ -114,12 +115,12 @@ scoop install lazygit
### Arch Linux
Packages for Arch Linux are available via AUR (Arch User Repository).
Packages for Arch Linux are available via pacman and AUR (Arch User Repository).
There are two packages. The stable one which is built with the latest release
and the git version which builds from the most recent commit.
- Stable: <https://aur.archlinux.org/packages/lazygit/>
- Stable: `sudo pacman -S lazygit`
- Development: <https://aur.archlinux.org/packages/lazygit-git/>
Instruction of how to install AUR content can be found here:
@@ -279,12 +280,10 @@ Run `lazygit --debug` in one terminal tab and `lazygit --logs` in another to vie
If you would like to support the development of lazygit, consider [sponsoring me](https://github.com/sponsors/jesseduffield) (github is matching all donations dollar-for-dollar for 12 months)
## Work in progress
## FAQ
This is still a work in progress so there's still bugs to iron out and as this
is my first project in Go the code could no doubt use an increase in quality,
but I'll be improving on it whenever I find the time. If you have any feedback
feel free to [raise an issue](https://github.com/jesseduffield/lazygit/issues)/[submit a PR](https://github.com/jesseduffield/lazygit/pulls).
### I'm struggling to see the selected line
see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#struggling-to-see-selected-line)
## Social

View File

@@ -2,181 +2,201 @@
Default path for the config file:
* Linux: `~/.config/jesseduffield/lazygit/config.yml`
* MacOS: `~/Library/Application Support/jesseduffield/lazygit/config.yml`
* Windows: `%APPDATA%\jesseduffield\lazygit\config.yml`
- Linux: `~/.config/lazygit/config.yml`
- MacOS: `~/Library/Application Support/lazygit/config.yml`
- Windows: `%APPDATA%\lazygit\config.yml`
For old installations (slightly embarrassing: I didn't realise at the time that you didn't need to supply a vendor name to the path so I just used my name):
- Linux: `~/.config/jesseduffield/lazygit/config.yml`
- MacOS: `~/Library/Application Support/jesseduffield/lazygit/config.yml`
- Windows: `%APPDATA%\jesseduffield\lazygit\config.yml`
## Default
```yaml
gui:
# stuff relating to the UI
scrollHeight: 2 # how many lines you scroll by
scrollPastBottom: true # enable scrolling past the bottom
sidePanelWidth: 0.3333 # number from 0 to 1
expandFocusedSidePanel: false
mainPanelSplitMode: 'flexible' # one of 'horizontal' | 'flexible' | 'vertical'
theme:
lightTheme: false # For terminals with a light background
activeBorderColor:
- white
- bold
inactiveBorderColor:
- green
optionsTextColor:
- blue
selectedLineBgColor:
- default
selectedRangeBgColor:
- blue
commitLength:
show: true
mouseEvents: true
skipUnstageLineWarning: false
skipStashWarning: true
git:
paging:
colorArg: always
useConfig: false
merging:
# only applicable to unix users
manualCommit: false
# extra args passed to `git merge`, e.g. --no-ff
args: ""
pull:
mode: 'merge' # one of 'merge' | 'rebase' | 'ff-only'
skipHookPrefix: WIP
autoFetch: true
branchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --"
allBranchesLogCmd: "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium"
overrideGpg: false # prevents lazygit from spawning a separate process when using GPG
disableForcePushing: false
refresher:
refreshInterval: 10 # file/submodule refresh interval in seconds
fetchInterval: 60 # re-fetch interval in seconds
update:
method: prompt # can be: prompt | background | never
days: 14 # how often an update is checked for
reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined'
confirmOnQuit: false
# determines whether hitting 'esc' will quit the application when there is nothing to cancel/close
quitOnTopLevelReturn: true
disableStartupPopups: false
notARepository: 'prompt' # one of: 'prompt' | 'create' | 'skip'
keybinding:
universal:
quit: 'q'
quit-alt1: '<c-c>' # alternative/alias of quit
return: '<esc>' # return to previous menu, will quit if there's nowhere to return
quitWithoutChangingDirectory: 'Q'
togglePanel: '<tab>' # goto the next panel
prevItem: '<up>' # go one line up
nextItem: '<down>' # go one line down
prevItem-alt: 'k' # go one line up
nextItem-alt: 'j' # go one line down
prevPage: ',' # go to next page in list
nextPage: '.' # go to previous page in list
gotoTop: '<' # go to top of list
gotoBottom: '>' # go to bottom of list
prevBlock: '<left>' # goto the previous block / panel
nextBlock: '<right>' # goto the next block / panel
prevBlock-alt: 'h' # goto the previous block / panel
nextBlock-alt: 'l' # goto the next block / panel
nextMatch: 'n'
prevMatch: 'N'
optionMenu: 'x' # show help menu
optionMenu-alt1: '?' # show help menu
select: '<space>'
goInto: '<enter>'
confirm: '<enter>'
confirm-alt1: 'y'
remove: 'd'
new: 'n'
edit: 'e'
openFile: 'o'
scrollUpMain: '<pgup>' # main panel scrool up
scrollDownMain: '<pgdown>' # main panel scrool down
scrollUpMain-alt1: 'K' # main panel scrool up
scrollDownMain-alt1: 'J' # main panel scrool down
scrollUpMain-alt2: '<c-u>' # main panel scrool up
scrollDownMain-alt2: '<c-d>' # main panel scrool down
executeCustomCommand: ':'
createRebaseOptionsMenu: 'm'
pushFiles: 'P'
pullFiles: 'p'
refresh: 'R'
createPatchOptionsMenu: '<c-p>'
nextTab: ']'
prevTab: '['
nextScreenMode: '+'
prevScreenMode: '_'
undo: 'z'
redo: '<c-z>'
filteringMenu: '<c-s>'
diffingMenu: 'W'
diffingMenu-alt: '<c-e>' # deprecated
copyToClipboard: '<c-o>'
submitEditorText: '<enter>'
appendNewline: '<tab>'
status:
checkForUpdate: 'u'
recentRepos: '<enter>'
files:
commitChanges: 'c'
commitChangesWithoutHook: 'w' # commit changes without pre-commit hook
amendLastCommit: 'A'
commitChangesWithEditor: 'C'
ignoreFile: 'i'
refreshFiles: 'r'
stashAllChanges: 's'
viewStashOptions: 'S'
toggleStagedAll: 'a' # stage/unstage all
viewResetOptions: 'D'
fetch: 'f'
branches:
createPullRequest: 'o'
checkoutBranchByName: 'c'
forceCheckoutBranch: 'F'
rebaseBranch: 'r'
mergeIntoCurrentBranch: 'M'
viewGitFlowOptions: 'i'
fastForward: 'f' # fast-forward this branch from its upstream
pushTag: 'P'
setUpstream: 'u' # set as upstream of checked-out branch
fetchRemote: 'f'
commits:
squashDown: 's'
renameCommit: 'r'
renameCommitWithEditor: 'R'
viewResetOptions: 'g'
markCommitAsFixup: 'f'
createFixupCommit: 'F' # create fixup commit for this commit
squashAboveCommits: 'S'
moveDownCommit: '<c-j>' # move commit down one
moveUpCommit: '<c-k>' # move commit up one
amendToCommit: 'A'
pickCommit: 'p' # pick commit (when mid-rebase)
revertCommit: 't'
cherryPickCopy: 'c'
cherryPickCopyRange: 'C'
pasteCommits: 'v'
tagCommit: 'T'
checkoutCommit: '<space>'
resetCherryPick: '<c-R>'
copyCommitMessageToClipboard: '<c-y>'
stash:
popStash: 'g'
commitFiles:
checkoutCommitFile: 'c'
main:
toggleDragSelect: 'v'
toggleDragSelect-alt: 'V'
toggleSelectHunk: 'a'
pickBothHunks: 'b'
submodules:
init: 'i'
update: 'u'
bulkMenu: 'b'
gui:
# stuff relating to the UI
scrollHeight: 2 # how many lines you scroll by
scrollPastBottom: true # enable scrolling past the bottom
sidePanelWidth: 0.3333 # number from 0 to 1
expandFocusedSidePanel: false
mainPanelSplitMode: 'flexible' # one of 'horizontal' | 'flexible' | 'vertical'
theme:
lightTheme: false # For terminals with a light background
activeBorderColor:
- white
- bold
inactiveBorderColor:
- green
optionsTextColor:
- blue
selectedLineBgColor:
- default
selectedRangeBgColor:
- blue
commitLength:
show: true
mouseEvents: true
skipUnstageLineWarning: false
skipStashWarning: true
showFileTree: false # for rendering changes files in a tree format
showListFooter: true # for seeing the '5 of 20' message in list panels
showRandomTip: true
showCommandLog: true
commandLogSize: 8
git:
paging:
colorArg: always
useConfig: false
merging:
# only applicable to unix users
manualCommit: false
# extra args passed to `git merge`, e.g. --no-ff
args: ''
pull:
mode: 'auto' # one of 'auto' | 'merge' | 'rebase' | 'ff-only', auto reads from git configuration
skipHookPrefix: WIP
autoFetch: true
branchLogCmd: 'git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --'
allBranchesLogCmd: 'git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium'
overrideGpg: false # prevents lazygit from spawning a separate process when using GPG
disableForcePushing: false
parseEmoji: false
os:
editCommand: '' # see 'Configuring File Editing' section
openCommand: ''
refresher:
refreshInterval: 10 # file/submodule refresh interval in seconds
fetchInterval: 60 # re-fetch interval in seconds
update:
method: prompt # can be: prompt | background | never
days: 14 # how often an update is checked for
reporting: 'undetermined' # one of: 'on' | 'off' | 'undetermined'
confirmOnQuit: false
# determines whether hitting 'esc' will quit the application when there is nothing to cancel/close
quitOnTopLevelReturn: false
disableStartupPopups: false
notARepository: 'prompt' # one of: 'prompt' | 'create' | 'skip'
keybinding:
universal:
quit: 'q'
quit-alt1: '<c-c>' # alternative/alias of quit
return: '<esc>' # return to previous menu, will quit if there's nowhere to return
quitWithoutChangingDirectory: 'Q'
togglePanel: '<tab>' # goto the next panel
prevItem: '<up>' # go one line up
nextItem: '<down>' # go one line down
prevItem-alt: 'k' # go one line up
nextItem-alt: 'j' # go one line down
prevPage: ',' # go to next page in list
nextPage: '.' # go to previous page in list
gotoTop: '<' # go to top of list
gotoBottom: '>' # go to bottom of list
prevBlock: '<left>' # goto the previous block / panel
nextBlock: '<right>' # goto the next block / panel
prevBlock-alt: 'h' # goto the previous block / panel
nextBlock-alt: 'l' # goto the next block / panel
nextMatch: 'n'
prevMatch: 'N'
optionMenu: 'x' # show help menu
optionMenu-alt1: '?' # show help menu
select: '<space>'
goInto: '<enter>'
openRecentRepos: '<c-r>'
confirm: '<enter>'
confirm-alt1: 'y'
remove: 'd'
new: 'n'
edit: 'e'
openFile: 'o'
scrollUpMain: '<pgup>' # main panel scroll up
scrollDownMain: '<pgdown>' # main panel scroll down
scrollUpMain-alt1: 'K' # main panel scroll up
scrollDownMain-alt1: 'J' # main panel scroll down
scrollUpMain-alt2: '<c-u>' # main panel scroll up
scrollDownMain-alt2: '<c-d>' # main panel scroll down
executeCustomCommand: ':'
createRebaseOptionsMenu: 'm'
pushFiles: 'P'
pullFiles: 'p'
refresh: 'R'
createPatchOptionsMenu: '<c-p>'
nextTab: ']'
prevTab: '['
nextScreenMode: '+'
prevScreenMode: '_'
undo: 'z'
redo: '<c-z>'
filteringMenu: '<c-s>'
diffingMenu: 'W'
diffingMenu-alt: '<c-e>' # deprecated
copyToClipboard: '<c-o>'
submitEditorText: '<enter>'
appendNewline: '<a-enter>'
extrasMenu: '@'
toggleWhitespaceInDiffView: '<c-w>'
status:
checkForUpdate: 'u'
recentRepos: '<enter>'
files:
commitChanges: 'c'
commitChangesWithoutHook: 'w' # commit changes without pre-commit hook
amendLastCommit: 'A'
commitChangesWithEditor: 'C'
ignoreFile: 'i'
refreshFiles: 'r'
stashAllChanges: 's'
viewStashOptions: 'S'
toggleStagedAll: 'a' # stage/unstage all
viewResetOptions: 'D'
fetch: 'f'
toggleTreeView: '`'
branches:
createPullRequest: 'o'
viewPullRequestOptions: 'O'
checkoutBranchByName: 'c'
forceCheckoutBranch: 'F'
rebaseBranch: 'r'
mergeIntoCurrentBranch: 'M'
viewGitFlowOptions: 'i'
fastForward: 'f' # fast-forward this branch from its upstream
pushTag: 'P'
setUpstream: 'u' # set as upstream of checked-out branch
fetchRemote: 'f'
commits:
squashDown: 's'
renameCommit: 'r'
renameCommitWithEditor: 'R'
viewResetOptions: 'g'
markCommitAsFixup: 'f'
createFixupCommit: 'F' # create fixup commit for this commit
squashAboveCommits: 'S'
moveDownCommit: '<c-j>' # move commit down one
moveUpCommit: '<c-k>' # move commit up one
amendToCommit: 'A'
pickCommit: 'p' # pick commit (when mid-rebase)
revertCommit: 't'
cherryPickCopy: 'c'
cherryPickCopyRange: 'C'
pasteCommits: 'v'
tagCommit: 'T'
checkoutCommit: '<space>'
resetCherryPick: '<c-R>'
copyCommitMessageToClipboard: '<c-y>'
stash:
popStash: 'g'
commitFiles:
checkoutCommitFile: 'c'
main:
toggleDragSelect: 'v'
toggleDragSelect-alt: 'V'
toggleSelectHunk: 'a'
pickBothHunks: 'b'
submodules:
init: 'i'
update: 'u'
bulkMenu: 'b'
```
## Platform Defaults
@@ -184,31 +204,50 @@ Default path for the config file:
### Windows
```yaml
os:
openCommand: 'cmd /c "start "" {{filename}}"'
os:
openCommand: 'cmd /c "start "" {{filename}}"'
```
### Linux
```yaml
os:
openCommand: 'sh -c "xdg-open {{filename}} >/dev/null"'
os:
openCommand: 'sh -c "xdg-open {{filename}} >/dev/null"'
```
### OSX
```yaml
os:
openCommand: 'open {{filename}}'
os:
openCommand: 'open {{filename}}'
```
### Configuring File Editing
Lazygit will edit a file with the first set editor in the following:
1. config.yaml
```yaml
os:
editCommand: 'vim' # as an example
```
2. \$(git config core.editor)
3. \$GIT_EDITOR
4. \$VISUAL
5. \$EDITOR
6. \$(which vi)
Lazygit will log an error if none of these options are set.
### Recommended Config Values
for users of VSCode
```yaml
os:
openCommand: 'code -rg {{filename}}'
os:
openCommand: 'code -rg {{filename}}'
```
## Color Attributes
@@ -216,7 +255,8 @@ for users of VSCode
For color attributes you can choose an array of attributes (with max one color attribute)
The available attributes are:
- default
**Colors**
- black
- red
- green
@@ -225,7 +265,12 @@ The available attributes are:
- magenta
- cyan
- white
- '#ff00ff'
**Modifiers**
- bold
- default
- reverse # useful for high-contrast
- underline
@@ -234,31 +279,50 @@ The available attributes are:
If you have issues with a light terminal theme where you can't read / see the text add these settings
```yaml
gui:
theme:
lightTheme: true
activeBorderColor:
- black
- bold
inactiveBorderColor:
- black
selectedLineBgColor:
- default
gui:
theme:
lightTheme: true
activeBorderColor:
- black
- bold
inactiveBorderColor:
- black
selectedLineBgColor:
- default
```
## Struggling to see selected line
If you struggle to see the selected line I recomment using the reverse attribute on selected lines like so:
If you struggle to see the selected line I recommend using the reverse attribute on selected lines like so:
```yaml
gui:
theme:
selectedLineBgColor:
- reverse
selectedRangeBgColor:
- reverse
gui:
theme:
selectedLineBgColor:
- reverse
selectedRangeBgColor:
- reverse
```
The following has also worked for a couple of people:
```yaml
gui:
theme:
activeBorderColor:
- white
- bold
inactiveBorderColor:
- white
selectedLineBgColor:
- reverse
- blue
```
Alternatively you may have bold fonts disabled in your terminal, in which case enabling bold fonts should solve the problem.
If you're still having trouble please raise an issue.
## Example Coloring
![border example](../../assets/colored-border-example.png)
@@ -270,33 +334,33 @@ For all possible keybinding options, check [Custom_Keybindings.md](https://githu
### Example Keybindings For Colemak Users
```yaml
keybinding:
universal:
prevItem-alt: 'u'
nextItem-alt: 'e'
prevBlock-alt: 'n'
nextBlock-alt: 'i'
nextMatch: '='
prevMatch: '-'
new: 'k'
edit: 'o'
openFile: 'O'
scrollUpMain-alt1: 'U'
scrollDownMain-alt1: 'E'
scrollUpMain-alt2: '<c-u>'
scrollDownMain-alt2: '<c-e>'
undo: 'l'
redo: '<c-r>'
diffingMenu: 'M'
filteringMenu: '<c-f>'
files:
ignoreFile: 'I'
commits:
moveDownCommit: '<c-e>'
moveUpCommit: '<c-u>'
branches:
viewGitFlowOptions: 'I'
setUpstream: 'U'
keybinding:
universal:
prevItem-alt: 'u'
nextItem-alt: 'e'
prevBlock-alt: 'n'
nextBlock-alt: 'i'
nextMatch: '='
prevMatch: '-'
new: 'k'
edit: 'o'
openFile: 'O'
scrollUpMain-alt1: 'U'
scrollDownMain-alt1: 'E'
scrollUpMain-alt2: '<c-u>'
scrollDownMain-alt2: '<c-e>'
undo: 'l'
redo: '<c-r>'
diffingMenu: 'M'
filteringMenu: '<c-f>'
files:
ignoreFile: 'I'
commits:
moveDownCommit: '<c-e>'
moveUpCommit: '<c-u>'
branches:
viewGitFlowOptions: 'I'
setUpstream: 'U'
```
## Custom pull request URLs
@@ -307,7 +371,7 @@ the pull request. You can do so on your `config.yml` file using the following sy
```yaml
services:
"<gitDomain>": "<provider>:<webDomain>"
'<gitDomain>': '<provider>:<webDomain>'
```
Where:
@@ -317,19 +381,21 @@ Where:
- `webDomain` is the URL where your git service exposes a web interface and APIs, e.g. `gitservice.work.com`
## Predefined commit message prefix
In situations where certain naming pattern is used for branches and commits, pattern can be used to populate
commit message with prefix that is parsed from the branch name.
Example:
* Branch name: feature/AB-123
* Commit message: [AB-123] Adding feature
- Branch name: feature/AB-123
- Commit message: [AB-123] Adding feature
```yaml
git:
commitPrefixes:
my_project: # This is repository folder name
pattern: "^\\w+\\/(\\w+-\\w+).*"
replace: "[$1] "
git:
commitPrefixes:
my_project: # This is repository folder name
pattern: "^\\w+\\/(\\w+-\\w+).*"
replace: '[$1] '
```
## Custom git log command

View File

@@ -35,6 +35,20 @@ customCommands:
command: "git flow {{index .PromptResponses 0}} start {{index .PromptResponses 1}}"
context: 'localBranches'
loadingText: 'creating branch'
- key : 'r'
description: 'Checkout a remote branch as FETCH_HEAD'
command: "git fetch {{index .PromptResponses 0}} {{index .PromptResponses 1}} && git checkout FETCH_HEAD"
context: 'remotes'
prompts:
- type: 'input'
title: 'Remote:'
initialValue: "{{index .SelectedRemote.Name }}"
- type: 'menuFromCommand'
title: 'Remote branch:'
command: 'git branch -r --list {{index .PromptResponses 0}}/*'
filter: '.*{{index .PromptResponses 0}}/(?P<branch>.*)'
valueFormat: '{{ .branch }}'
labelFormat: '{{ .branch | green }}'
```
Looking at the command assigned to the 'n' key, here's what the result looks like:
@@ -79,12 +93,28 @@ The permitted contexts are:
The permitted prompt fields are:
| _field_ | _description_ | _required_ |
| ------------ | -------------------------------------------------------------------------------- | ---------- |
| type | one of 'input' or 'menu' | yes |
| title | the title to display in the popup panel | no |
| initialValue | (only applicable to 'input' prompts) the initial value to appear in the text box | no |
| options | (only applicable to 'menu' prompts) the options to display in the menu | no |
| _field_ | _description_ | _required_ |
| ------------ | -------------------------------------------------------------------------------- | ---------- |
| type | one of 'input' or 'menu' | yes |
| title | the title to display in the popup panel | no |
| initialValue | (only applicable to 'input' prompts) the initial value to appear in the text box | no |
| options | (only applicable to 'menu' prompts) the options to display in the menu | no |
| command | (only applicable to 'menuFromCommand' prompts) the command to run to generate | yes |
| | menu options | |
| filter | (only applicable to 'menuFromCommand' prompts) the regexp to run specifying | yes |
| | groups which are going to be kept from the command's output | |
| valueFormat | (only applicable to 'menuFromCommand' prompts) how to format matched groups from | yes |
| | the filter to construct a menu item's value (What gets appended to prompt | |
| | responses when the item is selected). You can use named groups, | |
| | or `{{ .group_GROUPID }}`. | |
| | PS: named groups keep first match only | |
| labelFormat | (only applicable to 'menuFromCommand' prompts) how to format matched groups from | no |
| | the filter to construct the item's label (What's shown on screen). You can use | |
| | named groups, or `{{ .group_GROUPID }}`. You can also color each match with | |
| | `{{ .group_GROUPID | colorname }}` (Color names from | |
| | [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md)) | |
| | If `labelFormat` is not specified, `valueFormat` is shown instead. | |
| | PS: named groups keep first match only | |
The permitted option fields are:
| _field_ | _description_ | _required_ |

View File

@@ -21,7 +21,7 @@ the `colorArg` key is for whether you want the `--color=always` arg in your `git
git:
paging:
colorArg: always
pager: delta --dark --paging=never --24-bit-color=never
pager: delta --dark --paging=never
```
![](https://i.imgur.com/QJpQkF3.png)

View File

@@ -3,6 +3,7 @@
## Global Keybindings
<pre>
<kbd>ctrl+r</kbd>: switch to a recent repo (<c-r>)
<kbd>pgup</kbd>: scroll up main panel (fn+up)
<kbd>pgdown</kbd>: scroll down main panel (fn+down)
<kbd>m</kbd>: view merge/rebase options
@@ -16,9 +17,10 @@
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>:</kbd>: execute custom command
<kbd>|</kbd>: view scoping options
<kbd>ctrl+s</kbd>: view filter-by-path options
<kbd>W</kbd>: open diff menu
<kbd>ctrl+e</kbd>: open diff menu
<kbd>@</kbd>: open command log menu
</pre>
## List Panel Navigation
@@ -38,6 +40,7 @@
<pre>
<kbd>space</kbd>: checkout
<kbd>o</kbd>: create pull request
<kbd>O</kbd>: create pull request options
<kbd>ctrl+y</kbd>: copy pull request URL to clipboard
<kbd>c</kbd>: checkout by name
<kbd>F</kbd>: force checkout
@@ -109,7 +112,8 @@
<kbd>o</kbd>: open file
<kbd>e</kbd>: edit file
<kbd>space</kbd>: toggle file included in patch
<kbd>enter</kbd>: enter file to add selected lines to the patch
<kbd>enter</kbd>: enter file to add selected lines to the patch (or toggle directory collapsed)
<kbd>`</kbd>: toggle file tree view
</pre>
## Commits Panel (Commits)
@@ -121,7 +125,7 @@
<kbd>g</kbd>: reset to this commit
<kbd>f</kbd>: fixup commit
<kbd>F</kbd>: create fixup commit for this commit
<kbd>S</kbd>: squash above commits
<kbd>S</kbd>: squash all 'fixup!' commits above selected commit (autosquash)
<kbd>d</kbd>: delete commit
<kbd>ctrl+j</kbd>: move commit down one
<kbd>ctrl+k</kbd>: move commit up one
@@ -153,6 +157,12 @@
<kbd>ctrl+o</kbd>: copy commit SHA to clipboard
</pre>
## Extras Panel
<pre>
<kbd>@</kbd>: open command log menu
</pre>
## Files Panel (Files)
<pre>
@@ -170,10 +180,13 @@
<kbd>S</kbd>: view stash options
<kbd>a</kbd>: stage/unstage all
<kbd>D</kbd>: view reset options
<kbd>enter</kbd>: stage individual hunks/lines
<kbd>enter</kbd>: stage individual hunks/lines for file, or collapse/expand for directory
<kbd>f</kbd>: fetch
<kbd>ctrl+o</kbd>: copy the file name to the clipboard
<kbd>g</kbd>: view upstream reset options
<kbd>`</kbd>: toggle file tree view
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>ctrl+w</kbd>: Toggle whether or not whitespace changes are shown in the diff view
</pre>
## Files Panel (Submodules)
@@ -193,6 +206,7 @@
<pre>
<kbd>esc</kbd>: return to files panel
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>space</kbd>: pick hunk
<kbd>b</kbd>: pick both hunks
<kbd>◄</kbd>: select previous conflict
@@ -205,8 +219,8 @@
## Main Panel (Normal)
<pre>
<kbd></kbd>: scroll down (fn+up)
<kbd></kbd>: scroll up (fn+down)
<kbd>Ő</kbd>: scroll down (fn+up)
<kbd>ő</kbd>: scroll up (fn+down)
</pre>
## Main Panel (Patch Building)

View File

@@ -1,10 +1,11 @@
# Lazygit Sneltoetsen
## Globaale Sneltoetsen
## Globale Sneltoetsen
<pre>
<kbd>pgup</kbd>: scroll naar beneden vanaf hooft paneel (fn+up)
<kbd>pgdown</kbd>: scroll naar beneden vabaf hooft paneel (fn+down)
<kbd>ctrl+r</kbd>: wissel naar een recente repo (<c-r>)
<kbd>pgup</kbd>: scroll naar beneden vanaf hoofdpaneel (fn+up)
<kbd>pgdown</kbd>: scroll naar beneden vanaf hoofdpaneel (fn+down)
<kbd>m</kbd>: bekijk merge/rebase opties
<kbd>ctrl+p</kbd>: bekijk aangepaste patch opties
<kbd>P</kbd>: push
@@ -13,31 +14,33 @@
<kbd>x</kbd>: open menu
<kbd>z</kbd>: ongedaan maken (via reflog) (experimenteel)
<kbd>ctrl+z</kbd>: redo (via reflog) (experimenteel)
<kbd>+</kbd>: volgende schermmode (normaal/half/groot )
<kbd>_</kbd>: vorige schermmode
<kbd>:</kbd>: voor aangepast commando uit
<kbd>|</kbd>: bekijk scoping opties
<kbd>+</kbd>: volgende scherm modus (normaal/half/groot)
<kbd>_</kbd>: vorige scherm modus
<kbd>:</kbd>: voor aangepaste commando uit
<kbd>ctrl+s</kbd>: bekijk scoping opties
<kbd>W</kbd>: open diff menu
<kbd>ctrl+e</kbd>: open diff menu
<kbd>@</kbd>: open command log menu
</pre>
## List Panel Navigation
## Lijstpaneel Navigatie
<pre>
<kbd>.</kbd>: volgende pagina
<kbd>,</kbd>: vorige pagina
<kbd><</kbd>: scroll naar boven
<kbd>></kbd>: scroll naar beneden
<kbd>/</kbd>: start met zoekken
<kbd>]</kbd>: volgende tab
<kbd>[</kbd>: vorige tab
<kbd>/</kbd>: start met zoeken
<kbd>]</kbd>: volgende tabblad
<kbd>[</kbd>: vorige tabblad
</pre>
## Branches Paneel (Branches Tab)
## Branches Paneel (Branches Tabblad)
<pre>
<kbd>space</kbd>: uitchecken
<kbd>o</kbd>: maak een pull-aanvraag
<kbd>o</kbd>: maak een pull-request
<kbd>O</kbd>: bekijk opties voor pull-aanvraag
<kbd>ctrl+y</kbd>: kopieer de URL van het pull-verzoek naar het klembord
<kbd>c</kbd>: uitchecken bij naam
<kbd>F</kbd>: forceer checkout
@@ -49,16 +52,16 @@
<kbd>f</kbd>: fast-forward deze branch vanaf zijn upstream
<kbd>g</kbd>: bekijk reset opties
<kbd>R</kbd>: hernoem branch
<kbd>ctrl+o</kbd>: copieer branch name naar clipboard
<kbd>enter</kbd>: view commits
<kbd>ctrl+o</kbd>: kopieer branch name naar klembord
<kbd>enter</kbd>: bekijk commits
</pre>
## Branches Paneel (Remote Branches (in Remotes tab))
## Branches Paneel (Remote Branches (in Remotes tabblad))
<pre>
<kbd>esc</kbd>: Ga terug naar remotes lijst
<kbd>g</kbd>: bekijk reset opties
<kbd>enter</kbd>: view commits
<kbd>enter</kbd>: bekijk commits
<kbd>space</kbd>: uitchecken
<kbd>n</kbd>: nieuwe branch
<kbd>M</kbd>: merge in met huidige checked out branch
@@ -67,7 +70,7 @@
<kbd>u</kbd>: stel in als upstream van uitgecheckte branch
</pre>
## Branches Paneel (Remotes Tab)
## Branches Paneel (Remotes Tabblad)
<pre>
<kbd>f</kbd>: fetch remote
@@ -83,13 +86,13 @@
<kbd>space</kbd>: checkout commit
<kbd>g</kbd>: bekijk reset opties
<kbd>n</kbd>: nieuwe branch
<kbd>c</kbd>: kopiëer commit (cherry-pick)
<kbd>C</kbd>: kopiëer commit reeks (cherry-pick)
<kbd>ctrl+r</kbd>: reset cherry-picked (gecopieerde) commits selectie
<kbd>ctrl+o</kbd>: copieer commit SHA naar clipboard
<kbd>c</kbd>: kopieer commit (cherry-pick)
<kbd>C</kbd>: kopieer commit reeks (cherry-pick)
<kbd>ctrl+r</kbd>: reset cherry-picked (gekopieerde) commits selectie
<kbd>ctrl+o</kbd>: kopieer commit SHA naar klembord
</pre>
## Branches Paneel (Tags Tab)
## Branches Paneel (Tags Tabblad)
<pre>
<kbd>space</kbd>: uitchecken
@@ -97,7 +100,7 @@
<kbd>P</kbd>: push tag
<kbd>n</kbd>: creëer tag
<kbd>g</kbd>: bekijk reset opties
<kbd>enter</kbd>: view commits
<kbd>enter</kbd>: bekijk commits
</pre>
## Commit bestanden Paneel
@@ -109,7 +112,8 @@
<kbd>o</kbd>: open bestand
<kbd>e</kbd>: verander bestand
<kbd>space</kbd>: toggle bestand inbegrepen in patch
<kbd>enter</kbd>: enter bestand to add selecteered lines to the patch
<kbd>enter</kbd>: enter bestand om geselecteerde regels toe te voegen aan de patch
<kbd>`</kbd>: toggle bestandsboom weergave
</pre>
## Commits Paneel (Commits)
@@ -129,28 +133,34 @@
<kbd>A</kbd>: wijzig commit met staged veranderingen
<kbd>p</kbd>: kies commit (wanneer midden in rebase)
<kbd>t</kbd>: commit ongedaan maken
<kbd>c</kbd>: kopiëer commit (cherry-pick)
<kbd>ctrl+o</kbd>: copieer commit SHA naar clipboard
<kbd>C</kbd>: kopiëer commit reeks (cherry-pick)
<kbd>c</kbd>: kopieer commit (cherry-pick)
<kbd>ctrl+o</kbd>: kopieer commit SHA naar klembord
<kbd>C</kbd>: kopieer commit reeks (cherry-pick)
<kbd>v</kbd>: plak commits (cherry-pick)
<kbd>enter</kbd>: bekijk gecommite bestanden
<kbd>space</kbd>: checkout commit
<kbd>n</kbd>: create new branch off of commit
<kbd>n</kbd>: creëer nieuwe branch van commit
<kbd>T</kbd>: tag commit
<kbd>ctrl+r</kbd>: reset cherry-picked (gecopieerde) commits selectie
<kbd>ctrl+y</kbd>: copieer commit bericht naar clipboard
<kbd>ctrl+r</kbd>: reset cherry-picked (gekopieerde) commits selectie
<kbd>ctrl+y</kbd>: kopieer commit bericht naar klembord
</pre>
## Commits Paneel (Reflog Tab)
## Commits Paneel (Reflog Tabblad)
<pre>
<kbd>enter</kbd>: bekijk gecommite bestanden
<kbd>space</kbd>: checkout commit
<kbd>g</kbd>: bekijk reset opties
<kbd>c</kbd>: kopiëer commit (cherry-pick)
<kbd>C</kbd>: kopiëer commit reeks (cherry-pick)
<kbd>ctrl+r</kbd>: reset cherry-picked (gecopieerde) commits selectie
<kbd>ctrl+o</kbd>: copieer commit SHA naar clipboard
<kbd>c</kbd>: kopieer commit (cherry-pick)
<kbd>C</kbd>: kopieer commit reeks (cherry-pick)
<kbd>ctrl+r</kbd>: reset cherry-picked (gekopieerde) commits selectie
<kbd>ctrl+o</kbd>: kopieer commit SHA naar klembord
</pre>
## Extras Paneel
<pre>
<kbd>@</kbd>: open command log menu
</pre>
## Bestanden Paneel (Bestanden)
@@ -174,25 +184,29 @@
<kbd>f</kbd>: fetch
<kbd>ctrl+o</kbd>: kopieer de bestandsnaam naar het klembord
<kbd>g</kbd>: bekijk upstream reset opties
<kbd>`</kbd>: toggle bestandsboom weergave
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>ctrl+w</kbd>: Toggle whether or not whitespace changes are shown in the diff view
</pre>
## Bestanden Paneel (Submodules)
<pre>
<kbd>ctrl+o</kbd>: copy submodule name to clipboard
<kbd>ctrl+o</kbd>: kopieer submodule naam naar klembord
<kbd>enter</kbd>: enter submodule
<kbd>d</kbd>: view reset and remove submodule options
<kbd>d</kbd>: bekijk reset en verwijder submodule opties
<kbd>u</kbd>: update submodule
<kbd>n</kbd>: add new submodule
<kbd>n</kbd>: voeg nieuwe submodule toe
<kbd>e</kbd>: update submodule URL
<kbd>i</kbd>: initialize submodule
<kbd>b</kbd>: view bulk submodule options
<kbd>i</kbd>: initialiseer submodule
<kbd>b</kbd>: bekijk bulk submodule opties
</pre>
## Hooft Paneel (Merggen)
## Hoofd Paneel (Mergen)
<pre>
<kbd>esc</kbd>: ga terug naar het bestanden paneel
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>space</kbd>: kies hunk
<kbd>b</kbd>: kies bijde hunks
<kbd>◄</kbd>: selecteer voorgaand conflict
@@ -202,29 +216,29 @@
<kbd>z</kbd>: ongedaan maken
</pre>
## Hooft Paneel (Normaal)
## Hoofd Paneel (Normaal)
<pre>
<kbd></kbd>: scroll omlaag (fn+up)
<kbd></kbd>: scroll omhoog (fn+down)
<kbd>Ő</kbd>: scroll omlaag (fn+up)
<kbd>ő</kbd>: scroll omhoog (fn+down)
</pre>
## Hooft Paneel (Patch Bouwen)
## Hoofd Paneel (Patch Bouwen)
<pre>
<kbd>esc</kbd>: sluit lijn-bij-lijn mode
<kbd>esc</kbd>: sluit lijn-bij-lijn modus
<kbd>o</kbd>: open bestand
<kbd>▲</kbd>: selecteer de vorige lijn
<kbd>▼</kbd>: selecteer de volgende lijn
<kbd>◄</kbd>: selecteer de vorige hunk
<kbd>►</kbd>: selecteer de volgende hunk
<kbd>space</kbd>: voeg toe/verwijder lijn(en) in patch
<kbd>v</kbd>: toggle drag selecteer
<kbd>V</kbd>: toggle drag selecteer
<kbd>a</kbd>: toggle selecteer hunk
<kbd>v</kbd>: toggle drag selecteer
<kbd>V</kbd>: toggle drag selecteer
<kbd>a</kbd>: toggle selecteer hunk
</pre>
## Hooft Paneel (Staging)
## Hoofd Paneel (Staging)
<pre>
<kbd>esc</kbd>: ga terug naar het bestanden paneel
@@ -238,9 +252,9 @@
<kbd>►</kbd>: selecteer de volgende hunk
<kbd>e</kbd>: verander bestand
<kbd>o</kbd>: open bestand
<kbd>v</kbd>: toggle drag selecteer
<kbd>V</kbd>: toggle drag selecteer
<kbd>a</kbd>: toggle selecteer hunk
<kbd>v</kbd>: toggle drag selecteer
<kbd>V</kbd>: toggle drag selecteer
<kbd>a</kbd>: toggle selecteer hunk
<kbd>c</kbd>: Commit veranderingen
<kbd>w</kbd>: commit veranderingen zonder pre-commit hook
<kbd>C</kbd>: commit veranderingen met de git editor
@@ -255,7 +269,7 @@
## Stash Paneel
<pre>
<kbd>enter</kbd>: view stash entry's files
<kbd>enter</kbd>: bekijk bestanden van stash entry
<kbd>space</kbd>: toepassen
<kbd>g</kbd>: pop
<kbd>d</kbd>: laten vallen
@@ -269,5 +283,5 @@
<kbd>o</kbd>: open config bestand
<kbd>u</kbd>: check voor updates
<kbd>enter</kbd>: wissel naar een recente repo
<kbd>a</kbd>: alle takken van het houtblok laten zien
<kbd>a</kbd>: alle logs van de branch laten zien
</pre>

View File

@@ -3,6 +3,7 @@
## Globalne
<pre>
<kbd>ctrl+r</kbd>: switch to a recent repo (<c-r>)
<kbd>pgup</kbd>: scroll up main panel (fn+up)
<kbd>pgdown</kbd>: scroll down main panel (fn+down)
<kbd>m</kbd>: view merge/rebase options
@@ -16,9 +17,10 @@
<kbd>+</kbd>: next screen mode (normal/half/fullscreen)
<kbd>_</kbd>: prev screen mode
<kbd>:</kbd>: execute custom command
<kbd>|</kbd>: view scoping options
<kbd>ctrl+s</kbd>: view filter-by-path options
<kbd>W</kbd>: open diff menu
<kbd>ctrl+e</kbd>: open diff menu
<kbd>@</kbd>: open command log menu
</pre>
## List Panel Navigation
@@ -38,6 +40,7 @@
<pre>
<kbd>space</kbd>: przełącz
<kbd>o</kbd>: utwórz żądanie wyciągnięcia
<kbd>O</kbd>: utwórz opcje żądania ściągnięcia
<kbd>ctrl+y</kbd>: skopiuj adres URL żądania ściągnięcia do schowka
<kbd>c</kbd>: przełącz używając nazwy
<kbd>F</kbd>: wymuś przełączenie
@@ -109,7 +112,8 @@
<kbd>o</kbd>: otwórz plik
<kbd>e</kbd>: edytuj plik
<kbd>space</kbd>: toggle file included in patch
<kbd>enter</kbd>: enter file to add selected lines to the patch
<kbd>enter</kbd>: enter file to add selected lines to the patch (or toggle directory collapsed)
<kbd>`</kbd>: toggle file tree view
</pre>
## Commity Panel (Commity)
@@ -121,7 +125,7 @@
<kbd>g</kbd>: zresetuj do tego commita
<kbd>f</kbd>: napraw commit
<kbd>F</kbd>: create fixup commit for this commit
<kbd>S</kbd>: squash above commits
<kbd>S</kbd>: squash all 'fixup!' commits above selected commits (autosquash)
<kbd>d</kbd>: delete commit
<kbd>ctrl+j</kbd>: move commit down one
<kbd>ctrl+k</kbd>: move commit up one
@@ -153,6 +157,12 @@
<kbd>ctrl+o</kbd>: copy commit SHA to clipboard
</pre>
## Extras Panel
<pre>
<kbd>@</kbd>: open command log menu
</pre>
## Pliki Panel (Pliki)
<pre>
@@ -174,6 +184,9 @@
<kbd>f</kbd>: fetch
<kbd>ctrl+o</kbd>: copy the file name to the clipboard
<kbd>g</kbd>: view upstream reset options
<kbd>`</kbd>: toggle file tree view
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>ctrl+w</kbd>: Toggle whether or not whitespace changes are shown in the diff view
</pre>
## Pliki Panel (Submodules)
@@ -193,6 +206,7 @@
<pre>
<kbd>esc</kbd>: wróć do panelu plików
<kbd>M</kbd>: open external merge tool (git mergetool)
<kbd>space</kbd>: pick hunk
<kbd>b</kbd>: pick both hunks
<kbd>◄</kbd>: select previous conflict
@@ -205,8 +219,8 @@
## Main Panel (Normal)
<pre>
<kbd></kbd>: scroll down (fn+up)
<kbd></kbd>: scroll up (fn+down)
<kbd>Ő</kbd>: scroll down (fn+up)
<kbd>ő</kbd>: scroll up (fn+down)
</pre>
## Main Panel (Patch Building)

22
go.mod
View File

@@ -9,36 +9,40 @@ require (
github.com/cli/safeexec v1.0.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.11
github.com/fatih/color v1.9.0
github.com/fatih/color v1.9.0 // indirect
github.com/fsnotify/fsnotify v1.4.7
github.com/go-errors/errors v1.1.1
github.com/gdamore/tcell/v2 v2.3.11 // indirect
github.com/go-errors/errors v1.4.0
github.com/go-logfmt/logfmt v0.5.0 // indirect
github.com/golang-collections/collections v0.0.0-20130729185459-604e922904d3
github.com/golang/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.3.1 // indirect
github.com/gookit/color v1.4.2
github.com/imdario/mergo v0.3.11
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4
github.com/jesseduffield/gocui v0.3.1-0.20210208224444-2eecee85583d
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe
github.com/jesseduffield/gocui v0.3.1-0.20210614081440-74b42ecad52b
github.com/jesseduffield/yaml v2.1.0+incompatible
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.7 // indirect
github.com/mattn/go-runewidth v0.0.10
github.com/mattn/go-runewidth v0.0.13
github.com/mgutz/str v1.2.0
github.com/nsf/termbox-go v0.0.0-20210114135735-d04385b850e8 // indirect
github.com/onsi/ginkgo v1.10.3 // indirect
github.com/onsi/gomega v1.7.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/sahilm/fuzzy v0.1.0
github.com/sirupsen/logrus v1.4.2
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad
github.com/stretchr/testify v1.4.0
github.com/stretchr/testify v1.6.1
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 // indirect
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect
golang.org/x/sys v0.0.0-20201005172224-997123666555 // indirect
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273 // indirect
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 // indirect
golang.org/x/text v0.3.6 // indirect
)
replace github.com/go-git/go-git/v5 => github.com/jesseduffield/go-git/v5 v5.1.1

66
go.sum
View File

@@ -23,7 +23,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886 h1:NAFoy+QgUpERgK3y1xiVh5HcOvSeZHpXTTo5qnvnuK4=
github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
@@ -31,17 +30,22 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjr
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.0.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
github.com/gdamore/tcell/v2 v2.3.11 h1:ECO6WqHGbKZ3HrSL7bG/zArMCmLaNr5vcjjMVnLHpzc=
github.com/gdamore/tcell/v2 v2.3.11/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
github.com/go-errors/errors v1.1.1 h1:ljK/pL5ltg3qoN+OtN6yCv9HWSfMwxSx90GJCZQxYNg=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.0 h1:2OA7MFw38+e9na72T1xgkomPb6GzZzzxvJ5U630FoRM=
github.com/go-errors/errors v1.4.0/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM=
github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12 h1:PbKy9zOy4aAKrJ5pibIRpVO2BXnK1Tlcg+caKI7Ox5M=
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -53,9 +57,10 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg=
github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
@@ -65,14 +70,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4 h1:GOQrmaE8i+KEdB8NzAegKYd4tPn/inM0I1uo0NXFerg=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
github.com/jesseduffield/gocui v0.3.1-0.20161105104656-d666c9f652af h1:9ZI/QyVOerYYeqMt4svycU2Lz0WvxNHCpHHbsFsi/oA=
github.com/jesseduffield/gocui v0.3.1-0.20161105104656-d666c9f652af/go.mod h1:2RtZznzYKt8RLRwvFiSkXjU0Ei8WwHdubgnlaYH47dw=
github.com/jesseduffield/gocui v0.3.1-0.20201010224802-8a6768078fd7 h1:K3MGrjmpPtIhfXmKh/zsIF0CdmNKOkjpIwcUfAa/J2A=
github.com/jesseduffield/gocui v0.3.1-0.20201010224802-8a6768078fd7/go.mod h1:2RtZznzYKt8RLRwvFiSkXjU0Ei8WwHdubgnlaYH47dw=
github.com/jesseduffield/gocui v0.3.1-0.20210208224444-2eecee85583d h1:Jto9W9w8CFwZiAYXa7LsHDEOb5cKCA1f5LOL1A3jva4=
github.com/jesseduffield/gocui v0.3.1-0.20210208224444-2eecee85583d/go.mod h1:2RtZznzYKt8RLRwvFiSkXjU0Ei8WwHdubgnlaYH47dw=
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe h1:qsVhCf2RFyyKIUe/+gJblbCpXMUki9rZrHuEctg6M/E=
github.com/jesseduffield/termbox-go v0.0.0-20200823212418-a2289ed6aafe/go.mod h1:anMibpZtqNxjDbxrcDEAwSdaJ37vyUeM1f/M4uekib4=
github.com/jesseduffield/gocui v0.3.1-0.20210614081440-74b42ecad52b h1:Wc2zx6xKLNaHc7/wIO6iYyDSjcFGN1Osd6tQvbgMmgo=
github.com/jesseduffield/gocui v0.3.1-0.20210614081440-74b42ecad52b/go.mod h1:QWq79xplEoyhQO+dgpk3sojjTVRKjQklyTlzm5nC5Kg=
github.com/jesseduffield/yaml v2.1.0+incompatible h1:HWQJ1gIv2zHKbDYNp0Jwjlj24K8aqpFHnMCynY1EpmE=
github.com/jesseduffield/yaml v2.1.0+incompatible/go.mod h1:w0xGhOSIJCGYYW+hnFPTutCy5aACpkcwbmORt5axGqk=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -85,38 +84,38 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kyokomi/emoji/v2 v2.2.8 h1:jcofPxjHWEkJtkIbcLHvZhxKgCPl6C7MyjTrD4KDqUE=
github.com/kyokomi/emoji/v2 v2.2.8/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mgutz/str v1.2.0 h1:4IzWSdIz9qPQWLfKZ0rJcV0jcUDpxvP4JVZ4GXQyvSw=
github.com/mgutz/str v1.2.0/go.mod h1:w1v0ofgLaJdoD0HpQ3fycxKD1WtxpjSo151pK/31q6w=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nsf/termbox-go v0.0.0-20210114135735-d04385b850e8 h1:3vzIuru1svOK2sXlg4XcrO3KkGRneIejmfQfR+ptSW8=
github.com/nsf/termbox-go v0.0.0-20210114135735-d04385b850e8/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.3 h1:OoxbjfXVZyod1fmWYhI7SEyaD8B00ynP3T+D5GiyHOY=
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -126,7 +125,6 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -142,21 +140,22 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli v1.20.1-0.20180226030253-8e01ec4cd3e2/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM=
golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c h1:dk0ukUIHmGHqASjP0iue2261isepFCC6XRCSd1nHgDw=
golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c/go.mod h1:iQL9McJNjoIa5mjH6nYTCTZXUN6RP+XW3eib7Ya3XcI=
@@ -168,20 +167,25 @@ golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201005172224-997123666555 h1:fihtqzYxy4E31W1yUlyRGveTZT1JIP0bmKaDZ2ceKAw=
golang.org/x/sys v0.0.0-20201005172224-997123666555/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273 h1:faDu4veV+8pcThn4fewv6TVlNCezafGoC1gM/mxQLbQ=
golang.org/x/sys v0.0.0-20210611083646-a4fc73990273/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -195,3 +199,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -12,6 +12,7 @@ import (
"github.com/integrii/flaggy"
"github.com/jesseduffield/lazygit/pkg/app"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/env"
yaml "github.com/jesseduffield/yaml"
)
@@ -134,6 +135,6 @@ func main() {
stackTrace := newErr.ErrorStack()
app.Log.Error(stackTrace)
log.Fatal(fmt.Sprintf("%s\n\n%s", app.Tr.ErrorOccurred, stackTrace))
log.Fatal(fmt.Sprintf("%s: %s\n\n%s", app.Tr.ErrorOccurred, constants.Links.Issues, stackTrace))
}
}

View File

@@ -4,6 +4,15 @@ import (
"bufio"
"errors"
"fmt"
"github.com/aybabtme/humanlog"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/sirupsen/logrus"
"io"
"io/ioutil"
"log"
@@ -12,17 +21,6 @@ import (
"regexp"
"strconv"
"strings"
"github.com/aybabtme/humanlog"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/gui"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/sirupsen/logrus"
)
// App struct
@@ -97,6 +95,7 @@ func newLogger(config config.AppConfigurer) *logrus.Entry {
// NewApp bootstrap a new application
func NewApp(config config.AppConfigurer, filterPath string) (*App, error) {
app := &App{
closers: []io.Closer{},
Config: config,
@@ -182,7 +181,7 @@ func (app *App) setupRepo() (bool, error) {
}
// if we are not in a git repo, we ask if we want to `git init`
if err := app.OSCommand.RunCommand("git status"); err != nil {
if err := commands.VerifyInGitRepo(app.OSCommand); err != nil {
cwd, err := os.Getwd()
if err != nil {
return false, err
@@ -225,6 +224,7 @@ func (app *App) setupRepo() (bool, error) {
return false, err
}
}
return false, nil
}
@@ -237,7 +237,7 @@ func (app *App) Run() error {
os.Exit(0)
}
err := app.Gui.RunWithSubprocesses()
err := app.Gui.RunAndHandleError()
return err
}
@@ -316,6 +316,9 @@ func TailLogs() {
fmt.Printf("Tailing log file %s\n\n", logFilePath)
opts := humanlog.DefaultOptions
opts.Truncates = false
_, err = os.Stat(logFilePath)
if err != nil {
if os.IsNotExist(err) {
@@ -324,22 +327,5 @@ func TailLogs() {
log.Fatal(err)
}
cmd := secureexec.Command("tail", "-f", logFilePath)
stdout, _ := cmd.StdoutPipe()
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
opts := humanlog.DefaultOptions
opts.Truncates = false
if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil {
log.Fatal(err)
}
if err := cmd.Wait(); err != nil {
log.Fatal(err)
}
os.Exit(0)
TailLogsForPlatform(logFilePath, opts)
}

29
pkg/app/logging.go Normal file
View File

@@ -0,0 +1,29 @@
// +build !windows
package app
import (
"github.com/aybabtme/humanlog"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"log"
"os"
)
func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
cmd := secureexec.Command("tail", "-f", logFilePath)
stdout, _ := cmd.StdoutPipe()
if err := cmd.Start(); err != nil {
log.Fatal(err)
}
if err := humanlog.Scanner(stdout, os.Stdout, opts); err != nil {
log.Fatal(err)
}
if err := cmd.Wait(); err != nil {
log.Fatal(err)
}
os.Exit(0)
}

View File

@@ -0,0 +1,71 @@
// +build windows
package app
import (
"bufio"
"github.com/aybabtme/humanlog"
"log"
"os"
"strings"
"time"
)
func TailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) {
var lastModified int64 = 0
var lastOffset int64 = 0
for {
stat, err := os.Stat(logFilePath)
if err != nil {
log.Fatal(err)
}
if stat.ModTime().Unix() > lastModified {
err = TailFrom(lastOffset, logFilePath, opts)
if err != nil {
log.Fatal(err)
}
}
lastOffset = stat.Size()
time.Sleep(1 * time.Second)
}
}
func OpenAndSeek(filepath string, offset int64) (*os.File, error) {
file, err := os.Open(filepath)
if err != nil {
return nil, err
}
_, err = file.Seek(offset, 0)
if err != nil {
_ = file.Close()
return nil, err
}
return file, nil
}
func TailFrom(lastOffset int64, logFilePath string, opts *humanlog.HandlerOptions) error {
file, err := OpenAndSeek(logFilePath, lastOffset)
if err != nil {
return err
}
fileScanner := bufio.NewScanner(file)
var lines []string
for fileScanner.Scan() {
lines = append(lines, fileScanner.Text())
}
file.Close()
lineCount := len(lines)
lastTen := lines
if lineCount > 10 {
lastTen = lines[lineCount-10:]
}
for _, line := range lastTen {
reader := strings.NewReader(line)
if err := humanlog.Scanner(reader, os.Stdout, opts); err != nil {
log.Fatal(err)
}
}
return nil
}

View File

@@ -11,19 +11,19 @@ import (
// NewBranch create new branch
func (c *GitCommand) NewBranch(name string, base string) error {
return c.OSCommand.RunCommand("git checkout -b %s %s", name, base)
return c.RunCommand("git checkout -b %s %s", name, base)
}
// CurrentBranchName get the current branch name and displayname.
// the first returned string is the name and the second is the displayname
// e.g. name is 123asdf and displayname is '(HEAD detached at 123asdf)'
func (c *GitCommand) CurrentBranchName() (string, string, error) {
branchName, err := c.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD")
branchName, err := c.RunCommandWithOutput("git symbolic-ref --short HEAD")
if err == nil && branchName != "HEAD\n" {
trimmedBranchName := strings.TrimSpace(branchName)
return trimmedBranchName, trimmedBranchName, nil
}
output, err := c.OSCommand.RunCommandWithOutput("git branch --contains")
output, err := c.RunCommandWithOutput("git branch --contains")
if err != nil {
return "", "", err
}
@@ -59,9 +59,9 @@ type CheckoutOptions struct {
func (c *GitCommand) Checkout(branch string, options CheckoutOptions) error {
forceArg := ""
if options.Force {
forceArg = "--force "
forceArg = " --force"
}
return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git checkout %s %s", forceArg, branch), oscommands.RunCommandOptions{EnvVars: options.EnvVars})
return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git checkout%s %s", forceArg, branch), oscommands.RunCommandOptions{EnvVars: options.EnvVars})
}
// GetBranchGraph gets the color-formatted graph of the log for the given branch
@@ -73,7 +73,7 @@ func (c *GitCommand) GetBranchGraph(branchName string) (string, error) {
}
func (c *GitCommand) GetUpstreamForBranch(branchName string) (string, error) {
output, err := c.OSCommand.RunCommandWithOutput("git rev-parse --abbrev-ref --symbolic-full-name %s@{u}", branchName)
output, err := c.RunCommandWithOutput("git rev-parse --abbrev-ref --symbolic-full-name %s@{u}", branchName)
return strings.TrimSpace(output), err
}
@@ -86,11 +86,11 @@ func (c *GitCommand) GetBranchGraphCmdStr(branchName string) string {
}
func (c *GitCommand) SetUpstreamBranch(upstream string) error {
return c.OSCommand.RunCommand("git branch -u %s", upstream)
return c.RunCommand("git branch -u %s", upstream)
}
func (c *GitCommand) SetBranchUpstream(remoteName string, remoteBranchName string, branchName string) error {
return c.OSCommand.RunCommand("git branch --set-upstream-to=%s/%s %s", remoteName, remoteBranchName, branchName)
return c.RunCommand("git branch --set-upstream-to=%s/%s %s", remoteName, remoteBranchName, branchName)
}
func (c *GitCommand) GetCurrentBranchUpstreamDifferenceCount() (string, string) {
@@ -134,24 +134,28 @@ func (c *GitCommand) Merge(branchName string, opts MergeOpts) error {
// AbortMerge abort merge
func (c *GitCommand) AbortMerge() error {
return c.OSCommand.RunCommand("git merge --abort")
return c.RunCommand("git merge --abort")
}
func (c *GitCommand) IsHeadDetached() bool {
err := c.OSCommand.RunCommand("git symbolic-ref -q HEAD")
err := c.RunCommand("git symbolic-ref -q HEAD")
return err != nil
}
// ResetHardHead runs `git reset --hard`
func (c *GitCommand) ResetHard(ref string) error {
return c.OSCommand.RunCommand("git reset --hard " + ref)
return c.RunCommand("git reset --hard " + ref)
}
// ResetSoft runs `git reset --soft HEAD`
func (c *GitCommand) ResetSoft(ref string) error {
return c.OSCommand.RunCommand("git reset --soft " + ref)
return c.RunCommand("git reset --soft " + ref)
}
func (c *GitCommand) ResetMixed(ref string) error {
return c.RunCommand("git reset --mixed " + ref)
}
func (c *GitCommand) RenameBranch(oldName string, newName string) error {
return c.OSCommand.RunCommand("git branch --move %s %s", oldName, newName)
return c.RunCommand("git branch --move %s %s", oldName, newName)
}

View File

@@ -0,0 +1,338 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetCommitDifferences is a function.
func TestGitCommandGetCommitDifferences(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(string, string)
}
scenarios := []scenario{
{
"Can't retrieve pushable count",
func(string, ...string) *exec.Cmd {
return secureexec.Command("test")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "?", pushableCount)
assert.EqualValues(t, "?", pullableCount)
},
},
{
"Can't retrieve pullable count",
func(cmd string, args ...string) *exec.Cmd {
if args[1] == "HEAD..@{u}" {
return secureexec.Command("test")
}
return secureexec.Command("echo")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "?", pushableCount)
assert.EqualValues(t, "?", pullableCount)
},
},
{
"Retrieve pullable and pushable count",
func(cmd string, args ...string) *exec.Cmd {
if args[1] == "HEAD..@{u}" {
return secureexec.Command("echo", "10")
}
return secureexec.Command("echo", "11")
},
func(pushableCount string, pullableCount string) {
assert.EqualValues(t, "11", pushableCount)
assert.EqualValues(t, "10", pullableCount)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetCommitDifferences("HEAD", "@{u}"))
})
}
}
// TestGitCommandNewBranch is a function.
func TestGitCommandNewBranch(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "-b", "test", "master"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.NewBranch("test", "master"))
}
// TestGitCommandDeleteBranch is a function.
func TestGitCommandDeleteBranch(t *testing.T) {
type scenario struct {
testName string
branch string
force bool
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"Delete a branch",
"test",
false,
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"branch", "-d", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"Force delete a branch",
"test",
true,
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"branch", "-D", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DeleteBranch(s.branch, s.force))
})
}
}
// TestGitCommandMerge is a function.
func TestGitCommandMerge(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"merge", "--no-edit", "test"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.Merge("test", MergeOpts{}))
}
// TestGitCommandCheckout is a function.
func TestGitCommandCheckout(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
force bool
}
scenarios := []scenario{
{
"Checkout",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
false,
},
{
"Checkout forced",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"checkout", "--force", "test"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
true,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.Checkout("test", CheckoutOptions{Force: s.force}))
})
}
}
// TestGitCommandGetBranchGraph is a function.
func TestGitCommandGetBranchGraph(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"log", "--graph", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium", "test", "--"}, args)
return secureexec.Command("echo")
}
_, err := gitCmd.GetBranchGraph("test")
assert.NoError(t, err)
}
func TestGitCommandGetAllBranchGraph(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"log", "--graph", "--all", "--color=always", "--abbrev-commit", "--decorate", "--date=relative", "--pretty=medium"}, args)
return secureexec.Command("echo")
}
cmdStr := gitCmd.Config.GetUserConfig().Git.AllBranchesLogCmd
_, err := gitCmd.OSCommand.RunCommandWithOutput(cmdStr)
assert.NoError(t, err)
}
// TestGitCommandCurrentBranchName is a function.
func TestGitCommandCurrentBranchName(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(string, string, error)
}
scenarios := []scenario{
{
"says we are on the master branch if we are",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
return secureexec.Command("echo", "master")
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "master", name)
assert.EqualValues(t, "master", displayname)
},
},
{
"falls back to git `git branch --contains` if symbolic-ref fails",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
switch args[0] {
case "symbolic-ref":
assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args)
return secureexec.Command("test")
case "branch":
assert.EqualValues(t, []string{"branch", "--contains"}, args)
return secureexec.Command("echo", "* master")
}
return nil
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "master", name)
assert.EqualValues(t, "master", displayname)
},
},
{
"handles a detached head",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
switch args[0] {
case "symbolic-ref":
assert.EqualValues(t, []string{"symbolic-ref", "--short", "HEAD"}, args)
return secureexec.Command("test")
case "branch":
assert.EqualValues(t, []string{"branch", "--contains"}, args)
return secureexec.Command("echo", "* (HEAD detached at 123abcd)")
}
return nil
},
func(name string, displayname string, err error) {
assert.NoError(t, err)
assert.EqualValues(t, "123abcd", name)
assert.EqualValues(t, "(HEAD detached at 123abcd)", displayname)
},
},
{
"bubbles up error if there is one",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
return secureexec.Command("test")
},
func(name string, displayname string, err error) {
assert.Error(t, err)
assert.EqualValues(t, "", name)
assert.EqualValues(t, "", displayname)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CurrentBranchName())
})
}
}
// TestGitCommandResetHard is a function.
func TestGitCommandResetHard(t *testing.T) {
type scenario struct {
testName string
ref string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
"HEAD",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git reset --hard HEAD`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.ResetHard(s.ref))
})
}
}

View File

@@ -2,8 +2,6 @@ package commands
import (
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -12,7 +10,7 @@ import (
// RenameCommit renames the topmost commit with the given name
func (c *GitCommand) RenameCommit(name string) error {
return c.OSCommand.RunCommand("git commit --allow-empty --amend -m %s", c.OSCommand.Quote(name))
return c.RunCommand("git commit --allow-empty --amend --only -m %s", c.OSCommand.Quote(name))
}
// ResetToCommit reset to commit
@@ -20,20 +18,19 @@ func (c *GitCommand) ResetToCommit(sha string, strength string, options oscomman
return c.OSCommand.RunCommandWithOptions(fmt.Sprintf("git reset --%s %s", strength, sha), options)
}
// Commit commits to git
func (c *GitCommand) Commit(message string, flags string) (*exec.Cmd, error) {
func (c *GitCommand) CommitCmdStr(message string, flags string) string {
splitMessage := strings.Split(message, "\n")
lineArgs := ""
for _, line := range splitMessage {
lineArgs += fmt.Sprintf(" -m %s", strconv.Quote(line))
lineArgs += fmt.Sprintf(" -m %s", c.OSCommand.Quote(line))
}
command := fmt.Sprintf("git commit %s%s", flags, lineArgs)
if c.usingGpg() {
return c.OSCommand.ShellCommandFromString(command), nil
flagsStr := ""
if flags != "" {
flagsStr = fmt.Sprintf(" %s", flags)
}
return nil, c.OSCommand.RunCommand(command)
return fmt.Sprintf("git commit%s%s", flagsStr, lineArgs)
}
// Get the subject of the HEAD commit
@@ -50,19 +47,17 @@ func (c *GitCommand) GetCommitMessage(commitSha string) (string, error) {
return strings.TrimSpace(message), err
}
// AmendHead amends HEAD with whatever is staged in your working tree
func (c *GitCommand) AmendHead() (*exec.Cmd, error) {
command := "git commit --amend --no-edit --allow-empty"
if c.usingGpg() {
return c.OSCommand.ShellCommandFromString(command), nil
}
return nil, c.OSCommand.RunCommand(command)
func (c *GitCommand) GetCommitMessageFirstLine(sha string) (string, error) {
return c.RunCommandWithOutput("git show --no-patch --pretty=format:%%s %s", sha)
}
// PrepareCommitAmendSubProcess prepares a subprocess for `git commit --amend --allow-empty`
func (c *GitCommand) PrepareCommitAmendSubProcess() *exec.Cmd {
return c.OSCommand.PrepareSubProcess("git", "commit", "--amend", "--allow-empty")
// AmendHead amends HEAD with whatever is staged in your working tree
func (c *GitCommand) AmendHead() error {
return c.OSCommand.RunCommand(c.AmendHeadCmdStr())
}
func (c *GitCommand) AmendHeadCmdStr() string {
return "git commit --amend --no-edit --allow-empty"
}
func (c *GitCommand) ShowCmdStr(sha string, filterPath string) string {
@@ -75,7 +70,11 @@ func (c *GitCommand) ShowCmdStr(sha string, filterPath string) string {
// Revert reverts the selected commit by sha
func (c *GitCommand) Revert(sha string) error {
return c.OSCommand.RunCommand("git revert %s", sha)
return c.RunCommand("git revert %s", sha)
}
func (c *GitCommand) RevertMerge(sha string, parentNumber int) error {
return c.RunCommand("git revert %s -m %d", sha, parentNumber)
}
// CherryPickCommits begins an interactive rebase with the given shas being cherry picked onto HEAD
@@ -95,5 +94,5 @@ func (c *GitCommand) CherryPickCommits(commits []*models.Commit) error {
// CreateFixupCommit creates a commit that fixes up a previous commit
func (c *GitCommand) CreateFixupCommit(sha string) error {
return c.OSCommand.RunCommand("git commit --fixup=%s", sha)
return c.RunCommand("git commit --fixup=%s", sha)
}

View File

@@ -0,0 +1,111 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandRenameCommit is a function.
func TestGitCommandRenameCommit(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"commit", "--allow-empty", "--amend", "--only", "-m", "test"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.RenameCommit("test"))
}
// TestGitCommandResetToCommit is a function.
func TestGitCommandResetToCommit(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"reset", "--hard", "78976bc"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.ResetToCommit("78976bc", "hard", oscommands.RunCommandOptions{}))
}
// TestGitCommandCommitStr is a function.
func TestGitCommandCommitStr(t *testing.T) {
type scenario struct {
testName string
message string
flags string
expected string
}
scenarios := []scenario{
{
testName: "Commit",
message: "test",
flags: "",
expected: "git commit -m \"test\"",
},
{
testName: "Commit with --no-verify flag",
message: "test",
flags: "--no-verify",
expected: "git commit --no-verify -m \"test\"",
},
{
testName: "Commit with multiline message",
message: "line1\nline2",
flags: "",
expected: "git commit -m \"line1\" -m \"line2\"",
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
cmdStr := gitCmd.CommitCmdStr(s.message, s.flags)
assert.Equal(t, s.expected, cmdStr)
})
}
}
// TestGitCommandCreateFixupCommit is a function.
func TestGitCommandCreateFixupCommit(t *testing.T) {
type scenario struct {
testName string
sha string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
"12345",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git commit --fixup=12345`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CreateFixupCommit(s.sha))
})
}
}

View File

@@ -15,7 +15,7 @@ func (c *GitCommand) ConfiguredPager() string {
if os.Getenv("PAGER") != "" {
return os.Getenv("PAGER")
}
output, err := c.OSCommand.RunCommandWithOutput("git config --get-all core.pager")
output, err := c.RunCommandWithOutput("git config --get-all core.pager")
if err != nil {
return ""
}
@@ -46,3 +46,17 @@ func (c *GitCommand) GetConfigValue(key string) string {
output, _ := c.getGitConfigValue(key)
return output
}
// UsingGpg tells us whether the user has gpg enabled so that we can know
// whether we need to run a subprocess to allow them to enter their password
func (c *GitCommand) UsingGpg() bool {
overrideGpg := c.Config.GetUserConfig().Git.OverrideGpg
if overrideGpg {
return false
}
gpgsign := c.GetConfigValue("commit.gpgsign")
value := strings.ToLower(gpgsign)
return value == "true" || value == "1" || value == "yes" || value == "on"
}

View File

@@ -0,0 +1,70 @@
package commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestGitCommandUsingGpg is a function.
func TestGitCommandUsingGpg(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
test func(bool)
}
scenarios := []scenario{
{
"Option global and local config commit.gpgsign is not set",
func(string) (string, error) { return "", nil },
func(gpgEnabled bool) {
assert.False(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is true",
func(string) (string, error) {
return "True", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is on",
func(string) (string, error) {
return "ON", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is yes",
func(string) (string, error) {
return "YeS", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
{
"Option commit.gpgsign is 1",
func(string) (string, error) {
return "1", nil
},
func(gpgEnabled bool) {
assert.True(t, gpgEnabled)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.UsingGpg())
})
}
}

View File

@@ -20,6 +20,5 @@ func NewDummyGitCommandWithOSCommand(osCommand *oscommands.OSCommand) *GitComman
Tr: i18n.NewTranslationSet(utils.NewDummyLog()),
Config: config.NewDummyAppConfig(),
getGitConfigValue: func(string) (string, error) { return "", nil },
removeFile: func(string) error { return nil },
}
}

View File

@@ -2,48 +2,53 @@ package commands
import (
"fmt"
"os/exec"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str"
)
// CatFile obtains the content of a file
func (c *GitCommand) CatFile(fileName string) (string, error) {
return c.OSCommand.RunCommandWithOutput("%s %s", c.OSCommand.Platform.CatCmd, c.OSCommand.Quote(fileName))
return c.OSCommand.CatFile(fileName)
}
func (c *GitCommand) OpenMergeToolCmd() string {
return "git mergetool"
}
func (c *GitCommand) OpenMergeTool() error {
return c.OSCommand.RunCommand("git mergetool")
}
// StageFile stages a file
func (c *GitCommand) StageFile(fileName string) error {
// renamed files look like "file1 -> file2"
fileNames := strings.Split(fileName, " -> ")
return c.OSCommand.RunCommand("git add %s", c.OSCommand.Quote(fileNames[len(fileNames)-1]))
return c.RunCommand("git add -- %s", c.OSCommand.Quote(fileName))
}
// StageAll stages all files
func (c *GitCommand) StageAll() error {
return c.OSCommand.RunCommand("git add -A")
return c.RunCommand("git add -A")
}
// UnstageAll stages all files
// UnstageAll unstages all files
func (c *GitCommand) UnstageAll() error {
return c.OSCommand.RunCommand("git reset")
return c.RunCommand("git reset")
}
// UnStageFile unstages a file
func (c *GitCommand) UnStageFile(fileName string, tracked bool) error {
command := "git rm --cached --force %s"
if tracked {
command = "git reset HEAD %s"
// we accept an array of filenames for the cases where a file has been renamed i.e.
// we accept the current name and the previous name
func (c *GitCommand) UnStageFile(fileNames []string, reset bool) error {
command := "git rm --cached --force -- %s"
if reset {
command = "git reset HEAD -- %s"
}
// renamed files look like "file1 -> file2"
fileNames := strings.Split(fileName, " -> ")
for _, name := range fileNames {
if err := c.OSCommand.RunCommand(command, c.OSCommand.Quote(name)); err != nil {
return err
@@ -58,20 +63,19 @@ func (c *GitCommand) BeforeAndAfterFileForRename(file *models.File) (*models.Fil
return nil, nil, errors.New("Expected renamed file")
}
// we've got a file that represents a rename from one file to another. Unfortunately
// our File abstraction fails to consider this case, so here we will refetch
// we've got a file that represents a rename from one file to another. Here we will refetch
// all files, passing the --no-renames flag and then recursively call the function
// again for the before file and after file. At some point we should fix the abstraction itself
// again for the before file and after file.
split := strings.Split(file.Name, " -> ")
filesWithoutRenames := c.GetStatusFiles(GetStatusFileOptions{NoRenames: true})
var beforeFile *models.File
var afterFile *models.File
for _, f := range filesWithoutRenames {
if f.Name == split[0] {
if f.Name == file.PreviousName {
beforeFile = f
}
if f.Name == split[1] {
if f.Name == file.Name {
afterFile = f
}
}
@@ -107,24 +111,76 @@ func (c *GitCommand) DiscardAllFileChanges(file *models.File) error {
return nil
}
// if the file isn't tracked, we assume you want to delete it
quotedFileName := c.OSCommand.Quote(file.Name)
if file.ShortStatus == "AA" {
if err := c.RunCommand("git checkout --ours -- %s", quotedFileName); err != nil {
return err
}
if err := c.RunCommand("git add %s", quotedFileName); err != nil {
return err
}
return nil
}
if file.ShortStatus == "DU" {
return c.RunCommand("git rm %s", quotedFileName)
}
// if the file isn't tracked, we assume you want to delete it
if file.HasStagedChanges || file.HasMergeConflicts {
if err := c.OSCommand.RunCommand("git reset -- %s", quotedFileName); err != nil {
if err := c.RunCommand("git reset -- %s", quotedFileName); err != nil {
return err
}
}
if !file.Tracked {
return c.removeFile(file.Name)
if file.ShortStatus == "DD" || file.ShortStatus == "AU" {
return nil
}
if file.Added {
return c.OSCommand.RemoveFile(file.Name)
}
return c.DiscardUnstagedFileChanges(file)
}
func (c *GitCommand) DiscardAllDirChanges(node *filetree.FileNode) error {
// this could be more efficient but we would need to handle all the edge cases
return node.ForEachFile(c.DiscardAllFileChanges)
}
func (c *GitCommand) DiscardUnstagedDirChanges(node *filetree.FileNode) error {
if err := c.RemoveUntrackedDirFiles(node); err != nil {
return err
}
quotedPath := c.OSCommand.Quote(node.GetPath())
if err := c.RunCommand("git checkout -- %s", quotedPath); err != nil {
return err
}
return nil
}
func (c *GitCommand) RemoveUntrackedDirFiles(node *filetree.FileNode) error {
untrackedFilePaths := node.GetPathsMatching(
func(n *filetree.FileNode) bool { return n.File != nil && !n.File.GetIsTracked() },
)
for _, path := range untrackedFilePaths {
err := os.Remove(path)
if err != nil {
return err
}
}
return nil
}
// DiscardUnstagedFileChanges directly
func (c *GitCommand) DiscardUnstagedFileChanges(file *models.File) error {
quotedFileName := c.OSCommand.Quote(file.Name)
return c.OSCommand.RunCommand("git checkout -- %s", quotedFileName)
return c.RunCommand("git checkout -- %s", quotedFileName)
}
// Ignore adds a file to the gitignore for the repo
@@ -133,29 +189,32 @@ func (c *GitCommand) Ignore(filename string) error {
}
// WorktreeFileDiff returns the diff of a file
func (c *GitCommand) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
func (c *GitCommand) WorktreeFileDiff(file *models.File, plain bool, cached bool, ignoreWhitespace bool) string {
// for now we assume an error means the file was deleted
s, _ := c.OSCommand.RunCommandWithOutput(c.WorktreeFileDiffCmdStr(file, plain, cached))
s, _ := c.OSCommand.RunCommandWithOutput(c.WorktreeFileDiffCmdStr(file, plain, cached, ignoreWhitespace))
return s
}
func (c *GitCommand) WorktreeFileDiffCmdStr(file *models.File, plain bool, cached bool) string {
func (c *GitCommand) WorktreeFileDiffCmdStr(node models.IFile, plain bool, cached bool, ignoreWhitespace bool) string {
cachedArg := ""
trackedArg := "--"
colorArg := c.colorArg()
split := strings.Split(file.Name, " -> ") // in case of a renamed file we get the new filename
fileName := c.OSCommand.Quote(split[len(split)-1])
path := c.OSCommand.Quote(node.GetPath())
ignoreWhitespaceArg := ""
if cached {
cachedArg = "--cached"
}
if !file.Tracked && !file.HasStagedChanges && !cached {
trackedArg = "--no-index /dev/null"
if !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached {
trackedArg = "--no-index -- /dev/null"
}
if plain {
colorArg = "never"
}
if ignoreWhitespace {
ignoreWhitespaceArg = "--ignore-all-space"
}
return fmt.Sprintf("git diff --submodule --no-ext-diff --color=%s %s %s %s", colorArg, cachedArg, trackedArg, fileName)
return fmt.Sprintf("git diff --submodule --no-ext-diff --color=%s %s %s %s %s", colorArg, ignoreWhitespaceArg, cachedArg, trackedArg, path)
}
func (c *GitCommand) ApplyPatch(patch string, flags ...string) error {
@@ -170,7 +229,7 @@ func (c *GitCommand) ApplyPatch(patch string, flags ...string) error {
flagStr += " --" + flag
}
return c.OSCommand.RunCommand("git apply %s %s", flagStr, c.OSCommand.Quote(filepath))
return c.RunCommand("git apply %s %s", flagStr, c.OSCommand.Quote(filepath))
}
// ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc
@@ -191,12 +250,12 @@ func (c *GitCommand) ShowFileDiffCmdStr(from string, to string, reverse bool, fi
reverseFlag = " -R "
}
return fmt.Sprintf("git diff --submodule --no-ext-diff --no-renames --color=%s %s %s %s -- %s", colorArg, from, to, reverseFlag, fileName)
return fmt.Sprintf("git diff --submodule --no-ext-diff --no-renames --color=%s %s %s %s -- %s", colorArg, from, to, reverseFlag, c.OSCommand.Quote(fileName))
}
// CheckoutFile checks out the file for the given commit
func (c *GitCommand) CheckoutFile(commitSha, fileName string) error {
return c.OSCommand.RunCommand("git checkout %s %s", commitSha, fileName)
return c.RunCommand("git checkout %s -- %s", commitSha, c.OSCommand.Quote(fileName))
}
// DiscardOldFileChanges discards changes to a file from an old commit
@@ -206,7 +265,7 @@ func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex
}
// check if file exists in previous commit (this command returns an error if the file doesn't exist)
if err := c.OSCommand.RunCommand("git cat-file -e HEAD^:%s", fileName); err != nil {
if err := c.RunCommand("git cat-file -e HEAD^:%s", fileName); err != nil {
if err := c.OSCommand.Remove(fileName); err != nil {
return err
}
@@ -218,10 +277,7 @@ func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex
}
// amend the commit
cmd, err := c.AmendHead()
if cmd != nil {
return errors.New("received unexpected pointer to cmd")
}
err := c.AmendHead()
if err != nil {
return err
}
@@ -232,17 +288,17 @@ func (c *GitCommand) DiscardOldFileChanges(commits []*models.Commit, commitIndex
// DiscardAnyUnstagedFileChanges discards any unstages file changes via `git checkout -- .`
func (c *GitCommand) DiscardAnyUnstagedFileChanges() error {
return c.OSCommand.RunCommand("git checkout -- .")
return c.RunCommand("git checkout -- .")
}
// RemoveTrackedFiles will delete the given file(s) even if they are currently tracked
func (c *GitCommand) RemoveTrackedFiles(name string) error {
return c.OSCommand.RunCommand("git rm -r --cached %s", name)
return c.RunCommand("git rm -r --cached %s", name)
}
// RemoveUntrackedFiles runs `git clean -fd`
func (c *GitCommand) RemoveUntrackedFiles() error {
return c.OSCommand.RunCommand("git clean -fd")
return c.RunCommand("git clean -fd")
}
// ResetAndClean removes all unstaged changes and removes all untracked files
@@ -265,11 +321,16 @@ func (c *GitCommand) ResetAndClean() error {
return c.RemoveUntrackedFiles()
}
// EditFile opens a file in a subprocess using whatever editor is available,
// falling back to core.editor, VISUAL, EDITOR, then vi
func (c *GitCommand) EditFile(filename string) (*exec.Cmd, error) {
editor := c.GetConfigValue("core.editor")
func (c *GitCommand) EditFileCmdStr(filename string) (string, error) {
editor := c.Config.GetUserConfig().OS.EditCommand
if editor == "" {
editor = c.GetConfigValue("core.editor")
}
if editor == "" {
editor = c.OSCommand.Getenv("GIT_EDITOR")
}
if editor == "" {
editor = c.OSCommand.Getenv("VISUAL")
}
@@ -282,10 +343,8 @@ func (c *GitCommand) EditFile(filename string) (*exec.Cmd, error) {
}
}
if editor == "" {
return nil, errors.New("No editor defined in $VISUAL, $EDITOR, or git config")
return "", errors.New("No editor defined in config file, $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
}
splitCmd := str.ToArgv(fmt.Sprintf("%s %s", editor, c.OSCommand.Quote(filename)))
return c.OSCommand.PrepareSubProcess(splitCmd[0], splitCmd[1:]...), nil
return fmt.Sprintf("%s %s", editor, c.OSCommand.Quote(filename)), nil
}

894
pkg/commands/files_test.go Normal file
View File

@@ -0,0 +1,894 @@
package commands
import (
"fmt"
"io/ioutil"
"os/exec"
"runtime"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandCatFile tests emitting a file using commands, where commands vary by OS.
func TestGitCommandCatFile(t *testing.T) {
var osCmd string
switch os := runtime.GOOS; os {
case "windows":
osCmd = "type"
default:
osCmd = "cat"
}
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, osCmd, cmd)
assert.EqualValues(t, []string{"test.txt"}, args)
return secureexec.Command("echo", "-n", "test")
}
o, err := gitCmd.CatFile("test.txt")
assert.NoError(t, err)
assert.Equal(t, "test", o)
}
// TestGitCommandStageFile is a function.
func TestGitCommandStageFile(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"add", "--", "test.txt"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StageFile("test.txt"))
}
// TestGitCommandUnstageFile is a function.
func TestGitCommandUnstageFile(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
reset bool
}
scenarios := []scenario{
{
"Remove an untracked file from staging",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"rm", "--cached", "--force", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
false,
},
{
"Remove a tracked file from staging",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"reset", "HEAD", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
true,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.UnStageFile([]string{"test.txt"}, s.reset))
})
}
}
// TestGitCommandDiscardAllFileChanges is a function.
// these tests don't cover everything, in part because we already have an integration
// test which does cover everything. I don't want to unnecessarily assert on the 'how'
// when the 'what' is what matters
func TestGitCommandDiscardAllFileChanges(t *testing.T) {
type scenario struct {
testName string
command func() (func(string, ...string) *exec.Cmd, *[][]string)
test func(*[][]string, error)
file *models.File
removeFile func(string) error
}
scenarios := []scenario{
{
"An error occurred when resetting",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
})
},
&models.File{
Name: "test",
HasStagedChanges: true,
},
func(string) error {
return nil
},
},
{
"An error occurred when removing file",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.EqualError(t, err, "an error occurred when removing file")
assert.Len(t, *cmdsCalled, 0)
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
},
func(string) error {
return fmt.Errorf("an error occurred when removing file")
},
},
{
"An error occurred with checkout",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("test")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.Error(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: false,
},
func(string) error {
return nil
},
},
{
"Checkout only",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: false,
},
func(string) error {
return nil
},
},
{
"Reset and checkout staged changes",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 2)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasStagedChanges: true,
},
func(string) error {
return nil
},
},
{
"Reset and checkout merge conflicts",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 2)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
{"checkout", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: true,
HasMergeConflicts: true,
},
func(string) error {
return nil
},
},
{
"Reset and remove",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 1)
assert.EqualValues(t, *cmdsCalled, [][]string{
{"reset", "--", "test"},
})
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
HasStagedChanges: true,
},
func(filename string) error {
assert.Equal(t, "test", filename)
return nil
},
},
{
"Remove only",
func() (func(string, ...string) *exec.Cmd, *[][]string) {
cmdsCalled := [][]string{}
return func(cmd string, args ...string) *exec.Cmd {
cmdsCalled = append(cmdsCalled, args)
return secureexec.Command("echo")
}, &cmdsCalled
},
func(cmdsCalled *[][]string, err error) {
assert.NoError(t, err)
assert.Len(t, *cmdsCalled, 0)
},
&models.File{
Name: "test",
Tracked: false,
Added: true,
HasStagedChanges: false,
},
func(filename string) error {
assert.Equal(t, "test", filename)
return nil
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
var cmdsCalled *[][]string
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command, cmdsCalled = s.command()
gitCmd.OSCommand.SetRemoveFile(s.removeFile)
s.test(cmdsCalled, gitCmd.DiscardAllFileChanges(s.file))
})
}
}
// TestGitCommandDiff is a function.
func TestGitCommandDiff(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
file *models.File
plain bool
cached bool
ignoreWhitespace bool
}
scenarios := []scenario{
{
"Default case",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
false,
false,
false,
},
{
"cached",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--cached", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
false,
true,
false,
},
{
"plain",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=never", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
true,
false,
false,
},
{
"File not tracked and file has no staged changes",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: false,
},
false,
false,
false,
},
{
"Default case (ignore whitespace)",
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"diff", "--submodule", "--no-ext-diff", "--color=always", "--ignore-all-space", "--", "test.txt"}, args)
return secureexec.Command("echo")
},
&models.File{
Name: "test.txt",
HasStagedChanges: false,
Tracked: true,
},
false,
false,
true,
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
gitCmd.WorktreeFileDiff(s.file, s.plain, s.cached, s.ignoreWhitespace)
})
}
}
// TestGitCommandCheckoutFile is a function.
func TestGitCommandCheckoutFile(t *testing.T) {
type scenario struct {
testName string
commitSha string
fileName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"typical case",
"11af912",
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git checkout 11af912 -- test999.txt",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
{
"returns error if there is one",
"11af912",
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git checkout 11af912 -- test999.txt",
Replace: "test",
},
}),
func(err error) {
assert.Error(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.CheckoutFile(s.commitSha, s.fileName))
})
}
}
func TestGitCommandApplyPatch(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2])
filename := args[2]
content, err := ioutil.ReadFile(filename)
assert.NoError(t, err)
assert.Equal(t, "test", string(content))
return secureexec.Command("echo", "done")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"command returns error",
func(cmd string, args ...string) *exec.Cmd {
assert.Equal(t, "git", cmd)
assert.EqualValues(t, []string{"apply", "--cached"}, args[0:2])
filename := args[2]
// TODO: Ideally we want to mock out OSCommand here so that we're not
// double handling testing it's CreateTempFile functionality,
// but it is going to take a bit of work to make a proper mock for it
// so I'm leaving it for another PR
content, err := ioutil.ReadFile(filename)
assert.NoError(t, err)
assert.Equal(t, "test", string(content))
return secureexec.Command("test")
},
func(err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.ApplyPatch("test", "cached"))
})
}
}
// TestGitCommandDiscardOldFileChanges is a function.
func TestGitCommandDiscardOldFileChanges(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
commits []*models.Commit
commitIndex int
fileName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"returns error when index outside of range of commits",
func(string) (string, error) {
return "", nil
},
[]*models.Commit{},
0,
"test999.txt",
nil,
func(err error) {
assert.Error(t, err)
},
},
{
"returns error when using gpg",
func(string) (string, error) {
return "true", nil
},
[]*models.Commit{{Name: "commit", Sha: "123456"}},
0,
"test999.txt",
nil,
func(err error) {
assert.Error(t, err)
},
},
{
"checks out file if it already existed",
func(string) (string, error) {
return "", nil
},
[]*models.Commit{
{Name: "commit", Sha: "123456"},
{Name: "commit2", Sha: "abcdef"},
},
0,
"test999.txt",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty abcdef",
Replace: "echo",
},
{
Expect: "git cat-file -e HEAD^:test999.txt",
Replace: "echo",
},
{
Expect: "git checkout HEAD^ -- test999.txt",
Replace: "echo",
},
{
Expect: "git commit --amend --no-edit --allow-empty",
Replace: "echo",
},
{
Expect: "git rebase --continue",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
// test for when the file was created within the commit requires a refactor to support proper mocks
// currently we'd need to mock out the os.Remove function and that's gonna introduce tech debt
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.DiscardOldFileChanges(s.commits, s.commitIndex, s.fileName))
})
}
}
// TestGitCommandDiscardUnstagedFileChanges is a function.
func TestGitCommandDiscardUnstagedFileChanges(t *testing.T) {
type scenario struct {
testName string
file *models.File
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
&models.File{Name: "test.txt"},
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git checkout -- "test.txt"`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DiscardUnstagedFileChanges(s.file))
})
}
}
// TestGitCommandDiscardAnyUnstagedFileChanges is a function.
func TestGitCommandDiscardAnyUnstagedFileChanges(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git checkout -- .`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.DiscardAnyUnstagedFileChanges())
})
}
}
// TestGitCommandRemoveUntrackedFiles is a function.
func TestGitCommandRemoveUntrackedFiles(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"valid case",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: `git clean -fd`,
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.RemoveUntrackedFiles())
})
}
}
// TestEditFileCmdStr is a function.
func TestEditFileCmdStr(t *testing.T) {
type scenario struct {
filename string
configEditCommand string
command func(string, ...string) *exec.Cmd
getenv func(string) string
getGitConfigValue func(string) (string, error)
test func(string, error)
}
scenarios := []scenario{
{
"test",
"",
func(name string, arg ...string) *exec.Cmd {
return secureexec.Command("exit", "1")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.EqualError(t, err, "No editor defined in config file, $GIT_EDITOR, $VISUAL, $EDITOR, or git config")
},
},
{
"test",
"nano",
func(name string, args ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("echo")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "nano \"test\"", cmdStr)
},
},
{
"test",
"",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "nano", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "nano \"test\"", cmdStr)
},
},
{
"test",
"",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
if env == "VISUAL" {
return "nano"
}
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
},
},
{
"test",
"",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("exit", "1")
},
func(env string) string {
if env == "EDITOR" {
return "emacs"
}
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "emacs \"test\"", cmdStr)
},
},
{
"test",
"",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("echo")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "vi \"test\"", cmdStr)
},
},
{
"file/with space",
"",
func(name string, args ...string) *exec.Cmd {
assert.Equal(t, "which", name)
return secureexec.Command("echo")
},
func(env string) string {
return ""
},
func(cf string) (string, error) {
return "", nil
},
func(cmdStr string, err error) {
assert.NoError(t, err)
assert.Equal(t, "vi \"file/with space\"", cmdStr)
},
},
}
for _, s := range scenarios {
gitCmd := NewDummyGitCommand()
gitCmd.Config.GetUserConfig().OS.EditCommand = s.configEditCommand
gitCmd.OSCommand.Command = s.command
gitCmd.OSCommand.Getenv = s.getenv
gitCmd.getGitConfigValue = s.getGitConfigValue
s.test(gitCmd.EditFileCmdStr(s.filename))
}
}

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/go-errors/errors"
@@ -32,7 +33,6 @@ type GitCommand struct {
Tr *i18n.TranslationSet
Config config.AppConfigurer
getGitConfigValue func(string) (string, error)
removeFile func(string) error
DotGitDir string
onSuccessfulContinue func() error
PatchManager *patch.PatchManager
@@ -54,10 +54,6 @@ func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.
pushToCurrent = strings.TrimSpace(output) == "current"
}
if err := verifyInGitRepo(osCommand.RunCommand); err != nil {
return nil, err
}
if err := navigateToRepoRootDirectory(os.Stat, os.Chdir); err != nil {
return nil, err
}
@@ -78,7 +74,6 @@ func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.
Repo: repo,
Config: config,
getGitConfigValue: getGitConfigValue,
removeFile: os.RemoveAll,
DotGitDir: dotGitDir,
PushToCurrent: pushToCurrent,
}
@@ -88,8 +83,25 @@ func NewGitCommand(log *logrus.Entry, osCommand *oscommands.OSCommand, tr *i18n.
return gitCommand, nil
}
func verifyInGitRepo(runCmd func(string, ...interface{}) error) error {
return runCmd("git status")
func (c *GitCommand) WithSpan(span string) *GitCommand {
// sometimes .WithSpan(span) will be called where span actually is empty, in
// which case we don't need to log anything so we can just return early here
// with the original struct
if span == "" {
return c
}
newGitCommand := &GitCommand{}
*newGitCommand = *c
newGitCommand.OSCommand = c.OSCommand.WithSpan(span)
// NOTE: unlike the other things here which create shallow clones, this will
// actually update the PatchManager on the original struct to have the new span.
// This means each time we call ApplyPatch in PatchManager, we need to ensure
// we've called .WithSpan() ahead of time with the new span value
newGitCommand.PatchManager.ApplyPatch = newGitCommand.ApplyPatch
return newGitCommand
}
func navigateToRepoRootDirectory(stat func(string) (os.FileInfo, error), chdir func(string) error) error {
@@ -120,6 +132,18 @@ func navigateToRepoRootDirectory(stat func(string) (os.FileInfo, error), chdir f
if err = chdir(".."); err != nil {
return utils.WrapError(err)
}
currentPath, err := os.Getwd()
if err != nil {
return err
}
atRoot := currentPath == filepath.Dir(currentPath)
if atRoot {
// we should never really land here: the code that creates GitCommand should
// verify we're in a git directory
return errors.New("Must open lazygit in a git repository")
}
}
}
@@ -189,3 +213,36 @@ func findDotGitDir(stat func(string) (os.FileInfo, error), readFile func(filenam
}
return strings.TrimSpace(strings.TrimPrefix(fileContent, "gitdir: ")), nil
}
func VerifyInGitRepo(osCommand *oscommands.OSCommand) error {
return osCommand.RunCommand("git rev-parse --git-dir")
}
func (c *GitCommand) RunCommand(formatString string, formatArgs ...interface{}) error {
_, err := c.RunCommandWithOutput(formatString, formatArgs...)
return err
}
func (c *GitCommand) RunCommandWithOutput(formatString string, formatArgs ...interface{}) (string, error) {
// TODO: have this retry logic in other places we run the command
waitTime := 50 * time.Millisecond
retryCount := 5
attempt := 0
for {
output, err := c.OSCommand.RunCommandWithOutput(formatString, formatArgs...)
if err != nil {
// if we have an error based on the index lock, we should wait a bit and then retry
if strings.Contains(output, ".git/index.lock") {
c.Log.Error(output)
c.Log.Info("index.lock prevented command from running. Retrying command after a small wait")
attempt++
time.Sleep(waitTime)
if attempt < retryCount {
continue
}
}
}
return output, err
}
}

View File

@@ -1,9 +0,0 @@
package commands
// Conflict : A git conflict with a start middle and end corresponding to line
// numbers in the file where the conflict bars appear
type Conflict struct {
Start int
Middle int
End int
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,12 @@ func (b *BranchListBuilder) obtainBranches() []*models.Branch {
}
split := strings.Split(line, SEPARATION_CHAR)
if len(split) != 4 {
// Ignore line if it isn't separated into 4 parts
// This is probably a warning message, for more info see:
// https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439
continue
}
name := strings.TrimPrefix(split[1], "heads/")
branch := &models.Branch{

View File

@@ -4,45 +4,37 @@ import (
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
)
// GetFilesInDiff get the specified commit files
func (c *GitCommand) GetFilesInDiff(from string, to string, reverse bool, patchManager *patch.PatchManager) ([]*models.CommitFile, error) {
func (c *GitCommand) GetFilesInDiff(from string, to string, reverse bool) ([]*models.CommitFile, error) {
reverseFlag := ""
if reverse {
reverseFlag = " -R "
}
filenames, err := c.OSCommand.RunCommandWithOutput("git diff --submodule --no-ext-diff --name-status %s %s %s", reverseFlag, from, to)
filenames, err := c.RunCommandWithOutput("git diff --submodule --no-ext-diff --name-status -z --no-renames %s %s %s", reverseFlag, from, to)
if err != nil {
return nil, err
}
return c.getCommitFilesFromFilenames(filenames, to, patchManager), nil
return c.getCommitFilesFromFilenames(filenames), nil
}
// filenames string is something like "file1\nfile2\nfile3"
func (c *GitCommand) getCommitFilesFromFilenames(filenames string, parent string, patchManager *patch.PatchManager) []*models.CommitFile {
func (c *GitCommand) getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
commitFiles := make([]*models.CommitFile, 0)
for _, line := range strings.Split(strings.TrimRight(filenames, "\n"), "\n") {
lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
n := len(lines)
for i := 0; i < n-1; i += 2 {
// typical result looks like 'A my_file' meaning my_file was added
if line == "" {
continue
}
changeStatus := line[0:1]
name := line[2:]
status := patch.UNSELECTED
if patchManager != nil && patchManager.To == parent {
status = patchManager.GetFileStatus(name)
}
changeStatus := lines[i]
name := lines[i+1]
commitFiles = append(commitFiles, &models.CommitFile{
Parent: parent,
Name: name,
ChangeStatus: changeStatus,
PatchStatus: status,
})
}

View File

@@ -10,9 +10,9 @@ import (
"strconv"
"strings"
"github.com/fatih/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/sirupsen/logrus"
)
@@ -72,10 +72,6 @@ func (c *CommitListBuilder) extractCommitFromLine(line string) *models.Commit {
unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
// Any commit with multiple parents is a merge commit.
// If there's a space then it means there must be more than one parent hash
isMerge := strings.Contains(parentHashes, " ")
return &models.Commit{
Sha: sha,
Name: message,
@@ -83,7 +79,7 @@ func (c *CommitListBuilder) extractCommitFromLine(line string) *models.Commit {
ExtraInfo: extraInfo,
UnixTimestamp: int64(unitTimestampInt),
Author: author,
IsMerge: isMerge,
Parents: strings.Split(parentHashes, " "),
}
}
@@ -169,8 +165,7 @@ func (c *CommitListBuilder) GetCommits(opts GetCommitsOptions) ([]*models.Commit
if rebaseMode != "" {
currentCommit := commits[len(rebasingCommits)]
blue := color.New(color.FgYellow)
youAreHere := blue.Sprintf("<-- %s ---", c.Tr.YouAreHere)
youAreHere := style.FgYellow.Sprintf("<-- %s ---", c.Tr.YouAreHere)
currentCommit.Name = fmt.Sprintf("%s %s", youAreHere, currentCommit.Name)
}

View File

@@ -22,90 +22,95 @@ func (c *GitCommand) GetStatusFiles(opts GetStatusFileOptions) []*models.File {
}
untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting)
statusOutput, err := c.GitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg})
statuses, err := c.GitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg})
if err != nil {
c.Log.Error(err)
}
statusStrings := utils.SplitLines(statusOutput)
files := []*models.File{}
for _, statusString := range statusStrings {
if strings.HasPrefix(statusString, "warning") {
c.Log.Warningf("warning when calling git status: %s", statusString)
for _, status := range statuses {
if strings.HasPrefix(status.StatusString, "warning") {
c.Log.Warningf("warning when calling git status: %s", status.StatusString)
continue
}
change := statusString[0:2]
change := status.Change
stagedChange := change[0:1]
unstagedChange := statusString[1:2]
filename := c.OSCommand.Unquote(statusString[3:])
unstagedChange := change[1:2]
untracked := utils.IncludesString([]string{"??", "A ", "AM"}, change)
hasNoStagedChanges := utils.IncludesString([]string{" ", "U", "?"}, stagedChange)
hasMergeConflicts := utils.IncludesString([]string{"DD", "AA", "UU", "AU", "UA", "UD", "DU"}, change)
hasInlineMergeConflicts := utils.IncludesString([]string{"UU", "AA"}, change)
file := &models.File{
Name: filename,
DisplayString: statusString,
Name: status.Name,
PreviousName: status.PreviousName,
DisplayString: status.StatusString,
HasStagedChanges: !hasNoStagedChanges,
HasUnstagedChanges: unstagedChange != " ",
Tracked: !untracked,
Deleted: unstagedChange == "D" || stagedChange == "D",
Added: unstagedChange == "A" || untracked,
HasMergeConflicts: hasMergeConflicts,
HasInlineMergeConflicts: hasInlineMergeConflicts,
Type: c.OSCommand.FileType(filename),
Type: c.OSCommand.FileType(status.Name),
ShortStatus: change,
}
files = append(files, file)
}
return files
}
// GitStatus returns the plaintext short status of the repo
// GitStatus returns the file status of the repo
type GitStatusOptions struct {
NoRenames bool
UntrackedFilesArg string
}
func (c *GitCommand) GitStatus(opts GitStatusOptions) (string, error) {
type FileStatus struct {
StatusString string
Change string // ??, MM, AM, ...
Name string
PreviousName string
}
func (c *GitCommand) GitStatus(opts GitStatusOptions) ([]FileStatus, error) {
noRenamesFlag := ""
if opts.NoRenames {
noRenamesFlag = "--no-renames"
}
return c.OSCommand.RunCommandWithOutput("git status %s --porcelain %s", opts.UntrackedFilesArg, noRenamesFlag)
}
// MergeStatusFiles merge status files
func (c *GitCommand) MergeStatusFiles(oldFiles, newFiles []*models.File, selectedFile *models.File) []*models.File {
if len(oldFiles) == 0 {
return newFiles
statusLines, err := c.RunCommandWithOutput("git status %s --porcelain -z %s", opts.UntrackedFilesArg, noRenamesFlag)
if err != nil {
return []FileStatus{}, err
}
appendedIndexes := []int{}
splitLines := strings.Split(statusLines, "\x00")
response := []FileStatus{}
// retain position of files we already could see
result := []*models.File{}
for _, oldFile := range oldFiles {
for newIndex, newFile := range newFiles {
if utils.IncludesInt(appendedIndexes, newIndex) {
continue
}
// if we just staged B and in doing so created 'A -> B' and we are currently have oldFile: A and newFile: 'A -> B', we want to wait until we come across B so the our cursor isn't jumping anywhere
waitForMatchingFile := selectedFile != nil && newFile.IsRename() && !selectedFile.IsRename() && newFile.Matches(selectedFile) && !oldFile.Matches(selectedFile)
for i := 0; i < len(splitLines); i++ {
original := splitLines[i]
if oldFile.Matches(newFile) && !waitForMatchingFile {
result = append(result, newFile)
appendedIndexes = append(appendedIndexes, newIndex)
}
if len(original) < 3 {
continue
}
}
// append any new files to the end
for index, newFile := range newFiles {
if !utils.IncludesInt(appendedIndexes, index) {
result = append(result, newFile)
status := FileStatus{
StatusString: original,
Change: original[:2],
Name: original[3:],
PreviousName: "",
}
if strings.HasPrefix(status.Change, "R") {
// if a line starts with 'R' then the next line is the original file.
status.PreviousName = strings.TrimSpace(splitLines[i+1])
status.StatusString = fmt.Sprintf("%s %s -> %s", status.Change, status.PreviousName, status.Name)
i++
}
response = append(response, status)
}
return result
return response, nil
}

View File

@@ -0,0 +1,227 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetStatusFiles is a function.
func TestGitCommandGetStatusFiles(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func([]*models.File)
}
scenarios := []scenario{
{
"No files found",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command("echo")
},
func(files []*models.File) {
assert.Len(t, files, 0)
},
},
{
"Several files found",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command(
"printf",
`MM file1.txt\0A file3.txt\0AM file2.txt\0?? file4.txt\0UU file5.txt`,
)
},
func(files []*models.File) {
assert.Len(t, files, 5)
expected := []*models.File{
{
Name: "file1.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "MM file1.txt",
Type: "other",
ShortStatus: "MM",
},
{
Name: "file3.txt",
HasStagedChanges: true,
HasUnstagedChanges: false,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "A file3.txt",
Type: "other",
ShortStatus: "A ",
},
{
Name: "file2.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "AM file2.txt",
Type: "other",
ShortStatus: "AM",
},
{
Name: "file4.txt",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "?? file4.txt",
Type: "other",
ShortStatus: "??",
},
{
Name: "file5.txt",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: true,
HasInlineMergeConflicts: true,
DisplayString: "UU file5.txt",
Type: "other",
ShortStatus: "UU",
},
}
assert.EqualValues(t, expected, files)
},
},
{
"File with new line char",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command(
"printf",
`MM a\nb.txt`,
)
},
func(files []*models.File) {
assert.Len(t, files, 1)
expected := []*models.File{
{
Name: "a\nb.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "MM a\nb.txt",
Type: "other",
ShortStatus: "MM",
},
}
assert.EqualValues(t, expected, files)
},
},
{
"Renamed files",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command(
"printf",
`R after1.txt\0before1.txt\0RM after2.txt\0before2.txt`,
)
},
func(files []*models.File) {
assert.Len(t, files, 2)
expected := []*models.File{
{
Name: "after1.txt",
PreviousName: "before1.txt",
HasStagedChanges: true,
HasUnstagedChanges: false,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "R before1.txt -> after1.txt",
Type: "other",
ShortStatus: "R ",
},
{
Name: "after2.txt",
PreviousName: "before2.txt",
HasStagedChanges: true,
HasUnstagedChanges: true,
Tracked: true,
Added: false,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "RM before2.txt -> after2.txt",
Type: "other",
ShortStatus: "RM",
},
}
assert.EqualValues(t, expected, files)
},
},
{
"File with arrow in name",
func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command(
"printf",
`?? a -> b.txt`,
)
},
func(files []*models.File) {
assert.Len(t, files, 1)
expected := []*models.File{
{
Name: "a -> b.txt",
HasStagedChanges: false,
HasUnstagedChanges: true,
Tracked: false,
Added: true,
Deleted: false,
HasMergeConflicts: false,
HasInlineMergeConflicts: false,
DisplayString: "?? a -> b.txt",
Type: "other",
ShortStatus: "??",
},
}
assert.EqualValues(t, expected, files)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetStatusFiles(GetStatusFileOptions{}))
})
}
}

View File

@@ -25,7 +25,7 @@ func (c *GitCommand) GetStashEntries(filterPath string) []*models.StashEntry {
return c.getUnfilteredStashEntries()
}
rawString, err := c.OSCommand.RunCommandWithOutput("git stash list --name-only")
rawString, err := c.RunCommandWithOutput("git stash list --name-only")
if err != nil {
return c.getUnfilteredStashEntries()
}

View File

@@ -0,0 +1,61 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandGetStashEntries is a function.
func TestGitCommandGetStashEntries(t *testing.T) {
type scenario struct {
testName string
command func(string, ...string) *exec.Cmd
test func([]*models.StashEntry)
}
scenarios := []scenario{
{
"No stash entries found",
func(string, ...string) *exec.Cmd {
return secureexec.Command("echo")
},
func(entries []*models.StashEntry) {
assert.Len(t, entries, 0)
},
},
{
"Several stash entries found",
func(string, ...string) *exec.Cmd {
return secureexec.Command("echo", "WIP on add-pkg-commands-test: 55c6af2 increase parallel build\nWIP on master: bb86a3f update github template")
},
func(entries []*models.StashEntry) {
expected := []*models.StashEntry{
{
Index: 0,
Name: "WIP on add-pkg-commands-test: 55c6af2 increase parallel build",
},
{
Index: 1,
Name: "WIP on master: bb86a3f update github template",
},
}
assert.Len(t, entries, 2)
assert.EqualValues(t, expected, entries)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.GetStashEntries(""))
})
}
}

View File

@@ -1,28 +1,16 @@
package commands
import (
"regexp"
"sort"
"strconv"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
)
const semverRegex = `v?((\d+\.?)+)([^\d]?.*)`
func convertToInt(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
return 0
}
return i
}
func (c *GitCommand) GetTags() ([]*models.Tag, error) {
// get remote branches
remoteBranchesStr, err := c.OSCommand.RunCommandWithOutput(`git tag --list`)
// get remote branches, sorted by creation date (descending)
// see: https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---sortltkeygt
remoteBranchesStr, err := c.OSCommand.RunCommandWithOutput(`git tag --list --sort=-creatordate`)
if err != nil {
return nil, err
}
@@ -37,52 +25,10 @@ func (c *GitCommand) GetTags() ([]*models.Tag, error) {
// first step is to get our remotes from go-git
tags := make([]*models.Tag, len(split))
for i, tagName := range split {
tags[i] = &models.Tag{
Name: tagName,
}
}
// now lets sort our tags by name numerically
re := regexp.MustCompile(semverRegex)
// the reason this is complicated is because we're both sorting alphabetically
// and when we're dealing with semver strings
sort.Slice(tags, func(i, j int) bool {
a := tags[i].Name
b := tags[j].Name
matchA := re.FindStringSubmatch(a)
matchB := re.FindStringSubmatch(b)
if len(matchA) > 0 && len(matchB) > 0 {
numbersA := strings.Split(matchA[1], ".")
numbersB := strings.Split(matchB[1], ".")
k := 0
for {
if len(numbersA) == k && len(numbersB) == k {
break
}
if len(numbersA) == k {
return true
}
if len(numbersB) == k {
return false
}
if convertToInt(numbersA[k]) < convertToInt(numbersB[k]) {
return true
}
if convertToInt(numbersA[k]) > convertToInt(numbersB[k]) {
return false
}
k++
}
return strings.ToLower(matchA[3]) < strings.ToLower(matchB[3])
}
return strings.ToLower(a) < strings.ToLower(b)
})
return tags, nil
}

View File

@@ -24,3 +24,26 @@ func (b *Branch) ID() string {
func (b *Branch) Description() string {
return b.RefName()
}
// this method does not consider the case where the git config states that a branch is tracking the config.
// The Pullables value here is based on whether or not we saw an upstream when doing `git branch`
func (b *Branch) IsTrackingRemote() bool {
return b.IsRealBranch() && b.Pullables != "?"
}
func (b *Branch) MatchesUpstream() bool {
return b.IsRealBranch() && b.Pushables == "0" && b.Pullables == "0"
}
func (b *Branch) HasCommitsToPush() bool {
return b.IsRealBranch() && b.Pushables != "0"
}
func (b *Branch) HasCommitsToPull() bool {
return b.IsRealBranch() && b.Pullables != "0"
}
// for when we're in a detached head state
func (b *Branch) IsRealBranch() bool {
return b.Pushables != "" && b.Pullables != ""
}

View File

@@ -13,8 +13,8 @@ type Commit struct {
Author string
UnixTimestamp int64
// IsMerge tells us whether we're dealing with a merge commit i.e. a commit with two parents
IsMerge bool
// SHAs of parent commits (will be multiple if it's a merge commit)
Parents []string
}
func (c *Commit) ShortSha() string {
@@ -35,3 +35,7 @@ func (c *Commit) ID() string {
func (c *Commit) Description() string {
return fmt.Sprintf("%s %s", c.Sha[:7], c.Name)
}
func (c *Commit) IsMerge() bool {
return len(c.Parents) > 1
}

View File

@@ -2,12 +2,8 @@ package models
// CommitFile : A git commit file
type CommitFile struct {
// Parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
Parent string
Name string
// PatchStatus tells us whether the file has been wholly or partially added to a patch. We might want to pull this logic up into the gui package and make it a map like we do with cherry picked commits
PatchStatus int // one of 'WHOLE' 'PART' 'NONE'
// TODO: rename this to Path
Name string
ChangeStatus string // e.g. 'A' for added or 'M' for modified. This is based on the result from git diff --name-status
}

View File

@@ -1,8 +1,6 @@
package models
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -10,9 +8,11 @@ import (
// duplicating this for now
type File struct {
Name string
PreviousName string
HasStagedChanges bool
HasUnstagedChanges bool
Tracked bool
Added bool
Deleted bool
HasMergeConflicts bool
HasInlineMergeConflicts bool
@@ -21,15 +21,25 @@ type File struct {
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
}
const RENAME_SEPARATOR = " -> "
// sometimes we need to deal with either a node (which contains a file) or an actual file
type IFile interface {
GetHasUnstagedChanges() bool
GetHasStagedChanges() bool
GetIsTracked() bool
GetPath() string
}
func (f *File) IsRename() bool {
return strings.Contains(f.Name, RENAME_SEPARATOR)
return f.PreviousName != ""
}
// Names returns an array containing just the filename, or in the case of a rename, the after filename and the before filename
func (f *File) Names() []string {
return strings.Split(f.Name, RENAME_SEPARATOR)
result := []string{f.Name}
if f.PreviousName != "" {
result = append(result, f.PreviousName)
}
return result
}
// returns true if the file names are the same or if a a file rename includes the filename of the other
@@ -51,10 +61,27 @@ func (f *File) IsSubmodule(configs []*SubmoduleConfig) bool {
func (f *File) SubmoduleConfig(configs []*SubmoduleConfig) *SubmoduleConfig {
for _, config := range configs {
if f.Name == config.Name {
if f.Name == config.Path {
return config
}
}
return nil
}
func (f *File) GetHasUnstagedChanges() bool {
return f.HasUnstagedChanges
}
func (f *File) GetHasStagedChanges() bool {
return f.HasStagedChanges
}
func (f *File) GetIsTracked() bool {
return f.Tracked
}
func (f *File) GetPath() string {
// TODO: remove concept of name; just use path
return f.Name
}

View File

@@ -20,6 +20,7 @@ import (
// NOTE: If the return data is empty it won't written anything to stdin
func RunCommandWithOutputLiveWrapper(c *OSCommand, command string, output func(string) string) error {
c.Log.WithField("command", command).Info("RunCommand")
c.LogCommand(command, true)
cmd := c.ExecutableFromString(command)
cmd.Env = append(cmd.Env, "LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8")

View File

@@ -8,7 +8,6 @@ import (
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -24,14 +23,13 @@ import (
// Platform stores the os state
type Platform struct {
OS string
CatCmd string
Shell string
ShellArg string
EscapedQuote string
OpenCommand string
OpenLinkCommand string
FallbackEscapedQuote string
OS string
CatCmd []string
Shell string
ShellArg string
EscapedQuote string
OpenCommand string
OpenLinkCommand string
}
// OSCommand holds all the os commands
@@ -42,6 +40,44 @@ type OSCommand struct {
Command func(string, ...string) *exec.Cmd
BeforeExecuteCmd func(*exec.Cmd)
Getenv func(string) string
// callback to run before running a command, i.e. for the purposes of logging
onRunCommand func(CmdLogEntry)
// something like 'Staging File': allows us to group cmd logs under a single title
CmdLogSpan string
removeFile func(string) error
}
// TODO: make these fields private
type CmdLogEntry struct {
// e.g. 'git commit -m "haha"'
cmdStr string
// Span is something like 'Staging File'. Multiple commands can be grouped under the same
// span
span string
// sometimes our command is direct like 'git commit', and sometimes it's a
// command to remove a file but through Go's standard library rather than the
// command line
commandLine bool
}
func (e CmdLogEntry) GetCmdStr() string {
return e.cmdStr
}
func (e CmdLogEntry) GetSpan() string {
return e.span
}
func (e CmdLogEntry) GetCommandLine() bool {
return e.commandLine
}
func NewCmdLogEntry(cmdStr string, span string, commandLine bool) CmdLogEntry {
return CmdLogEntry{cmdStr: cmdStr, span: span, commandLine: commandLine}
}
// NewOSCommand os command runner
@@ -53,15 +89,51 @@ func NewOSCommand(log *logrus.Entry, config config.AppConfigurer) *OSCommand {
Command: secureexec.Command,
BeforeExecuteCmd: func(*exec.Cmd) {},
Getenv: os.Getenv,
removeFile: os.RemoveAll,
}
}
func (c *OSCommand) WithSpan(span string) *OSCommand {
// sometimes .WithSpan(span) will be called where span actually is empty, in
// which case we don't need to log anything so we can just return early here
// with the original struct
if span == "" {
return c
}
newOSCommand := &OSCommand{}
*newOSCommand = *c
newOSCommand.CmdLogSpan = span
return newOSCommand
}
func (c *OSCommand) LogExecCmd(cmd *exec.Cmd) {
c.LogCommand(strings.Join(cmd.Args, " "), true)
}
func (c *OSCommand) LogCommand(cmdStr string, commandLine bool) {
c.Log.WithField("command", cmdStr).Info("RunCommand")
if c.onRunCommand != nil && c.CmdLogSpan != "" {
c.onRunCommand(NewCmdLogEntry(cmdStr, c.CmdLogSpan, commandLine))
}
}
func (c *OSCommand) SetOnRunCommand(f func(CmdLogEntry)) {
c.onRunCommand = f
}
// SetCommand sets the command function used by the struct.
// To be used for testing only
func (c *OSCommand) SetCommand(cmd func(string, ...string) *exec.Cmd) {
c.Command = cmd
}
// To be used for testing only
func (c *OSCommand) SetRemoveFile(f func(string) error) {
c.removeFile = f
}
func (c *OSCommand) SetBeforeExecuteCmd(cmd func(*exec.Cmd)) {
c.BeforeExecuteCmd = cmd
}
@@ -71,9 +143,12 @@ type RunCommandOptions struct {
}
func (c *OSCommand) RunCommandWithOutputWithOptions(command string, options RunCommandOptions) (string, error) {
c.Log.WithField("command", command).Info("RunCommand")
c.LogCommand(command, true)
cmd := c.ExecutableFromString(command)
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0") // prevents git from prompting us for input which would freeze the program
cmd.Env = append(cmd.Env, options.EnvVars...)
return sanitisedCommandOutput(cmd.CombinedOutput())
}
@@ -93,17 +168,18 @@ func (c *OSCommand) RunCommandWithOutput(formatString string, formatArgs ...inte
if formatArgs != nil {
command = fmt.Sprintf(formatString, formatArgs...)
}
c.Log.WithField("command", command).Info("RunCommand")
cmd := c.ExecutableFromString(command)
c.LogExecCmd(cmd)
output, err := sanitisedCommandOutput(cmd.CombinedOutput())
if err != nil {
c.Log.WithField("command", command).Error(err)
c.Log.WithField("command", command).Error(output)
}
return output, err
}
// RunExecutableWithOutput runs an executable file and returns its output
func (c *OSCommand) RunExecutableWithOutput(cmd *exec.Cmd) (string, error) {
c.LogExecCmd(cmd)
c.BeforeExecuteCmd(cmd)
return sanitisedCommandOutput(cmd.CombinedOutput())
}
@@ -129,7 +205,7 @@ func (c *OSCommand) ShellCommandFromString(commandStr string) *exec.Cmd {
if c.Platform.OS == "windows" {
quotedCommand = commandStr
} else {
quotedCommand = strconv.Quote(commandStr)
quotedCommand = c.Quote(commandStr)
}
shellCommand := fmt.Sprintf("%s %s %s", c.Platform.Shell, c.Platform.ShellArg, quotedCommand)
@@ -141,6 +217,18 @@ func (c *OSCommand) RunCommandWithOutputLive(command string, output func(string)
return RunCommandWithOutputLiveWrapper(c, command, output)
}
func (c *OSCommand) CatFile(filename string) (string, error) {
arr := append(c.Platform.CatCmd, filename)
cmdStr := strings.Join(arr, " ")
c.Log.WithField("command", cmdStr).Info("Cat")
cmd := c.Command(arr[0], arr[1:]...)
output, err := sanitisedCommandOutput(cmd.CombinedOutput())
if err != nil {
c.Log.WithField("command", cmdStr).Error(output)
}
return output, err
}
// DetectUnamePass detect a username / password / passphrase question in a command
// promptUserForCredential is a function that gets executed when this function detect you need to fillin a password or passphrase
// The promptUserForCredential argument will be "username", "password" or "passphrase" and expects the user's password/passphrase or username back
@@ -174,6 +262,17 @@ func (c *OSCommand) RunCommand(formatString string, formatArgs ...interface{}) e
return err
}
// RunShellCommand runs shell commands i.e. 'sh -c <command>'. Good for when you
// need access to the shell
func (c *OSCommand) RunShellCommand(command string) error {
cmd := c.Command(c.Platform.Shell, c.Platform.ShellArg, command)
c.LogExecCmd(cmd)
_, err := sanitisedCommandOutput(cmd.CombinedOutput())
return err
}
// FileType tells us if the file is a file, directory or other
func (c *OSCommand) FileType(path string) string {
fileInfo, err := os.Stat(path)
@@ -186,16 +285,6 @@ func (c *OSCommand) FileType(path string) string {
return "file"
}
// RunDirectCommand wrapper around direct commands
func (c *OSCommand) RunDirectCommand(command string) (string, error) {
c.Log.WithField("command", command).Info("RunDirectCommand")
return sanitisedCommandOutput(
c.Command(c.Platform.Shell, c.Platform.ShellArg, command).
CombinedOutput(),
)
}
func sanitisedCommandOutput(output []byte, err error) (string, error) {
outputString := string(output)
if err != nil {
@@ -212,10 +301,15 @@ func sanitisedCommandOutput(output []byte, err error) (string, error) {
// OpenFile opens a file with the given
func (c *OSCommand) OpenFile(filename string) error {
commandTemplate := c.Config.GetUserConfig().OS.OpenCommand
templateValues := map[string]string{
"filename": c.Quote(filename),
quoted := c.Quote(filename)
if c.Platform.OS == "linux" {
// Add extra quoting to avoid issues with shell command string
quoted = c.Quote(quoted)
quoted = quoted[1 : len(quoted)-1]
}
templateValues := map[string]string{
"filename": quoted,
}
command := utils.ResolvePlaceholderString(commandTemplate, templateValues)
err := c.RunCommand(command)
return err
@@ -223,6 +317,7 @@ func (c *OSCommand) OpenFile(filename string) error {
// OpenLink opens a file with the given
func (c *OSCommand) OpenLink(link string) error {
c.LogCommand(fmt.Sprintf("Opening link '%s'", link), false)
commandTemplate := c.Config.GetUserConfig().OS.OpenLinkCommand
templateValues := map[string]string{
"link": c.Quote(link),
@@ -240,27 +335,33 @@ func (c *OSCommand) PrepareSubProcess(cmdName string, commandArgs ...string) *ex
if cmd != nil {
cmd.Env = append(os.Environ(), "GIT_OPTIONAL_LOCKS=0")
}
c.LogExecCmd(cmd)
return cmd
}
// PrepareShellSubProcess returns the pointer to a custom command
func (c *OSCommand) PrepareShellSubProcess(command string) *exec.Cmd {
return c.PrepareSubProcess(c.Platform.Shell, c.Platform.ShellArg, command)
}
// Quote wraps a message in platform-specific quotation marks
func (c *OSCommand) Quote(message string) string {
message = strings.Replace(message, "`", "\\`", -1)
escapedQuote := c.Platform.EscapedQuote
if strings.Contains(message, c.Platform.EscapedQuote) {
escapedQuote = c.Platform.FallbackEscapedQuote
if c.Platform.OS == "windows" {
message = strings.Replace(message, `"`, `"'"'"`, -1)
message = strings.Replace(message, `\"`, `\\"`, -1)
} else {
message = strings.Replace(message, `\`, `\\`, -1)
message = strings.Replace(message, `"`, `\"`, -1)
message = strings.Replace(message, "`", "\\`", -1)
message = strings.Replace(message, "$", "\\$", -1)
}
escapedQuote := c.Platform.EscapedQuote
return escapedQuote + message + escapedQuote
}
// Unquote removes wrapping quotations marks if they are present
// this is needed for removing quotes from staged filenames with spaces
func (c *OSCommand) Unquote(message string) string {
return strings.Replace(message, `"`, "", -1)
}
// AppendLineToFile adds a new line in file
func (c *OSCommand) AppendLineToFile(filename, line string) error {
c.LogCommand(fmt.Sprintf("Appending '%s' to file '%s'", line, filename), false)
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return utils.WrapError(err)
@@ -281,6 +382,7 @@ func (c *OSCommand) CreateTempFile(filename, content string) (string, error) {
c.Log.Error(err)
return "", utils.WrapError(err)
}
c.LogCommand(fmt.Sprintf("Creating temp file '%s'", tmpfile.Name()), false)
if _, err := tmpfile.WriteString(content); err != nil {
c.Log.Error(err)
@@ -296,6 +398,7 @@ func (c *OSCommand) CreateTempFile(filename, content string) (string, error) {
// CreateFileWithContent creates a file with the given content
func (c *OSCommand) CreateFileWithContent(path string, content string) error {
c.LogCommand(fmt.Sprintf("Creating file '%s'", path), false)
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
c.Log.Error(err)
return err
@@ -311,6 +414,7 @@ func (c *OSCommand) CreateFileWithContent(path string, content string) error {
// Remove removes a file or directory at the specified path
func (c *OSCommand) Remove(filename string) error {
c.LogCommand(fmt.Sprintf("Removing '%s'", filename), false)
err := os.RemoveAll(filename)
return utils.WrapError(err)
}
@@ -331,6 +435,7 @@ func (c *OSCommand) FileExists(path string) (bool, error) {
// before running it
func (c *OSCommand) RunPreparedCommand(cmd *exec.Cmd) error {
c.BeforeExecuteCmd(cmd)
c.LogExecCmd(cmd)
out, err := cmd.CombinedOutput()
outString := string(out)
c.Log.Info(outString)
@@ -352,19 +457,18 @@ func (c *OSCommand) GetLazygitPath() string {
return `"` + filepath.ToSlash(ex) + `"`
}
// RunCustomCommand returns the pointer to a custom command
func (c *OSCommand) RunCustomCommand(command string) *exec.Cmd {
return c.PrepareSubProcess(c.Platform.Shell, c.Platform.ShellArg, command)
}
// PipeCommands runs a heap of commands and pipes their inputs/outputs together like A | B | C
func (c *OSCommand) PipeCommands(commandStrings ...string) error {
cmds := make([]*exec.Cmd, len(commandStrings))
logCmdStr := ""
for i, str := range commandStrings {
if i > 0 {
logCmdStr += " | "
}
logCmdStr += str
cmds[i] = c.ExecutableFromString(str)
}
c.LogCommand(logCmdStr, true)
for i := 0; i < len(cmds)-1; i++ {
stdout, err := cmds[i].StdoutPipe()
@@ -455,5 +559,12 @@ func RunLineOutputCmd(cmd *exec.Cmd, onLine func(line string) (bool, error)) err
}
func (c *OSCommand) CopyToClipboard(str string) error {
c.LogCommand(fmt.Sprintf("Copying '%s' to clipboard", utils.TruncateWithEllipsis(str, 40)), false)
return clipboard.WriteAll(str)
}
func (c *OSCommand) RemoveFile(path string) error {
c.LogCommand(fmt.Sprintf("Deleting path '%s'", path), false)
return c.removeFile(path)
}

View File

@@ -8,13 +8,12 @@ import (
func getPlatform() *Platform {
return &Platform{
OS: runtime.GOOS,
CatCmd: "cat",
Shell: "bash",
ShellArg: "-c",
EscapedQuote: "'",
OpenCommand: "open {{filename}}",
OpenLinkCommand: "open {{link}}",
FallbackEscapedQuote: "\"",
OS: runtime.GOOS,
CatCmd: []string{"cat"},
Shell: "bash",
ShellArg: "-c",
EscapedQuote: `"`,
OpenCommand: "open {{filename}}",
OpenLinkCommand: "open {{link}}",
}
}

View File

@@ -103,6 +103,7 @@ func TestOSCommandOpenFile(t *testing.T) {
for _, s := range scenarios {
OSCmd := NewDummyOSCommand()
OSCmd.Platform.OS = "darwin"
OSCmd.Command = s.command
OSCmd.Config.GetUserConfig().OS.OpenCommand = "open {{filename}}"
@@ -110,10 +111,86 @@ func TestOSCommandOpenFile(t *testing.T) {
}
}
// TestOSCommandOpenFile tests the OpenFile command on Linux
func TestOSCommandOpenFileLinux(t *testing.T) {
type scenario struct {
filename string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"test",
func(name string, arg ...string) *exec.Cmd {
return secureexec.Command("exit", "1")
},
func(err error) {
assert.Error(t, err)
},
},
{
"test",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "sh", name)
assert.Equal(t, []string{"-c", "xdg-open \"test\" > /dev/null"}, arg)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"filename with spaces",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "sh", name)
assert.Equal(t, []string{"-c", "xdg-open \"filename with spaces\" > /dev/null"}, arg)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"let's_test_with_single_quote",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "sh", name)
assert.Equal(t, []string{"-c", "xdg-open \"let's_test_with_single_quote\" > /dev/null"}, arg)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
{
"$USER.txt",
func(name string, arg ...string) *exec.Cmd {
assert.Equal(t, "sh", name)
assert.Equal(t, []string{"-c", "xdg-open \"\\$USER.txt\" > /dev/null"}, arg)
return secureexec.Command("echo")
},
func(err error) {
assert.NoError(t, err)
},
},
}
for _, s := range scenarios {
OSCmd := NewDummyOSCommand()
OSCmd.Command = s.command
OSCmd.Platform.OS = "linux"
OSCmd.Config.GetUserConfig().OS.OpenCommand = `sh -c "xdg-open {{filename}} > /dev/null"`
s.test(OSCmd.OpenFile(s.filename))
}
}
// TestOSCommandQuote is a function.
func TestOSCommandQuote(t *testing.T) {
osCommand := NewDummyOSCommand()
osCommand.Platform.OS = "linux"
actual := osCommand.Quote("hello `test`")
expected := osCommand.Platform.EscapedQuote + "hello \\`test\\`" + osCommand.Platform.EscapedQuote
@@ -129,7 +206,7 @@ func TestOSCommandQuoteSingleQuote(t *testing.T) {
actual := osCommand.Quote("hello 'test'")
expected := osCommand.Platform.FallbackEscapedQuote + "hello 'test'" + osCommand.Platform.FallbackEscapedQuote
expected := osCommand.Platform.EscapedQuote + "hello 'test'" + osCommand.Platform.EscapedQuote
assert.EqualValues(t, expected, actual)
}
@@ -142,18 +219,20 @@ func TestOSCommandQuoteDoubleQuote(t *testing.T) {
actual := osCommand.Quote(`hello "test"`)
expected := osCommand.Platform.EscapedQuote + "hello \"test\"" + osCommand.Platform.EscapedQuote
expected := osCommand.Platform.EscapedQuote + `hello \"test\"` + osCommand.Platform.EscapedQuote
assert.EqualValues(t, expected, actual)
}
// TestOSCommandUnquote is a function.
func TestOSCommandUnquote(t *testing.T) {
// TestOSCommandQuoteWindows tests the quote function for Windows
func TestOSCommandQuoteWindows(t *testing.T) {
osCommand := NewDummyOSCommand()
actual := osCommand.Unquote(`hello "test"`)
osCommand.Platform.OS = "windows"
expected := "hello test"
actual := osCommand.Quote(`hello "test"`)
expected := osCommand.Platform.EscapedQuote + `hello "'"'"test"'"'"` + osCommand.Platform.EscapedQuote
assert.EqualValues(t, expected, actual)
}

View File

@@ -2,11 +2,10 @@ package oscommands
func getPlatform() *Platform {
return &Platform{
OS: "windows",
CatCmd: "cmd /c type",
Shell: "cmd",
ShellArg: "/c",
EscapedQuote: `\"`,
FallbackEscapedQuote: "\\'",
OS: "windows",
CatCmd: []string{"cmd", "/c", "type"},
Shell: "cmd",
ShellArg: "/c",
EscapedQuote: `\"`,
}
}

View File

@@ -1,7 +1,6 @@
package patch
import (
"errors"
"sort"
"strings"
@@ -9,9 +8,11 @@ import (
"github.com/sirupsen/logrus"
)
type PatchStatus int
const (
// UNSELECTED is for when the commit file has not been added to the patch in any way
UNSELECTED = iota
UNSELECTED PatchStatus = iota
// WHOLE is for when you want to add the whole diff of a file to the patch,
// including e.g. if it was deleted
WHOLE
@@ -20,7 +21,7 @@ const (
)
type fileInfo struct {
mode int // one of WHOLE/PART
mode PatchStatus
includedLineIndices []int
diff string
}
@@ -81,20 +82,25 @@ func (p *PatchManager) removeFile(info *fileInfo) {
info.includedLineIndices = nil
}
func (p *PatchManager) ToggleFileWhole(filename string) error {
func (p *PatchManager) AddFileWhole(filename string) error {
info, err := p.getFileInfo(filename)
if err != nil {
return err
}
switch info.mode {
case UNSELECTED, PART:
p.addFileWhole(info)
case WHOLE:
p.removeFile(info)
default:
return errors.New("unknown file mode")
p.addFileWhole(info)
return nil
}
func (p *PatchManager) RemoveFile(filename string) error {
info, err := p.getFileInfo(filename)
if err != nil {
return err
}
p.removeFile(info)
return nil
}
@@ -176,11 +182,8 @@ func (p *PatchManager) RenderPatchForFile(filename string, plain bool, reverse b
if plain {
return patch
}
parser, err := NewPatchParser(p.Log, patch)
if err != nil {
// swallowing for now
return ""
}
parser := NewPatchParser(p.Log, patch)
// not passing included lines because we don't want to see them in the secondary panel
return parser.Render(-1, -1, nil)
}
@@ -216,7 +219,11 @@ func (p *PatchManager) RenderAggregatedPatchColored(plain bool) string {
return result
}
func (p *PatchManager) GetFileStatus(filename string) int {
func (p *PatchManager) GetFileStatus(filename string, parent string) PatchStatus {
if parent != p.To {
return UNSELECTED
}
info, ok := p.fileInfoMap[filename]
if !ok {
return UNSELECTED

View File

@@ -4,14 +4,16 @@ import (
"regexp"
"strings"
"github.com/fatih/color"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sirupsen/logrus"
)
type PatchLineKind int
const (
PATCH_HEADER = iota
PATCH_HEADER PatchLineKind = iota
COMMIT_SHA
COMMIT_DESCRIPTION
HUNK_HEADER
@@ -24,7 +26,7 @@ const (
// the job of this file is to parse a diff, find out where the hunks begin and end, which lines are stageable, and how to find the next hunk from the current position or the next stageable line from the current position.
type PatchLine struct {
Kind int
Kind PatchLineKind
Content string // something like '+ hello' (note the first character is not removed)
}
@@ -37,11 +39,8 @@ type PatchParser struct {
}
// NewPatchParser builds a new branch list builder
func NewPatchParser(log *logrus.Entry, patch string) (*PatchParser, error) {
hunkStarts, stageableLines, patchLines, err := parsePatch(patch)
if err != nil {
return nil, err
}
func NewPatchParser(log *logrus.Entry, patch string) *PatchParser {
hunkStarts, stageableLines, patchLines := parsePatch(patch)
patchHunks := GetHunksFromDiff(patch)
@@ -51,7 +50,7 @@ func NewPatchParser(log *logrus.Entry, patch string) (*PatchParser, error) {
StageableLines: stageableLines,
PatchLines: patchLines,
PatchHunks: patchHunks,
}, nil
}
}
// GetHunkContainingLine takes a line index and an offset and finds the hunk
@@ -96,55 +95,49 @@ func (l *PatchLine) render(selected bool, included bool) string {
if l.Kind == HUNK_HEADER {
re := regexp.MustCompile("(@@.*?@@)(.*)")
match := re.FindStringSubmatch(content)
return coloredString(color.FgCyan, match[1], selected, included) + coloredString(theme.DefaultTextColor, match[2], selected, false)
return coloredString(style.FgCyan, match[1], selected, included) + coloredString(theme.DefaultTextColor, match[2], selected, false)
}
var colorAttr color.Attribute
textStyle := theme.DefaultTextColor
switch l.Kind {
case PATCH_HEADER:
colorAttr = color.Bold
textStyle = textStyle.SetBold()
case ADDITION:
colorAttr = color.FgGreen
textStyle = style.FgGreen
case DELETION:
colorAttr = color.FgRed
textStyle = style.FgRed
case COMMIT_SHA:
colorAttr = color.FgYellow
default:
colorAttr = theme.DefaultTextColor
textStyle = style.FgYellow
}
return coloredString(colorAttr, content, selected, included)
return coloredString(textStyle, content, selected, included)
}
func coloredString(colorAttr color.Attribute, str string, selected bool, included bool) string {
var cl *color.Color
attributes := []color.Attribute{colorAttr}
func coloredString(textStyle style.TextStyle, str string, selected bool, included bool) string {
if selected {
attributes = append(attributes, theme.SelectedRangeBgColor)
textStyle = textStyle.MergeStyle(theme.SelectedRangeBgColor)
}
cl = color.New(attributes...)
var clIncluded *color.Color
firstCharStyle := textStyle
if included {
clIncluded = color.New(append(attributes, color.BgGreen)...)
} else {
clIncluded = color.New(attributes...)
firstCharStyle = firstCharStyle.MergeStyle(style.BgGreen)
}
if len(str) < 2 {
return utils.ColoredStringDirect(str, clIncluded)
return firstCharStyle.Sprint(str)
}
return utils.ColoredStringDirect(str[:1], clIncluded) + utils.ColoredStringDirect(str[1:], cl)
return firstCharStyle.Sprint(str[:1]) + textStyle.Sprint(str[1:])
}
func parsePatch(patch string) ([]int, []int, []*PatchLine, error) {
func parsePatch(patch string) ([]int, []int, []*PatchLine) {
lines := strings.Split(patch, "\n")
hunkStarts := []int{}
stageableLines := []int{}
pastFirstHunkHeader := false
pastCommitDescription := true
patchLines := make([]*PatchLine, len(lines))
var lineKind int
var lineKind PatchLineKind
var firstChar string
for index, line := range lines {
firstChar = " "
@@ -183,7 +176,7 @@ func parsePatch(patch string) ([]int, []int, []*PatchLine, error) {
}
patchLines[index] = &PatchLine{Kind: lineKind, Content: line}
}
return hunkStarts, stageableLines, patchLines, nil
return hunkStarts, stageableLines, patchLines
}
// Render returns the coloured string of the diff with any selected lines highlighted

View File

@@ -23,7 +23,7 @@ func (c *GitCommand) DeletePatchesFromCommit(commits []*models.Commit, commitInd
}
// time to amend the selected commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -51,7 +51,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the destination commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -71,7 +71,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
// we can make this GPG thing possible it just means we need to do this in two parts:
// one where we handle the possibility of a credential request, and the other
// where we continue the rebase
if c.usingGpg() {
if c.UsingGpg() {
return errors.New(c.Tr.DisabledForGPG)
}
@@ -103,7 +103,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the source commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -122,7 +122,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
}
// amend the destination commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -137,7 +137,7 @@ func (c *GitCommand) MovePatchToSelectedCommit(commits []*models.Commit, sourceC
return c.GenericMergeOrRebaseAction("rebase", "continue")
}
func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int, p *patch.PatchManager, stash bool) error {
func (c *GitCommand) MovePatchIntoIndex(commits []*models.Commit, commitIdx int, p *patch.PatchManager, stash bool) error {
if stash {
if err := c.StashSave(c.Tr.StashPrefix + commits[commitIdx].Sha); err != nil {
return err
@@ -158,7 +158,7 @@ func (c *GitCommand) PullPatchIntoIndex(commits []*models.Commit, commitIdx int,
}
// amend the commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -203,7 +203,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx
}
// amend the commit
if _, err := c.AmendHead(); err != nil {
if err := c.AmendHead(); err != nil {
return err
}
@@ -217,7 +217,7 @@ func (c *GitCommand) PullPatchIntoNewCommit(commits []*models.Commit, commitIdx
head_message, _ := c.GetHeadCommitMessage()
new_message := fmt.Sprintf("Split from \"%s\"", head_message)
_, err := c.Commit(new_message, "")
err := c.OSCommand.RunCommand(c.CommitCmdStr(new_message, ""))
if err != nil {
return err
}

View File

@@ -5,14 +5,62 @@ import (
"strings"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
)
// Service is a service that repository is on (Github, Bitbucket, ...)
type Service struct {
Name string
PullRequestURL string
Name string
pullRequestURLIntoDefaultBranch func(owner string, repository string, from string) string
pullRequestURLIntoTargetBranch func(owner string, repository string, from string, to string) string
}
// NewService builds a Service based on the host type
func NewService(typeName string, repositoryDomain string, siteDomain string) *Service {
var service *Service
switch typeName {
case "github":
service = &Service{
Name: repositoryDomain,
pullRequestURLIntoDefaultBranch: func(owner string, repository string, from string) string {
return fmt.Sprintf("https://%s/%s/%s/compare/%s?expand=1", siteDomain, owner, repository, from)
},
pullRequestURLIntoTargetBranch: func(owner string, repository string, from string, to string) string {
return fmt.Sprintf("https://%s/%s/%s/compare/%s...%s?expand=1", siteDomain, owner, repository, to, from)
},
}
case "bitbucket":
service = &Service{
Name: repositoryDomain,
pullRequestURLIntoDefaultBranch: func(owner string, repository string, from string) string {
return fmt.Sprintf("https://%s/%s/%s/pull-requests/new?source=%s&t=1", siteDomain, owner, repository, from)
},
pullRequestURLIntoTargetBranch: func(owner string, repository string, from string, to string) string {
return fmt.Sprintf("https://%s/%s/%s/pull-requests/new?source=%s&dest=%s&t=1", siteDomain, owner, repository, from, to)
},
}
case "gitlab":
service = &Service{
Name: repositoryDomain,
pullRequestURLIntoDefaultBranch: func(owner string, repository string, from string) string {
return fmt.Sprintf("https://%s/%s/%s/merge_requests/new?merge_request[source_branch]=%s", siteDomain, owner, repository, from)
},
pullRequestURLIntoTargetBranch: func(owner string, repository string, from string, to string) string {
return fmt.Sprintf("https://%s/%s/%s/merge_requests/new?merge_request[source_branch]=%s&merge_request[target_branch]=%s", siteDomain, owner, repository, from, to)
},
}
}
return service
}
func (s *Service) PullRequestURL(owner string, repository string, from string, to string) string {
if to == "" {
return s.pullRequestURLIntoDefaultBranch(owner, repository, from)
} else {
return s.pullRequestURLIntoTargetBranch(owner, repository, from, to)
}
}
// PullRequest opens a link in browser to create new pull request
@@ -28,31 +76,6 @@ type RepoInformation struct {
Repository string
}
// NewService builds a Service based on the host type
func NewService(typeName string, repositoryDomain string, siteDomain string) *Service {
var service *Service
switch typeName {
case "github":
service = &Service{
Name: repositoryDomain,
PullRequestURL: fmt.Sprintf("https://%s%s", siteDomain, "/%s/%s/compare/%s?expand=1"),
}
case "bitbucket":
service = &Service{
Name: repositoryDomain,
PullRequestURL: fmt.Sprintf("https://%s%s", siteDomain, "/%s/%s/pull-requests/new?source=%s&t=1"),
}
case "gitlab":
service = &Service{
Name: repositoryDomain,
PullRequestURL: fmt.Sprintf("https://%s%s", siteDomain, "/%s/%s/merge_requests/new?merge_request[source_branch]=%s"),
}
}
return service
}
func getServices(config config.AppConfigurer) []*Service {
services := []*Service{
NewService("github", "github.com", "github.com"),
@@ -90,27 +113,27 @@ func NewPullRequest(gitCommand *GitCommand) *PullRequest {
}
// Create opens link to new pull request in browser
func (pr *PullRequest) Create(branch *models.Branch) error {
pullRequestURL, err := pr.getPullRequestURL(branch)
func (pr *PullRequest) Create(from string, to string) (string, error) {
pullRequestURL, err := pr.getPullRequestURL(from, to)
if err != nil {
return err
return "", err
}
return pr.GitCommand.OSCommand.OpenLink(pullRequestURL)
return pullRequestURL, pr.GitCommand.OSCommand.OpenLink(pullRequestURL)
}
// CopyURL copies the pull request URL to the clipboard
func (pr *PullRequest) CopyURL(branch *models.Branch) error {
pullRequestURL, err := pr.getPullRequestURL(branch)
func (pr *PullRequest) CopyURL(from string, to string) (string, error) {
pullRequestURL, err := pr.getPullRequestURL(from, to)
if err != nil {
return err
return "", err
}
return pr.GitCommand.OSCommand.CopyToClipboard(pullRequestURL)
return pullRequestURL, pr.GitCommand.OSCommand.CopyToClipboard(pullRequestURL)
}
func (pr *PullRequest) getPullRequestURL(branch *models.Branch) (string, error) {
branchExistsOnRemote := pr.GitCommand.CheckRemoteBranchExists(branch)
func (pr *PullRequest) getPullRequestURL(from string, to string) (string, error) {
branchExistsOnRemote := pr.GitCommand.CheckRemoteBranchExists(from)
if !branchExistsOnRemote {
return "", errors.New(pr.GitCommand.Tr.NoBranchOnRemote)
@@ -131,9 +154,8 @@ func (pr *PullRequest) getPullRequestURL(branch *models.Branch) (string, error)
}
repoInfo := getRepoInfoFromURL(repoURL)
pullRequestURL := fmt.Sprintf(
gitService.PullRequestURL, repoInfo.Owner, repoInfo.Repository, branch.Name,
)
pullRequestURL := gitService.PullRequestURL(repoInfo.Owner, repoInfo.Repository, from, to)
return pullRequestURL, nil
}

View File

@@ -5,7 +5,6 @@ import (
"strings"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
@@ -48,18 +47,17 @@ func TestGetRepoInfoFromURL(t *testing.T) {
func TestCreatePullRequest(t *testing.T) {
type scenario struct {
testName string
branch *models.Branch
from string
to string
remoteUrl string
command func(string, ...string) *exec.Cmd
test func(err error)
test func(url string, err error)
}
scenarios := []scenario{
{
testName: "Opens a link to new pull request on bitbucket",
branch: &models.Branch{
Name: "feature/profile-page",
},
testName: "Opens a link to new pull request on bitbucket",
from: "feature/profile-page",
remoteUrl: "git@bitbucket.org:johndoe/social_network.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
@@ -71,15 +69,14 @@ func TestCreatePullRequest(t *testing.T) {
assert.Equal(t, args, []string{"https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1"})
return secureexec.Command("echo")
},
test: func(err error) {
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page&t=1", url)
},
},
{
testName: "Opens a link to new pull request on bitbucket with http remote url",
branch: &models.Branch{
Name: "feature/events",
},
testName: "Opens a link to new pull request on bitbucket with http remote url",
from: "feature/events",
remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
@@ -91,15 +88,14 @@ func TestCreatePullRequest(t *testing.T) {
assert.Equal(t, args, []string{"https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/events&t=1"})
return secureexec.Command("echo")
},
test: func(err error) {
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/events&t=1", url)
},
},
{
testName: "Opens a link to new pull request on github",
branch: &models.Branch{
Name: "feature/sum-operation",
},
testName: "Opens a link to new pull request on github",
from: "feature/sum-operation",
remoteUrl: "git@github.com:peter/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
@@ -111,15 +107,74 @@ func TestCreatePullRequest(t *testing.T) {
assert.Equal(t, args, []string{"https://github.com/peter/calculator/compare/feature/sum-operation?expand=1"})
return secureexec.Command("echo")
},
test: func(err error) {
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://github.com/peter/calculator/compare/feature/sum-operation?expand=1", url)
},
},
{
testName: "Opens a link to new pull request on gitlab",
branch: &models.Branch{
Name: "feature/ui",
testName: "Opens a link to new pull request on bitbucket with specific target branch",
from: "feature/profile-page/avatar",
to: "feature/profile-page",
remoteUrl: "git@bitbucket.org:johndoe/social_network.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "git@bitbucket.org:johndoe/social_network.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page/avatar&dest=feature/profile-page&t=1"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/profile-page/avatar&dest=feature/profile-page&t=1", url)
},
},
{
testName: "Opens a link to new pull request on bitbucket with http remote url with specified target branch",
from: "feature/remote-events",
to: "feature/events",
remoteUrl: "https://my_username@bitbucket.org/johndoe/social_network.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "https://my_username@bitbucket.org/johndoe/social_network.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/remote-events&dest=feature/events&t=1"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://bitbucket.org/johndoe/social_network/pull-requests/new?source=feature/remote-events&dest=feature/events&t=1", url)
},
},
{
testName: "Opens a link to new pull request on github with specific target branch",
from: "feature/sum-operation",
to: "feature/operations",
remoteUrl: "git@github.com:peter/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "git@github.com:peter/calculator.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://github.com/peter/calculator/compare/feature/operations...feature/sum-operation?expand=1"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://github.com/peter/calculator/compare/feature/operations...feature/sum-operation?expand=1", url)
},
},
{
testName: "Opens a link to new pull request on gitlab",
from: "feature/ui",
remoteUrl: "git@gitlab.com:peter/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
@@ -131,20 +186,78 @@ func TestCreatePullRequest(t *testing.T) {
assert.Equal(t, args, []string{"https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/ui"})
return secureexec.Command("echo")
},
test: func(err error) {
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/ui", url)
},
},
{
testName: "Throws an error if git service is unsupported",
branch: &models.Branch{
Name: "feature/divide-operation",
testName: "Opens a link to new pull request on gitlab in nested groups",
from: "feature/ui",
remoteUrl: "git@gitlab.com:peter/public/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "git@gitlab.com:peter/calculator.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/ui"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/ui", url)
},
},
{
testName: "Opens a link to new pull request on gitlab with specific target branch",
from: "feature/commit-ui",
to: "epic/ui",
remoteUrl: "git@gitlab.com:peter/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "git@gitlab.com:peter/calculator.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://gitlab.com/peter/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui", url)
},
},
{
testName: "Opens a link to new pull request on gitlab with specific target branch in nested groups",
from: "feature/commit-ui",
to: "epic/ui",
remoteUrl: "git@gitlab.com:peter/public/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
// Handle git remote url call
if strings.HasPrefix(cmd, "git") {
return secureexec.Command("echo", "git@gitlab.com:peter/calculator.git")
}
assert.Equal(t, cmd, "open")
assert.Equal(t, args, []string{"https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui"})
return secureexec.Command("echo")
},
test: func(url string, err error) {
assert.NoError(t, err)
assert.Equal(t, "https://gitlab.com/peter/public/calculator/merge_requests/new?merge_request[source_branch]=feature/commit-ui&merge_request[target_branch]=epic/ui", url)
},
},
{
testName: "Throws an error if git service is unsupported",
from: "feature/divide-operation",
remoteUrl: "git@something.com:peter/calculator.git",
command: func(cmd string, args ...string) *exec.Cmd {
return secureexec.Command("echo")
},
test: func(err error) {
test: func(url string, err error) {
assert.Error(t, err)
},
},
@@ -167,7 +280,7 @@ func TestCreatePullRequest(t *testing.T) {
return s.remoteUrl, nil
}
dummyPullRequest := NewPullRequest(gitCommand)
s.test(dummyPullRequest.Create(s.branch))
s.test(dummyPullRequest.Create(s.from, s.to))
})
}
}

View File

@@ -77,6 +77,8 @@ func (c *GitCommand) PrepareInteractiveRebaseCommand(baseSha string, todo string
gitSequenceEditor := ex
if todo == "" {
gitSequenceEditor = "true"
} else {
c.OSCommand.LogCommand(fmt.Sprintf("Creating TODO file for interactive rebase: \n\n%s", todo), false)
}
cmd.Env = os.Environ()
@@ -117,7 +119,7 @@ func (c *GitCommand) GenerateGenericRebaseTodo(commits []*models.Commit, actionI
var commitAction string
if i == actionIndex {
commitAction = action
} else if commit.IsMerge {
} else if commit.IsMerge() {
// your typical interactive rebase will actually drop merge commits by default. Damn git CLI, you scary!
// doing this means we don't need to worry about rebasing over merges which always causes problems.
// you typically shouldn't be doing rebases that pass over merge commits anyway.
@@ -211,7 +213,7 @@ func (c *GitCommand) BeginInteractiveRebaseForCommit(commits []*models.Commit, c
// we can make this GPG thing possible it just means we need to do this in two parts:
// one where we handle the possibility of a credential request, and the other
// where we continue the rebase
if c.usingGpg() {
if c.UsingGpg() {
return errors.New(c.Tr.DisabledForGPG)
}

View File

@@ -0,0 +1,96 @@
package commands
import (
"os/exec"
"regexp"
"testing"
"github.com/jesseduffield/lazygit/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestGitCommandRebaseBranch is a function.
func TestGitCommandRebaseBranch(t *testing.T) {
type scenario struct {
testName string
arg string
command func(string, ...string) *exec.Cmd
test func(error)
}
scenarios := []scenario{
{
"successful rebase",
"master",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty master",
Replace: "echo",
},
}),
func(err error) {
assert.NoError(t, err)
},
},
{
"unsuccessful rebase",
"master",
test.CreateMockCommand(t, []*test.CommandSwapper{
{
Expect: "git rebase --interactive --autostash --keep-empty master",
Replace: "test",
},
}),
func(err error) {
assert.Error(t, err)
},
},
}
gitCmd := NewDummyGitCommand()
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd.OSCommand.Command = s.command
s.test(gitCmd.RebaseBranch(s.arg))
})
}
}
// TestGitCommandSkipEditorCommand confirms that SkipEditorCommand injects
// environment variables that suppress an interactive editor
func TestGitCommandSkipEditorCommand(t *testing.T) {
cmd := NewDummyGitCommand()
cmd.OSCommand.SetBeforeExecuteCmd(func(cmd *exec.Cmd) {
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^VISUAL="),
"expected VISUAL to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^EDITOR="),
"expected EDITOR to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^GIT_EDITOR="),
"expected GIT_EDITOR to be set for a non-interactive external command",
)
test.AssertContainsMatch(
t,
cmd.Env,
regexp.MustCompile("^LAZYGIT_CLIENT_COMMAND=EXIT_IMMEDIATELY$"),
"expected LAZYGIT_CLIENT_COMMAND to be set for a non-interactive external command",
)
})
_ = cmd.runSkipEditorCommand("true")
}

View File

@@ -2,24 +2,22 @@ package commands
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
func (c *GitCommand) AddRemote(name string, url string) error {
return c.OSCommand.RunCommand("git remote add %s %s", name, url)
return c.RunCommand("git remote add %s %s", name, url)
}
func (c *GitCommand) RemoveRemote(name string) error {
return c.OSCommand.RunCommand("git remote remove %s", name)
return c.RunCommand("git remote remove %s", name)
}
func (c *GitCommand) RenameRemote(oldRemoteName string, newRemoteName string) error {
return c.OSCommand.RunCommand("git remote rename %s %s", oldRemoteName, newRemoteName)
return c.RunCommand("git remote rename %s %s", oldRemoteName, newRemoteName)
}
func (c *GitCommand) UpdateRemoteUrl(remoteName string, updatedUrl string) error {
return c.OSCommand.RunCommand("git remote set-url %s %s", remoteName, updatedUrl)
return c.RunCommand("git remote set-url %s %s", remoteName, updatedUrl)
}
func (c *GitCommand) DeleteRemoteBranch(remoteName string, branchName string, promptUserForCredential func(string) string) error {
@@ -28,10 +26,10 @@ func (c *GitCommand) DeleteRemoteBranch(remoteName string, branchName string, pr
}
// CheckRemoteBranchExists Returns remote branch
func (c *GitCommand) CheckRemoteBranchExists(branch *models.Branch) bool {
func (c *GitCommand) CheckRemoteBranchExists(branchName string) bool {
_, err := c.OSCommand.RunCommandWithOutput(
"git show-ref --verify -- refs/remotes/origin/%s",
branch.Name,
branchName,
)
return err == nil

View File

@@ -4,13 +4,13 @@ import "fmt"
// StashDo modify stash
func (c *GitCommand) StashDo(index int, method string) error {
return c.OSCommand.RunCommand("git stash %s stash@{%d}", method, index)
return c.RunCommand("git stash %s stash@{%d}", method, index)
}
// StashSave save stash
// TODO: before calling this, check if there is anything to save
func (c *GitCommand) StashSave(message string) error {
return c.OSCommand.RunCommand("git stash save %s", c.OSCommand.Quote(message))
return c.RunCommand("git stash save %s", c.OSCommand.Quote(message))
}
// GetStashEntryDiff stash diff
@@ -21,8 +21,8 @@ func (c *GitCommand) ShowStashEntryCmdStr(index int) string {
// StashSaveStagedChanges stashes only the currently staged changes. This takes a few steps
// shoutouts to Joe on https://stackoverflow.com/questions/14759748/stashing-only-staged-changes-in-git-is-it-possible
func (c *GitCommand) StashSaveStagedChanges(message string) error {
if err := c.OSCommand.RunCommand("git stash --keep-index"); err != nil {
// wrap in 'writing', which uses a mutex
if err := c.RunCommand("git stash --keep-index"); err != nil {
return err
}
@@ -30,7 +30,7 @@ func (c *GitCommand) StashSaveStagedChanges(message string) error {
return err
}
if err := c.OSCommand.RunCommand("git stash apply stash@{1}"); err != nil {
if err := c.RunCommand("git stash apply stash@{1}"); err != nil {
return err
}
@@ -38,7 +38,7 @@ func (c *GitCommand) StashSaveStagedChanges(message string) error {
return err
}
if err := c.OSCommand.RunCommand("git stash drop stash@{1}"); err != nil {
if err := c.RunCommand("git stash drop stash@{1}"); err != nil {
return err
}
@@ -48,7 +48,7 @@ func (c *GitCommand) StashSaveStagedChanges(message string) error {
files := c.GetStatusFiles(GetStatusFileOptions{})
for _, file := range files {
if file.ShortStatus == "AD" {
if err := c.UnStageFile(file.Name, false); err != nil {
if err := c.UnStageFile(file.Names(), false); err != nil {
return err
}
}

View File

@@ -0,0 +1,35 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandStashDo is a function.
func TestGitCommandStashDo(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"stash", "drop", "stash@{1}"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StashDo(1, "drop"))
}
// TestGitCommandStashSave is a function.
func TestGitCommandStashSave(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"stash", "save", "A stash message"}, args)
return secureexec.Command("echo")
}
assert.NoError(t, gitCmd.StashSave("A stash message"))
}

View File

@@ -69,28 +69,28 @@ func (c *GitCommand) SubmoduleStash(submodule *models.SubmoduleConfig) error {
return nil
}
return c.OSCommand.RunCommand("git -C %s stash --include-untracked", submodule.Path)
return c.RunCommand("git -C %s stash --include-untracked", c.OSCommand.Quote(submodule.Path))
}
func (c *GitCommand) SubmoduleReset(submodule *models.SubmoduleConfig) error {
return c.OSCommand.RunCommand("git submodule update --init --force %s", submodule.Path)
return c.RunCommand("git submodule update --init --force -- %s", c.OSCommand.Quote(submodule.Path))
}
func (c *GitCommand) SubmoduleUpdateAll() error {
// not doing an --init here because the user probably doesn't want that
return c.OSCommand.RunCommand("git submodule update --force")
return c.RunCommand("git submodule update --force")
}
func (c *GitCommand) SubmoduleDelete(submodule *models.SubmoduleConfig) error {
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
if err := c.OSCommand.RunCommand("git submodule deinit --force %s", submodule.Path); err != nil {
if err := c.RunCommand("git submodule deinit --force -- %s", c.OSCommand.Quote(submodule.Path)); err != nil {
if strings.Contains(err.Error(), "did not match any file(s) known to git") {
if err := c.OSCommand.RunCommand("git config --file .gitmodules --remove-section submodule.%s", submodule.Name); err != nil {
if err := c.RunCommand("git config --file .gitmodules --remove-section submodule.%s", c.OSCommand.Quote(submodule.Name)); err != nil {
return err
}
if err := c.OSCommand.RunCommand("git config --remove-section submodule.%s", submodule.Name); err != nil {
if err := c.RunCommand("git config --remove-section submodule.%s", c.OSCommand.Quote(submodule.Name)); err != nil {
return err
}
@@ -100,7 +100,7 @@ func (c *GitCommand) SubmoduleDelete(submodule *models.SubmoduleConfig) error {
}
}
if err := c.OSCommand.RunCommand("git rm --force -r %s", submodule.Path); err != nil {
if err := c.RunCommand("git rm --force -r %s", submodule.Path); err != nil {
// if the directory isn't there then that's fine
c.Log.Error(err)
}
@@ -119,11 +119,11 @@ func (c *GitCommand) SubmoduleAdd(name string, path string, url string) error {
func (c *GitCommand) SubmoduleUpdateUrl(name string, path string, newUrl string) error {
// the set-url command is only for later git versions so we're doing it manually here
if err := c.OSCommand.RunCommand("git config --file .gitmodules submodule.%s.url %s", name, newUrl); err != nil {
if err := c.RunCommand("git config --file .gitmodules submodule.%s.url %s", c.OSCommand.Quote(name), newUrl); err != nil {
return err
}
if err := c.OSCommand.RunCommand("git submodule sync %s", path); err != nil {
if err := c.RunCommand("git submodule sync -- %s", c.OSCommand.Quote(path)); err != nil {
return err
}
@@ -131,11 +131,11 @@ func (c *GitCommand) SubmoduleUpdateUrl(name string, path string, newUrl string)
}
func (c *GitCommand) SubmoduleInit(path string) error {
return c.OSCommand.RunCommand("git submodule init %s", path)
return c.RunCommand("git submodule init -- %s", c.OSCommand.Quote(path))
}
func (c *GitCommand) SubmoduleUpdate(path string) error {
return c.OSCommand.RunCommand("git submodule update --init %s", path)
return c.RunCommand("git submodule update --init -- %s", c.OSCommand.Quote(path))
}
func (c *GitCommand) SubmoduleBulkInitCmdStr() string {

View File

@@ -2,23 +2,9 @@ package commands
import (
"fmt"
"strings"
"sync"
)
// usingGpg tells us whether the user has gpg enabled so that we can know
// whether we need to run a subprocess to allow them to enter their password
func (c *GitCommand) usingGpg() bool {
overrideGpg := c.Config.GetUserConfig().Git.OverrideGpg
if overrideGpg {
return false
}
gpgsign := c.GetConfigValue("commit.gpgsign")
value := strings.ToLower(gpgsign)
return value == "true" || value == "1" || value == "yes" || value == "on"
}
// Push pushes to a branch
func (c *GitCommand) Push(branchName string, force bool, upstream string, args string, promptUserForCredential func(string) string) error {
followTagsFlag := "--follow-tags"
@@ -74,3 +60,32 @@ func (c *GitCommand) FetchRemote(remoteName string, promptUserForCredential func
command := fmt.Sprintf("git fetch %s", remoteName)
return c.OSCommand.DetectUnamePass(command, promptUserForCredential)
}
func (c *GitCommand) GetPullMode(mode string) string {
if mode != "auto" {
return mode
}
var isRebase bool
var isFf bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
isRebase = c.GetConfigValue("pull.rebase") == "true"
wg.Done()
}()
go func() {
isFf = c.GetConfigValue("pull.ff") == "only"
wg.Done()
}()
wg.Wait()
if isRebase {
return "rebase"
} else if isFf {
return "ff-only"
} else {
return "merge"
}
}

223
pkg/commands/sync_test.go Normal file
View File

@@ -0,0 +1,223 @@
package commands
import (
"os/exec"
"testing"
"github.com/jesseduffield/lazygit/pkg/secureexec"
"github.com/stretchr/testify/assert"
)
// TestGitCommandPush is a function.
func TestGitCommandPush(t *testing.T) {
type scenario struct {
testName string
getGitConfigValue func(string) (string, error)
command func(string, ...string) *exec.Cmd
forcePush bool
test func(error)
}
scenarios := []scenario{
{
"Push with force disabled, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags"}, args)
return secureexec.Command("echo")
},
false,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with force enabled, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags", "--force-with-lease"}, args)
return secureexec.Command("echo")
},
true,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with force disabled, follow-tags off",
func(string) (string, error) {
return "false", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push"}, args)
return secureexec.Command("echo")
},
false,
func(err error) {
assert.NoError(t, err)
},
},
{
"Push with an error occurring, follow-tags on",
func(string) (string, error) {
return "", nil
},
func(cmd string, args ...string) *exec.Cmd {
assert.EqualValues(t, "git", cmd)
assert.EqualValues(t, []string{"push", "--follow-tags"}, args)
return secureexec.Command("test")
},
false,
func(err error) {
assert.Error(t, err)
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.OSCommand.Command = s.command
gitCmd.getGitConfigValue = s.getGitConfigValue
err := gitCmd.Push("test", s.forcePush, "", "", func(passOrUname string) string {
return "\n"
})
s.test(err)
})
}
}
type getPullModeScenario struct {
testName string
getGitConfigValueMock func(string) (string, error)
configPullModeValue string
test func(string)
}
func TestGetPullMode(t *testing.T) {
scenarios := getPullModeScenarios(t)
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
gitCmd := NewDummyGitCommand()
gitCmd.getGitConfigValue = s.getGitConfigValueMock
s.test(gitCmd.GetPullMode(s.configPullModeValue))
})
}
}
func getPullModeScenarios(t *testing.T) []getPullModeScenario {
return []getPullModeScenario{
{
testName: "Merge is default",
getGitConfigValueMock: func(s string) (string, error) {
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "merge", actual)
},
}, {
testName: "Reads rebase when pull.rebase is true",
getGitConfigValueMock: func(s string) (string, error) {
if s == "pull.rebase" {
return "true", nil
}
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "rebase", actual)
},
}, {
testName: "Reads ff-only when pull.ff is only",
getGitConfigValueMock: func(s string) (string, error) {
if s == "pull.ff" {
return "only", nil
}
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "ff-only", actual)
},
}, {
testName: "Reads rebase when rebase is true and ff is only",
getGitConfigValueMock: func(s string) (string, error) {
if s == "pull.rebase" {
return "true", nil
}
if s == "pull.ff" {
return "only", nil
}
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "rebase", actual)
},
}, {
testName: "Reads rebase when pull.rebase is true",
getGitConfigValueMock: func(s string) (string, error) {
if s == "pull.rebase" {
return "true", nil
}
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "rebase", actual)
},
}, {
testName: "Reads ff-only when pull.ff is only",
getGitConfigValueMock: func(s string) (string, error) {
if s == "pull.ff" {
return "only", nil
}
return "", nil
},
configPullModeValue: "auto",
test: func(actual string) {
assert.Equal(t, "ff-only", actual)
},
}, {
testName: "Respects merge config",
getGitConfigValueMock: func(s string) (string, error) {
return "", nil
},
configPullModeValue: "merge",
test: func(actual string) {
assert.Equal(t, "merge", actual)
},
}, {
testName: "Respects rebase config",
getGitConfigValueMock: func(s string) (string, error) {
return "", nil
},
configPullModeValue: "rebase",
test: func(actual string) {
assert.Equal(t, "rebase", actual)
},
}, {
testName: "Respects ff-only config",
getGitConfigValueMock: func(s string) (string, error) {
return "", nil
},
configPullModeValue: "ff-only",
test: func(actual string) {
assert.Equal(t, "ff-only", actual)
},
},
}
}

View File

@@ -3,11 +3,11 @@ package commands
import "fmt"
func (c *GitCommand) CreateLightweightTag(tagName string, commitSha string) error {
return c.OSCommand.RunCommand("git tag %s %s", tagName, commitSha)
return c.RunCommand("git tag %s %s", tagName, commitSha)
}
func (c *GitCommand) DeleteTag(tagName string) error {
return c.OSCommand.RunCommand("git tag -d %s", tagName)
return c.RunCommand("git tag -d %s", tagName)
}
func (c *GitCommand) PushTag(remoteName string, tagName string, promptUserForCredential func(string) string) error {

View File

@@ -41,6 +41,7 @@ type AppConfigurer interface {
SaveAppState() error
SetIsNewRepo(bool)
GetIsNewRepo() bool
ReloadUserConfig() error
}
// NewAppConfig makes a new app config
@@ -203,6 +204,16 @@ func (c *AppConfig) GetUserConfigDir() string {
return c.UserConfigDir
}
func (c *AppConfig) ReloadUserConfig() error {
userConfig, err := loadUserConfigWithDefaults(c.UserConfigDir)
if err != nil {
return err
}
c.UserConfig = userConfig
return nil
}
func configFilePath(filename string) (string, error) {
folder, err := findOrCreateConfigDir()
if err != nil {

View File

@@ -5,6 +5,7 @@ package config
// GetPlatformDefaultConfig gets the defaults for the platform
func GetPlatformDefaultConfig() OSConfig {
return OSConfig{
EditCommand: ``,
OpenCommand: "open {{filename}}",
OpenLinkCommand: "open {{link}}",
}

View File

@@ -3,6 +3,7 @@ package config
// GetPlatformDefaultConfig gets the defaults for the platform
func GetPlatformDefaultConfig() OSConfig {
return OSConfig{
EditCommand: ``,
OpenCommand: `sh -c "xdg-open {{filename}} >/dev/null"`,
OpenLinkCommand: `sh -c "xdg-open {{link}} >/dev/null"`,
}

View File

@@ -3,6 +3,7 @@ package config
// GetPlatformDefaultConfig gets the defaults for the platform
func GetPlatformDefaultConfig() OSConfig {
return OSConfig{
EditCommand: ``,
OpenCommand: `cmd /c "start "" {{filename}}"`,
OpenLinkCommand: `cmd /c "start "" {{link}}"`,
}

View File

@@ -35,6 +35,11 @@ type GuiConfig struct {
Theme ThemeConfig `yaml:"theme"`
CommitLength CommitLengthConfig `yaml:"commitLength"`
SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"`
ShowListFooter bool `yaml:"showListFooter"`
ShowFileTree bool `yaml:"showFileTree"`
ShowRandomTip bool `yaml:"showRandomTip"`
ShowCommandLog bool `yaml:"showCommandLog"`
CommandLogSize int `yaml:"commandLogSize"`
}
type ThemeConfig struct {
@@ -61,6 +66,7 @@ type GitConfig struct {
OverrideGpg bool `yaml:"overrideGpg"`
DisableForcePushing bool `yaml:"disableForcePushing"`
CommitPrefixes map[string]CommitPrefixConfig `yaml:"commitPrefixes"`
ParseEmoji bool `yaml:"parseEmoji"`
}
type PagingConfig struct {
@@ -100,6 +106,7 @@ type KeybindingConfig struct {
Submodules KeybindingSubmodulesConfig `yaml:"submodules"`
}
// damn looks like we have some inconsistencies here with -alt and -alt1
type KeybindingUniversalConfig struct {
Quit string `yaml:"quit"`
QuitAlt1 string `yaml:"quit-alt1"`
@@ -118,6 +125,8 @@ type KeybindingUniversalConfig struct {
NextBlock string `yaml:"nextBlock"`
PrevBlockAlt string `yaml:"prevBlock-alt"`
NextBlockAlt string `yaml:"nextBlock-alt"`
NextBlockAlt2 string `yaml:"nextBlock-alt2"`
PrevBlockAlt2 string `yaml:"prevBlock-alt2"`
NextMatch string `yaml:"nextMatch"`
PrevMatch string `yaml:"prevMatch"`
StartSearch string `yaml:"startSearch"`
@@ -153,8 +162,11 @@ type KeybindingUniversalConfig struct {
DiffingMenu string `yaml:"diffingMenu"`
DiffingMenuAlt string `yaml:"diffingMenu-alt"`
CopyToClipboard string `yaml:"copyToClipboard"`
OpenRecentRepos string `yaml:"openRecentRepos"`
SubmitEditorText string `yaml:"submitEditorText"`
AppendNewline string `yaml:"appendNewline"`
ExtrasMenu string `yaml:"extrasMenu"`
ToggleWhitespaceInDiffView string `yaml:"toggleWhitespaceInDiffView"`
}
type KeybindingStatusConfig struct {
@@ -175,10 +187,13 @@ type KeybindingFilesConfig struct {
ToggleStagedAll string `yaml:"toggleStagedAll"`
ViewResetOptions string `yaml:"viewResetOptions"`
Fetch string `yaml:"fetch"`
ToggleTreeView string `yaml:"toggleTreeView"`
OpenMergeTool string `yaml:"openMergeTool"`
}
type KeybindingBranchesConfig struct {
CreatePullRequest string `yaml:"createPullRequest"`
ViewPullRequestOptions string `yaml:"viewPullRequestOptions"`
CopyPullRequestURL string `yaml:"copyPullRequestURL"`
CheckoutBranchByName string `yaml:"checkoutBranchByName"`
ForceCheckoutBranch string `yaml:"forceCheckoutBranch"`
@@ -237,6 +252,9 @@ type KeybindingSubmodulesConfig struct {
// OSConfig contains config on the level of the os
type OSConfig struct {
// EditCommand is the command for editing a file
EditCommand string `yaml:"editCommand,omitempty"`
// OpenCommand is the command for opening a file
OpenCommand string `yaml:"openCommand,omitempty"`
@@ -263,6 +281,12 @@ type CustomCommandPrompt struct {
// this only applies to menus
Options []CustomCommandMenuOption
// this only applies to menuFromCommand
Command string `yaml:"command"`
Filter string `yaml:"filter"`
ValueFormat string `yaml:"valueFormat"`
LabelFormat string `yaml:"labelFormat"`
}
type CustomCommandMenuOption struct {
@@ -292,6 +316,11 @@ func GetDefaultConfig() *UserConfig {
},
CommitLength: CommitLengthConfig{Show: true},
SkipNoStagedFilesWarning: false,
ShowListFooter: true,
ShowCommandLog: true,
ShowFileTree: false,
ShowRandomTip: true,
CommandLogSize: 8,
},
Git: GitConfig{
Paging: PagingConfig{
@@ -303,7 +332,7 @@ func GetDefaultConfig() *UserConfig {
Args: "",
},
Pull: PullConfig{
Mode: "merge",
Mode: "auto",
},
SkipHookPrefix: "WIP",
AutoFetch: true,
@@ -311,6 +340,7 @@ func GetDefaultConfig() *UserConfig {
AllBranchesLogCmd: "git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium",
DisableForcePushing: false,
CommitPrefixes: map[string]CommitPrefixConfig(nil),
ParseEmoji: false,
},
Refresher: RefresherConfig{
RefreshInterval: 10,
@@ -323,7 +353,7 @@ func GetDefaultConfig() *UserConfig {
Reporting: "undetermined",
SplashUpdatesIndex: 0,
ConfirmOnQuit: false,
QuitOnTopLevelReturn: true,
QuitOnTopLevelReturn: false,
Keybinding: KeybindingConfig{
Universal: KeybindingUniversalConfig{
Quit: "q",
@@ -343,6 +373,8 @@ func GetDefaultConfig() *UserConfig {
NextBlock: "<right>",
PrevBlockAlt: "h",
NextBlockAlt: "l",
PrevBlockAlt2: "<backtab>",
NextBlockAlt2: "<tab>",
NextMatch: "n",
PrevMatch: "N",
StartSearch: "/",
@@ -356,6 +388,7 @@ func GetDefaultConfig() *UserConfig {
New: "n",
Edit: "e",
OpenFile: "o",
OpenRecentRepos: "<c-r>",
ScrollUpMain: "<pgup>",
ScrollDownMain: "<pgdown>",
ScrollUpMainAlt1: "K",
@@ -379,7 +412,9 @@ func GetDefaultConfig() *UserConfig {
DiffingMenuAlt: "<c-e>",
CopyToClipboard: "<c-o>",
SubmitEditorText: "<enter>",
AppendNewline: "<tab>",
AppendNewline: "<a-enter>",
ExtrasMenu: "@",
ToggleWhitespaceInDiffView: "<c-w>",
},
Status: KeybindingStatusConfig{
CheckForUpdate: "u",
@@ -398,10 +433,13 @@ func GetDefaultConfig() *UserConfig {
ToggleStagedAll: "a",
ViewResetOptions: "D",
Fetch: "f",
ToggleTreeView: "`",
OpenMergeTool: "M",
},
Branches: KeybindingBranchesConfig{
CopyPullRequestURL: "<c-y>",
CreatePullRequest: "o",
ViewPullRequestOptions: "O",
CheckoutBranchByName: "c",
ForceCheckoutBranch: "F",
RebaseBranch: "r",

35
pkg/constants/links.go Normal file
View File

@@ -0,0 +1,35 @@
package constants
type Docs struct {
CustomPagers string
CustomCommands string
CustomKeybindings string
Keybindings string
Undoing string
Config string
Tutorial string
}
var Links = struct {
Docs Docs
Issues string
Donate string
Discussions string
RepoUrl string
Releases string
}{
RepoUrl: "https://github.com/jesseduffield/lazygit",
Issues: "https://github.com/jesseduffield/lazygit/issues",
Donate: "https://github.com/sponsors/jesseduffield",
Discussions: "https://github.com/jesseduffield/lazygit/discussions",
Releases: "https://github.com/jesseduffield/lazygit/releases",
Docs: Docs{
CustomPagers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md",
CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md",
CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium",
Keybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings",
Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md",
Config: "https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md",
Tutorial: "https://youtu.be/VDXvbHZYeKY",
},
}

View File

@@ -97,10 +97,10 @@ func (gui *Gui) renderAppStatus() {
for range ticker.C {
appStatus := gui.statusManager.getStatusString()
if appStatus == "" {
gui.renderString("appStatus", "")
gui.renderString(gui.Views.AppStatus, "")
return
}
gui.renderString("appStatus", appStatus)
gui.renderString(gui.Views.AppStatus, appStatus)
}
})
}

View File

@@ -5,6 +5,8 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
)
const INFO_SECTION_PADDING = " "
func (gui *Gui) mainSectionChildren() []*boxlayout.Box {
currentWindow := gui.currentWindow()
@@ -130,6 +132,24 @@ func (gui *Gui) splitMainPanelSideBySide() bool {
}
}
func (gui *Gui) getExtrasWindowSize(screenHeight int) int {
if !gui.ShowExtrasWindow {
return 0
}
var baseSize int
if gui.currentStaticContext().GetKey() == COMMAND_LOG_CONTEXT_KEY {
baseSize = 1000 // my way of saying 'fill the available space'
} else if screenHeight < 40 {
baseSize = 1
} else {
baseSize = gui.Config.GetUserConfig().Gui.CommandLogSize
}
frameSize := 2
return baseSize + frameSize
}
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
width, height := gui.g.Size()
@@ -146,6 +166,8 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
mainPanelsDirection = boxlayout.COLUMN
}
extrasWindowSize := gui.getExtrasWindowSize(height)
root := &boxlayout.Box{
Direction: boxlayout.ROW,
Children: []*boxlayout.Box{
@@ -159,9 +181,19 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
ConditionalChildren: gui.sidePanelChildren,
},
{
Direction: mainPanelsDirection,
Direction: boxlayout.ROW,
Weight: mainSectionWeight,
Children: gui.mainSectionChildren(),
Children: []*boxlayout.Box{
{
Direction: mainPanelsDirection,
Children: gui.mainSectionChildren(),
Weight: 1,
},
{
Window: "extras",
Size: extrasWindowSize,
},
},
},
},
},
@@ -181,9 +213,12 @@ func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map
// the default behaviour when accordian mode is NOT in effect. If it is in effect
// then when it's accessed it will have weight 2, not 1.
func (gui *Gui) getDefaultStashWindowBox() *boxlayout.Box {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
box := &boxlayout.Box{Window: "stash"}
stashWindowAccessed := false
for _, context := range gui.State.ContextStack {
for _, context := range gui.State.ContextManager.ContextStack {
if context.GetWindowName() == "stash" {
stashWindowAccessed = true
}
@@ -278,9 +313,12 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
func (gui *Gui) currentSideWindowName() string {
// there is always one and only one cyclable context in the context stack. We'll look from top to bottom
for idx := range gui.State.ContextStack {
reversedIdx := len(gui.State.ContextStack) - 1 - idx
context := gui.State.ContextStack[reversedIdx]
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
for idx := range gui.State.ContextManager.ContextStack {
reversedIdx := len(gui.State.ContextManager.ContextStack) - 1 - idx
context := gui.State.ContextManager.ContextStack[reversedIdx]
if context.GetKind() == SIDE_CONTEXT {
return context.GetWindowName()

77
pkg/gui/basic_context.go Normal file
View File

@@ -0,0 +1,77 @@
package gui
type BasicContext struct {
OnFocus func() error
OnFocusLost func() error
OnRender func() error
Kind ContextKind
Key ContextKey
ViewName string
WindowName string
OnGetOptionsMap func() map[string]string
ParentContext Context
// we can't know on the calling end whether a Context is actually a nil value without reflection, so we're storing this flag here to tell us. There has got to be a better way around this
hasParent bool
}
func (c *BasicContext) GetOptionsMap() map[string]string {
if c.OnGetOptionsMap != nil {
return c.OnGetOptionsMap()
}
return nil
}
func (c *BasicContext) SetParentContext(context Context) {
c.ParentContext = context
c.hasParent = true
}
func (c *BasicContext) GetParentContext() (Context, bool) {
return c.ParentContext, c.hasParent
}
func (c *BasicContext) SetWindowName(windowName string) {
c.WindowName = windowName
}
func (c *BasicContext) GetWindowName() string {
windowName := c.WindowName
if windowName != "" {
return windowName
}
// TODO: actually set this for everything so we don't default to the view name
return c.ViewName
}
func (c *BasicContext) HandleRender() error {
if c.OnRender != nil {
return c.OnRender()
}
return nil
}
func (c *BasicContext) GetViewName() string {
return c.ViewName
}
func (c *BasicContext) HandleFocus() error {
return c.OnFocus()
}
func (c *BasicContext) HandleFocusLost() error {
if c.OnFocusLost != nil {
return c.OnFocusLost()
}
return nil
}
func (c *BasicContext) GetKind() ContextKind {
return c.Kind
}
func (c *BasicContext) GetKey() ContextKey {
return c.Key
}

View File

@@ -9,8 +9,10 @@ type Dimensions struct {
Y1 int
}
type Direction int
const (
ROW = iota
ROW Direction = iota
COLUMN
)
@@ -26,10 +28,10 @@ const (
type Box struct {
// Direction decides how the children boxes are laid out. ROW means the children will each form a row i.e. that they will be stacked on top of eachother.
Direction int // ROW or COLUMN
Direction Direction
// function which takes the width and height assigned to the box and decides which orientation it will have
ConditionalDirection func(width int, height int) int
ConditionalDirection func(width int, height int) Direction
Children []*Box
@@ -94,6 +96,11 @@ func ArrangeWindows(root *Box, x0, y0, width, height int) map[string]Dimensions
var boxSize int
if child.isStatic() {
boxSize = child.Size
// assuming that only one static child can have a size greater than the
// available space. In that case we just crop the size to what's available
if boxSize > availableSize {
boxSize = availableSize
}
} else {
// TODO: consider more evenly distributing the remainder
boxSize = unitSize * child.Weight
@@ -120,7 +127,7 @@ func (b *Box) isStatic() bool {
return b.Size > 0
}
func (b *Box) getDirection(width int, height int) int {
func (b *Box) getDirection(width int, height int) Direction {
if b.ConditionalDirection != nil {
return b.ConditionalDirection(width, height)
}

View File

@@ -87,7 +87,7 @@ func TestArrangeWindows(t *testing.T) {
},
{
"Box with COLUMN direction only on wide boxes with narrow box",
&Box{ConditionalDirection: func(width int, height int) int {
&Box{ConditionalDirection: func(width int, height int) Direction {
if width > 4 {
return COLUMN
} else {
@@ -111,7 +111,7 @@ func TestArrangeWindows(t *testing.T) {
},
{
"Box with COLUMN direction only on wide boxes with wide box",
&Box{ConditionalDirection: func(width int, height int) int {
&Box{ConditionalDirection: func(width int, height int) Direction {
if width > 4 {
return COLUMN
} else {
@@ -179,6 +179,27 @@ func TestArrangeWindows(t *testing.T) {
)
},
},
{
"Box with static child with size too large",
&Box{Direction: COLUMN, Children: []*Box{{Size: 11, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
0,
0,
10,
10,
func(result map[string]Dimensions) {
assert.EqualValues(
t,
result,
map[string]Dimensions{
"static": {X0: 0, X1: 9, Y0: 0, Y1: 9},
// not sure if X0: 10, X1: 9 makes any sense, but testing this in the
// actual GUI it seems harmless
"dynamic1": {X0: 10, X1: 9, Y0: 0, Y1: 9},
"dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9},
},
)
},
},
}
for _, s := range scenarios {

View File

@@ -4,9 +4,9 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -31,13 +31,13 @@ func (gui *Gui) handleBranchSelect() error {
var task updateTask
branch := gui.getSelectedBranch()
if branch == nil {
task = gui.createRenderStringTask(gui.Tr.NoBranchesThisRepo)
task = NewRenderStringTask(gui.Tr.NoBranchesThisRepo)
} else {
cmd := gui.OSCommand.ExecutableFromString(
gui.GitCommand.GetBranchGraphCmdStr(branch.Name),
)
task = gui.createRunPtyTask(cmd)
task = NewRunPtyTask(cmd)
}
return gui.refreshMainViews(refreshMainOpts{
@@ -70,7 +70,7 @@ func (gui *Gui) refreshBranches() {
}
gui.State.Branches = builder.Build()
if err := gui.postRefreshUpdate(gui.Contexts.Branches.Context); err != nil {
if err := gui.postRefreshUpdate(gui.State.Contexts.Branches); err != nil {
gui.Log.Error(err)
}
@@ -79,7 +79,7 @@ func (gui *Gui) refreshBranches() {
// specific functions
func (gui *Gui) handleBranchPress(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleBranchPress() error {
if gui.State.Panels.Branches.SelectedLineIdx == -1 {
return nil
}
@@ -87,46 +87,52 @@ func (gui *Gui) handleBranchPress(g *gocui.Gui, v *gocui.View) error {
return gui.createErrorPanel(gui.Tr.AlreadyCheckedOutBranch)
}
branch := gui.getSelectedBranch()
return gui.handleCheckoutRef(branch.Name, handleCheckoutRefOptions{})
return gui.handleCheckoutRef(branch.Name, handleCheckoutRefOptions{span: gui.Tr.Spans.CheckoutBranch})
}
func (gui *Gui) handleCreatePullRequestPress(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreatePullRequestPress() error {
branch := gui.getSelectedBranch()
return gui.createPullRequest(branch.Name, "")
}
func (gui *Gui) handleCreatePullRequestMenu() error {
selectedBranch := gui.getSelectedBranch()
if selectedBranch == nil {
return nil
}
checkedOutBranch := gui.getCheckedOutBranch()
return gui.createPullRequestMenu(selectedBranch, checkedOutBranch)
}
func (gui *Gui) handleCopyPullRequestURLPress() error {
pullRequest := commands.NewPullRequest(gui.GitCommand)
branch := gui.getSelectedBranch()
if err := pullRequest.Create(branch); err != nil {
return gui.surfaceError(err)
}
return nil
}
func (gui *Gui) handleCopyPullRequestURLPress(g *gocui.Gui, v *gocui.View) error {
pullRequest := commands.NewPullRequest(gui.GitCommand)
branch := gui.getSelectedBranch()
if err := pullRequest.CopyURL(branch); err != nil {
url, err := pullRequest.CopyURL(branch.Name, "")
if err != nil {
return gui.surfaceError(err)
}
gui.OnRunCommand(oscommands.NewCmdLogEntry(fmt.Sprintf("Copying to clipboard: '%s'", url), "Copy URL", false))
gui.raiseToast(gui.Tr.PullRequestURLCopiedToClipboard)
return nil
}
func (gui *Gui) handleGitFetch(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleGitFetch() error {
if err := gui.createLoaderPanel(gui.Tr.FetchWait); err != nil {
return err
}
go utils.Safe(func() {
err := gui.fetch(true)
err := gui.fetch(true, "Fetch")
gui.handleCredentialsPopup(err)
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC})
})
return nil
}
func (gui *Gui) handleForceCheckout(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleForceCheckout() error {
branch := gui.getSelectedBranch()
message := gui.Tr.SureForceCheckout
title := gui.Tr.ForceCheckoutBranch
@@ -135,7 +141,7 @@ func (gui *Gui) handleForceCheckout(g *gocui.Gui, v *gocui.View) error {
title: title,
prompt: message,
handleConfirm: func() error {
if err := gui.GitCommand.Checkout(branch.Name, commands.CheckoutOptions{Force: true}); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.ForceCheckoutBranch).Checkout(branch.Name, commands.CheckoutOptions{Force: true}); err != nil {
_ = gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
@@ -147,6 +153,7 @@ type handleCheckoutRefOptions struct {
WaitingStatus string
EnvVars []string
onRefNotFound func(ref string) error
span string
}
func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions) error {
@@ -164,8 +171,10 @@ func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions)
gui.State.Panels.Commits.LimitCommits = true
}
gitCommand := gui.GitCommand.WithSpan(options.span)
return gui.WithWaitingStatus(waitingStatus, func() error {
if err := gui.GitCommand.Checkout(ref, cmdOptions); err != nil {
if err := gitCommand.Checkout(ref, cmdOptions); err != nil {
// note, this will only work for english-language git commands. If we force git to use english, and the error isn't this one, then the user will receive an english command they may not understand. I'm not sure what the best solution to this is. Running the command once in english and a second time in the native language is one option
if options.onRefNotFound != nil && strings.Contains(err.Error(), "did not match any file(s) known to git") {
@@ -179,15 +188,15 @@ func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions)
title: gui.Tr.AutoStashTitle,
prompt: gui.Tr.AutoStashPrompt,
handleConfirm: func() error {
if err := gui.GitCommand.StashSave(gui.Tr.StashPrefix + ref); err != nil {
if err := gitCommand.StashSave(gui.Tr.StashPrefix + ref); err != nil {
return gui.surfaceError(err)
}
if err := gui.GitCommand.Checkout(ref, cmdOptions); err != nil {
if err := gitCommand.Checkout(ref, cmdOptions); err != nil {
return gui.surfaceError(err)
}
onSuccess()
if err := gui.GitCommand.StashDo(0, "pop"); err != nil {
if err := gitCommand.StashDo(0, "pop"); err != nil {
if err := gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI}); err != nil {
return err
}
@@ -208,12 +217,13 @@ func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions)
})
}
func (gui *Gui) handleCheckoutByName(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCheckoutByName() error {
return gui.prompt(promptOpts{
title: gui.Tr.BranchName + ":",
findSuggestionsFunc: gui.findBranchNameSuggestions,
handleConfirm: func(response string) error {
return gui.handleCheckoutRef(response, handleCheckoutRefOptions{
span: "Checkout branch",
onRefNotFound: func(ref string) error {
return gui.ask(askOpts{
@@ -251,7 +261,7 @@ func (gui *Gui) createNewBranchWithName(newBranchName string) error {
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
}
func (gui *Gui) handleDeleteBranch(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleDeleteBranch() error {
return gui.deleteBranch(false)
}
@@ -286,14 +296,14 @@ func (gui *Gui) deleteNamedBranch(selectedBranch *models.Branch, force bool) err
title: title,
prompt: message,
handleConfirm: func() error {
if err := gui.GitCommand.DeleteBranch(selectedBranch.Name, force); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DeleteBranch).DeleteBranch(selectedBranch.Name, force); err != nil {
errMessage := err.Error()
if !force && strings.Contains(errMessage, "is not fully merged") {
return gui.deleteNamedBranch(selectedBranch, true)
}
return gui.createErrorPanel(errMessage)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{BRANCHES}})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{BRANCHES}})
},
})
}
@@ -322,13 +332,13 @@ func (gui *Gui) mergeBranchIntoCheckedOutBranch(branchName string) error {
title: gui.Tr.MergingTitle,
prompt: prompt,
handleConfirm: func() error {
err := gui.GitCommand.Merge(branchName, commands.MergeOpts{})
err := gui.GitCommand.WithSpan(gui.Tr.Spans.Merge).Merge(branchName, commands.MergeOpts{})
return gui.handleGenericMergeCommandResult(err)
},
})
}
func (gui *Gui) handleMerge(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleMerge() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -337,7 +347,7 @@ func (gui *Gui) handleMerge(g *gocui.Gui, v *gocui.View) error {
return gui.mergeBranchIntoCheckedOutBranch(selectedBranchName)
}
func (gui *Gui) handleRebaseOntoLocalBranch(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleRebaseOntoLocalBranch() error {
selectedBranchName := gui.getSelectedBranch().Name
return gui.handleRebaseOntoBranch(selectedBranchName)
}
@@ -363,24 +373,22 @@ func (gui *Gui) handleRebaseOntoBranch(selectedBranchName string) error {
title: gui.Tr.RebasingTitle,
prompt: prompt,
handleConfirm: func() error {
err := gui.GitCommand.RebaseBranch(selectedBranchName)
err := gui.GitCommand.WithSpan(gui.Tr.Spans.RebaseBranch).RebaseBranch(selectedBranchName)
return gui.handleGenericMergeCommandResult(err)
},
})
}
func (gui *Gui) handleFastForward(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleFastForward() error {
branch := gui.getSelectedBranch()
if branch == nil {
if branch == nil || !branch.IsRealBranch() {
return nil
}
if branch.Pushables == "" {
return nil
}
if branch.Pushables == "?" {
if !branch.IsTrackingRemote() {
return gui.createErrorPanel(gui.Tr.FwdNoUpstream)
}
if branch.Pushables != "0" {
if branch.HasCommitsToPush() {
return gui.createErrorPanel(gui.Tr.FwdCommitsToPush)
}
@@ -389,6 +397,8 @@ func (gui *Gui) handleFastForward(g *gocui.Gui, v *gocui.View) error {
return gui.surfaceError(err)
}
span := gui.Tr.Spans.FastForwardBranch
split := strings.Split(upstream, "/")
remoteName := split[0]
remoteBranchName := strings.Join(split[1:], "/")
@@ -404,17 +414,17 @@ func (gui *Gui) handleFastForward(g *gocui.Gui, v *gocui.View) error {
_ = gui.createLoaderPanel(message)
if gui.State.Panels.Branches.SelectedLineIdx == 0 {
_ = gui.pullWithMode("ff-only", PullFilesOptions{})
_ = gui.pullWithMode("ff-only", PullFilesOptions{span: span})
} else {
err := gui.GitCommand.FastForward(branch.Name, remoteName, remoteBranchName, gui.promptUserForCredential)
err := gui.GitCommand.WithSpan(span).FastForward(branch.Name, remoteName, remoteBranchName, gui.promptUserForCredential)
gui.handleCredentialsPopup(err)
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{BRANCHES}})
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{BRANCHES}})
}
})
return nil
}
func (gui *Gui) handleCreateResetToBranchMenu(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreateResetToBranchMenu() error {
branch := gui.getSelectedBranch()
if branch == nil {
return nil
@@ -423,30 +433,35 @@ func (gui *Gui) handleCreateResetToBranchMenu(g *gocui.Gui, v *gocui.View) error
return gui.createResetMenu(branch.Name)
}
func (gui *Gui) handleRenameBranch(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleRenameBranch() error {
branch := gui.getSelectedBranch()
if branch == nil {
if branch == nil || !branch.IsRealBranch() {
return nil
}
// TODO: find a way to not checkout the branch here if it's not the current branch (i.e. find some
// way to get it to show up in the reflog)
promptForNewName := func() error {
return gui.prompt(promptOpts{
title: gui.Tr.NewBranchNamePrompt + " " + branch.Name + ":",
initialContent: branch.Name,
handleConfirm: func(newBranchName string) error {
if err := gui.GitCommand.RenameBranch(branch.Name, newBranchName); err != nil {
return gui.surfaceError(err)
}
// need to checkout so that the branch shows up in our reflog and therefore
// doesn't get lost among all the other branches when we switch to something else
if err := gui.GitCommand.Checkout(newBranchName, commands.CheckoutOptions{Force: false}); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.RenameBranch).RenameBranch(branch.Name, newBranchName); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
// need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch
gui.refreshBranches()
// now that we've got our stuff again we need to find that branch and reselect it.
for i, newBranch := range gui.State.Branches {
if newBranch.Name == newBranchName {
gui.State.Panels.Branches.SetSelectedLineIdx(i)
if err := gui.State.Contexts.Branches.HandleRender(); err != nil {
return err
}
}
}
return nil
},
})
}
@@ -454,8 +469,7 @@ func (gui *Gui) handleRenameBranch(g *gocui.Gui, v *gocui.View) error {
// I could do an explicit check here for whether the branch is tracking a remote branch
// but if we've selected it we'll already know that via Pullables and Pullables.
// Bit of a hack but I'm lazy.
notTrackingRemote := branch.Pullables == "?"
if notTrackingRemote {
if !branch.IsTrackingRemote() {
return promptForNewName()
}
@@ -474,7 +488,7 @@ func (gui *Gui) currentBranch() *models.Branch {
}
func (gui *Gui) handleNewBranchOffCurrentItem() error {
context := gui.currentSideContext()
context := gui.currentSideListContext()
item, ok := context.GetSelectedItem()
if !ok {
@@ -490,15 +504,15 @@ func (gui *Gui) handleNewBranchOffCurrentItem() error {
prefilledName := ""
if context.GetKey() == REMOTE_BRANCHES_CONTEXT_KEY {
// will set to the remote's existing name
prefilledName = item.ID()
// will set to the remote's branch name without the remote name
prefilledName = strings.SplitAfterN(item.ID(), "/", 2)[1]
}
return gui.prompt(promptOpts{
title: message,
initialContent: prefilledName,
handleConfirm: func(response string) error {
if err := gui.GitCommand.NewBranch(sanitizedBranchName(response), item.ID()); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.CreateBranch).NewBranch(sanitizedBranchName(response), item.ID()); err != nil {
return err
}
@@ -508,8 +522,8 @@ func (gui *Gui) handleNewBranchOffCurrentItem() error {
context.GetPanelState().SetSelectedLineIdx(0)
}
if context.GetKey() != gui.Contexts.Branches.Context.GetKey() {
if err := gui.pushContext(gui.Contexts.Branches.Context); err != nil {
if context.GetKey() != gui.State.Contexts.Branches.GetKey() {
if err := gui.pushContext(gui.State.Contexts.Branches); err != nil {
return err
}
}
@@ -540,7 +554,7 @@ func (gui *Gui) findBranchNameSuggestions(input string) []*types.Suggestion {
for i, branchName := range matchingBranchNames {
suggestions[i] = &types.Suggestion{
Value: branchName,
Label: utils.ColoredString(branchName, presentation.GetBranchColor(branchName)),
Label: presentation.GetBranchTextStyle(branchName).Sprint(branchName),
}
}
@@ -550,5 +564,5 @@ func (gui *Gui) findBranchNameSuggestions(input string) []*types.Suggestion {
// sanitizedBranchName will remove all spaces in favor of a dash "-" to meet
// git's branch naming requirement.
func sanitizedBranchName(input string) string {
return strings.ReplaceAll(input, " ", "-")
return strings.Replace(input, " ", "-", -1)
}

View File

@@ -5,11 +5,11 @@ import "github.com/jesseduffield/lazygit/pkg/commands/models"
// you can only copy from one context at a time, because the order and position of commits matter
func (gui *Gui) resetCherryPickingIfNecessary(context Context) error {
oldContextKey := gui.State.Modes.CherryPicking.ContextKey
oldContextKey := ContextKey(gui.State.Modes.CherryPicking.ContextKey)
if oldContextKey != context.GetKey() {
// need to reset the cherry picking mode
gui.State.Modes.CherryPicking.ContextKey = context.GetKey()
gui.State.Modes.CherryPicking.ContextKey = string(context.GetKey())
gui.State.Modes.CherryPicking.CherryPickedCommits = make([]*models.Commit, 0)
return gui.rerenderContextViewIfPresent(oldContextKey)
@@ -24,7 +24,7 @@ func (gui *Gui) handleCopyCommit() error {
}
// get currently selected commit, add the sha to state.
context := gui.currentSideContext()
context := gui.currentSideListContext()
if context == nil {
return nil
}
@@ -63,7 +63,7 @@ func (gui *Gui) cherryPickedCommitShaMap() map[string]bool {
}
func (gui *Gui) commitsListForContext() []*models.Commit {
context := gui.currentSideContext()
context := gui.currentSideListContext()
if context == nil {
return nil
}
@@ -104,7 +104,7 @@ func (gui *Gui) handleCopyCommitRange() error {
}
// get currently selected commit, add the sha to state.
context := gui.currentSideContext()
context := gui.currentSideListContext()
if context == nil {
return nil
}
@@ -148,7 +148,7 @@ func (gui *Gui) HandlePasteCommits() error {
prompt: gui.Tr.SureCherryPick,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.CherryPickingStatus, func() error {
err := gui.GitCommand.CherryPickCommits(gui.State.Modes.CherryPicking.CherryPickedCommits)
err := gui.GitCommand.WithSpan(gui.Tr.Spans.CherryPick).CherryPickCommits(gui.State.Modes.CherryPicking.CherryPickedCommits)
return gui.handleGenericMergeCommandResult(err)
})
},
@@ -156,7 +156,7 @@ func (gui *Gui) HandlePasteCommits() error {
}
func (gui *Gui) exitCherryPickingMode() error {
contextKey := gui.State.Modes.CherryPicking.ContextKey
contextKey := ContextKey(gui.State.Modes.CherryPicking.ContextKey)
gui.State.Modes.CherryPicking.ContextKey = ""
gui.State.Modes.CherryPicking.CherryPickedCommits = nil
@@ -169,7 +169,7 @@ func (gui *Gui) exitCherryPickingMode() error {
return gui.rerenderContextViewIfPresent(contextKey)
}
func (gui *Gui) rerenderContextViewIfPresent(contextKey string) error {
func (gui *Gui) rerenderContextViewIfPresent(contextKey ContextKey) error {
if contextKey == "" {
return nil
}
@@ -184,7 +184,7 @@ func (gui *Gui) rerenderContextViewIfPresent(contextKey string) error {
return nil
}
if view.Context == contextKey {
if ContextKey(view.Context) == contextKey {
if err := context.HandleRender(); err != nil {
return err
}

View File

@@ -0,0 +1,184 @@
package gui
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func (gui *Gui) GetOnRunCommand() func(entry oscommands.CmdLogEntry) {
// closing over this so that nobody else can modify it
currentSpan := ""
return func(entry oscommands.CmdLogEntry) {
if gui.Views.Extras == nil {
return
}
gui.Views.Extras.Autoscroll = true
if entry.GetSpan() != currentSpan {
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(entry.GetSpan()))
currentSpan = entry.GetSpan()
}
textStyle := theme.DefaultTextColor
if !entry.GetCommandLine() {
textStyle = style.FgMagenta
}
gui.CmdLog = append(gui.CmdLog, entry.GetCmdStr())
indentedCmdStr := " " + strings.Replace(entry.GetCmdStr(), "\n", "\n ", -1)
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
}
}
func (gui *Gui) printCommandLogHeader() {
introStr := fmt.Sprintf(
gui.Tr.CommandLogHeader,
gui.getKeyDisplay(gui.Config.GetUserConfig().Keybinding.Universal.ExtrasMenu),
)
fmt.Fprintln(gui.Views.Extras, style.FgCyan.Sprint(introStr))
if gui.Config.GetUserConfig().Gui.ShowRandomTip {
fmt.Fprintf(
gui.Views.Extras,
"%s: %s",
style.FgYellow.Sprint(gui.Tr.RandomTip),
style.FgGreen.Sprint(gui.getRandomTip()),
)
}
}
func (gui *Gui) getRandomTip() string {
config := gui.Config.GetUserConfig().Keybinding
formattedKey := func(key string) string {
return gui.getKeyDisplay(key)
}
tips := []string{
// keybindings and lazygit-specific advice
fmt.Sprintf(
"To force push, press '%s' and then if the push is rejected you will be asked if you want to force push",
formattedKey(config.Universal.PushFiles),
),
fmt.Sprintf(
"To filter commits by path, press '%s'",
formattedKey(config.Universal.FilteringMenu),
),
fmt.Sprintf(
"To start an interactive rebase, press '%s' on a commit. You can always abort the rebase by pressing '%s' and selecting 'abort'",
formattedKey(config.Universal.Edit),
formattedKey(config.Universal.CreateRebaseOptionsMenu),
),
fmt.Sprintf(
"In flat file view, merge conflicts are sorted to the top. To switch to flat file view press '%s'",
formattedKey(config.Files.ToggleTreeView),
),
"If you want to learn Go and can think of ways to improve lazygit, join the team! Click 'Ask Question' and express your interest",
fmt.Sprintf(
"If you press '%s'/'%s' you can undo/redo your changes. Be wary though, this only applies to branches/commits, so only do this if your worktree is clear.\nDocs: %s",
formattedKey(config.Universal.Undo),
formattedKey(config.Universal.Redo),
constants.Links.Docs.Undoing,
),
fmt.Sprintf(
"to hard reset onto your current upstream branch, press '%s' in the files panel",
formattedKey(config.Files.ViewResetOptions),
),
fmt.Sprintf(
"To push a tag, navigate to the tag in the tags tab and press '%s'",
formattedKey(config.Branches.PushTag),
),
fmt.Sprintf(
"You can view the individual files of a stash entry by pressing '%s'",
formattedKey(config.Universal.GoInto),
),
fmt.Sprintf(
"You can diff two commits by pressing '%s' on one commit and then navigating to the other. You can then press '%s' to view the files of the diff",
formattedKey(config.Universal.DiffingMenu),
formattedKey(config.Universal.GoInto),
),
fmt.Sprintf(
"press '%s' on a commit to drop it (delete it)",
formattedKey(config.Universal.Remove),
),
fmt.Sprintf(
"If you need to pull out the big guns to resolve merge conflicts, you can press '%s' in the files panel to open 'git mergetool'",
formattedKey(config.Files.OpenMergeTool),
),
fmt.Sprintf(
"To revert a commit, press '%s' on that commit",
formattedKey(config.Commits.RevertCommit),
),
fmt.Sprintf(
"To escape a mode, for example cherry-picking, patch-building, diffing, or filtering mode, you can just spam the '%s' button. Unless of course you have `quitOnTopLevelReturn` enabled in your config",
formattedKey(config.Universal.Return),
),
fmt.Sprintf(
"To search for a string in your panel, press '%s'",
formattedKey(config.Universal.StartSearch),
),
fmt.Sprintf(
"You can page through the items of a panel using '%s' and '%s'",
formattedKey(config.Universal.PrevPage),
formattedKey(config.Universal.NextPage),
),
fmt.Sprintf(
"You can jump to the top/bottom of a panel using '%s' and '%s'",
formattedKey(config.Universal.GotoTop),
formattedKey(config.Universal.GotoBottom),
),
fmt.Sprintf(
"To collapse/expand a directory, press '%s'",
formattedKey(config.Universal.GoInto),
),
fmt.Sprintf(
"You can append your staged changes to an older commit by pressing '%s' on that commit",
formattedKey(config.Commits.AmendToCommit),
),
fmt.Sprintf(
"You can amend the last commit with your new file changes by pressing '%s' in the files panel",
formattedKey(config.Files.AmendLastCommit),
),
fmt.Sprintf(
"You can now navigate the side panels with '%s' and '%s'",
formattedKey(config.Universal.NextBlockAlt2),
formattedKey(config.Universal.PrevBlockAlt2),
),
"You can use lazygit with a bare repo by passing the --git-dir and --work-tree arguments as you would for the git CLI",
// general advice
"`git commit` is really just the programmer equivalent of saving your game. Always do it before embarking on an ambitious change!",
"Try to separate commits that refactor code from commits that add new functionality: if they're squashed into the one commit, it can be hard to spot what's new.",
"If you ever want to experiment, it's easy to create a new branch off your current one and go nuts, then delete it afterwards",
"Always read through the diff of your changes before assigning somebody to review your code. Better for you to catch any silly mistakes than your colleagues!",
"If something goes wrong, you can always checkout a commit from your reflog to return to an earlier state",
"The stash is a good place to save snippets of code that you always find yourself adding when debugging.",
// links
fmt.Sprintf(
"If you want a git diff with syntax colouring, check out lazygit's integration with delta:\n%s",
constants.Links.Docs.CustomPagers,
),
fmt.Sprintf(
"You can build your own custom menus and commands to run from within lazygit. For examples see:\n%s",
constants.Links.Docs.CustomCommands,
),
fmt.Sprintf(
"If you ever find a bug, do not hesistate to raise an issue on the repo:\n%s",
constants.Links.Issues,
),
}
rand.Seed(time.Now().UnixNano())
randomIndex := rand.Intn(len(tips))
return tips[randomIndex]
}

View File

@@ -1,34 +1,51 @@
package gui
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
)
func (gui *Gui) getSelectedCommitFile() *models.CommitFile {
func (gui *Gui) getSelectedCommitFileNode() *filetree.CommitFileNode {
selectedLine := gui.State.Panels.CommitFiles.SelectedLineIdx
if selectedLine == -1 || selectedLine > len(gui.State.CommitFiles)-1 {
if selectedLine == -1 || selectedLine > gui.State.CommitFileManager.GetItemsLength()-1 {
return nil
}
return gui.State.CommitFiles[selectedLine]
return gui.State.CommitFileManager.GetItemAtIndex(selectedLine)
}
func (gui *Gui) getSelectedCommitFile() *models.CommitFile {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
return node.File
}
func (gui *Gui) getSelectedCommitFilePath() string {
node := gui.getSelectedCommitFileNode()
if node == nil {
return ""
}
return node.GetPath()
}
func (gui *Gui) handleCommitFileSelect() error {
gui.escapeLineByLinePanel()
commitFile := gui.getSelectedCommitFile()
if commitFile == nil {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
to := commitFile.Parent
to := gui.State.CommitFileManager.GetParent()
from, reverse := gui.getFromAndReverseArgsForDiff(to)
cmd := gui.OSCommand.ExecutableFromString(
gui.GitCommand.ShowFileDiffCmdStr(from, to, reverse, commitFile.Name, false),
gui.GitCommand.ShowFileDiffCmdStr(from, to, reverse, node.GetPath(), false),
)
task := gui.createRunPtyTask(cmd)
task := NewRunPtyTask(cmd)
return gui.refreshMainViews(refreshMainOpts{
main: &viewUpdateOpts{
@@ -39,32 +56,32 @@ func (gui *Gui) handleCommitFileSelect() error {
})
}
func (gui *Gui) handleCheckoutCommitFile(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedCommitFile()
if file == nil {
func (gui *Gui) handleCheckoutCommitFile() error {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
if err := gui.GitCommand.CheckoutFile(file.Parent, file.Name); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.CheckoutFile).CheckoutFile(gui.State.CommitFileManager.GetParent(), node.GetPath()); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
}
func (gui *Gui) handleDiscardOldFileChange(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleDiscardOldFileChange() error {
if ok, err := gui.validateNormalWorkingTreeState(); !ok {
return err
}
fileName := gui.State.CommitFiles[gui.State.Panels.CommitFiles.SelectedLineIdx].Name
fileName := gui.getSelectedCommitFileName()
return gui.ask(askOpts{
title: gui.Tr.DiscardFileChangesTitle,
prompt: gui.Tr.DiscardFileChangesPrompt,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
if err := gui.GitCommand.DiscardOldFileChanges(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, fileName); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DiscardOldFileChange).DiscardOldFileChanges(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, fileName); err != nil {
if err := gui.handleGenericMergeCommandResult(err); err != nil {
return err
}
@@ -77,43 +94,50 @@ func (gui *Gui) handleDiscardOldFileChange(g *gocui.Gui, v *gocui.View) error {
}
func (gui *Gui) refreshCommitFilesView() error {
if err := gui.handleRefreshPatchBuildingPanel(-1); err != nil {
return err
currentSideContext := gui.currentSideContext()
if currentSideContext.GetKey() == COMMIT_FILES_CONTEXT_KEY || currentSideContext.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
if err := gui.handleRefreshPatchBuildingPanel(-1); err != nil {
return err
}
}
to := gui.State.Panels.CommitFiles.refName
from, reverse := gui.getFromAndReverseArgsForDiff(to)
files, err := gui.GitCommand.GetFilesInDiff(from, to, reverse, gui.GitCommand.PatchManager)
files, err := gui.GitCommand.GetFilesInDiff(from, to, reverse)
if err != nil {
return gui.surfaceError(err)
}
gui.State.CommitFiles = files
gui.State.CommitFileManager.SetFiles(files, to)
return gui.postRefreshUpdate(gui.Contexts.CommitFiles.Context)
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
}
func (gui *Gui) handleOpenOldCommitFile(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedCommitFile()
if file == nil {
func (gui *Gui) handleOpenOldCommitFile() error {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
return gui.openFile(file.Name)
return gui.openFile(node.GetPath())
}
func (gui *Gui) handleEditCommitFile(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedCommitFile()
if file == nil {
func (gui *Gui) handleEditCommitFile() error {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
return gui.editFile(file.Name)
if node.File == nil {
return gui.createErrorPanel(gui.Tr.ErrCannotEditDirectory)
}
return gui.editFile(node.GetPath())
}
func (gui *Gui) handleToggleFileForPatch(g *gocui.Gui, v *gocui.View) error {
commitFile := gui.getSelectedCommitFile()
if commitFile == nil {
func (gui *Gui) handleToggleFileForPatch() error {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
@@ -124,18 +148,32 @@ func (gui *Gui) handleToggleFileForPatch(g *gocui.Gui, v *gocui.View) error {
}
}
if err := gui.GitCommand.PatchManager.ToggleFileWhole(commitFile.Name); err != nil {
return err
// if there is any file that hasn't been fully added we'll fully add everything,
// otherwise we'll remove everything
adding := node.AnyFile(func(file *models.CommitFile) bool {
return gui.GitCommand.PatchManager.GetFileStatus(file.Name, gui.State.CommitFileManager.GetParent()) != patch.WHOLE
})
err := node.ForEachFile(func(file *models.CommitFile) error {
if adding {
return gui.GitCommand.PatchManager.AddFileWhole(file.Name)
} else {
return gui.GitCommand.PatchManager.RemoveFile(file.Name)
}
})
if err != nil {
return gui.surfaceError(err)
}
if gui.GitCommand.PatchManager.IsEmpty() {
gui.GitCommand.PatchManager.Reset()
}
return gui.refreshCommitFilesView()
return gui.postRefreshUpdate(gui.State.Contexts.CommitFiles)
}
if gui.GitCommand.PatchManager.Active() && gui.GitCommand.PatchManager.To != commitFile.Parent {
if gui.GitCommand.PatchManager.Active() && gui.GitCommand.PatchManager.To != gui.State.CommitFileManager.GetParent() {
return gui.ask(askOpts{
title: gui.Tr.DiscardPatch,
prompt: gui.Tr.DiscardPatchConfirm,
@@ -159,16 +197,20 @@ func (gui *Gui) startPatchManager() error {
return nil
}
func (gui *Gui) handleEnterCommitFile(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleEnterCommitFile() error {
return gui.enterCommitFile(-1)
}
func (gui *Gui) enterCommitFile(selectedLineIdx int) error {
commitFile := gui.getSelectedCommitFile()
if commitFile == nil {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
if node.File == nil {
return gui.handleToggleCommitFileDirCollapsed()
}
enterTheFile := func(selectedLineIdx int) error {
if !gui.GitCommand.PatchManager.Active() {
if err := gui.startPatchManager(); err != nil {
@@ -176,13 +218,13 @@ func (gui *Gui) enterCommitFile(selectedLineIdx int) error {
}
}
if err := gui.pushContext(gui.Contexts.PatchBuilding.Context); err != nil {
if err := gui.pushContext(gui.State.Contexts.PatchBuilding); err != nil {
return err
}
return gui.handleRefreshPatchBuildingPanel(selectedLineIdx)
}
if gui.GitCommand.PatchManager.Active() && gui.GitCommand.PatchManager.To != commitFile.Parent {
if gui.GitCommand.PatchManager.Active() && gui.GitCommand.PatchManager.To != gui.State.CommitFileManager.GetParent() {
return gui.ask(askOpts{
title: gui.Tr.DiscardPatch,
prompt: gui.Tr.DiscardPatchConfirm,
@@ -192,7 +234,7 @@ func (gui *Gui) enterCommitFile(selectedLineIdx int) error {
return enterTheFile(selectedLineIdx)
},
handleClose: func() error {
return gui.pushContext(gui.Contexts.CommitFiles.Context)
return gui.pushContext(gui.State.Contexts.CommitFiles)
},
})
}
@@ -200,20 +242,60 @@ func (gui *Gui) enterCommitFile(selectedLineIdx int) error {
return enterTheFile(selectedLineIdx)
}
func (gui *Gui) handleToggleCommitFileDirCollapsed() error {
node := gui.getSelectedCommitFileNode()
if node == nil {
return nil
}
gui.State.CommitFileManager.ToggleCollapsed(node.GetPath())
if err := gui.postRefreshUpdate(gui.State.Contexts.CommitFiles); err != nil {
gui.Log.Error(err)
}
return nil
}
func (gui *Gui) switchToCommitFilesContext(refName string, canRebase bool, context Context, windowName string) error {
// sometimes the commitFiles view is already shown in another window, so we need to ensure that window
// no longer considers the commitFiles view as its main view.
gui.resetWindowForView("commitFiles")
gui.resetWindowForView(gui.Views.CommitFiles)
gui.State.Panels.CommitFiles.SelectedLineIdx = 0
gui.State.Panels.CommitFiles.refName = refName
gui.State.Panels.CommitFiles.canRebase = canRebase
gui.Contexts.CommitFiles.Context.SetParentContext(context)
gui.Contexts.CommitFiles.Context.SetWindowName(windowName)
gui.State.Contexts.CommitFiles.SetParentContext(context)
gui.State.Contexts.CommitFiles.SetWindowName(windowName)
if err := gui.refreshCommitFilesView(); err != nil {
return err
}
return gui.pushContext(gui.Contexts.CommitFiles.Context)
return gui.pushContext(gui.State.Contexts.CommitFiles)
}
// NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics
func (gui *Gui) handleToggleCommitFileTreeView() error {
path := gui.getSelectedCommitFilePath()
gui.State.CommitFileManager.ToggleShowTree()
// find that same node in the new format and move the cursor to it
if path != "" {
gui.State.CommitFileManager.ExpandToPath(path)
index, found := gui.State.CommitFileManager.GetIndexForPath(path)
if found {
gui.State.Contexts.CommitFiles.GetPanelState().SetSelectedLineIdx(index)
}
}
if err := gui.State.Contexts.CommitFiles.HandleRender(); err != nil {
return err
}
if err := gui.State.Contexts.CommitFiles.HandleFocus(); err != nil {
return err
}
return nil
}

View File

@@ -1,33 +1,16 @@
package gui
import (
"os/exec"
"strconv"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// runSyncOrAsyncCommand takes the output of a command that may have returned
// either no error, an error, or a subprocess to execute, and if a subprocess
// needs to be set on the gui object, it does so, and then returns the error
// the bool returned tells us whether the calling code should continue
func (gui *Gui) runSyncOrAsyncCommand(sub *exec.Cmd, err error) (bool, error) {
if err != nil {
if err != gui.Errors.ErrSubProcess {
return false, gui.surfaceError(err)
}
}
if sub != nil {
gui.SubProcess = sub
return false, gui.Errors.ErrSubProcess
}
return true, nil
}
func (gui *Gui) handleCommitConfirm(g *gocui.Gui, v *gocui.View) error {
message := gui.trimmedContent(v)
func (gui *Gui) handleCommitConfirm() error {
message := gui.trimmedContent(gui.Views.CommitMessage)
if message == "" {
return gui.createErrorPanel(gui.Tr.CommitWithoutMessageErr)
}
@@ -36,20 +19,17 @@ func (gui *Gui) handleCommitConfirm(g *gocui.Gui, v *gocui.View) error {
if skipHookPrefix != "" && strings.HasPrefix(message, skipHookPrefix) {
flags = "--no-verify"
}
ok, err := gui.runSyncOrAsyncCommand(gui.GitCommand.Commit(message, flags))
if err != nil {
return err
}
if !ok {
return nil
}
gui.clearEditorView(v)
_ = gui.returnFromContext()
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
cmdStr := gui.GitCommand.CommitCmdStr(message, flags)
gui.OnRunCommand(oscommands.NewCmdLogEntry(cmdStr, gui.Tr.Spans.Commit, true))
return gui.withGpgHandling(cmdStr, gui.Tr.CommittingStatus, func() error {
_ = gui.returnFromContext()
gui.clearEditorView(gui.Views.CommitMessage)
return nil
})
}
func (gui *Gui) handleCommitClose(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitClose() error {
return gui.returnFromContext()
}
@@ -57,13 +37,13 @@ func (gui *Gui) handleCommitMessageFocused() error {
message := utils.ResolvePlaceholderString(
gui.Tr.CommitMessageConfirm,
map[string]string{
"keyBindClose": "esc",
"keyBindConfirm": "enter",
"keyBindNewLine": "tab",
"keyBindClose": gui.getKeyDisplay(gui.Config.GetUserConfig().Keybinding.Universal.Return),
"keyBindConfirm": gui.getKeyDisplay(gui.Config.GetUserConfig().Keybinding.Universal.Confirm),
"keyBindNewLine": gui.getKeyDisplay(gui.Config.GetUserConfig().Keybinding.Universal.AppendNewline),
},
)
gui.renderString("options", message)
gui.renderString(gui.Views.Options, message)
return nil
}
@@ -76,6 +56,6 @@ func (gui *Gui) RenderCommitLength() {
if !gui.Config.GetUserConfig().Gui.CommitLength.Show {
return
}
v := gui.getCommitMessageView()
v.Subtitle = gui.getBufferLength(v)
gui.Views.CommitMessage.Subtitle = gui.getBufferLength(gui.Views.CommitMessage)
}

View File

@@ -1,11 +1,12 @@
package gui
import (
"fmt"
"sync"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
)
@@ -13,7 +14,7 @@ import (
func (gui *Gui) getSelectedLocalCommit() *models.Commit {
selectedLine := gui.State.Panels.Commits.SelectedLineIdx
if selectedLine == -1 {
if selectedLine == -1 || selectedLine > len(gui.State.Commits)-1 {
return nil
}
@@ -36,12 +37,12 @@ func (gui *Gui) handleCommitSelect() error {
var task updateTask
commit := gui.getSelectedLocalCommit()
if commit == nil {
task = gui.createRenderStringTask(gui.Tr.NoCommitsThisBranch)
task = NewRenderStringTask(gui.Tr.NoCommitsThisBranch)
} else {
cmd := gui.OSCommand.ExecutableFromString(
gui.GitCommand.ShowCmdStr(commit.Sha, gui.State.Modes.Filtering.Path),
gui.GitCommand.ShowCmdStr(commit.Sha, gui.State.Modes.Filtering.GetPath()),
)
task = gui.createRunPtyTask(cmd)
task = NewRunPtyTask(cmd)
}
return gui.refreshMainViews(refreshMainOpts{
@@ -87,7 +88,7 @@ func (gui *Gui) refreshCommits() error {
go utils.Safe(func() {
_ = gui.refreshCommitsWithLimit()
context, ok := gui.Contexts.CommitFiles.Context.GetParentContext()
context, ok := gui.State.Contexts.CommitFiles.GetParentContext()
if ok && context.GetKey() == BRANCH_COMMITS_CONTEXT_KEY {
// This makes sense when we've e.g. just amended a commit, meaning we get a new commit SHA at the same position.
// However if we've just added a brand new commit, it pushes the list down by one and so we would end up
@@ -118,7 +119,7 @@ func (gui *Gui) refreshCommitsWithLimit() error {
commits, err := builder.GetCommits(
commands.GetCommitsOptions{
Limit: gui.State.Panels.Commits.LimitCommits,
FilterPath: gui.State.Modes.Filtering.Path,
FilterPath: gui.State.Modes.Filtering.GetPath(),
IncludeRebaseCommits: true,
RefName: "HEAD",
},
@@ -128,7 +129,7 @@ func (gui *Gui) refreshCommitsWithLimit() error {
}
gui.State.Commits = commits
return gui.postRefreshUpdate(gui.Contexts.BranchCommits.Context)
return gui.postRefreshUpdate(gui.State.Contexts.BranchCommits)
}
func (gui *Gui) refreshRebaseCommits() error {
@@ -143,12 +144,12 @@ func (gui *Gui) refreshRebaseCommits() error {
}
gui.State.Commits = updatedCommits
return gui.postRefreshUpdate(gui.Contexts.BranchCommits.Context)
return gui.postRefreshUpdate(gui.State.Contexts.BranchCommits)
}
// specific functions
func (gui *Gui) handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitSquashDown() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -170,14 +171,14 @@ func (gui *Gui) handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error {
prompt: gui.Tr.SureSquashThisCommit,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.SquashingStatus, func() error {
err := gui.GitCommand.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "squash")
err := gui.GitCommand.WithSpan(gui.Tr.Spans.SquashCommitDown).InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "squash")
return gui.handleGenericMergeCommandResult(err)
})
},
})
}
func (gui *Gui) handleCommitFixup(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitFixup() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -199,14 +200,14 @@ func (gui *Gui) handleCommitFixup(g *gocui.Gui, v *gocui.View) error {
prompt: gui.Tr.SureFixupThisCommit,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.FixingStatus, func() error {
err := gui.GitCommand.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "fixup")
err := gui.GitCommand.WithSpan(gui.Tr.Spans.FixupCommit).InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "fixup")
return gui.handleGenericMergeCommandResult(err)
})
},
})
}
func (gui *Gui) handleRenameCommit(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleRenameCommit() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -237,7 +238,7 @@ func (gui *Gui) handleRenameCommit(g *gocui.Gui, v *gocui.View) error {
title: gui.Tr.LcRenameCommit,
initialContent: message,
handleConfirm: func(response string) error {
if err := gui.GitCommand.RenameCommit(response); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.RewordCommit).RenameCommit(response); err != nil {
return gui.surfaceError(err)
}
@@ -246,7 +247,7 @@ func (gui *Gui) handleRenameCommit(g *gocui.Gui, v *gocui.View) error {
})
}
func (gui *Gui) handleRenameCommitEditor(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleRenameCommitEditor() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -259,13 +260,12 @@ func (gui *Gui) handleRenameCommitEditor(g *gocui.Gui, v *gocui.View) error {
return nil
}
subProcess, err := gui.GitCommand.RewordCommit(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx)
subProcess, err := gui.GitCommand.WithSpan(gui.Tr.Spans.RewordCommit).RewordCommit(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx)
if err != nil {
return gui.surfaceError(err)
}
if subProcess != nil {
gui.SubProcess = subProcess
return gui.Errors.ErrSubProcess
return gui.runSubprocessWithSuspenseAndRefresh(subProcess)
}
return nil
@@ -288,6 +288,12 @@ func (gui *Gui) handleMidRebaseCommand(action string) (bool, error) {
return true, gui.createErrorPanel(gui.Tr.LcRewordNotSupported)
}
gui.OnRunCommand(oscommands.NewCmdLogEntry(
fmt.Sprintf("Updating rebase action of commit %s to '%s'", selectedCommit.ShortSha(), action),
"Update rebase TODO",
false,
))
if err := gui.GitCommand.EditRebaseTodo(gui.State.Panels.Commits.SelectedLineIdx, action); err != nil {
return false, gui.surfaceError(err)
}
@@ -295,7 +301,7 @@ func (gui *Gui) handleMidRebaseCommand(action string) (bool, error) {
return true, gui.refreshRebaseCommits()
}
func (gui *Gui) handleCommitDelete(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitDelete() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -313,24 +319,35 @@ func (gui *Gui) handleCommitDelete(g *gocui.Gui, v *gocui.View) error {
prompt: gui.Tr.DeleteCommitPrompt,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.DeletingStatus, func() error {
err := gui.GitCommand.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "drop")
err := gui.GitCommand.WithSpan(gui.Tr.Spans.DropCommit).InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "drop")
return gui.handleGenericMergeCommandResult(err)
})
},
})
}
func (gui *Gui) handleCommitMoveDown(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitMoveDown() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
span := gui.Tr.Spans.MoveCommitDown
index := gui.State.Panels.Commits.SelectedLineIdx
selectedCommit := gui.State.Commits[index]
if selectedCommit.Status == "rebasing" {
if gui.State.Commits[index+1].Status != "rebasing" {
return nil
}
// logging directly here because MoveTodoDown doesn't have enough information
// to provide a useful log
gui.OnRunCommand(oscommands.NewCmdLogEntry(
fmt.Sprintf("Moving commit %s down", selectedCommit.ShortSha()),
span,
false,
))
if err := gui.GitCommand.MoveTodoDown(index); err != nil {
return gui.surfaceError(err)
}
@@ -339,7 +356,7 @@ func (gui *Gui) handleCommitMoveDown(g *gocui.Gui, v *gocui.View) error {
}
return gui.WithWaitingStatus(gui.Tr.MovingStatus, func() error {
err := gui.GitCommand.MoveCommitDown(gui.State.Commits, index)
err := gui.GitCommand.WithSpan(span).MoveCommitDown(gui.State.Commits, index)
if err == nil {
gui.State.Panels.Commits.SelectedLineIdx++
}
@@ -347,7 +364,7 @@ func (gui *Gui) handleCommitMoveDown(g *gocui.Gui, v *gocui.View) error {
})
}
func (gui *Gui) handleCommitMoveUp(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitMoveUp() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -356,8 +373,19 @@ func (gui *Gui) handleCommitMoveUp(g *gocui.Gui, v *gocui.View) error {
if index == 0 {
return nil
}
span := gui.Tr.Spans.MoveCommitUp
selectedCommit := gui.State.Commits[index]
if selectedCommit.Status == "rebasing" {
// logging directly here because MoveTodoDown doesn't have enough information
// to provide a useful log
gui.OnRunCommand(oscommands.NewCmdLogEntry(
fmt.Sprintf("Moving commit %s up", selectedCommit.ShortSha()),
span,
false,
))
if err := gui.GitCommand.MoveTodoDown(index - 1); err != nil {
return gui.surfaceError(err)
}
@@ -366,7 +394,7 @@ func (gui *Gui) handleCommitMoveUp(g *gocui.Gui, v *gocui.View) error {
}
return gui.WithWaitingStatus(gui.Tr.MovingStatus, func() error {
err := gui.GitCommand.MoveCommitDown(gui.State.Commits, index-1)
err := gui.GitCommand.WithSpan(span).MoveCommitDown(gui.State.Commits, index-1)
if err == nil {
gui.State.Panels.Commits.SelectedLineIdx--
}
@@ -374,7 +402,7 @@ func (gui *Gui) handleCommitMoveUp(g *gocui.Gui, v *gocui.View) error {
})
}
func (gui *Gui) handleCommitEdit(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitEdit() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -388,12 +416,12 @@ func (gui *Gui) handleCommitEdit(g *gocui.Gui, v *gocui.View) error {
}
return gui.WithWaitingStatus(gui.Tr.RebasingStatus, func() error {
err = gui.GitCommand.InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "edit")
err = gui.GitCommand.WithSpan(gui.Tr.Spans.EditCommit).InteractiveRebase(gui.State.Commits, gui.State.Panels.Commits.SelectedLineIdx, "edit")
return gui.handleGenericMergeCommandResult(err)
})
}
func (gui *Gui) handleCommitAmendTo(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitAmendTo() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -403,14 +431,14 @@ func (gui *Gui) handleCommitAmendTo(g *gocui.Gui, v *gocui.View) error {
prompt: gui.Tr.AmendCommitPrompt,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.AmendingStatus, func() error {
err := gui.GitCommand.AmendTo(gui.State.Commits[gui.State.Panels.Commits.SelectedLineIdx].Sha)
err := gui.GitCommand.WithSpan(gui.Tr.Spans.AmendCommit).AmendTo(gui.State.Commits[gui.State.Panels.Commits.SelectedLineIdx].Sha)
return gui.handleGenericMergeCommandResult(err)
})
},
})
}
func (gui *Gui) handleCommitPick(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitPick() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -425,19 +453,53 @@ func (gui *Gui) handleCommitPick(g *gocui.Gui, v *gocui.View) error {
// at this point we aren't actually rebasing so we will interpret this as an
// attempt to pull. We might revoke this later after enabling configurable keybindings
return gui.handlePullFiles(g, v)
return gui.handlePullFiles()
}
func (gui *Gui) handleCommitRevert(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCommitRevert() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
if err := gui.GitCommand.Revert(gui.State.Commits[gui.State.Panels.Commits.SelectedLineIdx].Sha); err != nil {
return gui.surfaceError(err)
commit := gui.getSelectedLocalCommit()
if commit.IsMerge() {
return gui.createRevertMergeCommitMenu(commit)
} else {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.RevertCommit).Revert(commit.Sha); err != nil {
return gui.surfaceError(err)
}
return gui.afterRevertCommit()
}
}
func (gui *Gui) createRevertMergeCommitMenu(commit *models.Commit) error {
menuItems := make([]*menuItem, len(commit.Parents))
for i, parentSha := range commit.Parents {
i := i
message, err := gui.GitCommand.GetCommitMessageFirstLine(parentSha)
if err != nil {
return gui.surfaceError(err)
}
menuItems[i] = &menuItem{
displayString: fmt.Sprintf("%s: %s", utils.SafeTruncate(parentSha, 8), message),
onPress: func() error {
parentNumber := i + 1
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.RevertCommit).RevertMerge(commit.Sha, parentNumber); err != nil {
return gui.surfaceError(err)
}
return gui.afterRevertCommit()
},
}
}
return gui.createMenu(gui.Tr.SelectParentCommitForMerge, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) afterRevertCommit() error {
gui.State.Panels.Commits.SelectedLineIdx++
return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI, scope: []int{COMMITS, BRANCHES}})
return gui.refreshSidePanels(refreshOptions{mode: BLOCK_UI, scope: []RefreshableView{COMMITS, BRANCHES}})
}
func (gui *Gui) handleViewCommitFiles() error {
@@ -446,10 +508,10 @@ func (gui *Gui) handleViewCommitFiles() error {
return nil
}
return gui.switchToCommitFilesContext(commit.Sha, true, gui.Contexts.BranchCommits.Context, "commits")
return gui.switchToCommitFilesContext(commit.Sha, true, gui.State.Contexts.BranchCommits, "commits")
}
func (gui *Gui) handleCreateFixupCommit(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreateFixupCommit() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -470,7 +532,7 @@ func (gui *Gui) handleCreateFixupCommit(g *gocui.Gui, v *gocui.View) error {
title: gui.Tr.CreateFixupCommit,
prompt: prompt,
handleConfirm: func() error {
if err := gui.GitCommand.CreateFixupCommit(commit.Sha); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.CreateFixupCommit).CreateFixupCommit(commit.Sha); err != nil {
return gui.surfaceError(err)
}
@@ -479,7 +541,7 @@ func (gui *Gui) handleCreateFixupCommit(g *gocui.Gui, v *gocui.View) error {
})
}
func (gui *Gui) handleSquashAllAboveFixupCommits(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleSquashAllAboveFixupCommits() error {
if ok, err := gui.validateNotInFilterMode(); err != nil || !ok {
return err
}
@@ -501,14 +563,14 @@ func (gui *Gui) handleSquashAllAboveFixupCommits(g *gocui.Gui, v *gocui.View) er
prompt: prompt,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.SquashingStatus, func() error {
err := gui.GitCommand.SquashAllAboveFixupCommits(commit.Sha)
err := gui.GitCommand.WithSpan(gui.Tr.Spans.SquashAllAboveFixupCommits).SquashAllAboveFixupCommits(commit.Sha)
return gui.handleGenericMergeCommandResult(err)
})
},
})
}
func (gui *Gui) handleTagCommit(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleTagCommit() error {
// TODO: bring up menu asking if you want to make a lightweight or annotated tag
// if annotated, switch to a subprocess to create the message
@@ -524,15 +586,15 @@ func (gui *Gui) handleCreateLightweightTag(commitSha string) error {
return gui.prompt(promptOpts{
title: gui.Tr.TagNameTitle,
handleConfirm: func(response string) error {
if err := gui.GitCommand.CreateLightweightTag(response, commitSha); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.CreateLightweightTag).CreateLightweightTag(response, commitSha); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{COMMITS, TAGS}})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{COMMITS, TAGS}})
},
})
}
func (gui *Gui) handleCheckoutCommit(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCheckoutCommit() error {
commit := gui.getSelectedLocalCommit()
if commit == nil {
return nil
@@ -542,12 +604,12 @@ func (gui *Gui) handleCheckoutCommit(g *gocui.Gui, v *gocui.View) error {
title: gui.Tr.LcCheckoutCommit,
prompt: gui.Tr.SureCheckoutThisCommit,
handleConfirm: func() error {
return gui.handleCheckoutRef(commit.Sha, handleCheckoutRefOptions{})
return gui.handleCheckoutRef(commit.Sha, handleCheckoutRefOptions{span: gui.Tr.Spans.CheckoutCommit})
},
})
}
func (gui *Gui) handleCreateCommitResetMenu(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreateCommitResetMenu() error {
commit := gui.getSelectedLocalCommit()
if commit == nil {
return gui.createErrorPanel(gui.Tr.NoCommitsThisBranch)
@@ -556,30 +618,30 @@ func (gui *Gui) handleCreateCommitResetMenu(g *gocui.Gui, v *gocui.View) error {
return gui.createResetMenu(commit.Sha)
}
func (gui *Gui) handleOpenSearchForCommitsPanel(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleOpenSearchForCommitsPanel(_viewName string) error {
// we usually lazyload these commits but now that we're searching we need to load them now
if gui.State.Panels.Commits.LimitCommits {
gui.State.Panels.Commits.LimitCommits = false
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{COMMITS}}); err != nil {
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{COMMITS}}); err != nil {
return err
}
}
return gui.handleOpenSearch(gui.g, v)
return gui.handleOpenSearch("commits")
}
func (gui *Gui) handleGotoBottomForCommitsPanel(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleGotoBottomForCommitsPanel() error {
// we usually lazyload these commits but now that we're searching we need to load them now
if gui.State.Panels.Commits.LimitCommits {
gui.State.Panels.Commits.LimitCommits = false
if err := gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []int{COMMITS}}); err != nil {
if err := gui.refreshSidePanels(refreshOptions{mode: SYNC, scope: []RefreshableView{COMMITS}}); err != nil {
return err
}
}
for _, context := range gui.getListContexts() {
if context.ViewName == "commits" {
return context.handleGotoBottom(g, v)
return context.handleGotoBottom()
}
}
@@ -597,7 +659,7 @@ func (gui *Gui) handleCopySelectedCommitMessageToClipboard() error {
return gui.surfaceError(err)
}
if err := gui.OSCommand.CopyToClipboard(message); err != nil {
if err := gui.OSCommand.WithSpan(gui.Tr.Spans.CopyCommitMessageToClipboard).CopyToClipboard(message); err != nil {
return gui.surfaceError(err)
}

View File

@@ -8,10 +8,9 @@ package gui
import (
"strings"
"time"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -108,19 +107,17 @@ func (gui *Gui) wrappedPromptConfirmationFunction(handlersManageFocus bool, func
}
}
func (gui *Gui) deleteConfirmationView() {
func (gui *Gui) clearConfirmationViewKeyBindings() {
keybindingConfig := gui.Config.GetUserConfig().Keybinding
_ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone)
_ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone)
_ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone)
_ = gui.g.DeleteView("confirmation")
}
func (gui *Gui) closeConfirmationPrompt(handlersManageFocus bool) error {
view := gui.getConfirmationView()
if view == nil {
return nil // if it's already been closed we can just return
// we've already closed it so we can just return
if !gui.Views.Confirmation.Visible {
return nil
}
if !handlersManageFocus {
@@ -129,9 +126,9 @@ func (gui *Gui) closeConfirmationPrompt(handlersManageFocus bool) error {
}
}
gui.deleteConfirmationView()
_, _ = gui.g.SetViewOnBottom("suggestions")
gui.clearConfirmationViewKeyBindings()
gui.Views.Confirmation.Visible = false
gui.Views.Suggestions.Visible = false
return nil
}
@@ -172,67 +169,62 @@ func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, i
height/2 + panelHeight/2
}
func (gui *Gui) prepareConfirmationPanel(title, prompt string, hasLoader bool, findSuggestionsFunc func(string) []*types.Suggestion) (*gocui.View, error) {
func (gui *Gui) prepareConfirmationPanel(title, prompt string, hasLoader bool, findSuggestionsFunc func(string) []*types.Suggestion) error {
x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(true, prompt)
confirmationView, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
// calling SetView on an existing view returns the same view, so I'm not bothering
// to reassign to gui.Views.Confirmation
_, err := gui.g.SetView("confirmation", x0, y0, x1, y1, 0)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return nil, err
}
confirmationView.HasLoader = hasLoader
if hasLoader {
gui.g.StartTicking()
}
confirmationView.Title = title
confirmationView.Wrap = true
confirmationView.FgColor = theme.GocuiDefaultTextColor
return err
}
gui.Views.Confirmation.HasLoader = hasLoader
if hasLoader {
gui.g.StartTicking()
}
gui.Views.Confirmation.Title = title
gui.Views.Confirmation.Wrap = true
gui.Views.Confirmation.FgColor = theme.GocuiDefaultTextColor
gui.findSuggestions = findSuggestionsFunc
if findSuggestionsFunc != nil {
suggestionsViewHeight := 11
suggestionsView, err := gui.g.SetView("suggestions", x0, y1, x1, y1+suggestionsViewHeight, 0)
if err != nil {
if err.Error() != UNKNOWN_VIEW_ERROR_MSG {
return nil, err
}
suggestionsView.Wrap = true
suggestionsView.FgColor = theme.GocuiDefaultTextColor
return err
}
suggestionsView.Wrap = true
suggestionsView.FgColor = theme.GocuiDefaultTextColor
gui.setSuggestions([]*types.Suggestion{})
_, _ = gui.g.SetViewOnTop("suggestions")
suggestionsView.Visible = true
}
gui.g.Update(func(g *gocui.Gui) error {
return gui.pushContext(gui.Contexts.Confirmation.Context)
return gui.pushContext(gui.State.Contexts.Confirmation)
})
return confirmationView, nil
return nil
}
func (gui *Gui) createPopupPanel(opts createPopupPanelOpts) error {
gui.g.Update(func(g *gocui.Gui) error {
// delete the existing confirmation panel if it exists
if view, _ := g.View("confirmation"); view != nil {
gui.deleteConfirmationView()
}
confirmationView, err := gui.prepareConfirmationPanel(opts.title, opts.prompt, opts.hasLoader, opts.findSuggestionsFunc)
// remove any previous keybindings
gui.clearConfirmationViewKeyBindings()
err := gui.prepareConfirmationPanel(opts.title, opts.prompt, opts.hasLoader, opts.findSuggestionsFunc)
if err != nil {
return err
}
confirmationView.Editable = opts.editable
confirmationView.Editor = gocui.EditorFunc(gui.defaultEditor)
if opts.editable {
go utils.Safe(func() {
// TODO: remove this wait (right now if you remove it the EditGotoToEndOfLine method doesn't seem to work)
time.Sleep(time.Millisecond)
gui.g.Update(func(g *gocui.Gui) error {
confirmationView.EditGotoToEndOfLine()
return nil
})
})
}
gui.Views.Confirmation.Editable = opts.editable
gui.Views.Confirmation.Editor = gocui.EditorFunc(gui.defaultEditor)
gui.renderString("confirmation", opts.prompt)
if opts.editable {
if err := gui.Views.Confirmation.SetEditorContent(opts.prompt); err != nil {
return err
}
} else {
if err := gui.renderStringSync(gui.Views.Confirmation, opts.prompt); err != nil {
return err
}
}
return gui.setKeyBindings(opts)
})
@@ -248,10 +240,10 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
},
)
gui.renderString("options", actions)
gui.renderString(gui.Views.Options, actions)
var onConfirm func() error
if opts.handleConfirmPrompt != nil {
onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.getConfirmationView().Buffer() })
onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.Views.Confirmation.Buffer() })
} else {
onConfirm = gui.wrappedConfirmationFunction(opts.handlersManageFocus, opts.handleConfirm)
}
@@ -284,7 +276,7 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
{
viewName: "confirmation",
key: gui.getKey(keybindingConfig.Universal.TogglePanel),
handler: func() error { return gui.replaceContext(gui.Contexts.Suggestions.Context) },
handler: func() error { return gui.replaceContext(gui.State.Contexts.Suggestions) },
},
{
viewName: "suggestions",
@@ -304,7 +296,7 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
{
viewName: "suggestions",
key: gui.getKey(keybindingConfig.Universal.TogglePanel),
handler: func() error { return gui.replaceContext(gui.Contexts.Confirmation.Context) },
handler: func() error { return gui.replaceContext(gui.State.Contexts.Confirmation) },
},
}
@@ -317,9 +309,14 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error {
return nil
}
func (gui *Gui) wrappedHandler(f func() error) func(g *gocui.Gui, v *gocui.View) error {
return func(g *gocui.Gui, v *gocui.View) error {
return f()
}
}
func (gui *Gui) createErrorPanel(message string) error {
colorFunction := color.New(color.FgRed).SprintFunc()
coloredMessage := colorFunction(strings.TrimSpace(message))
coloredMessage := style.FgRed.Sprint(strings.TrimSpace(message))
if err := gui.refreshSidePanels(refreshOptions{mode: ASYNC}); err != nil {
return err
}
@@ -335,11 +332,5 @@ func (gui *Gui) surfaceError(err error) error {
return nil
}
for _, sentinelError := range gui.sentinelErrorsArr() {
if err == sentinelError {
return err
}
}
return gui.createErrorPanel(err.Error())
}

View File

@@ -6,131 +6,25 @@ import (
"github.com/jesseduffield/gocui"
)
type ContextKind int
const (
SIDE_CONTEXT int = iota
SIDE_CONTEXT ContextKind = iota
MAIN_CONTEXT
TEMPORARY_POPUP
PERSISTENT_POPUP
EXTRAS_CONTEXT
)
const (
STATUS_CONTEXT_KEY = "status"
FILES_CONTEXT_KEY = "files"
LOCAL_BRANCHES_CONTEXT_KEY = "localBranches"
REMOTES_CONTEXT_KEY = "remotes"
REMOTE_BRANCHES_CONTEXT_KEY = "remoteBranches"
TAGS_CONTEXT_KEY = "tags"
BRANCH_COMMITS_CONTEXT_KEY = "commits"
REFLOG_COMMITS_CONTEXT_KEY = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY = "subCommits"
COMMIT_FILES_CONTEXT_KEY = "commitFiles"
STASH_CONTEXT_KEY = "stash"
MAIN_NORMAL_CONTEXT_KEY = "normal"
MAIN_MERGING_CONTEXT_KEY = "merging"
MAIN_PATCH_BUILDING_CONTEXT_KEY = "patchBuilding"
MAIN_STAGING_CONTEXT_KEY = "staging"
MENU_CONTEXT_KEY = "menu"
CREDENTIALS_CONTEXT_KEY = "credentials"
CONFIRMATION_CONTEXT_KEY = "confirmation"
SEARCH_CONTEXT_KEY = "search"
COMMIT_MESSAGE_CONTEXT_KEY = "commitMessage"
SUBMODULES_CONTEXT_KEY = "submodules"
SUGGESTIONS_CONTEXT_KEY = "suggestions"
)
var allContextKeys = []string{
STATUS_CONTEXT_KEY,
FILES_CONTEXT_KEY,
LOCAL_BRANCHES_CONTEXT_KEY,
REMOTES_CONTEXT_KEY,
REMOTE_BRANCHES_CONTEXT_KEY,
TAGS_CONTEXT_KEY,
BRANCH_COMMITS_CONTEXT_KEY,
REFLOG_COMMITS_CONTEXT_KEY,
SUB_COMMITS_CONTEXT_KEY,
COMMIT_FILES_CONTEXT_KEY,
STASH_CONTEXT_KEY,
MAIN_NORMAL_CONTEXT_KEY,
MAIN_MERGING_CONTEXT_KEY,
MAIN_PATCH_BUILDING_CONTEXT_KEY,
MAIN_STAGING_CONTEXT_KEY,
MENU_CONTEXT_KEY,
CREDENTIALS_CONTEXT_KEY,
CONFIRMATION_CONTEXT_KEY,
SEARCH_CONTEXT_KEY,
COMMIT_MESSAGE_CONTEXT_KEY,
SUBMODULES_CONTEXT_KEY,
SUGGESTIONS_CONTEXT_KEY,
}
type SimpleContextNode struct {
Context Context
}
type RemotesContextNode struct {
Context Context
Branches SimpleContextNode
}
type ContextTree struct {
Status SimpleContextNode
Files SimpleContextNode
Submodules SimpleContextNode
Menu SimpleContextNode
Branches SimpleContextNode
Remotes RemotesContextNode
Tags SimpleContextNode
BranchCommits SimpleContextNode
CommitFiles SimpleContextNode
ReflogCommits SimpleContextNode
SubCommits SimpleContextNode
Stash SimpleContextNode
Normal SimpleContextNode
Staging SimpleContextNode
PatchBuilding SimpleContextNode
Merging SimpleContextNode
Credentials SimpleContextNode
Confirmation SimpleContextNode
CommitMessage SimpleContextNode
Search SimpleContextNode
Suggestions SimpleContextNode
}
func (gui *Gui) allContexts() []Context {
return []Context{
gui.Contexts.Status.Context,
gui.Contexts.Files.Context,
gui.Contexts.Submodules.Context,
gui.Contexts.Branches.Context,
gui.Contexts.Remotes.Context,
gui.Contexts.Remotes.Branches.Context,
gui.Contexts.Tags.Context,
gui.Contexts.BranchCommits.Context,
gui.Contexts.CommitFiles.Context,
gui.Contexts.ReflogCommits.Context,
gui.Contexts.Stash.Context,
gui.Contexts.Menu.Context,
gui.Contexts.Confirmation.Context,
gui.Contexts.Credentials.Context,
gui.Contexts.CommitMessage.Context,
gui.Contexts.Normal.Context,
gui.Contexts.Staging.Context,
gui.Contexts.Merging.Context,
gui.Contexts.PatchBuilding.Context,
gui.Contexts.SubCommits.Context,
gui.Contexts.Suggestions.Context,
}
}
type Context interface {
HandleFocus() error
HandleFocusLost() error
HandleRender() error
GetKind() int
GetKind() ContextKind
GetViewName() string
GetWindowName() string
SetWindowName(string)
GetKey() string
GetKey() ContextKey
SetParentContext(Context)
// we return a bool here to tell us whether or not the returned value just wraps a nil
@@ -138,262 +32,22 @@ type Context interface {
GetOptionsMap() map[string]string
}
type BasicContext struct {
OnFocus func() error
OnFocusLost func() error
OnRender func() error
OnGetOptionsMap func() map[string]string
Kind int
Key string
ViewName string
}
func (c BasicContext) GetOptionsMap() map[string]string {
if c.OnGetOptionsMap != nil {
return c.OnGetOptionsMap()
func (gui *Gui) popupViewNames() []string {
result := []string{}
for _, context := range gui.allContexts() {
if context.GetKind() == PERSISTENT_POPUP || context.GetKind() == TEMPORARY_POPUP {
result = append(result, context.GetViewName())
}
}
return nil
return result
}
func (c BasicContext) SetWindowName(windowName string) {
panic("can't set window name on basic context")
}
func (gui *Gui) currentContextKeyIgnoringPopups() ContextKey {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
func (c BasicContext) GetWindowName() string {
// TODO: fix this up
return c.GetViewName()
}
func (c BasicContext) SetParentContext(Context) {
panic("can't set parent context on basic context")
}
func (c BasicContext) GetParentContext() (Context, bool) {
return nil, false
}
func (c BasicContext) HandleRender() error {
if c.OnRender != nil {
return c.OnRender()
}
return nil
}
func (c BasicContext) GetViewName() string {
return c.ViewName
}
func (c BasicContext) HandleFocus() error {
return c.OnFocus()
}
func (c BasicContext) HandleFocusLost() error {
if c.OnFocusLost != nil {
return c.OnFocusLost()
}
return nil
}
func (c BasicContext) GetKind() int {
return c.Kind
}
func (c BasicContext) GetKey() string {
return c.Key
}
func (gui *Gui) contextTree() ContextTree {
return ContextTree{
Status: SimpleContextNode{
Context: BasicContext{
OnFocus: gui.handleStatusSelect,
Kind: SIDE_CONTEXT,
ViewName: "status",
Key: STATUS_CONTEXT_KEY,
},
},
Files: SimpleContextNode{
Context: gui.filesListContext(),
},
Submodules: SimpleContextNode{
Context: gui.submodulesListContext(),
},
Menu: SimpleContextNode{
Context: gui.menuListContext(),
},
Remotes: RemotesContextNode{
Context: gui.remotesListContext(),
Branches: SimpleContextNode{
Context: gui.remoteBranchesListContext(),
},
},
BranchCommits: SimpleContextNode{
Context: gui.branchCommitsListContext(),
},
CommitFiles: SimpleContextNode{
Context: gui.commitFilesListContext(),
},
ReflogCommits: SimpleContextNode{
Context: gui.reflogCommitsListContext(),
},
SubCommits: SimpleContextNode{
Context: gui.subCommitsListContext(),
},
Branches: SimpleContextNode{
Context: gui.branchesListContext(),
},
Tags: SimpleContextNode{
Context: gui.tagsListContext(),
},
Stash: SimpleContextNode{
Context: gui.stashListContext(),
},
Normal: SimpleContextNode{
Context: BasicContext{
OnFocus: func() error {
return nil // TODO: should we do something here? We should allow for scrolling the panel
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_NORMAL_CONTEXT_KEY,
},
},
Staging: SimpleContextNode{
Context: BasicContext{
OnFocus: func() error {
return nil
// TODO: centralise the code here
// return gui.refreshStagingPanel(false, -1)
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_STAGING_CONTEXT_KEY,
},
},
PatchBuilding: SimpleContextNode{
Context: BasicContext{
OnFocus: func() error {
return nil
// TODO: centralise the code here
// return gui.refreshPatchBuildingPanel(-1)
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_PATCH_BUILDING_CONTEXT_KEY,
},
},
Merging: SimpleContextNode{
Context: BasicContext{
OnFocus: gui.refreshMergePanel,
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_MERGING_CONTEXT_KEY,
OnGetOptionsMap: gui.getMergingOptions,
},
},
Credentials: SimpleContextNode{
Context: BasicContext{
OnFocus: gui.handleCredentialsViewFocused,
Kind: PERSISTENT_POPUP,
ViewName: "credentials",
Key: CREDENTIALS_CONTEXT_KEY,
},
},
Confirmation: SimpleContextNode{
Context: BasicContext{
OnFocus: func() error { return nil },
Kind: TEMPORARY_POPUP,
ViewName: "confirmation",
Key: CONFIRMATION_CONTEXT_KEY,
},
},
Suggestions: SimpleContextNode{
Context: gui.suggestionsListContext(),
},
CommitMessage: SimpleContextNode{
Context: BasicContext{
OnFocus: gui.handleCommitMessageFocused,
Kind: PERSISTENT_POPUP,
ViewName: "commitMessage",
Key: COMMIT_MESSAGE_CONTEXT_KEY,
},
},
Search: SimpleContextNode{
Context: BasicContext{
OnFocus: func() error { return nil },
Kind: PERSISTENT_POPUP,
ViewName: "search",
Key: SEARCH_CONTEXT_KEY,
},
},
}
}
func (gui *Gui) initialViewContextMap() map[string]Context {
return map[string]Context{
"status": gui.Contexts.Status.Context,
"files": gui.Contexts.Files.Context,
"branches": gui.Contexts.Branches.Context,
"commits": gui.Contexts.BranchCommits.Context,
"commitFiles": gui.Contexts.CommitFiles.Context,
"stash": gui.Contexts.Stash.Context,
"menu": gui.Contexts.Menu.Context,
"confirmation": gui.Contexts.Confirmation.Context,
"credentials": gui.Contexts.Credentials.Context,
"commitMessage": gui.Contexts.CommitMessage.Context,
"main": gui.Contexts.Normal.Context,
"secondary": gui.Contexts.Normal.Context,
}
}
func (gui *Gui) viewTabContextMap() map[string][]tabContext {
return map[string][]tabContext{
"branches": {
{
tab: "Local Branches",
contexts: []Context{gui.Contexts.Branches.Context},
},
{
tab: "Remotes",
contexts: []Context{
gui.Contexts.Remotes.Context,
gui.Contexts.Remotes.Branches.Context,
},
},
{
tab: "Tags",
contexts: []Context{gui.Contexts.Tags.Context},
},
},
"commits": {
{
tab: "Commits",
contexts: []Context{gui.Contexts.BranchCommits.Context},
},
{
tab: "Reflog",
contexts: []Context{
gui.Contexts.ReflogCommits.Context,
},
},
},
"files": {
{
tab: "Files",
contexts: []Context{gui.Contexts.Files.Context},
},
{
tab: "Submodules",
contexts: []Context{
gui.Contexts.Submodules.Context,
},
},
},
}
}
func (gui *Gui) currentContextKeyIgnoringPopups() string {
stack := gui.State.ContextStack
stack := gui.State.ContextManager.ContextStack
for i := range stack {
reversedIndex := len(stack) - 1 - i
@@ -411,11 +65,14 @@ func (gui *Gui) currentContextKeyIgnoringPopups() string {
// hitting escape: you want to go that context's parent instead.
func (gui *Gui) replaceContext(c Context) error {
gui.g.Update(func(*gocui.Gui) error {
if len(gui.State.ContextStack) == 0 {
gui.State.ContextStack = []Context{c}
gui.State.ContextManager.Lock()
defer gui.State.ContextManager.Unlock()
if len(gui.State.ContextManager.ContextStack) == 0 {
gui.State.ContextManager.ContextStack = []Context{c}
} else {
// replace the last item with the given item
gui.State.ContextStack = append(gui.State.ContextStack[0:len(gui.State.ContextStack)-1], c)
gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack[0:len(gui.State.ContextManager.ContextStack)-1], c)
}
return gui.activateContext(c)
@@ -426,28 +83,42 @@ func (gui *Gui) replaceContext(c Context) error {
func (gui *Gui) pushContext(c Context) error {
gui.g.Update(func(*gocui.Gui) error {
// push onto stack
// if we are switching to a side context, remove all other contexts in the stack
if c.GetKind() == SIDE_CONTEXT {
for _, stackContext := range gui.State.ContextStack {
if stackContext.GetKey() != c.GetKey() {
if err := gui.deactivateContext(stackContext); err != nil {
return err
}
}
}
gui.State.ContextStack = []Context{c}
} else {
// TODO: think about other exceptional cases
gui.State.ContextStack = append(gui.State.ContextStack, c)
}
return gui.activateContext(c)
return gui.pushContextDirect(c)
})
return nil
}
func (gui *Gui) pushContextDirect(c Context) error {
gui.State.ContextManager.Lock()
// push onto stack
// if we are switching to a side context, remove all other contexts in the stack
if c.GetKind() == SIDE_CONTEXT {
for _, stackContext := range gui.State.ContextManager.ContextStack {
if stackContext.GetKey() != c.GetKey() {
if err := gui.deactivateContext(stackContext); err != nil {
gui.State.ContextManager.Unlock()
return err
}
}
}
gui.State.ContextManager.ContextStack = []Context{c}
} else if len(gui.State.ContextManager.ContextStack) == 0 || gui.currentContextWithoutLock().GetKey() != c.GetKey() {
// Do not append if the one at the end is the same context (e.g. opening a menu from a menu)
// In that case we'll just close the menu entirely when the user hits escape.
// TODO: think about other exceptional cases
gui.State.ContextManager.ContextStack = append(gui.State.ContextManager.ContextStack, c)
}
gui.State.ContextManager.Unlock()
return gui.activateContext(c)
}
// asynchronous code idea: functions return an error via a channel, when done
// pushContextWithView is to be used when you don't know which context you
// want to switch to: you only know the view that you want to switch to. It will
// look up the context currently active for that view and switch to that context
@@ -457,19 +128,22 @@ func (gui *Gui) pushContextWithView(viewName string) error {
func (gui *Gui) returnFromContext() error {
gui.g.Update(func(*gocui.Gui) error {
// TODO: add mutexes
gui.State.ContextManager.Lock()
if len(gui.State.ContextStack) == 1 {
if len(gui.State.ContextManager.ContextStack) == 1 {
// cannot escape from bottommost context
gui.State.ContextManager.Unlock()
return nil
}
n := len(gui.State.ContextStack) - 1
n := len(gui.State.ContextManager.ContextStack) - 1
currentContext := gui.State.ContextStack[n]
newContext := gui.State.ContextStack[n-1]
currentContext := gui.State.ContextManager.ContextStack[n]
newContext := gui.State.ContextManager.ContextStack[n-1]
gui.State.ContextStack = gui.State.ContextStack[:n]
gui.State.ContextManager.ContextStack = gui.State.ContextManager.ContextStack[:n]
gui.State.ContextManager.Unlock()
if err := gui.deactivateContext(currentContext); err != nil {
return err
@@ -482,9 +156,17 @@ func (gui *Gui) returnFromContext() error {
}
func (gui *Gui) deactivateContext(c Context) error {
view, _ := gui.g.View(c.GetViewName())
if view != nil && view.IsSearching() {
if err := gui.onSearchEscape(); err != nil {
return err
}
}
// if we are the kind of context that is sent to back upon deactivation, we should do that
if c.GetKind() == TEMPORARY_POPUP || c.GetKind() == PERSISTENT_POPUP {
_, _ = gui.g.SetViewOnBottom(c.GetViewName())
if view != nil && c.GetKind() == TEMPORARY_POPUP || c.GetKind() == PERSISTENT_POPUP || c.GetKey() == COMMIT_FILES_CONTEXT_KEY {
view.Visible = false
}
if err := c.HandleFocusLost(); err != nil {
@@ -503,7 +185,7 @@ func (gui *Gui) postRefreshUpdate(c Context) error {
return nil
}
if v.Context != c.GetKey() {
if ContextKey(v.Context) != c.GetKey() {
return nil
}
@@ -523,14 +205,13 @@ func (gui *Gui) postRefreshUpdate(c Context) error {
func (gui *Gui) activateContext(c Context) error {
viewName := c.GetViewName()
v, err := gui.g.View(viewName)
// if view no longer exists, pop again
if err != nil {
return gui.returnFromContext()
return err
}
originalViewContextKey := v.Context
originalViewContextKey := ContextKey(v.Context)
// ensure that any other window for which this view was active is now set to the default for that window.
gui.setViewAsActiveForWindow(viewName)
gui.setViewAsActiveForWindow(v)
if viewName == "main" {
gui.changeMainViewsContext(c.GetKey())
@@ -541,14 +222,10 @@ func (gui *Gui) activateContext(c Context) error {
gui.setViewTabForContext(c)
if _, err := gui.g.SetCurrentView(viewName); err != nil {
// if view no longer exists, pop again
return gui.returnFromContext()
return err
}
if _, err := gui.g.SetViewOnTop(viewName); err != nil {
// if view no longer exists, pop again
return gui.returnFromContext()
}
v.Visible = true
// if the new context's view was previously displaying another context, render the new context
if originalViewContextKey != c.GetKey() {
@@ -557,7 +234,7 @@ func (gui *Gui) activateContext(c Context) error {
}
}
v.Context = c.GetKey()
v.Context = string(c.GetKey())
gui.g.Cursor = v.Editable
@@ -578,29 +255,50 @@ func (gui *Gui) activateContext(c Context) error {
return nil
}
// currently unused
// // currently unused
// func (gui *Gui) renderContextStack() string {
// result := ""
// for _, context := range gui.State.ContextStack {
// result += context.GetKey() + "\n"
// for _, context := range gui.State.ContextManager.ContextStack {
// result += string(context.GetKey()) + "\n"
// }
// return result
// }
func (gui *Gui) currentContext() Context {
if len(gui.State.ContextStack) == 0 {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
return gui.currentContextWithoutLock()
}
func (gui *Gui) currentContextWithoutLock() Context {
if len(gui.State.ContextManager.ContextStack) == 0 {
return gui.defaultSideContext()
}
return gui.State.ContextStack[len(gui.State.ContextStack)-1]
return gui.State.ContextManager.ContextStack[len(gui.State.ContextManager.ContextStack)-1]
}
func (gui *Gui) currentSideContext() *ListContext {
stack := gui.State.ContextStack
// the status panel is not yet a list context (and may never be), so this method is not
// quite the same as currentSideContext()
func (gui *Gui) currentSideListContext() *ListContext {
context := gui.currentSideContext()
listContext, ok := context.(*ListContext)
if !ok {
return nil
}
return listContext
}
func (gui *Gui) currentSideContext() Context {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
stack := gui.State.ContextManager.ContextStack
// on startup the stack can be empty so we'll return an empty string in that case
if len(stack) == 0 {
return nil
return gui.defaultSideContext()
}
// find the first context in the stack with the type of SIDE_CONTEXT
@@ -608,22 +306,45 @@ func (gui *Gui) currentSideContext() *ListContext {
context := stack[len(stack)-1-i]
if context.GetKind() == SIDE_CONTEXT {
// not all side contexts are list contexts (e.g. the status panel)
listContext, ok := context.(*ListContext)
if !ok {
return nil
}
return listContext
return context
}
}
return nil
return gui.defaultSideContext()
}
// static as opposed to popup
func (gui *Gui) currentStaticContext() Context {
gui.State.ContextManager.RLock()
defer gui.State.ContextManager.RUnlock()
stack := gui.State.ContextManager.ContextStack
if len(stack) == 0 {
return gui.defaultSideContext()
}
// find the first context in the stack without a popup type
for i := range stack {
context := stack[len(stack)-1-i]
if context.GetKind() != TEMPORARY_POPUP && context.GetKind() != PERSISTENT_POPUP {
return context
}
}
return gui.defaultSideContext()
}
func (gui *Gui) defaultSideContext() Context {
return gui.Contexts.Files.Context
if gui.State.Modes.Filtering.Active() {
return gui.State.Contexts.BranchCommits
} else {
return gui.State.Contexts.Files
}
}
// remove the need to do this: always use a mapping
func (gui *Gui) setInitialViewContexts() {
// arguably we should only have our ViewContextMap and we should do away with
// contexts on views, or vice versa
@@ -634,7 +355,7 @@ func (gui *Gui) setInitialViewContexts() {
continue
}
view.Context = context.GetKey()
view.Context = string(context.GetKey())
}
}
@@ -664,18 +385,19 @@ func (gui *Gui) onViewFocusChange() error {
currentView := gui.g.CurrentView()
for _, view := range gui.g.Views() {
view.Highlight = view.Name() != "main" && view == currentView
view.Highlight = view.Name() != "main" && view.Name() != "extras" && view == currentView
}
return nil
}
func (gui *Gui) onViewFocusLost(v *gocui.View, newView *gocui.View) error {
if v == nil {
func (gui *Gui) onViewFocusLost(oldView *gocui.View, newView *gocui.View) error {
if oldView == nil {
return nil
}
if v.IsSearching() && newView.Name() != "search" {
if err := gui.onSearchEscape(); err != nil {
if oldView == gui.Views.CommitFiles && newView != gui.Views.Main && newView != gui.Views.Secondary && newView != gui.Views.Search {
gui.resetWindowForView(gui.Views.CommitFiles)
if err := gui.deactivateContext(gui.State.Contexts.CommitFiles); err != nil {
return err
}
}
@@ -687,15 +409,15 @@ func (gui *Gui) onViewFocusLost(v *gocui.View, newView *gocui.View) error {
// which currently just means a context that affects both the main and secondary views
// other views can have their context changed directly but this function helps
// keep the main and secondary views in sync
func (gui *Gui) changeMainViewsContext(contextKey string) {
func (gui *Gui) changeMainViewsContext(contextKey ContextKey) {
if gui.State.MainContext == contextKey {
return
}
switch contextKey {
case MAIN_NORMAL_CONTEXT_KEY, MAIN_PATCH_BUILDING_CONTEXT_KEY, MAIN_STAGING_CONTEXT_KEY, MAIN_MERGING_CONTEXT_KEY:
gui.getMainView().Context = contextKey
gui.getSecondaryView().Context = contextKey
gui.Views.Main.Context = string(contextKey)
gui.Views.Secondary.Context = string(contextKey)
default:
panic(fmt.Sprintf("unknown context for main: %s", contextKey))
}
@@ -704,7 +426,7 @@ func (gui *Gui) changeMainViewsContext(contextKey string) {
}
func (gui *Gui) viewTabNames(viewName string) []string {
tabContexts := gui.ViewTabContextMap[viewName]
tabContexts := gui.State.ViewTabContextMap[viewName]
if len(tabContexts) == 0 {
return nil
@@ -720,7 +442,7 @@ func (gui *Gui) viewTabNames(viewName string) []string {
func (gui *Gui) setViewTabForContext(c Context) {
// search for the context in our map and if we find it, set the tab for the corresponding view
tabContexts, ok := gui.ViewTabContextMap[c.GetViewName()]
tabContexts, ok := gui.State.ViewTabContextMap[c.GetViewName()]
if !ok {
return
}
@@ -746,7 +468,7 @@ type tabContext struct {
contexts []Context
}
func (gui *Gui) mustContextForContextKey(contextKey string) Context {
func (gui *Gui) mustContextForContextKey(contextKey ContextKey) Context {
context, ok := gui.contextForContextKey(contextKey)
if !ok {
@@ -756,7 +478,7 @@ func (gui *Gui) mustContextForContextKey(contextKey string) Context {
return context
}
func (gui *Gui) contextForContextKey(contextKey string) (Context, bool) {
func (gui *Gui) contextForContextKey(contextKey ContextKey) (Context, bool) {
for _, context := range gui.allContexts() {
if context.GetKey() == contextKey {
return context, true
@@ -766,18 +488,28 @@ func (gui *Gui) contextForContextKey(contextKey string) (Context, bool) {
return nil, false
}
func (gui *Gui) rerenderView(viewName string) error {
v, err := gui.g.View(viewName)
if err != nil {
return nil
}
contextKey := v.Context
func (gui *Gui) rerenderView(view *gocui.View) error {
contextKey := ContextKey(view.Context)
context := gui.mustContextForContextKey(contextKey)
return context.HandleRender()
}
func (gui *Gui) getSideContextSelectedItemId() string {
currentSideContext := gui.currentSideListContext()
if currentSideContext == nil {
return ""
}
item, ok := currentSideContext.GetSelectedItem()
if ok {
return item.ID()
}
return ""
}
// currently unused
// func (gui *Gui) getCurrentSideView() *gocui.View {
// currentSideContext := gui.currentSideContext()
@@ -790,17 +522,11 @@ func (gui *Gui) rerenderView(viewName string) error {
// return view
// }
func (gui *Gui) getSideContextSelectedItemId() string {
currentSideContext := gui.currentSideContext()
if currentSideContext == nil {
return ""
}
item, ok := currentSideContext.GetSelectedItem()
if ok {
return item.ID()
}
return ""
}
// currently unused
// func (gui *Gui) renderContextStack() string {
// result := ""
// for _, context := range gui.State.ContextManager.ContextStack {
// result += context.GetViewName() + "\n"
// }
// return result
// }

266
pkg/gui/context_config.go Normal file
View File

@@ -0,0 +1,266 @@
package gui
type ContextKey string
const (
STATUS_CONTEXT_KEY ContextKey = "status"
FILES_CONTEXT_KEY ContextKey = "files"
LOCAL_BRANCHES_CONTEXT_KEY ContextKey = "localBranches"
REMOTES_CONTEXT_KEY ContextKey = "remotes"
REMOTE_BRANCHES_CONTEXT_KEY ContextKey = "remoteBranches"
TAGS_CONTEXT_KEY ContextKey = "tags"
BRANCH_COMMITS_CONTEXT_KEY ContextKey = "commits"
REFLOG_COMMITS_CONTEXT_KEY ContextKey = "reflogCommits"
SUB_COMMITS_CONTEXT_KEY ContextKey = "subCommits"
COMMIT_FILES_CONTEXT_KEY ContextKey = "commitFiles"
STASH_CONTEXT_KEY ContextKey = "stash"
MAIN_NORMAL_CONTEXT_KEY ContextKey = "normal"
MAIN_MERGING_CONTEXT_KEY ContextKey = "merging"
MAIN_PATCH_BUILDING_CONTEXT_KEY ContextKey = "patchBuilding"
MAIN_STAGING_CONTEXT_KEY ContextKey = "staging"
MENU_CONTEXT_KEY ContextKey = "menu"
CREDENTIALS_CONTEXT_KEY ContextKey = "credentials"
CONFIRMATION_CONTEXT_KEY ContextKey = "confirmation"
SEARCH_CONTEXT_KEY ContextKey = "search"
COMMIT_MESSAGE_CONTEXT_KEY ContextKey = "commitMessage"
SUBMODULES_CONTEXT_KEY ContextKey = "submodules"
SUGGESTIONS_CONTEXT_KEY ContextKey = "suggestions"
COMMAND_LOG_CONTEXT_KEY ContextKey = "cmdLog"
)
var allContextKeys = []ContextKey{
STATUS_CONTEXT_KEY,
FILES_CONTEXT_KEY,
LOCAL_BRANCHES_CONTEXT_KEY,
REMOTES_CONTEXT_KEY,
REMOTE_BRANCHES_CONTEXT_KEY,
TAGS_CONTEXT_KEY,
BRANCH_COMMITS_CONTEXT_KEY,
REFLOG_COMMITS_CONTEXT_KEY,
SUB_COMMITS_CONTEXT_KEY,
COMMIT_FILES_CONTEXT_KEY,
STASH_CONTEXT_KEY,
MAIN_NORMAL_CONTEXT_KEY,
MAIN_MERGING_CONTEXT_KEY,
MAIN_PATCH_BUILDING_CONTEXT_KEY,
MAIN_STAGING_CONTEXT_KEY,
MENU_CONTEXT_KEY,
CREDENTIALS_CONTEXT_KEY,
CONFIRMATION_CONTEXT_KEY,
SEARCH_CONTEXT_KEY,
COMMIT_MESSAGE_CONTEXT_KEY,
SUBMODULES_CONTEXT_KEY,
SUGGESTIONS_CONTEXT_KEY,
COMMAND_LOG_CONTEXT_KEY,
}
type ContextTree struct {
Status Context
Files *ListContext
Submodules *ListContext
Menu *ListContext
Branches *ListContext
Remotes *ListContext
RemoteBranches *ListContext
Tags *ListContext
BranchCommits *ListContext
CommitFiles *ListContext
ReflogCommits *ListContext
SubCommits *ListContext
Stash *ListContext
Suggestions *ListContext
Normal Context
Staging Context
PatchBuilding Context
Merging Context
Credentials Context
Confirmation Context
CommitMessage Context
Search Context
CommandLog Context
}
func (gui *Gui) allContexts() []Context {
return []Context{
gui.State.Contexts.Status,
gui.State.Contexts.Files,
gui.State.Contexts.Submodules,
gui.State.Contexts.Branches,
gui.State.Contexts.Remotes,
gui.State.Contexts.RemoteBranches,
gui.State.Contexts.Tags,
gui.State.Contexts.BranchCommits,
gui.State.Contexts.CommitFiles,
gui.State.Contexts.ReflogCommits,
gui.State.Contexts.Stash,
gui.State.Contexts.Menu,
gui.State.Contexts.Confirmation,
gui.State.Contexts.Credentials,
gui.State.Contexts.CommitMessage,
gui.State.Contexts.Normal,
gui.State.Contexts.Staging,
gui.State.Contexts.Merging,
gui.State.Contexts.PatchBuilding,
gui.State.Contexts.SubCommits,
gui.State.Contexts.Suggestions,
gui.State.Contexts.CommandLog,
}
}
func (gui *Gui) contextTree() ContextTree {
return ContextTree{
Status: &BasicContext{
OnFocus: gui.handleStatusSelect,
Kind: SIDE_CONTEXT,
ViewName: "status",
Key: STATUS_CONTEXT_KEY,
},
Files: gui.filesListContext(),
Submodules: gui.submodulesListContext(),
Menu: gui.menuListContext(),
Remotes: gui.remotesListContext(),
RemoteBranches: gui.remoteBranchesListContext(),
BranchCommits: gui.branchCommitsListContext(),
CommitFiles: gui.commitFilesListContext(),
ReflogCommits: gui.reflogCommitsListContext(),
SubCommits: gui.subCommitsListContext(),
Branches: gui.branchesListContext(),
Tags: gui.tagsListContext(),
Stash: gui.stashListContext(),
Normal: &BasicContext{
OnFocus: func() error {
return nil // TODO: should we do something here? We should allow for scrolling the panel
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_NORMAL_CONTEXT_KEY,
},
Staging: &BasicContext{
OnFocus: func() error {
return nil
// TODO: centralise the code here
// return gui.refreshStagingPanel(false, -1)
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_STAGING_CONTEXT_KEY,
},
PatchBuilding: &BasicContext{
OnFocus: func() error {
return nil
// TODO: centralise the code here
// return gui.refreshPatchBuildingPanel(-1)
},
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_PATCH_BUILDING_CONTEXT_KEY,
},
Merging: &BasicContext{
OnFocus: gui.refreshMergePanelWithLock,
Kind: MAIN_CONTEXT,
ViewName: "main",
Key: MAIN_MERGING_CONTEXT_KEY,
OnGetOptionsMap: gui.getMergingOptions,
},
Credentials: &BasicContext{
OnFocus: gui.handleCredentialsViewFocused,
Kind: PERSISTENT_POPUP,
ViewName: "credentials",
Key: CREDENTIALS_CONTEXT_KEY,
},
Confirmation: &BasicContext{
OnFocus: func() error { return nil },
Kind: TEMPORARY_POPUP,
ViewName: "confirmation",
Key: CONFIRMATION_CONTEXT_KEY,
},
Suggestions: gui.suggestionsListContext(),
CommitMessage: &BasicContext{
OnFocus: gui.handleCommitMessageFocused,
Kind: PERSISTENT_POPUP,
ViewName: "commitMessage",
Key: COMMIT_MESSAGE_CONTEXT_KEY,
},
Search: &BasicContext{
OnFocus: func() error { return nil },
Kind: PERSISTENT_POPUP,
ViewName: "search",
Key: SEARCH_CONTEXT_KEY,
},
CommandLog: &BasicContext{
OnFocus: func() error { return nil },
Kind: EXTRAS_CONTEXT,
ViewName: "extras",
Key: COMMAND_LOG_CONTEXT_KEY,
OnGetOptionsMap: gui.getMergingOptions,
OnFocusLost: func() error {
gui.Views.Extras.Autoscroll = true
return nil
},
},
}
}
func (tree ContextTree) initialViewContextMap() map[string]Context {
return map[string]Context{
"status": tree.Status,
"files": tree.Files,
"branches": tree.Branches,
"commits": tree.BranchCommits,
"commitFiles": tree.CommitFiles,
"stash": tree.Stash,
"menu": tree.Menu,
"confirmation": tree.Confirmation,
"credentials": tree.Credentials,
"commitMessage": tree.CommitMessage,
"main": tree.Normal,
"secondary": tree.Normal,
"extras": tree.CommandLog,
}
}
func (tree ContextTree) initialViewTabContextMap() map[string][]tabContext {
return map[string][]tabContext{
"branches": {
{
tab: "Local Branches",
contexts: []Context{tree.Branches},
},
{
tab: "Remotes",
contexts: []Context{
tree.Remotes,
tree.RemoteBranches,
},
},
{
tab: "Tags",
contexts: []Context{tree.Tags},
},
},
"commits": {
{
tab: "Commits",
contexts: []Context{tree.BranchCommits},
},
{
tab: "Reflog",
contexts: []Context{
tree.ReflogCommits,
},
},
},
"files": {
{
tab: "Files",
contexts: []Context{tree.Files},
},
{
tab: "Submodules",
contexts: []Context{
tree.Submodules,
},
},
},
}
}

View File

@@ -13,7 +13,7 @@ type credentials chan string
func (gui *Gui) promptUserForCredential(passOrUname string) string {
gui.credentials = make(chan string)
gui.g.Update(func(g *gocui.Gui) error {
credentialsView, _ := g.View("credentials")
credentialsView := gui.Views.Credentials
switch passOrUname {
case "username":
credentialsView.Title = gui.Tr.CredentialsUsername
@@ -26,7 +26,7 @@ func (gui *Gui) promptUserForCredential(passOrUname string) string {
credentialsView.Mask = '*'
}
if err := gui.pushContext(gui.Contexts.Credentials.Context); err != nil {
if err := gui.pushContext(gui.State.Contexts.Credentials); err != nil {
return err
}
@@ -39,18 +39,19 @@ func (gui *Gui) promptUserForCredential(passOrUname string) string {
return userInput + "\n"
}
func (gui *Gui) handleSubmitCredential(g *gocui.Gui, v *gocui.View) error {
message := gui.trimmedContent(v)
func (gui *Gui) handleSubmitCredential() error {
credentialsView := gui.Views.Credentials
message := gui.trimmedContent(credentialsView)
gui.credentials <- message
gui.clearEditorView(v)
gui.clearEditorView(credentialsView)
if err := gui.returnFromContext(); err != nil {
return err
}
return gui.refreshSidePanels(refreshOptions{})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
}
func (gui *Gui) handleCloseCredentialsView(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCloseCredentialsView() error {
gui.credentials <- ""
return gui.returnFromContext()
}
@@ -66,7 +67,7 @@ func (gui *Gui) handleCredentialsViewFocused() error {
},
)
gui.renderString("options", message)
gui.renderString(gui.Views.Options, message)
return nil
}
@@ -77,6 +78,7 @@ func (gui *Gui) handleCredentialsPopup(cmdErr error) {
if strings.Contains(errMessage, "Invalid username, password or passphrase") {
errMessage = gui.Tr.PassUnameWrong
}
_ = gui.returnFromContext()
// we are not logging this error because it may contain a password or a passphrase
_ = gui.createErrorPanel(errMessage)
} else {

View File

@@ -1,50 +1,238 @@
package gui
import (
"bytes"
"errors"
"log"
"regexp"
"strconv"
"strings"
"text/template"
"github.com/fatih/color"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type CustomCommandObjects struct {
SelectedLocalCommit *models.Commit
SelectedReflogCommit *models.Commit
SelectedSubCommit *models.Commit
SelectedFile *models.File
SelectedLocalBranch *models.Branch
SelectedRemoteBranch *models.RemoteBranch
SelectedRemote *models.Remote
SelectedTag *models.Tag
SelectedStashEntry *models.StashEntry
SelectedCommitFile *models.CommitFile
CheckedOutBranch *models.Branch
PromptResponses []string
SelectedLocalCommit *models.Commit
SelectedReflogCommit *models.Commit
SelectedSubCommit *models.Commit
SelectedFile *models.File
SelectedPath string
SelectedLocalBranch *models.Branch
SelectedRemoteBranch *models.RemoteBranch
SelectedRemote *models.Remote
SelectedTag *models.Tag
SelectedStashEntry *models.StashEntry
SelectedCommitFile *models.CommitFile
SelectedCommitFilePath string
CheckedOutBranch *models.Branch
PromptResponses []string
}
type commandMenuEntry struct {
label string
value string
}
func (gui *Gui) resolveTemplate(templateStr string, promptResponses []string) (string, error) {
objects := CustomCommandObjects{
SelectedFile: gui.getSelectedFile(),
SelectedLocalCommit: gui.getSelectedLocalCommit(),
SelectedReflogCommit: gui.getSelectedReflogCommit(),
SelectedLocalBranch: gui.getSelectedBranch(),
SelectedRemoteBranch: gui.getSelectedRemoteBranch(),
SelectedRemote: gui.getSelectedRemote(),
SelectedTag: gui.getSelectedTag(),
SelectedStashEntry: gui.getSelectedStashEntry(),
SelectedCommitFile: gui.getSelectedCommitFile(),
SelectedSubCommit: gui.getSelectedSubCommit(),
CheckedOutBranch: gui.currentBranch(),
PromptResponses: promptResponses,
SelectedFile: gui.getSelectedFile(),
SelectedPath: gui.getSelectedPath(),
SelectedLocalCommit: gui.getSelectedLocalCommit(),
SelectedReflogCommit: gui.getSelectedReflogCommit(),
SelectedLocalBranch: gui.getSelectedBranch(),
SelectedRemoteBranch: gui.getSelectedRemoteBranch(),
SelectedRemote: gui.getSelectedRemote(),
SelectedTag: gui.getSelectedTag(),
SelectedStashEntry: gui.getSelectedStashEntry(),
SelectedCommitFile: gui.getSelectedCommitFile(),
SelectedCommitFilePath: gui.getSelectedCommitFilePath(),
SelectedSubCommit: gui.getSelectedSubCommit(),
CheckedOutBranch: gui.currentBranch(),
PromptResponses: promptResponses,
}
return utils.ResolveTemplate(templateStr, objects)
}
func (gui *Gui) inputPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
initialValue, err := gui.resolveTemplate(prompt.InitialValue, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
return gui.prompt(promptOpts{
title: title,
initialContent: initialValue,
handleConfirm: func(str string) error {
promptResponses[responseIdx] = str
return wrappedF()
},
})
}
func (gui *Gui) menuPrompt(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
// need to make a menu here some how
menuItems := make([]*menuItem, len(prompt.Options))
for i, option := range prompt.Options {
option := option
nameTemplate := option.Name
if nameTemplate == "" {
// this allows you to only pass values rather than bother with names/descriptions
nameTemplate = option.Value
}
name, err := gui.resolveTemplate(nameTemplate, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
description, err := gui.resolveTemplate(option.Description, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
value, err := gui.resolveTemplate(option.Value, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
menuItems[i] = &menuItem{
displayStrings: []string{name, style.FgYellow.Sprint(description)},
onPress: func() error {
promptResponses[responseIdx] = value
return wrappedF()
},
}
}
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) GenerateMenuCandidates(commandOutput, filter, valueFormat, labelFormat string) ([]commandMenuEntry, error) {
reg, err := regexp.Compile(filter)
if err != nil {
return nil, gui.surfaceError(errors.New("unable to parse filter regex, error: " + err.Error()))
}
buff := bytes.NewBuffer(nil)
valueTemp, err := template.New("format").Parse(valueFormat)
if err != nil {
return nil, gui.surfaceError(errors.New("unable to parse value format, error: " + err.Error()))
}
colorFuncMap := style.TemplateFuncMapAddColors(template.FuncMap{})
descTemp, err := template.New("format").Funcs(colorFuncMap).Parse(labelFormat)
if err != nil {
return nil, gui.surfaceError(errors.New("unable to parse label format, error: " + err.Error()))
}
candidates := []commandMenuEntry{}
for _, str := range strings.Split(string(commandOutput), "\n") {
if str == "" {
continue
}
tmplData := map[string]string{}
out := reg.FindAllStringSubmatch(str, -1)
if len(out) > 0 {
for groupIdx, group := range reg.SubexpNames() {
// Record matched group with group ids
matchName := "group_" + strconv.Itoa(groupIdx)
tmplData[matchName] = out[0][groupIdx]
// Record last named group non-empty matches as group matches
if group != "" {
tmplData[group] = out[0][groupIdx]
}
}
}
err = valueTemp.Execute(buff, tmplData)
if err != nil {
return candidates, gui.surfaceError(err)
}
entry := commandMenuEntry{
value: strings.TrimSpace(buff.String()),
}
if labelFormat != "" {
buff.Reset()
err = descTemp.Execute(buff, tmplData)
if err != nil {
return candidates, gui.surfaceError(err)
}
entry.label = strings.TrimSpace(buff.String())
} else {
entry.label = entry.value
}
candidates = append(candidates, entry)
buff.Reset()
}
return candidates, err
}
func (gui *Gui) menuPromptFromCommand(prompt config.CustomCommandPrompt, promptResponses []string, responseIdx int, wrappedF func() error) error {
// Collect cmd to run from config
cmdStr, err := gui.resolveTemplate(prompt.Command, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
// Collect Filter regexp
filter, err := gui.resolveTemplate(prompt.Filter, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
// Run and save output
message, err := gui.GitCommand.RunCommandWithOutput(cmdStr)
if err != nil {
return gui.surfaceError(err)
}
// Need to make a menu out of what the cmd has displayed
candidates, err := gui.GenerateMenuCandidates(message, filter, prompt.ValueFormat, prompt.LabelFormat)
if err != nil {
return gui.surfaceError(err)
}
menuItems := make([]*menuItem, len(candidates))
for i := range candidates {
menuItems[i] = &menuItem{
displayStrings: []string{candidates[i].label},
onPress: func() error {
promptResponses[responseIdx] = candidates[i].value
return wrappedF()
},
}
}
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand) func() error {
return func() error {
promptResponses := make([]string, len(customCommand.Prompts))
@@ -56,8 +244,7 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
}
if customCommand.Subprocess {
gui.PrepareSubProcess(cmdStr)
return nil
return gui.runSubprocessWithSuspenseAndRefresh(gui.OSCommand.PrepareShellSubProcess(cmdStr))
}
loadingText := customCommand.LoadingText
@@ -65,9 +252,7 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
loadingText = gui.Tr.LcRunningCustomCommandStatus
}
return gui.WithWaitingStatus(loadingText, func() error {
gui.OSCommand.PrepareSubProcess(cmdStr)
if err := gui.OSCommand.RunCommand(cmdStr); err != nil {
if err := gui.OSCommand.WithSpan(gui.Tr.Spans.CustomCommand).RunShellCommand(cmdStr); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{})
@@ -88,72 +273,18 @@ func (gui *Gui) handleCustomCommandKeybinding(customCommand config.CustomCommand
switch prompt.Type {
case "input":
f = func() error {
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
initialValue, err := gui.resolveTemplate(prompt.InitialValue, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
return gui.prompt(promptOpts{
title: title,
initialContent: initialValue,
handleConfirm: func(str string) error {
promptResponses[idx] = str
return wrappedF()
},
})
return gui.inputPrompt(prompt, promptResponses, idx, wrappedF)
}
case "menu":
f = func() error {
// need to make a menu here some how
menuItems := make([]*menuItem, len(prompt.Options))
for i, option := range prompt.Options {
option := option
nameTemplate := option.Name
if nameTemplate == "" {
// this allows you to only pass values rather than bother with names/descriptions
nameTemplate = option.Value
}
name, err := gui.resolveTemplate(nameTemplate, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
description, err := gui.resolveTemplate(option.Description, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
value, err := gui.resolveTemplate(option.Value, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
menuItems[i] = &menuItem{
displayStrings: []string{name, utils.ColoredString(description, color.FgYellow)},
onPress: func() error {
promptResponses[idx] = value
return wrappedF()
},
}
}
title, err := gui.resolveTemplate(prompt.Title, promptResponses)
if err != nil {
return gui.surfaceError(err)
}
return gui.createMenu(title, menuItems, createMenuOptions{showCancel: true})
return gui.menuPrompt(prompt, promptResponses, idx, wrappedF)
}
case "menuFromCommand":
f = func() error {
return gui.menuPromptFromCommand(prompt, promptResponses, idx, wrappedF)
}
default:
return gui.createErrorPanel("custom command prompt must have a type of 'input' or 'menu'")
return gui.createErrorPanel("custom command prompt must have a type of 'input', 'menu' or 'menuFromCommand'")
}
}
@@ -175,9 +306,14 @@ func (gui *Gui) GetCustomCommandKeybindings() []*Binding {
case "":
log.Fatalf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command)
default:
context, ok := gui.contextForContextKey(customCommand.Context)
context, ok := gui.contextForContextKey(ContextKey(customCommand.Context))
// stupid golang making me build an array of strings for this.
allContextKeyStrings := make([]string, len(allContextKeys))
for i := range allContextKeys {
allContextKeyStrings[i] = string(allContextKeys[i])
}
if !ok {
log.Fatalf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeys, ", "))
log.Fatalf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", "))
}
// here we assume that a given context will always belong to the same view.
// Currently this is a safe bet but it's by no means guaranteed in the long term
@@ -196,7 +332,7 @@ func (gui *Gui) GetCustomCommandKeybindings() []*Binding {
Contexts: contexts,
Key: gui.getKey(customCommand.Key),
Modifier: gocui.ModNone,
Handler: gui.wrappedHandler(gui.handleCustomCommandKeybinding(customCommand)),
Handler: gui.handleCustomCommandKeybinding(customCommand),
Description: description,
})
}

View File

@@ -4,11 +4,11 @@ import (
"fmt"
"strings"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
)
func (gui *Gui) exitDiffMode() error {
gui.State.Modes.Diffing = Diffing{}
gui.State.Modes.Diffing = diffing.New()
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
}
@@ -16,7 +16,7 @@ func (gui *Gui) renderDiff() error {
cmd := gui.OSCommand.ExecutableFromString(
fmt.Sprintf("git diff --submodule --no-ext-diff --color %s", gui.diffStr()),
)
task := gui.createRunPtyTask(cmd)
task := NewRunPtyTask(cmd)
return gui.refreshMainViews(refreshMainOpts{
main: &viewUpdateOpts{
@@ -51,7 +51,7 @@ func (gui *Gui) currentDiffTerminals() []string {
}
return nil
default:
context := gui.currentSideContext()
context := gui.currentSideListContext()
if context == nil {
return nil
}
@@ -96,17 +96,13 @@ func (gui *Gui) diffStr() string {
if file != "" {
output += " -- " + file
} else if gui.State.Modes.Filtering.Active() {
output += " -- " + gui.State.Modes.Filtering.Path
output += " -- " + gui.State.Modes.Filtering.GetPath()
}
return output
}
func (gui *Gui) handleCreateDiffingMenuPanel(g *gocui.Gui, v *gocui.View) error {
if gui.popupPanelFocused() {
return nil
}
func (gui *Gui) handleCreateDiffingMenuPanel() error {
names := gui.currentDiffTerminals()
menuItems := []*menuItem{}
@@ -151,7 +147,7 @@ func (gui *Gui) handleCreateDiffingMenuPanel(g *gocui.Gui, v *gocui.View) error
{
displayString: gui.Tr.LcExitDiffMode,
onPress: func() error {
gui.State.Modes.Diffing = Diffing{}
gui.State.Modes.Diffing = diffing.New()
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
},
},

View File

@@ -1,56 +1,79 @@
package gui
import (
"github.com/jesseduffield/gocui"
)
func (gui *Gui) handleCreateDiscardMenu(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedFile()
if file == nil {
func (gui *Gui) handleCreateDiscardMenu() error {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
var menuItems []*menuItem
submodules := gui.State.Submodules
if file.IsSubmodule(submodules) {
submodule := file.SubmoduleConfig(submodules)
menuItems = []*menuItem{
{
displayString: gui.Tr.LcSubmoduleStashAndReset,
onPress: func() error {
return gui.handleResetSubmodule(submodule)
},
},
}
} else {
if node.File == nil {
menuItems = []*menuItem{
{
displayString: gui.Tr.LcDiscardAllChanges,
onPress: func() error {
if err := gui.GitCommand.DiscardAllFileChanges(file); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DiscardAllChangesInDirectory).DiscardAllDirChanges(node); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{FILES}})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
},
},
}
if file.HasStagedChanges && file.HasUnstagedChanges {
if node.GetHasStagedChanges() && node.GetHasUnstagedChanges() {
menuItems = append(menuItems, &menuItem{
displayString: gui.Tr.LcDiscardUnstagedChanges,
onPress: func() error {
if err := gui.GitCommand.DiscardUnstagedFileChanges(file); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DiscardUnstagedChangesInDirectory).DiscardUnstagedDirChanges(node); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{FILES}})
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
},
})
}
} else {
file := node.File
submodules := gui.State.Submodules
if file.IsSubmodule(submodules) {
submodule := file.SubmoduleConfig(submodules)
menuItems = []*menuItem{
{
displayString: gui.Tr.LcSubmoduleStashAndReset,
onPress: func() error {
return gui.handleResetSubmodule(submodule)
},
},
}
} else {
menuItems = []*menuItem{
{
displayString: gui.Tr.LcDiscardAllChanges,
onPress: func() error {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DiscardAllChangesInFile).DiscardAllFileChanges(file); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
},
},
}
if file.HasStagedChanges && file.HasUnstagedChanges {
menuItems = append(menuItems, &menuItem{
displayString: gui.Tr.LcDiscardUnstagedChanges,
onPress: func() error {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.DiscardAllUnstagedChangesInFile).DiscardUnstagedFileChanges(file); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
},
})
}
}
}
return gui.createMenu(file.Name, menuItems, createMenuOptions{showCancel: true})
return gui.createMenu(node.GetPath(), menuItems, createMenuOptions{showCancel: true})
}

21
pkg/gui/dummies.go Normal file
View File

@@ -0,0 +1,21 @@
package gui
import (
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/jesseduffield/lazygit/pkg/updates"
"github.com/jesseduffield/lazygit/pkg/utils"
)
// NewDummyGui creates a new dummy GUI for testing
func NewDummyUpdater() *updates.Updater {
DummyUpdater, _ := updates.NewUpdater(utils.NewDummyLog(), config.NewDummyAppConfig(), oscommands.NewDummyOSCommand(), i18n.NewTranslationSet(utils.NewDummyLog()))
return DummyUpdater
}
func NewDummyGui() *Gui {
DummyGui, _ := NewGui(utils.NewDummyLog(), commands.NewDummyGitCommand(), oscommands.NewDummyOSCommand(), i18n.NewTranslationSet(utils.NewDummyLog()), config.NewDummyAppConfig(), NewDummyUpdater(), "", false)
return DummyGui
}

View File

@@ -1,21 +1,24 @@
package gui
import (
"unicode"
"github.com/jesseduffield/gocui"
)
// we've just copy+pasted the editor from gocui to here so that we can also re-
// render the commit message length on each keypress
func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
newlineKey, ok := gui.getKey(gui.Config.GetUserConfig().Keybinding.Universal.AppendNewline).(gocui.Key)
if !ok {
newlineKey = gocui.KeyTab
newlineKey = gocui.KeyAltEnter
}
matched := true
switch {
case key == gocui.KeyBackspace || key == gocui.KeyBackspace2:
v.EditDelete(true)
case key == gocui.KeyDelete:
case key == gocui.KeyCtrlD || key == gocui.KeyDelete:
v.EditDelete(false)
case key == gocui.KeyArrowDown:
v.MoveCursor(0, 1, false)
@@ -37,18 +40,25 @@ func (gui *Gui) commitMessageEditor(v *gocui.View, key gocui.Key, ch rune, mod g
v.EditGotoToStartOfLine()
case key == gocui.KeyCtrlE:
v.EditGotoToEndOfLine()
default:
// TODO: see if we need all three of these conditions: maybe the final one is sufficient
case ch != 0 && mod == 0 && unicode.IsPrint(ch):
v.EditWrite(ch)
default:
matched = false
}
gui.RenderCommitLength()
return matched
}
func (gui *Gui) defaultEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) {
func (gui *Gui) defaultEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := true
switch {
case key == gocui.KeyBackspace || key == gocui.KeyBackspace2:
v.EditDelete(true)
case key == gocui.KeyDelete:
case key == gocui.KeyCtrlD || key == gocui.KeyDelete:
v.EditDelete(false)
case key == gocui.KeyArrowDown:
v.MoveCursor(0, 1, false)
@@ -68,8 +78,12 @@ func (gui *Gui) defaultEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.M
v.EditGotoToStartOfLine()
case key == gocui.KeyCtrlE:
v.EditGotoToEndOfLine()
default:
// TODO: see if we need all three of these conditions: maybe the final one is sufficient
case ch != 0 && mod == 0 && unicode.IsPrint(ch):
v.EditWrite(ch)
default:
matched = false
}
if gui.findSuggestions != nil {
@@ -77,4 +91,6 @@ func (gui *Gui) defaultEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.M
suggestions := gui.findSuggestions(input)
gui.setSuggestions(suggestions)
}
return matched
}

3
pkg/gui/errors.go Normal file
View File

@@ -0,0 +1,3 @@
package gui
const UNKNOWN_VIEW_ERROR_MSG = "unknown view"

49
pkg/gui/extras_panel.go Normal file
View File

@@ -0,0 +1,49 @@
package gui
func (gui *Gui) handleCreateExtrasMenuPanel() error {
menuItems := []*menuItem{
{
displayString: gui.Tr.ToggleShowCommandLog,
onPress: func() error {
currentContext := gui.currentStaticContext()
if gui.ShowExtrasWindow && currentContext.GetKey() == COMMAND_LOG_CONTEXT_KEY {
if err := gui.returnFromContext(); err != nil {
return err
}
}
gui.ShowExtrasWindow = !gui.ShowExtrasWindow
return nil
},
},
{
displayString: gui.Tr.FocusCommandLog,
onPress: func() error {
return gui.handleFocusCommandLog()
},
},
}
return gui.createMenu(gui.Tr.CommandLog, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) handleFocusCommandLog() error {
gui.ShowExtrasWindow = true
gui.State.Contexts.CommandLog.SetParentContext(gui.currentSideContext())
return gui.pushContext(gui.State.Contexts.CommandLog)
}
func (gui *Gui) scrollUpExtra() error {
gui.Views.Extras.Autoscroll = false
return gui.scrollUpView(gui.Views.Extras)
}
func (gui *Gui) scrollDownExtra() error {
gui.Views.Extras.Autoscroll = false
if err := gui.scrollDownView(gui.Views.Extras); err != nil {
return err
}
return nil
}

View File

@@ -117,7 +117,7 @@ func (gui *Gui) watchFilesForChanges() {
}
// only refresh if we're not already
if !gui.State.IsRefreshingFiles {
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []int{FILES}})
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC, scope: []RefreshableView{FILES}})
}
// watch for errors

View File

@@ -1,12 +1,6 @@
package gui
import (
// "io"
// "io/ioutil"
// "strings"
"fmt"
"regexp"
"strings"
@@ -14,66 +8,88 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mgutz/str"
)
// list panel functions
func (gui *Gui) getSelectedFile() *models.File {
func (gui *Gui) getSelectedFileNode() *filetree.FileNode {
selectedLine := gui.State.Panels.Files.SelectedLineIdx
if selectedLine == -1 {
return nil
}
return gui.State.Files[selectedLine]
return gui.State.FileManager.GetItemAtIndex(selectedLine)
}
func (gui *Gui) getSelectedFile() *models.File {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
return node.File
}
func (gui *Gui) getSelectedPath() string {
node := gui.getSelectedFileNode()
if node == nil {
return ""
}
return node.GetPath()
}
func (gui *Gui) selectFile(alreadySelected bool) error {
gui.getFilesView().FocusPoint(0, gui.State.Panels.Files.SelectedLineIdx)
gui.Views.Files.FocusPoint(0, gui.State.Panels.Files.SelectedLineIdx)
file := gui.getSelectedFile()
if file == nil {
node := gui.getSelectedFileNode()
if node == nil {
return gui.refreshMainViews(refreshMainOpts{
main: &viewUpdateOpts{
title: "",
task: gui.createRenderStringTask(gui.Tr.NoChangedFiles),
task: NewRenderStringTask(gui.Tr.NoChangedFiles),
},
})
}
if !alreadySelected {
// TODO: pull into update task interface
if err := gui.resetOrigin(gui.getMainView()); err != nil {
if err := gui.resetOrigin(gui.Views.Main); err != nil {
return err
}
if err := gui.resetOrigin(gui.getSecondaryView()); err != nil {
if err := gui.resetOrigin(gui.Views.Secondary); err != nil {
return err
}
gui.takeOverMergeConflictScrolling()
}
if file.HasInlineMergeConflicts {
return gui.refreshMergePanel()
if node.File != nil && node.File.HasInlineMergeConflicts {
return gui.refreshMergePanelWithLock()
}
cmdStr := gui.GitCommand.WorktreeFileDiffCmdStr(file, false, !file.HasUnstagedChanges && file.HasStagedChanges)
cmdStr := gui.GitCommand.WorktreeFileDiffCmdStr(node, false, !node.GetHasUnstagedChanges() && node.GetHasStagedChanges(), gui.State.IgnoreWhitespaceInDiffView)
cmd := gui.OSCommand.ExecutableFromString(cmdStr)
refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
title: gui.Tr.UnstagedChanges,
task: gui.createRunPtyTask(cmd),
task: NewRunPtyTask(cmd),
}}
if file.HasStagedChanges && file.HasUnstagedChanges {
cmdStr := gui.GitCommand.WorktreeFileDiffCmdStr(file, false, true)
cmd := gui.OSCommand.ExecutableFromString(cmdStr)
if node.GetHasUnstagedChanges() {
if node.GetHasStagedChanges() {
cmdStr := gui.GitCommand.WorktreeFileDiffCmdStr(node, false, true, gui.State.IgnoreWhitespaceInDiffView)
cmd := gui.OSCommand.ExecutableFromString(cmdStr)
refreshOpts.secondary = &viewUpdateOpts{
title: gui.Tr.StagedChanges,
task: gui.createRunPtyTask(cmd),
refreshOpts.secondary = &viewUpdateOpts{
title: gui.Tr.StagedChanges,
task: NewRunPtyTask(cmd),
}
}
} else if !file.HasUnstagedChanges {
} else {
refreshOpts.main.title = gui.Tr.StagedChanges
}
@@ -88,13 +104,8 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
gui.Mutexes.RefreshingFilesMutex.Unlock()
}()
selectedFile := gui.getSelectedFile()
selectedPath := gui.getSelectedPath()
filesView := gui.getFilesView()
if filesView == nil {
// if the filesView hasn't been instantiated yet we just return
return nil
}
if err := gui.refreshStateSubmoduleConfigs(); err != nil {
return err
}
@@ -103,20 +114,20 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
}
gui.g.Update(func(g *gocui.Gui) error {
if err := gui.postRefreshUpdate(gui.Contexts.Submodules.Context); err != nil {
if err := gui.postRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
gui.Log.Error(err)
}
if gui.getFilesView().Context == FILES_CONTEXT_KEY {
if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
// doing this a little custom (as opposed to using gui.postRefreshUpdate) because we handle selecting the file explicitly below
if err := gui.Contexts.Files.Context.HandleRender(); err != nil {
if err := gui.State.Contexts.Files.HandleRender(); err != nil {
return err
}
}
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY || (g.CurrentView() == gui.getMainView() && g.CurrentView().Context == MAIN_MERGING_CONTEXT_KEY) {
newSelectedFile := gui.getSelectedFile()
alreadySelected := selectedFile != nil && newSelectedFile != nil && newSelectedFile.Name == selectedFile.Name
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY || (g.CurrentView() == gui.Views.Main && ContextKey(g.CurrentView().Context) == MAIN_MERGING_CONTEXT_KEY) {
newSelectedPath := gui.getSelectedPath()
alreadySelected := selectedPath != "" && newSelectedPath == selectedPath
if err := gui.selectFile(alreadySelected); err != nil {
return err
}
@@ -131,7 +142,7 @@ func (gui *Gui) refreshFilesAndSubmodules() error {
// specific functions
func (gui *Gui) stagedFiles() []*models.File {
files := gui.State.Files
files := gui.State.FileManager.GetAllFiles()
result := make([]*models.File, 0)
for _, file := range files {
if file.HasStagedChanges {
@@ -142,7 +153,7 @@ func (gui *Gui) stagedFiles() []*models.File {
}
func (gui *Gui) trackedFiles() []*models.File {
files := gui.State.Files
files := gui.State.FileManager.GetAllFiles()
result := make([]*models.File, 0, len(files))
for _, file := range files {
if file.Tracked {
@@ -161,16 +172,22 @@ func (gui *Gui) stageSelectedFile() error {
return gui.GitCommand.StageFile(file.Name)
}
func (gui *Gui) handleEnterFile(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleEnterFile() error {
return gui.enterFile(false, -1)
}
func (gui *Gui) enterFile(forceSecondaryFocused bool, selectedLineIdx int) error {
file := gui.getSelectedFile()
if file == nil {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
if node.File == nil {
return gui.handleToggleDirCollapsed()
}
file := node.File
submoduleConfigs := gui.State.Submodules
if file.IsSubmodule(submoduleConfigs) {
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
@@ -183,32 +200,53 @@ func (gui *Gui) enterFile(forceSecondaryFocused bool, selectedLineIdx int) error
if file.HasMergeConflicts {
return gui.createErrorPanel(gui.Tr.FileStagingRequirements)
}
_ = gui.pushContext(gui.Contexts.Staging.Context)
_ = gui.pushContext(gui.State.Contexts.Staging)
return gui.handleRefreshStagingPanel(forceSecondaryFocused, selectedLineIdx) // TODO: check if this is broken, try moving into context code
}
func (gui *Gui) handleFilePress() error {
file := gui.getSelectedFile()
if file == nil {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
if file.HasInlineMergeConflicts {
return gui.handleSwitchToMerge()
}
if node.IsLeaf() {
file := node.File
if file.HasUnstagedChanges {
if err := gui.GitCommand.StageFile(file.Name); err != nil {
return gui.surfaceError(err)
if file.HasInlineMergeConflicts {
return gui.handleSwitchToMerge()
}
if file.HasUnstagedChanges {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.StageFile).StageFile(file.Name); err != nil {
return gui.surfaceError(err)
}
} else {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.UnstageFile).UnStageFile(file.Names(), file.Tracked); err != nil {
return gui.surfaceError(err)
}
}
} else {
if err := gui.GitCommand.UnStageFile(file.Name, file.Tracked); err != nil {
return gui.surfaceError(err)
// if any files within have inline merge conflicts we can't stage or unstage,
// or it'll end up with those >>>>>> lines actually staged
if node.GetHasInlineMergeConflicts() {
return gui.createErrorPanel(gui.Tr.ErrStageDirWithInlineMergeConflicts)
}
if node.GetHasUnstagedChanges() {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.StageFile).StageFile(node.Path); err != nil {
return gui.surfaceError(err)
}
} else {
// pretty sure it doesn't matter that we're always passing true here
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.UnstageFile).UnStageFile([]string{node.Path}, true); err != nil {
return gui.surfaceError(err)
}
}
}
if err := gui.refreshSidePanels(refreshOptions{scope: []int{FILES}}); err != nil {
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
return err
}
@@ -216,7 +254,7 @@ func (gui *Gui) handleFilePress() error {
}
func (gui *Gui) allFilesStaged() bool {
for _, file := range gui.State.Files {
for _, file := range gui.State.FileManager.GetAllFiles() {
if file.HasUnstagedChanges {
return false
}
@@ -228,64 +266,88 @@ func (gui *Gui) focusAndSelectFile() error {
return gui.selectFile(false)
}
func (gui *Gui) handleStageAll(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleStageAll() error {
var err error
if gui.allFilesStaged() {
err = gui.GitCommand.UnstageAll()
err = gui.GitCommand.WithSpan(gui.Tr.Spans.UnstageAllFiles).UnstageAll()
} else {
err = gui.GitCommand.StageAll()
err = gui.GitCommand.WithSpan(gui.Tr.Spans.StageAllFiles).StageAll()
}
if err != nil {
_ = gui.surfaceError(err)
}
if err := gui.refreshSidePanels(refreshOptions{scope: []int{FILES}}); err != nil {
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
return err
}
return gui.selectFile(false)
}
func (gui *Gui) handleIgnoreFile(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedFile()
if file == nil {
func (gui *Gui) handleIgnoreFile() error {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
if file.Name == ".gitignore" {
if node.GetPath() == ".gitignore" {
return gui.createErrorPanel("Cannot ignore .gitignore")
}
if file.Tracked {
gitCommand := gui.GitCommand.WithSpan(gui.Tr.Spans.IgnoreFile)
unstageFiles := func() error {
return node.ForEachFile(func(file *models.File) error {
if file.HasStagedChanges {
if err := gitCommand.UnStageFile(file.Names(), file.Tracked); err != nil {
return err
}
}
return nil
})
}
if node.GetIsTracked() {
return gui.ask(askOpts{
title: gui.Tr.IgnoreTracked,
prompt: gui.Tr.IgnoreTrackedPrompt,
handleConfirm: func() error {
if err := gui.GitCommand.Ignore(file.Name); err != nil {
// not 100% sure if this is necessary but I'll assume it is
if err := unstageFiles(); err != nil {
return err
}
if err := gui.GitCommand.RemoveTrackedFiles(file.Name); err != nil {
if err := gitCommand.RemoveTrackedFiles(node.GetPath()); err != nil {
return err
}
return gui.refreshSidePanels(refreshOptions{scope: []int{FILES}})
if err := gitCommand.Ignore(node.GetPath()); err != nil {
return err
}
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
},
})
}
if err := gui.GitCommand.Ignore(file.Name); err != nil {
if err := unstageFiles(); err != nil {
return err
}
if err := gitCommand.Ignore(node.GetPath()); err != nil {
return gui.surfaceError(err)
}
return gui.refreshSidePanels(refreshOptions{scope: []int{FILES}})
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
}
func (gui *Gui) handleWIPCommitPress(g *gocui.Gui, filesView *gocui.View) error {
skipHookPreifx := gui.Config.GetUserConfig().Git.SkipHookPrefix
if skipHookPreifx == "" {
func (gui *Gui) handleWIPCommitPress() error {
skipHookPrefix := gui.Config.GetUserConfig().Git.SkipHookPrefix
if skipHookPrefix == "" {
return gui.createErrorPanel(gui.Tr.SkipHookPrefixNotConfigured)
}
_ = gui.renderStringSync("commitMessage", skipHookPreifx)
if err := gui.getCommitMessageView().SetCursor(len(skipHookPreifx), 0); err != nil {
if err := gui.Views.CommitMessage.SetEditorContent(skipHookPrefix); err != nil {
return err
}
@@ -304,7 +366,7 @@ func (gui *Gui) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
func (gui *Gui) prepareFilesForCommit() error {
noStagedFiles := len(gui.stagedFiles()) == 0
if noStagedFiles && gui.Config.GetUserConfig().Gui.SkipNoStagedFilesWarning {
err := gui.GitCommand.StageAll()
err := gui.GitCommand.WithSpan(gui.Tr.Spans.StageAllFiles).StageAll()
if err != nil {
return err
}
@@ -320,11 +382,14 @@ func (gui *Gui) handleCommitPress() error {
return gui.surfaceError(err)
}
if gui.State.FileManager.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
if len(gui.stagedFiles()) == 0 {
return gui.promptToStageAllAndRetry(gui.handleCommitPress)
}
commitMessageView := gui.getCommitMessageView()
commitPrefixConfig := gui.commitPrefixConfigForRepo()
if commitPrefixConfig != nil {
prefixPattern := commitPrefixConfig.Pattern
@@ -334,14 +399,14 @@ func (gui *Gui) handleCommitPress() error {
return gui.createErrorPanel(fmt.Sprintf("%s: %s", gui.Tr.LcCommitPrefixPatternError, err.Error()))
}
prefix := rgx.ReplaceAllString(gui.getCheckedOutBranch().Name, prefixReplace)
gui.renderString("commitMessage", prefix)
if err := commitMessageView.SetCursor(len(prefix), 0); err != nil {
gui.renderString(gui.Views.CommitMessage, prefix)
if err := gui.Views.CommitMessage.SetCursor(len(prefix), 0); err != nil {
return err
}
}
gui.g.Update(func(g *gocui.Gui) error {
if err := gui.pushContext(gui.Contexts.CommitMessage.Context); err != nil {
if err := gui.pushContext(gui.State.Contexts.CommitMessage); err != nil {
return err
}
@@ -356,7 +421,7 @@ func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
title: gui.Tr.NoFilesStagedTitle,
prompt: gui.Tr.NoFilesStagedPrompt,
handleConfirm: func() error {
if err := gui.GitCommand.StageAll(); err != nil {
if err := gui.GitCommand.WithSpan(gui.Tr.Spans.StageAllFiles).StageAll(); err != nil {
return gui.surfaceError(err)
}
if err := gui.refreshFilesAndSubmodules(); err != nil {
@@ -369,6 +434,10 @@ func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
}
func (gui *Gui) handleAmendCommitPress() error {
if gui.State.FileManager.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
if len(gui.stagedFiles()) == 0 {
return gui.promptToStageAllAndRetry(gui.handleAmendCommitPress)
}
@@ -381,17 +450,9 @@ func (gui *Gui) handleAmendCommitPress() error {
title: strings.Title(gui.Tr.AmendLastCommit),
prompt: gui.Tr.SureToAmend,
handleConfirm: func() error {
return gui.WithWaitingStatus(gui.Tr.AmendingStatus, func() error {
ok, err := gui.runSyncOrAsyncCommand(gui.GitCommand.AmendHead())
if err != nil {
return err
}
if !ok {
return nil
}
return gui.refreshSidePanels(refreshOptions{mode: ASYNC})
})
cmdStr := gui.GitCommand.AmendHeadCmdStr()
gui.OnRunCommand(oscommands.NewCmdLogEntry(cmdStr, gui.Tr.Spans.AmendCommit, true))
return gui.withGpgHandling(cmdStr, gui.Tr.AmendingStatus, nil)
},
})
}
@@ -399,84 +460,156 @@ func (gui *Gui) handleAmendCommitPress() error {
// handleCommitEditorPress - handle when the user wants to commit changes via
// their editor rather than via the popup panel
func (gui *Gui) handleCommitEditorPress() error {
if gui.State.FileManager.GetItemsLength() == 0 {
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
}
if len(gui.stagedFiles()) == 0 {
return gui.promptToStageAllAndRetry(gui.handleCommitEditorPress)
}
gui.PrepareSubProcess("git commit")
return nil
}
// PrepareSubProcess - prepare a subprocess for execution and tell the gui to switch to it
func (gui *Gui) PrepareSubProcess(command string) {
splitCmd := str.ToArgv(command)
gui.SubProcess = gui.OSCommand.PrepareSubProcess(splitCmd[0], splitCmd[1:]...)
gui.g.Update(func(g *gocui.Gui) error {
return gui.Errors.ErrSubProcess
})
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.WithSpan(gui.Tr.Spans.Commit).PrepareSubProcess("git", "commit"),
)
}
func (gui *Gui) editFile(filename string) error {
_, err := gui.runSyncOrAsyncCommand(gui.GitCommand.EditFile(filename))
return err
cmdStr, err := gui.GitCommand.EditFileCmdStr(filename)
if err != nil {
return gui.surfaceError(err)
}
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.WithSpan(gui.Tr.Spans.EditFile).PrepareShellSubProcess(cmdStr),
)
}
func (gui *Gui) handleFileEdit(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedFile()
if file == nil {
func (gui *Gui) handleFileEdit() error {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
return gui.editFile(file.Name)
if node.File == nil {
return gui.createErrorPanel(gui.Tr.ErrCannotEditDirectory)
}
return gui.editFile(node.GetPath())
}
func (gui *Gui) handleFileOpen(g *gocui.Gui, v *gocui.View) error {
file := gui.getSelectedFile()
if file == nil {
func (gui *Gui) handleFileOpen() error {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
return gui.openFile(file.Name)
return gui.openFile(node.GetPath())
}
func (gui *Gui) handleRefreshFiles(g *gocui.Gui, v *gocui.View) error {
return gui.refreshSidePanels(refreshOptions{scope: []int{FILES}})
func (gui *Gui) handleRefreshFiles() error {
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
}
func (gui *Gui) refreshStateFiles() error {
state := gui.State
// keep track of where the cursor is currently and the current file names
// when we refresh, go looking for a matching name
// move the cursor to there.
selectedFile := gui.getSelectedFile()
selectedNode := gui.getSelectedFileNode()
prevNodes := gui.State.FileManager.GetAllItems()
prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
// get files to stage
files := gui.GitCommand.GetStatusFiles(commands.GetStatusFileOptions{})
gui.State.Files = gui.GitCommand.MergeStatusFiles(gui.State.Files, files, selectedFile)
// for when you stage the old file of a rename and the new file is in a collapsed dir
state.FileManager.RWMutex.Lock()
for _, file := range files {
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
state.FileManager.ExpandToPath(file.Name)
}
}
state.FileManager.SetFiles(files)
state.FileManager.RWMutex.Unlock()
if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
return err
}
// let's try to find our file again and move the cursor to that
if selectedFile != nil {
for idx, f := range gui.State.Files {
selectedFileHasMoved := f.Matches(selectedFile) && idx != prevSelectedLineIdx
if selectedFileHasMoved {
gui.State.Panels.Files.SelectedLineIdx = idx
break
if selectedNode != nil {
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileManager.GetAllItems())
if newIdx != -1 && newIdx != prevSelectedLineIdx {
newNode := state.FileManager.GetItemAtIndex(newIdx)
// when not in tree mode, we show merge conflict files at the top, so you
// can work through them one by one without having to sift through a large
// set of files. If you have just fixed the merge conflicts of a file, we
// actually don't want to jump to that file's new position, because that
// file will now be ages away amidst the other files without merge
// conflicts: the user in this case would rather work on the next file
// with merge conflicts, which will have moved up to fill the gap left by
// the last file, meaning the cursor doesn't need to move at all.
leaveCursor := !state.FileManager.InTreeMode() && newNode != nil &&
selectedNode.File != nil && selectedNode.File.HasMergeConflicts &&
newNode.File != nil && !newNode.File.HasMergeConflicts
if !leaveCursor {
state.Panels.Files.SelectedLineIdx = newIdx
}
}
}
gui.refreshSelectedLine(gui.State.Panels.Files, len(gui.State.Files))
gui.refreshSelectedLine(state.Panels.Files, state.FileManager.GetItemsLength())
return nil
}
func (gui *Gui) handlePullFiles(g *gocui.Gui, v *gocui.View) error {
// Let's try to find our file again and move the cursor to that.
// If we can't find our file, it was probably just removed by the user. In that
// case, we go looking for where the next file has been moved to. Given that the
// user could have removed a whole directory, we continue iterating through the old
// nodes until we find one that exists in the new set of nodes, then move the cursor
// to that.
// prevNodes starts from our previously selected node because we don't need to consider anything above that
func (gui *Gui) findNewSelectedIdx(prevNodes []*filetree.FileNode, currNodes []*filetree.FileNode) int {
getPaths := func(node *filetree.FileNode) []string {
if node == nil {
return nil
}
if node.File != nil && node.File.IsRename() {
return node.File.Names()
} else {
return []string{node.Path}
}
}
for _, prevNode := range prevNodes {
selectedPaths := getPaths(prevNode)
for idx, node := range currNodes {
paths := getPaths(node)
// If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
// This is because the new should be in the same position as the rename was meaning less cursor jumping
foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.Path == prevNode.File.PreviousName
foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
if foundNode {
return idx
}
}
}
return -1
}
func (gui *Gui) handlePullFiles() error {
if gui.popupPanelFocused() {
return nil
}
span := gui.Tr.Spans.Pull
currentBranch := gui.currentBranch()
if currentBranch == nil {
// need to wait for branches to refresh
@@ -484,7 +617,7 @@ func (gui *Gui) handlePullFiles(g *gocui.Gui, v *gocui.View) error {
}
// if we have no upstream branch we need to set that first
if currentBranch.Pullables == "?" {
if !currentBranch.IsTrackingRemote() {
// see if we have this branch in our config with an upstream
conf, err := gui.GitCommand.Repo.Config()
if err != nil {
@@ -492,7 +625,7 @@ func (gui *Gui) handlePullFiles(g *gocui.Gui, v *gocui.View) error {
}
for branchName, branch := range conf.Branches {
if branchName == currentBranch.Name {
return gui.pullFiles(PullFilesOptions{RemoteName: branch.Remote, BranchName: branch.Name})
return gui.pullFiles(PullFilesOptions{RemoteName: branch.Remote, BranchName: branch.Name, span: span})
}
}
@@ -507,17 +640,18 @@ func (gui *Gui) handlePullFiles(g *gocui.Gui, v *gocui.View) error {
}
return gui.createErrorPanel(errorMessage)
}
return gui.pullFiles(PullFilesOptions{})
return gui.pullFiles(PullFilesOptions{span: span})
},
})
}
return gui.pullFiles(PullFilesOptions{})
return gui.pullFiles(PullFilesOptions{span: span})
}
type PullFilesOptions struct {
RemoteName string
BranchName string
span string
}
func (gui *Gui) pullFiles(opts PullFilesOptions) error {
@@ -525,9 +659,11 @@ func (gui *Gui) pullFiles(opts PullFilesOptions) error {
return err
}
mode := gui.Config.GetUserConfig().Git.Pull.Mode
mode := &gui.Config.GetUserConfig().Git.Pull.Mode
*mode = gui.GitCommand.GetPullMode(*mode)
go utils.Safe(func() { _ = gui.pullWithMode(mode, opts) })
// TODO: this doesn't look like a good idea. Why the goroutine?
go utils.Safe(func() { _ = gui.pullWithMode(*mode, opts) })
return nil
}
@@ -536,7 +672,9 @@ func (gui *Gui) pullWithMode(mode string, opts PullFilesOptions) error {
gui.Mutexes.FetchMutex.Lock()
defer gui.Mutexes.FetchMutex.Unlock()
err := gui.GitCommand.Fetch(
gitCommand := gui.GitCommand.WithSpan(opts.span)
err := gitCommand.Fetch(
commands.FetchOptions{
PromptUserForCredential: gui.promptUserForCredential,
RemoteName: opts.RemoteName,
@@ -550,26 +688,26 @@ func (gui *Gui) pullWithMode(mode string, opts PullFilesOptions) error {
switch mode {
case "rebase":
err := gui.GitCommand.RebaseBranch("FETCH_HEAD")
err := gitCommand.RebaseBranch("FETCH_HEAD")
return gui.handleGenericMergeCommandResult(err)
case "merge":
err := gui.GitCommand.Merge("FETCH_HEAD", commands.MergeOpts{})
err := gitCommand.Merge("FETCH_HEAD", commands.MergeOpts{})
return gui.handleGenericMergeCommandResult(err)
case "ff-only":
err := gui.GitCommand.Merge("FETCH_HEAD", commands.MergeOpts{FastForwardOnly: true})
err := gitCommand.Merge("FETCH_HEAD", commands.MergeOpts{FastForwardOnly: true})
return gui.handleGenericMergeCommandResult(err)
default:
return gui.createErrorPanel(fmt.Sprintf("git pull mode '%s' unrecognised", mode))
}
}
func (gui *Gui) pushWithForceFlag(v *gocui.View, force bool, upstream string, args string) error {
func (gui *Gui) pushWithForceFlag(force bool, upstream string, args string) error {
if err := gui.createLoaderPanel(gui.Tr.PushWait); err != nil {
return err
}
go utils.Safe(func() {
branchName := gui.getCheckedOutBranch().Name
err := gui.GitCommand.Push(branchName, force, upstream, args, gui.promptUserForCredential)
err := gui.GitCommand.WithSpan(gui.Tr.Spans.Push).Push(branchName, force, upstream, args, gui.promptUserForCredential)
if err != nil && !force && strings.Contains(err.Error(), "Updates were rejected") {
forcePushDisabled := gui.Config.GetUserConfig().Git.DisableForcePushing
if forcePushDisabled {
@@ -580,7 +718,7 @@ func (gui *Gui) pushWithForceFlag(v *gocui.View, force bool, upstream string, ar
title: gui.Tr.ForcePush,
prompt: gui.Tr.ForcePushPrompt,
handleConfirm: func() error {
return gui.pushWithForceFlag(v, true, upstream, args)
return gui.pushWithForceFlag(true, upstream, args)
},
})
return
@@ -591,41 +729,50 @@ func (gui *Gui) pushWithForceFlag(v *gocui.View, force bool, upstream string, ar
return nil
}
func (gui *Gui) pushFiles(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) pushFiles() error {
if gui.popupPanelFocused() {
return nil
}
// if we have pullables we'll ask if the user wants to force push
currentBranch := gui.currentBranch()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
}
if currentBranch.Pullables == "?" {
// see if we have this branch in our config with an upstream
conf, err := gui.GitCommand.Repo.Config()
if currentBranch.IsTrackingRemote() {
if currentBranch.HasCommitsToPull() {
return gui.requestToForcePush()
} else {
return gui.pushWithForceFlag(false, "", "")
}
} else {
// see if we have an upstream for this branch in our config
upstream, err := gui.upstreamForBranchInConfig(currentBranch.Name)
if err != nil {
return gui.surfaceError(err)
}
for branchName, branch := range conf.Branches {
if branchName == currentBranch.Name {
return gui.pushWithForceFlag(v, false, "", fmt.Sprintf("%s %s", branch.Remote, branchName))
}
if upstream != "" {
return gui.pushWithForceFlag(false, "", upstream)
}
if gui.GitCommand.PushToCurrent {
return gui.pushWithForceFlag(v, false, "", "--set-upstream")
return gui.pushWithForceFlag(false, "", "--set-upstream")
} else {
return gui.prompt(promptOpts{
title: gui.Tr.EnterUpstream,
initialContent: "origin " + currentBranch.Name,
handleConfirm: func(response string) error {
return gui.pushWithForceFlag(v, false, response, "")
handleConfirm: func(upstream string) error {
return gui.pushWithForceFlag(false, upstream, "")
},
})
}
} else if currentBranch.Pullables == "0" {
return gui.pushWithForceFlag(v, false, "", "")
}
}
func (gui *Gui) requestToForcePush() error {
forcePushDisabled := gui.Config.GetUserConfig().Git.DisableForcePushing
if forcePushDisabled {
return gui.createErrorPanel(gui.Tr.ForcePushDisabled)
@@ -635,11 +782,26 @@ func (gui *Gui) pushFiles(g *gocui.Gui, v *gocui.View) error {
title: gui.Tr.ForcePush,
prompt: gui.Tr.ForcePushPrompt,
handleConfirm: func() error {
return gui.pushWithForceFlag(v, true, "", "")
return gui.pushWithForceFlag(true, "", "")
},
})
}
func (gui *Gui) upstreamForBranchInConfig(branchName string) (string, error) {
conf, err := gui.GitCommand.Repo.Config()
if err != nil {
return "", err
}
for configBranchName, configBranch := range conf.Branches {
if configBranchName == branchName {
return fmt.Sprintf("%s %s", configBranch.Remote, configBranchName), nil
}
}
return "", nil
}
func (gui *Gui) handleSwitchToMerge() error {
file := gui.getSelectedFile()
if file == nil {
@@ -650,18 +812,18 @@ func (gui *Gui) handleSwitchToMerge() error {
return gui.createErrorPanel(gui.Tr.FileNoMergeCons)
}
return gui.pushContext(gui.Contexts.Merging.Context)
return gui.pushContext(gui.State.Contexts.Merging)
}
func (gui *Gui) openFile(filename string) error {
if err := gui.OSCommand.OpenFile(filename); err != nil {
if err := gui.OSCommand.WithSpan(gui.Tr.Spans.OpenFile).OpenFile(filename); err != nil {
return gui.surfaceError(err)
}
return nil
}
func (gui *Gui) anyFilesWithMergeConflicts() bool {
for _, file := range gui.State.Files {
for _, file := range gui.State.FileManager.GetAllFiles() {
if file.HasMergeConflicts {
return true
}
@@ -669,28 +831,30 @@ func (gui *Gui) anyFilesWithMergeConflicts() bool {
return false
}
func (gui *Gui) handleCustomCommand(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCustomCommand() error {
return gui.prompt(promptOpts{
title: gui.Tr.CustomCommand,
handleConfirm: func(command string) error {
gui.SubProcess = gui.OSCommand.RunCustomCommand(command)
return gui.Errors.ErrSubProcess
gui.OnRunCommand(oscommands.NewCmdLogEntry(command, gui.Tr.Spans.CustomCommand, true))
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.PrepareShellSubProcess(command),
)
},
})
}
func (gui *Gui) handleCreateStashMenu(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreateStashMenu() error {
menuItems := []*menuItem{
{
displayString: gui.Tr.LcStashAllChanges,
onPress: func() error {
return gui.handleStashSave(gui.GitCommand.StashSave)
return gui.handleStashSave(gui.GitCommand.WithSpan(gui.Tr.Spans.StashAllChanges).StashSave)
},
},
{
displayString: gui.Tr.LcStashStagedChanges,
onPress: func() error {
return gui.handleStashSave(gui.GitCommand.StashSaveStagedChanges)
return gui.handleStashSave(gui.GitCommand.WithSpan(gui.Tr.Spans.StashStagedChanges).StashSaveStagedChanges)
},
},
}
@@ -698,10 +862,64 @@ func (gui *Gui) handleCreateStashMenu(g *gocui.Gui, v *gocui.View) error {
return gui.createMenu(gui.Tr.LcStashOptions, menuItems, createMenuOptions{showCancel: true})
}
func (gui *Gui) handleStashChanges(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleStashChanges() error {
return gui.handleStashSave(gui.GitCommand.StashSave)
}
func (gui *Gui) handleCreateResetToUpstreamMenu(g *gocui.Gui, v *gocui.View) error {
func (gui *Gui) handleCreateResetToUpstreamMenu() error {
return gui.createResetMenu("@{upstream}")
}
func (gui *Gui) handleToggleDirCollapsed() error {
node := gui.getSelectedFileNode()
if node == nil {
return nil
}
gui.State.FileManager.ToggleCollapsed(node.GetPath())
if err := gui.postRefreshUpdate(gui.State.Contexts.Files); err != nil {
gui.Log.Error(err)
}
return nil
}
func (gui *Gui) handleToggleFileTreeView() error {
// get path of currently selected file
path := gui.getSelectedPath()
gui.State.FileManager.ToggleShowTree()
// find that same node in the new format and move the cursor to it
if path != "" {
gui.State.FileManager.ExpandToPath(path)
index, found := gui.State.FileManager.GetIndexForPath(path)
if found {
gui.filesListContext().GetPanelState().SetSelectedLineIdx(index)
}
}
if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
if err := gui.State.Contexts.Files.HandleRender(); err != nil {
return err
}
if err := gui.State.Contexts.Files.HandleFocus(); err != nil {
return err
}
}
return nil
}
func (gui *Gui) handleOpenMergeTool() error {
return gui.ask(askOpts{
title: gui.Tr.MergeToolTitle,
prompt: gui.Tr.MergeToolPrompt,
handleConfirm: func() error {
return gui.runSubprocessWithSuspenseAndRefresh(
gui.OSCommand.ExecutableFromString(gui.GitCommand.OpenMergeToolCmd()),
)
},
})
}

View File

@@ -0,0 +1,140 @@
package filetree
import (
"sort"
"strings"
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
func BuildTreeFromFiles(files []*models.File) *FileNode {
root := &FileNode{}
var curr *FileNode
for _, file := range files {
splitPath := split(file.Name)
curr = root
outer:
for i := range splitPath {
var setFile *models.File
isFile := i == len(splitPath)-1
if isFile {
setFile = file
}
path := join(splitPath[:i+1])
for _, existingChild := range curr.Children {
if existingChild.Path == path {
curr = existingChild
continue outer
}
}
newChild := &FileNode{
Path: path,
File: setFile,
}
curr.Children = append(curr.Children, newChild)
curr = newChild
}
}
root.Sort()
root.Compress()
return root
}
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
rootAux := BuildTreeFromCommitFiles(files)
sortedFiles := rootAux.GetLeaves()
return &CommitFileNode{Children: sortedFiles}
}
func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode {
root := &CommitFileNode{}
var curr *CommitFileNode
for _, file := range files {
splitPath := split(file.Name)
curr = root
outer:
for i := range splitPath {
var setFile *models.CommitFile
isFile := i == len(splitPath)-1
if isFile {
setFile = file
}
path := join(splitPath[:i+1])
for _, existingChild := range curr.Children {
if existingChild.Path == path {
curr = existingChild
continue outer
}
}
newChild := &CommitFileNode{
Path: path,
File: setFile,
}
curr.Children = append(curr.Children, newChild)
curr = newChild
}
}
root.Sort()
root.Compress()
return root
}
func BuildFlatTreeFromFiles(files []*models.File) *FileNode {
rootAux := BuildTreeFromFiles(files)
sortedFiles := rootAux.GetLeaves()
// from top down we have merge conflict files, then tracked file, then untracked
// files. This is the one way in which sorting differs between flat mode and
// tree mode
sort.SliceStable(sortedFiles, func(i, j int) bool {
iFile := sortedFiles[i].File
jFile := sortedFiles[j].File
// never going to happen but just to be safe
if iFile == nil || jFile == nil {
return false
}
if iFile.HasMergeConflicts && !jFile.HasMergeConflicts {
return true
}
if jFile.HasMergeConflicts && !iFile.HasMergeConflicts {
return false
}
if iFile.Tracked && !jFile.Tracked {
return true
}
if jFile.Tracked && !iFile.Tracked {
return false
}
return false
})
return &FileNode{Children: sortedFiles}
}
func split(str string) []string {
return strings.Split(str, "/")
}
func join(strs []string) string {
return strings.Join(strs, "/")
}

View File

@@ -0,0 +1,530 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
func TestBuildTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *FileNode
}{
{
name: "no files",
files: []*models.File{},
expected: &FileNode{
Path: "",
Children: []*FileNode{},
},
},
{
name: "files in same directory",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
},
{
File: &models.File{Name: "dir1/b"},
Path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
{
Name: "dir1/dir3/a",
},
{
Name: "dir2/dir4/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1/dir3",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/dir3/a"},
Path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
Path: "dir2/dir4",
Children: []*FileNode{
{
File: &models.File{Name: "dir2/dir4/b"},
Path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
},
},
},
{
name: "paths that can be sorted including a merge conflict file",
files: []*models.File{
{
Name: "b",
},
{
Name: "z",
HasMergeConflicts: true,
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
// it is a little strange that we're not bubbling up our merge conflict
// here but we are technically still in in tree mode and that's the rule
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
{
File: &models.File{Name: "z", HasMergeConflicts: true},
Path: "z",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildFlatTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *FileNode
}{
{
name: "no files",
files: []*models.File{},
expected: &FileNode{
Path: "",
Children: []*FileNode{},
},
},
{
name: "files in same directory",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Name: "dir1/b"},
Path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "a"},
Path: "a",
},
{
File: &models.File{Name: "b"},
Path: "b",
},
},
},
},
{
name: "tracked, untracked, and conflicted files",
files: []*models.File{
{
Name: "a2",
Tracked: false,
},
{
Name: "a1",
Tracked: false,
},
{
Name: "c2",
HasMergeConflicts: true,
},
{
Name: "c1",
HasMergeConflicts: true,
},
{
Name: "b2",
Tracked: true,
},
{
Name: "b1",
Tracked: true,
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
File: &models.File{Name: "c1", HasMergeConflicts: true},
Path: "c1",
},
{
File: &models.File{Name: "c2", HasMergeConflicts: true},
Path: "c2",
},
{
File: &models.File{Name: "b1", Tracked: true},
Path: "b1",
},
{
File: &models.File{Name: "b2", Tracked: true},
Path: "b2",
},
{
File: &models.File{Name: "a1", Tracked: false},
Path: "a1",
},
{
File: &models.File{Name: "a2", Tracked: false},
Path: "a2",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *CommitFileNode
}{
{
name: "no files",
files: []*models.CommitFile{},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{},
},
},
{
name: "files in same directory",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
Path: "dir1",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
},
{
File: &models.CommitFile{Name: "dir1/b"},
Path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
{
Name: "dir1/dir3/a",
},
{
Name: "dir2/dir4/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
Path: "dir1/dir3",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/dir3/a"},
Path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
Path: "dir2/dir4",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir2/dir4/b"},
Path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
},
{
File: &models.CommitFile{Name: "b"},
Path: "b",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromCommitFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}
func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *CommitFileNode
}{
{
name: "no files",
files: []*models.CommitFile{},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{},
},
},
{
name: "files in same directory",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir1/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.CommitFile{Name: "dir1/b"},
Path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
{
Name: "dir1/a",
},
{
Name: "dir2/b",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "dir1/a"},
Path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.CommitFile{Name: "dir2/b"},
Path: "dir2/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
{
Name: "b",
},
{
Name: "a",
},
},
expected: &CommitFileNode{
Path: "",
Children: []*CommitFileNode{
{
File: &models.CommitFile{Name: "a"},
Path: "a",
},
{
File: &models.CommitFile{Name: "b"},
Path: "b",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromCommitFiles(s.files)
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@@ -0,0 +1,20 @@
package filetree
type CollapsedPaths map[string]bool
func (cp CollapsedPaths) ExpandToPath(path string) {
// need every directory along the way
splitPath := split(path)
for i := range splitPath {
dir := join(splitPath[0 : i+1])
cp[dir] = false
}
}
func (cp CollapsedPaths) IsCollapsed(path string) bool {
return cp[path]
}
func (cp CollapsedPaths) ToggleCollapsed(path string) {
cp[path] = !cp[path]
}

View File

@@ -0,0 +1,119 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/sirupsen/logrus"
)
type CommitFileManager struct {
files []*models.CommitFile
tree *CommitFileNode
showTree bool
log *logrus.Entry
collapsedPaths CollapsedPaths
// parent is the identifier of the parent object e.g. a commit SHA if this commit file is for a commit, or a stash entry ref like 'stash@{1}'
parent string
}
func (m *CommitFileManager) GetParent() string {
return m.parent
}
func NewCommitFileManager(files []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileManager {
return &CommitFileManager{
files: files,
log: log,
showTree: showTree,
collapsedPaths: CollapsedPaths{},
}
}
func (m *CommitFileManager) ExpandToPath(path string) {
m.collapsedPaths.ExpandToPath(path)
}
func (m *CommitFileManager) ToggleShowTree() {
m.showTree = !m.showTree
m.SetTree()
}
func (m *CommitFileManager) GetItemAtIndex(index int) *CommitFileNode {
// need to traverse the three depth first until we get to the index.
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
}
func (m *CommitFileManager) GetIndexForPath(path string) (int, bool) {
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
return index - 1, found
}
func (m *CommitFileManager) GetAllItems() []*CommitFileNode {
if m.tree == nil {
return nil
}
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
}
func (m *CommitFileManager) GetItemsLength() int {
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
}
func (m *CommitFileManager) GetAllFiles() []*models.CommitFile {
return m.files
}
func (m *CommitFileManager) SetFiles(files []*models.CommitFile, parent string) {
m.files = files
m.parent = parent
m.SetTree()
}
func (m *CommitFileManager) SetTree() {
if m.showTree {
m.tree = BuildTreeFromCommitFiles(m.files)
} else {
m.tree = BuildFlatTreeFromCommitFiles(m.files)
}
}
func (m *CommitFileManager) IsCollapsed(path string) bool {
return m.collapsedPaths.IsCollapsed(path)
}
func (m *CommitFileManager) ToggleCollapsed(path string) {
m.collapsedPaths.ToggleCollapsed(path)
}
func (m *CommitFileManager) Render(diffName string, patchManager *patch.PatchManager) []string {
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
if m.tree == nil {
return []string{}
}
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
castN := n.(*CommitFileNode)
// This is a little convoluted because we're dealing with either a leaf or a non-leaf.
// But this code actually applies to both. If it's a leaf, the status will just
// be whatever status it is, but if it's a non-leaf it will determine its status
// based on the leaves of that subtree
var status patch.PatchStatus
if castN.EveryFile(func(file *models.CommitFile) bool {
return patchManager.GetFileStatus(file.Name, m.parent) == patch.WHOLE
}) {
status = patch.WHOLE
} else if castN.EveryFile(func(file *models.CommitFile) bool {
return patchManager.GetFileStatus(file.Name, m.parent) == patch.UNSELECTED
}) {
status = patch.UNSELECTED
} else {
status = patch.PART
}
return presentation.GetCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status)
})
}

View File

@@ -0,0 +1,172 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
type CommitFileNode struct {
Children []*CommitFileNode
File *models.CommitFile
Path string // e.g. '/path/to/mydir'
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
}
// methods satisfying ListItem interface
func (s *CommitFileNode) ID() string {
return s.GetPath()
}
func (s *CommitFileNode) Description() string {
return s.GetPath()
}
// methods satisfying INode interface
func (s *CommitFileNode) IsLeaf() bool {
return s.File != nil
}
func (s *CommitFileNode) GetPath() string {
return s.Path
}
func (s *CommitFileNode) GetChildren() []INode {
result := make([]INode, len(s.Children))
for i, child := range s.Children {
result[i] = child
}
return result
}
func (s *CommitFileNode) SetChildren(children []INode) {
castChildren := make([]*CommitFileNode, len(children))
for i, child := range children {
castChildren[i] = child.(*CommitFileNode)
}
s.Children = castChildren
}
func (s *CommitFileNode) GetCompressionLevel() int {
return s.CompressionLevel
}
func (s *CommitFileNode) SetCompressionLevel(level int) {
s.CompressionLevel = level
}
// methods utilising generic functions for INodes
func (s *CommitFileNode) Sort() {
sortNode(s)
}
func (s *CommitFileNode) ForEachFile(cb func(*models.CommitFile) error) error {
return forEachLeaf(s, func(n INode) error {
castNode := n.(*CommitFileNode)
return cb(castNode.File)
})
}
func (s *CommitFileNode) Any(test func(node *CommitFileNode) bool) bool {
return any(s, func(n INode) bool {
castNode := n.(*CommitFileNode)
return test(castNode)
})
}
func (s *CommitFileNode) Every(test func(node *CommitFileNode) bool) bool {
return every(s, func(n INode) bool {
castNode := n.(*CommitFileNode)
return test(castNode)
})
}
func (s *CommitFileNode) EveryFile(test func(file *models.CommitFile) bool) bool {
return every(s, func(n INode) bool {
castNode := n.(*CommitFileNode)
return castNode.File == nil || test(castNode.File)
})
}
func (n *CommitFileNode) Flatten(collapsedPaths map[string]bool) []*CommitFileNode {
results := flatten(n, collapsedPaths)
nodes := make([]*CommitFileNode, len(results))
for i, result := range results {
nodes[i] = result.(*CommitFileNode)
}
return nodes
}
func (node *CommitFileNode) GetNodeAtIndex(index int, collapsedPaths map[string]bool) *CommitFileNode {
if node == nil {
return nil
}
result := getNodeAtIndex(node, index, collapsedPaths)
if result == nil {
// not sure how this can be nil: we probably are missing a mutex somewhere
return nil
}
return result.(*CommitFileNode)
}
func (node *CommitFileNode) GetIndexForPath(path string, collapsedPaths map[string]bool) (int, bool) {
return getIndexForPath(node, path, collapsedPaths)
}
func (node *CommitFileNode) Size(collapsedPaths map[string]bool) int {
if node == nil {
return 0
}
return size(node, collapsedPaths)
}
func (s *CommitFileNode) Compress() {
// with these functions I try to only have type conversion code on the actual struct,
// but comparing interface values to nil is fraught with danger so I'm duplicating
// that code here.
if s == nil {
return
}
compressAux(s)
}
// This ignores the root
func (node *CommitFileNode) GetPathsMatching(test func(*CommitFileNode) bool) []string {
return getPathsMatching(node, func(n INode) bool {
return test(n.(*CommitFileNode))
})
}
func (s *CommitFileNode) GetLeaves() []*CommitFileNode {
leaves := getLeaves(s)
castLeaves := make([]*CommitFileNode, len(leaves))
for i := range leaves {
castLeaves[i] = leaves[i].(*CommitFileNode)
}
return castLeaves
}
// extra methods
func (s *CommitFileNode) AnyFile(test func(file *models.CommitFile) bool) bool {
return s.Any(func(node *CommitFileNode) bool {
return node.IsLeaf() && test(node.File)
})
}
func (s *CommitFileNode) NameAtDepth(depth int) string {
splitName := split(s.Path)
name := join(splitName[depth:])
return name
}

View File

@@ -0,0 +1,9 @@
package filetree
const EXPANDED_ARROW = "▼"
const COLLAPSED_ARROW = "►"
const INNER_ITEM = "├─ "
const LAST_ITEM = "└─ "
const NESTED = "│ "
const NOTHING = " "

View File

@@ -0,0 +1,101 @@
package filetree
import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/sirupsen/logrus"
)
type FileManager struct {
files []*models.File
tree *FileNode
showTree bool
log *logrus.Entry
collapsedPaths CollapsedPaths
sync.RWMutex
}
func NewFileManager(files []*models.File, log *logrus.Entry, showTree bool) *FileManager {
return &FileManager{
files: files,
log: log,
showTree: showTree,
collapsedPaths: CollapsedPaths{},
RWMutex: sync.RWMutex{},
}
}
func (m *FileManager) InTreeMode() bool {
return m.showTree
}
func (m *FileManager) ExpandToPath(path string) {
m.collapsedPaths.ExpandToPath(path)
}
func (m *FileManager) ToggleShowTree() {
m.showTree = !m.showTree
m.SetTree()
}
func (m *FileManager) GetItemAtIndex(index int) *FileNode {
// need to traverse the three depth first until we get to the index.
return m.tree.GetNodeAtIndex(index+1, m.collapsedPaths) // ignoring root
}
func (m *FileManager) GetIndexForPath(path string) (int, bool) {
index, found := m.tree.GetIndexForPath(path, m.collapsedPaths)
return index - 1, found
}
func (m *FileManager) GetAllItems() []*FileNode {
if m.tree == nil {
return nil
}
return m.tree.Flatten(m.collapsedPaths)[1:] // ignoring root
}
func (m *FileManager) GetItemsLength() int {
return m.tree.Size(m.collapsedPaths) - 1 // ignoring root
}
func (m *FileManager) GetAllFiles() []*models.File {
return m.files
}
func (m *FileManager) SetFiles(files []*models.File) {
m.files = files
m.SetTree()
}
func (m *FileManager) SetTree() {
if m.showTree {
m.tree = BuildTreeFromFiles(m.files)
} else {
m.tree = BuildFlatTreeFromFiles(m.files)
}
}
func (m *FileManager) IsCollapsed(path string) bool {
return m.collapsedPaths.IsCollapsed(path)
}
func (m *FileManager) ToggleCollapsed(path string) {
m.collapsedPaths.ToggleCollapsed(path)
}
func (m *FileManager) Render(diffName string, submoduleConfigs []*models.SubmoduleConfig) []string {
// can't rely on renderAux to check for nil because an interface won't be nil if its concrete value is nil
if m.tree == nil {
return []string{}
}
return renderAux(m.tree, m.collapsedPaths, "", -1, func(n INode, depth int) string {
castN := n.(*FileNode)
return presentation.GetFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File)
})
}

View File

@@ -0,0 +1,95 @@
package filetree
import (
"testing"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
)
func TestRender(t *testing.T) {
color.ForceSetColorLevel(terminfo.ColorLevelNone)
scenarios := []struct {
name string
root *FileNode
collapsedPaths map[string]bool
expected []string
}{
{
name: "nil node",
root: nil,
expected: []string{},
},
{
name: "leaf node",
root: &FileNode{
Path: "",
Children: []*FileNode{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
expected: []string{" M test"},
},
{
name: "big example",
root: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
},
{
File: &models.File{Name: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file3",
},
},
},
{
Path: "dir2",
Children: []*FileNode{
{
Path: "dir2/dir2",
Children: []*FileNode{
{
File: &models.File{Name: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/dir2/file3",
},
{
File: &models.File{Name: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/dir2/file4",
},
},
},
{
File: &models.File{Name: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/file5",
},
},
},
{
File: &models.File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "file1",
},
},
},
expected: []string{"dir1 ►", "dir2 ▼", "├─ dir2 ▼", "│ ├─ M file3", "│ └─ M file4", "└─ M file5", "M file1"},
collapsedPaths: map[string]bool{"dir1": true},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
mngr := &FileManager{tree: s.root, collapsedPaths: s.collapsedPaths}
result := mngr.Render("", nil)
assert.EqualValues(t, s.expected, result)
})
}
}

View File

@@ -0,0 +1,187 @@
package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
type FileNode struct {
Children []*FileNode
File *models.File
Path string // e.g. '/path/to/mydir'
CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode
}
// methods satisfying ListItem interface
func (s *FileNode) ID() string {
return s.GetPath()
}
func (s *FileNode) Description() string {
return s.GetPath()
}
// methods satisfying INode interface
func (s *FileNode) IsLeaf() bool {
return s.File != nil
}
func (s *FileNode) GetPath() string {
return s.Path
}
func (s *FileNode) GetChildren() []INode {
result := make([]INode, len(s.Children))
for i, child := range s.Children {
result[i] = child
}
return result
}
func (s *FileNode) SetChildren(children []INode) {
castChildren := make([]*FileNode, len(children))
for i, child := range children {
castChildren[i] = child.(*FileNode)
}
s.Children = castChildren
}
func (s *FileNode) GetCompressionLevel() int {
return s.CompressionLevel
}
func (s *FileNode) SetCompressionLevel(level int) {
s.CompressionLevel = level
}
// methods utilising generic functions for INodes
func (s *FileNode) Sort() {
sortNode(s)
}
func (s *FileNode) ForEachFile(cb func(*models.File) error) error {
return forEachLeaf(s, func(n INode) error {
castNode := n.(*FileNode)
return cb(castNode.File)
})
}
func (s *FileNode) Any(test func(node *FileNode) bool) bool {
return any(s, func(n INode) bool {
castNode := n.(*FileNode)
return test(castNode)
})
}
func (n *FileNode) Flatten(collapsedPaths map[string]bool) []*FileNode {
results := flatten(n, collapsedPaths)
nodes := make([]*FileNode, len(results))
for i, result := range results {
nodes[i] = result.(*FileNode)
}
return nodes
}
func (node *FileNode) GetNodeAtIndex(index int, collapsedPaths map[string]bool) *FileNode {
if node == nil {
return nil
}
result := getNodeAtIndex(node, index, collapsedPaths)
if result == nil {
// not sure how this can be nil: we probably are missing a mutex somewhere
return nil
}
return result.(*FileNode)
}
func (node *FileNode) GetIndexForPath(path string, collapsedPaths map[string]bool) (int, bool) {
return getIndexForPath(node, path, collapsedPaths)
}
func (node *FileNode) Size(collapsedPaths map[string]bool) int {
if node == nil {
return 0
}
return size(node, collapsedPaths)
}
func (s *FileNode) Compress() {
// with these functions I try to only have type conversion code on the actual struct,
// but comparing interface values to nil is fraught with danger so I'm duplicating
// that code here.
if s == nil {
return
}
compressAux(s)
}
// This ignores the root
func (node *FileNode) GetPathsMatching(test func(*FileNode) bool) []string {
return getPathsMatching(node, func(n INode) bool {
return test(n.(*FileNode))
})
}
func (s *FileNode) GetLeaves() []*FileNode {
leaves := getLeaves(s)
castLeaves := make([]*FileNode, len(leaves))
for i := range leaves {
castLeaves[i] = leaves[i].(*FileNode)
}
return castLeaves
}
// extra methods
func (s *FileNode) GetHasUnstagedChanges() bool {
return s.AnyFile(func(file *models.File) bool { return file.HasUnstagedChanges })
}
func (s *FileNode) GetHasStagedChanges() bool {
return s.AnyFile(func(file *models.File) bool { return file.HasStagedChanges })
}
func (s *FileNode) GetHasInlineMergeConflicts() bool {
return s.AnyFile(func(file *models.File) bool { return file.HasInlineMergeConflicts })
}
func (s *FileNode) GetIsTracked() bool {
return s.AnyFile(func(file *models.File) bool { return file.Tracked })
}
func (s *FileNode) AnyFile(test func(file *models.File) bool) bool {
return s.Any(func(node *FileNode) bool {
return node.IsLeaf() && test(node.File)
})
}
func (s *FileNode) NameAtDepth(depth int) string {
splitName := split(s.Path)
name := join(splitName[depth:])
if s.File != nil && s.File.IsRename() {
splitPrevName := split(s.File.PreviousName)
prevName := s.File.PreviousName
// if the file has just been renamed inside the same directory, we can shave off
// the prefix for the previous path too. Otherwise we'll keep it unchanged
sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth])
if sameParentDir {
prevName = join(splitPrevName[depth:])
}
return prevName + " → " + name
}
return name
}

View File

@@ -0,0 +1,134 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/stretchr/testify/assert"
)
func TestCompress(t *testing.T) {
scenarios := []struct {
name string
root *FileNode
expected *FileNode
}{
{
name: "nil node",
root: nil,
expected: nil,
},
{
name: "leaf node",
root: &FileNode{
Path: "",
Children: []*FileNode{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"},
},
},
},
{
name: "big example",
root: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
},
},
},
{
Path: "dir2",
Children: []*FileNode{
{
File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/file3",
},
{
File: &models.File{Name: "file4", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/file4",
},
},
},
{
Path: "dir3",
Children: []*FileNode{
{
Path: "dir3/dir3-1",
Children: []*FileNode{
{
File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir3/dir3-1/file5",
},
},
},
},
},
{
File: &models.File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "file1",
},
},
},
expected: &FileNode{
Path: "",
Children: []*FileNode{
{
Path: "dir1",
Children: []*FileNode{
{
File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir1/file2",
},
},
},
{
Path: "dir2",
Children: []*FileNode{
{
File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true},
Path: "dir2/file3",
},
{
File: &models.File{Name: "file4", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir2/file4",
},
},
},
{
Path: "dir3/dir3-1",
CompressionLevel: 1,
Children: []*FileNode{
{
File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "dir3/dir3-1/file5",
},
},
},
{
File: &models.File{Name: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
Path: "file1",
},
},
},
},
}
for _, s := range scenarios {
s := s
t.Run(s.name, func(t *testing.T) {
s.root.Compress()
assert.EqualValues(t, s.expected, s.root)
})
}
}

262
pkg/gui/filetree/inode.go Normal file
View File

@@ -0,0 +1,262 @@
package filetree
import (
"fmt"
"sort"
"strings"
)
type INode interface {
IsLeaf() bool
GetPath() string
GetChildren() []INode
SetChildren([]INode)
GetCompressionLevel() int
SetCompressionLevel(int)
}
func sortNode(node INode) {
sortChildren(node)
for _, child := range node.GetChildren() {
sortNode(child)
}
}
func sortChildren(node INode) {
if node.IsLeaf() {
return
}
children := node.GetChildren()
sortedChildren := make([]INode, len(children))
copy(sortedChildren, children)
sort.Slice(sortedChildren, func(i, j int) bool {
if !sortedChildren[i].IsLeaf() && sortedChildren[j].IsLeaf() {
return true
}
if sortedChildren[i].IsLeaf() && !sortedChildren[j].IsLeaf() {
return false
}
return sortedChildren[i].GetPath() < sortedChildren[j].GetPath()
})
// TODO: think about making this in-place
node.SetChildren(sortedChildren)
}
func forEachLeaf(node INode, cb func(INode) error) error {
if node.IsLeaf() {
if err := cb(node); err != nil {
return err
}
}
for _, child := range node.GetChildren() {
if err := forEachLeaf(child, cb); err != nil {
return err
}
}
return nil
}
func any(node INode, test func(INode) bool) bool {
if test(node) {
return true
}
for _, child := range node.GetChildren() {
if any(child, test) {
return true
}
}
return false
}
func every(node INode, test func(INode) bool) bool {
if !test(node) {
return false
}
for _, child := range node.GetChildren() {
if !every(child, test) {
return false
}
}
return true
}
func flatten(node INode, collapsedPaths map[string]bool) []INode {
result := []INode{}
result = append(result, node)
if !collapsedPaths[node.GetPath()] {
for _, child := range node.GetChildren() {
result = append(result, flatten(child, collapsedPaths)...)
}
}
return result
}
func getNodeAtIndex(node INode, index int, collapsedPaths map[string]bool) INode {
foundNode, _ := getNodeAtIndexAux(node, index, collapsedPaths)
return foundNode
}
func getNodeAtIndexAux(node INode, index int, collapsedPaths map[string]bool) (INode, int) {
offset := 1
if index == 0 {
return node, offset
}
if !collapsedPaths[node.GetPath()] {
for _, child := range node.GetChildren() {
foundNode, offsetChange := getNodeAtIndexAux(child, index-offset, collapsedPaths)
offset += offsetChange
if foundNode != nil {
return foundNode, offset
}
}
}
return nil, offset
}
func getIndexForPath(node INode, path string, collapsedPaths map[string]bool) (int, bool) {
offset := 0
if node.GetPath() == path {
return offset, true
}
if !collapsedPaths[node.GetPath()] {
for _, child := range node.GetChildren() {
offsetChange, found := getIndexForPath(child, path, collapsedPaths)
offset += offsetChange + 1
if found {
return offset, true
}
}
}
return offset, false
}
func size(node INode, collapsedPaths map[string]bool) int {
output := 1
if !collapsedPaths[node.GetPath()] {
for _, child := range node.GetChildren() {
output += size(child, collapsedPaths)
}
}
return output
}
func compressAux(node INode) INode {
if node.IsLeaf() {
return node
}
children := node.GetChildren()
for i := range children {
grandchildren := children[i].GetChildren()
for len(grandchildren) == 1 && !grandchildren[0].IsLeaf() {
grandchildren[0].SetCompressionLevel(children[i].GetCompressionLevel() + 1)
children[i] = grandchildren[0]
grandchildren = children[i].GetChildren()
}
}
for i := range children {
children[i] = compressAux(children[i])
}
node.SetChildren(children)
return node
}
func getPathsMatching(node INode, test func(INode) bool) []string {
paths := []string{}
if test(node) {
paths = append(paths, node.GetPath())
}
for _, child := range node.GetChildren() {
paths = append(paths, getPathsMatching(child, test)...)
}
return paths
}
func getLeaves(node INode) []INode {
if node.IsLeaf() {
return []INode{node}
}
output := []INode{}
for _, child := range node.GetChildren() {
output = append(output, getLeaves(child)...)
}
return output
}
func renderAux(s INode, collapsedPaths CollapsedPaths, prefix string, depth int, renderLine func(INode, int) string) []string {
isRoot := depth == -1
renderLineWithPrefix := func() string {
return prefix + renderLine(s, depth)
}
if s.IsLeaf() {
if isRoot {
return []string{}
}
return []string{renderLineWithPrefix()}
}
if collapsedPaths.IsCollapsed(s.GetPath()) {
return []string{fmt.Sprintf("%s %s", renderLineWithPrefix(), COLLAPSED_ARROW)}
}
arr := []string{}
if !isRoot {
arr = append(arr, fmt.Sprintf("%s %s", renderLineWithPrefix(), EXPANDED_ARROW))
}
newPrefix := prefix
if strings.HasSuffix(prefix, LAST_ITEM) {
newPrefix = strings.TrimSuffix(prefix, LAST_ITEM) + NOTHING
} else if strings.HasSuffix(prefix, INNER_ITEM) {
newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED
}
for i, child := range s.GetChildren() {
isLast := i == len(s.GetChildren())-1
var childPrefix string
if isRoot {
childPrefix = newPrefix
} else if isLast {
childPrefix = newPrefix + LAST_ITEM
} else {
childPrefix = newPrefix + INNER_ITEM
}
arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...)
}
return arr
}

Some files were not shown because too many files have changed in this diff Show More