Skip to content

Commit c877291

Browse files
authored
feat: support committing local changes (#13743)
* feat: support committing local changes * add GitCommitAction to MainMenu * fix broken jablib tests * fix: add missing localization keys * refactor: remove unused field and avoid null parameter in Git commit logic * fix: localize error message in GitCommitDialogViewModel * chore: bind commit message validator * refactor: extract commit logic in GitCommitDialogViewModel * fix: move Git submenu before remote DB * chore: reorder Git menu and apply minor cleanup adjustments * Add early Git status check in GitCommitAction * trigger CI
1 parent c262252 commit c877291

File tree

6 files changed

+285
-0
lines changed

6 files changed

+285
-0
lines changed

jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.jabref.gui.externalfiles.AutoLinkFilesAction;
3838
import org.jabref.gui.externalfiles.DownloadFullTextAction;
3939
import org.jabref.gui.externalfiles.FindUnlinkedFilesAction;
40+
import org.jabref.gui.git.GitCommitAction;
4041
import org.jabref.gui.git.GitShareToGitHubAction;
4142
import org.jabref.gui.help.AboutAction;
4243
import org.jabref.gui.help.ErrorConsoleAction;
@@ -182,6 +183,8 @@ private void createMenu() {
182183

183184
// TODO: Should be only enabled if not yet shared.
184185
factory.createSubMenu(StandardActions.GIT,
186+
factory.createMenuItem(StandardActions.GIT_COMMIT, new GitCommitAction(dialogService, stateManager)),
187+
new SeparatorMenuItem(),
185188
factory.createMenuItem(StandardActions.GIT_SHARE, new GitShareToGitHubAction(dialogService, stateManager))
186189
),
187190

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package org.jabref.gui.git;
2+
3+
import org.jabref.gui.DialogService;
4+
import org.jabref.gui.StateManager;
5+
import org.jabref.gui.actions.ActionHelper;
6+
import org.jabref.gui.actions.SimpleCommand;
7+
import org.jabref.logic.git.GitHandler;
8+
import org.jabref.logic.git.status.GitStatusChecker;
9+
import org.jabref.logic.l10n.Localization;
10+
11+
public class GitCommitAction extends SimpleCommand {
12+
13+
private final DialogService dialogService;
14+
private final StateManager stateManager;
15+
16+
public GitCommitAction(DialogService dialogService, StateManager stateManager) {
17+
this.dialogService = dialogService;
18+
this.stateManager = stateManager;
19+
20+
this.executable.bind(ActionHelper.needsDatabase(stateManager));
21+
}
22+
23+
@Override
24+
public void execute() {
25+
if (hasNothingToCommit()) {
26+
dialogService.notify(Localization.lang("Nothing to commit."));
27+
return;
28+
}
29+
30+
dialogService.showCustomDialogAndWait(
31+
new GitCommitDialogView()
32+
);
33+
}
34+
35+
private boolean hasNothingToCommit() {
36+
return stateManager.getActiveDatabase()
37+
.flatMap(context -> context.getDatabasePath())
38+
.flatMap(GitHandler::fromAnyPath)
39+
.map(GitStatusChecker::checkStatus)
40+
.map(status -> !status.uncommittedChanges())
41+
.orElse(true);
42+
}
43+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package org.jabref.gui.git;
2+
3+
import javafx.application.Platform;
4+
import javafx.fxml.FXML;
5+
import javafx.scene.control.ButtonType;
6+
import javafx.scene.control.TextArea;
7+
8+
import org.jabref.gui.DialogService;
9+
import org.jabref.gui.StateManager;
10+
import org.jabref.gui.preferences.GuiPreferences;
11+
import org.jabref.gui.util.BaseDialog;
12+
import org.jabref.gui.util.IconValidationDecorator;
13+
import org.jabref.logic.l10n.Localization;
14+
import org.jabref.logic.util.TaskExecutor;
15+
16+
import com.airhacks.afterburner.views.ViewLoader;
17+
import de.saxsys.mvvmfx.utils.validation.visualization.ControlsFxVisualizer;
18+
import jakarta.inject.Inject;
19+
20+
public class GitCommitDialogView extends BaseDialog<Void> {
21+
22+
@FXML private TextArea commitMessage;
23+
@FXML private ButtonType commitButton;
24+
25+
private GitCommitDialogViewModel viewModel;
26+
27+
@Inject
28+
private StateManager stateManager;
29+
30+
@Inject
31+
private DialogService dialogService;
32+
33+
@Inject
34+
private GuiPreferences preferences;
35+
@Inject
36+
private TaskExecutor taskExecutor;
37+
38+
private final ControlsFxVisualizer visualizer = new ControlsFxVisualizer();
39+
40+
public GitCommitDialogView() {
41+
ViewLoader.view(this)
42+
.load()
43+
.setAsDialogPane(this);
44+
}
45+
46+
@FXML
47+
private void initialize() {
48+
setTitle(Localization.lang("Git Commit"));
49+
this.viewModel = new GitCommitDialogViewModel(stateManager, dialogService, taskExecutor);
50+
51+
commitMessage.textProperty().bindBidirectional(viewModel.commitMessageProperty());
52+
commitMessage.setPromptText(Localization.lang("Enter commit message here"));
53+
54+
this.setResultConverter(button -> {
55+
if (button != ButtonType.CANCEL) {
56+
viewModel.commit(() -> this.close());
57+
}
58+
return null;
59+
});
60+
61+
Platform.runLater(() -> {
62+
visualizer.setDecoration(new IconValidationDecorator());
63+
visualizer.initVisualization(viewModel.commitMessageValidation(), commitMessage, true);
64+
});
65+
}
66+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package org.jabref.gui.git;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.Optional;
6+
7+
import javafx.beans.property.BooleanProperty;
8+
import javafx.beans.property.SimpleBooleanProperty;
9+
import javafx.beans.property.SimpleStringProperty;
10+
import javafx.beans.property.StringProperty;
11+
12+
import org.jabref.gui.AbstractViewModel;
13+
import org.jabref.gui.DialogService;
14+
import org.jabref.gui.StateManager;
15+
import org.jabref.logic.JabRefException;
16+
import org.jabref.logic.git.GitHandler;
17+
import org.jabref.logic.git.status.GitStatusChecker;
18+
import org.jabref.logic.git.status.GitStatusSnapshot;
19+
import org.jabref.logic.git.util.GitHandlerRegistry;
20+
import org.jabref.logic.l10n.Localization;
21+
import org.jabref.logic.util.BackgroundTask;
22+
import org.jabref.logic.util.TaskExecutor;
23+
import org.jabref.model.database.BibDatabaseContext;
24+
25+
import de.saxsys.mvvmfx.utils.validation.FunctionBasedValidator;
26+
import de.saxsys.mvvmfx.utils.validation.ValidationMessage;
27+
import de.saxsys.mvvmfx.utils.validation.ValidationStatus;
28+
import de.saxsys.mvvmfx.utils.validation.Validator;
29+
import org.eclipse.jgit.api.errors.GitAPIException;
30+
31+
public class GitCommitDialogViewModel extends AbstractViewModel {
32+
33+
private final StateManager stateManager;
34+
private final DialogService dialogService;
35+
private final TaskExecutor taskExecutor;
36+
37+
private final StringProperty commitMessage = new SimpleStringProperty("");
38+
private final BooleanProperty amend = new SimpleBooleanProperty(false);
39+
40+
private final Validator commitMessageValidator;
41+
42+
public GitCommitDialogViewModel(
43+
StateManager stateManager,
44+
DialogService dialogService,
45+
TaskExecutor taskExecutor) {
46+
this.stateManager = stateManager;
47+
this.dialogService = dialogService;
48+
this.taskExecutor = taskExecutor;
49+
50+
this.commitMessageValidator = new FunctionBasedValidator<>(
51+
commitMessage,
52+
message -> message != null && !message.trim().isEmpty(),
53+
ValidationMessage.error(Localization.lang("Commit message cannot be empty"))
54+
);
55+
}
56+
57+
public void commit(Runnable onSuccess) {
58+
commitTask()
59+
.onSuccess(_-> {
60+
dialogService.notify(Localization.lang("Committed successfully"));
61+
onSuccess.run();
62+
})
63+
.onFailure(ex ->
64+
dialogService.showErrorDialogAndWait(
65+
Localization.lang("Git Commit Failed"),
66+
ex.getMessage(),
67+
ex
68+
)
69+
)
70+
.executeWith(taskExecutor);
71+
}
72+
73+
public BackgroundTask<Void> commitTask() {
74+
return BackgroundTask.wrap(() -> {
75+
doCommit();
76+
return null;
77+
});
78+
}
79+
80+
private void doCommit() throws JabRefException, GitAPIException, IOException {
81+
Optional<BibDatabaseContext> activeDatabaseOpt = stateManager.getActiveDatabase();
82+
if (activeDatabaseOpt.isEmpty()) {
83+
throw new JabRefException(Localization.lang("No library open"));
84+
}
85+
86+
BibDatabaseContext dbContext = activeDatabaseOpt.get();
87+
Optional<Path> bibFilePathOpt = dbContext.getDatabasePath();
88+
if (bibFilePathOpt.isEmpty()) {
89+
throw new JabRefException(Localization.lang("No library file path. Please save the library to a file first."));
90+
}
91+
92+
Path bibFilePath = bibFilePathOpt.get();
93+
GitHandlerRegistry registry = new GitHandlerRegistry();
94+
Optional<Path> repoRootOpt = GitHandler.findRepositoryRoot(bibFilePath);
95+
if (repoRootOpt.isEmpty()) {
96+
throw new JabRefException(Localization.lang("Commit aborted: Path is not inside a Git repository."));
97+
}
98+
99+
GitHandler gitHandler = registry.get(repoRootOpt.get());
100+
101+
GitStatusSnapshot status = GitStatusChecker.checkStatus(gitHandler);
102+
if (!status.tracking()) {
103+
throw new JabRefException(Localization.lang("Commit aborted: The file is not under Git version control."));
104+
}
105+
if (status.conflict()) {
106+
throw new JabRefException(Localization.lang("Commit aborted: Local repository has unresolved merge conflicts."));
107+
}
108+
109+
String message = commitMessage.get();
110+
if (message == null || message.isBlank()) {
111+
message = Localization.lang("Update references");
112+
}
113+
114+
boolean committed = gitHandler.createCommitOnCurrentBranch(message, amend.get());
115+
// TODO: Replace control-flow-by-exception with a proper control structure
116+
if (!committed) {
117+
throw new JabRefException(Localization.lang("Nothing to commit."));
118+
}
119+
}
120+
121+
public StringProperty commitMessageProperty() {
122+
return commitMessage;
123+
}
124+
125+
public BooleanProperty amendProperty() {
126+
return amend;
127+
}
128+
129+
public ValidationStatus commitMessageValidation() {
130+
return commitMessageValidator.getValidationStatus();
131+
}
132+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
4+
<?import javafx.geometry.Insets?>
5+
<?import javafx.scene.control.ButtonType?>
6+
<?import javafx.scene.control.DialogPane?>
7+
<?import javafx.scene.control.TextArea?>
8+
<?import javafx.scene.layout.VBox?>
9+
<DialogPane
10+
xmlns="http://javafx.com/javafx"
11+
xmlns:fx="http://javafx.com/fxml"
12+
fx:controller="org.jabref.gui.git.GitCommitDialogView"
13+
prefHeight="400.0"
14+
prefWidth="600.0">
15+
<content>
16+
<VBox spacing="15">
17+
<padding>
18+
<Insets top="20" right="20" bottom="20" left="20"/>
19+
</padding>
20+
21+
<TextArea fx:id="commitMessage"
22+
prefRowCount="6"
23+
wrapText="true"
24+
VBox.vgrow="ALWAYS"/>
25+
</VBox>
26+
</content>
27+
<buttonTypes>
28+
<ButtonType fx:id="commitButton" text="%Commit" buttonData="OK_DONE"/>
29+
<ButtonType fx:constant="CANCEL"/>
30+
</buttonTypes>
31+
</DialogPane>

jablib/src/main/resources/l10n/JabRef_en.properties

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3370,3 +3370,13 @@ Unexpected\ error\:\ %0=Unexpected error: %0
33703370
Create\ an\ empty\ repository\ on\ GitHub,\ then\ copy\ the\ HTTPS\ URL\ (ends\ with\ .git).\ Click\ to\ open\ GitHub.=Create an empty repository on GitHub, then copy the HTTPS URL (ends with .git). Click to open GitHub.
33713371
Merge\ completed\ with\ conflicts.=Merge completed with conflicts.
33723372
Successfully\ merged\ and\ updated.=Successfully merged and updated.
3373+
Commit\ message\ cannot\ be\ empty=Commit message cannot be empty
3374+
Committed\ successfully=Committed successfully
3375+
Enter\ commit\ message\ here=Enter commit message here
3376+
Git\ Commit=Git Commit
3377+
Git\ Commit\ Failed=Git Commit Failed
3378+
Nothing\ to\ commit.=Nothing to commit.
3379+
Commit\ aborted\:\ Local\ repository\ has\ unresolved\ merge\ conflicts.=Commit aborted: Local repository has unresolved merge conflicts.
3380+
Commit\ aborted\:\ Path\ is\ not\ inside\ a\ Git\ repository.=Commit aborted: Path is not inside a Git repository.
3381+
Commit\ aborted\:\ The\ file\ is\ not\ under\ Git\ version\ control.=Commit aborted: The file is not under Git version control.
3382+
Update\ references=Update references

0 commit comments

Comments
 (0)